Compare commits
54 Commits
b4effc8545
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30b55f887f | ||
|
|
15e2c4c961 | ||
|
|
8870845fa1 | ||
|
|
437e2bc96a | ||
|
|
696dc69763 | ||
|
|
f752d27101 | ||
|
|
ce6871d623 | ||
|
|
3a8c049092 | ||
|
|
24f53ddbce | ||
|
|
e0ff9dec0a | ||
|
|
14f9b622b0 | ||
|
|
0c6c9da56c | ||
|
|
33a260017a | ||
|
|
1e810e1782 | ||
|
|
34c8f90e26 | ||
|
|
ea85a053c5 | ||
|
|
78d7c51a7a | ||
|
|
f3d8bf9b94 | ||
|
|
668568b5b5 | ||
|
|
90ac590737 | ||
|
|
b7e10efd1d | ||
|
|
026debdc06 | ||
|
|
53edb2b307 | ||
|
|
d2732828cc | ||
|
|
22e2fde058 | ||
|
|
fbd184e2be | ||
|
|
7d2b2ddd71 | ||
|
|
792e8c7346 | ||
|
|
baebc1fc80 | ||
|
|
8b5aa8c8a6 | ||
|
|
e3cf141f55 | ||
|
|
8d26e375ff | ||
|
|
820eea7eca | ||
|
|
7518baccfa | ||
|
|
620e41edae | ||
|
|
50dc415987 | ||
|
|
be4b2c6000 | ||
|
|
37baeb9172 | ||
|
|
45775c8c3f | ||
|
|
18d5b42136 | ||
|
|
4c618781f6 | ||
|
|
e62c6f07df | ||
|
|
488845ae1f | ||
|
|
9ea006ec22 | ||
|
|
a5c2bcbc4d | ||
|
|
c2aba25814 | ||
|
|
2278d2a218 | ||
|
|
0b92f999bc | ||
|
|
cc9686147e | ||
|
|
bd613adb8a | ||
|
|
205743f3b1 | ||
|
|
a8bd13ef43 | ||
|
|
647a44e46a | ||
|
|
6495369c56 |
141
.agents/skills/find-skills/SKILL.md
Normal file
141
.agents/skills/find-skills/SKILL.md
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
---
|
||||||
|
name: find-skills
|
||||||
|
description: Helps users discover and install agent skills when they ask questions like "how do I do X", "find a skill for X", "is there a skill that can...", or express interest in extending capabilities. This skill should be used when the user is looking for functionality that might exist as an installable skill.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Find Skills
|
||||||
|
|
||||||
|
This skill helps you discover and install skills from the open agent skills ecosystem.
|
||||||
|
|
||||||
|
## When to Use This Skill
|
||||||
|
|
||||||
|
Use this skill when the user:
|
||||||
|
|
||||||
|
- Asks "how do I do X" where X might be a common task with an existing skill
|
||||||
|
- Says "find a skill for X" or "is there a skill for X"
|
||||||
|
- Asks "can you do X" where X is a specialized capability
|
||||||
|
- Expresses interest in extending agent capabilities
|
||||||
|
- Wants to search for tools, templates, or workflows
|
||||||
|
- Mentions they wish they had help with a specific domain (design, testing, deployment, etc.)
|
||||||
|
|
||||||
|
## What is the Skills CLI?
|
||||||
|
|
||||||
|
The Skills CLI (`npx skills`) is the package manager for the open agent skills ecosystem. Skills are modular packages that extend agent capabilities with specialized knowledge, workflows, and tools.
|
||||||
|
|
||||||
|
**Key commands:**
|
||||||
|
|
||||||
|
- `npx skills find [query] [--owner <owner>]` - Search for skills interactively or by keyword, optionally scoped to a GitHub owner
|
||||||
|
- `npx skills add <package>` - Install a skill from GitHub or other sources
|
||||||
|
- `npx skills update` - Update all installed skills
|
||||||
|
|
||||||
|
**Browse skills at:** https://skills.sh/
|
||||||
|
|
||||||
|
## How to Help Users Find Skills
|
||||||
|
|
||||||
|
### Step 1: Understand What They Need
|
||||||
|
|
||||||
|
When a user asks for help with something, identify:
|
||||||
|
|
||||||
|
1. The domain (e.g., React, testing, design, deployment)
|
||||||
|
2. The specific task (e.g., writing tests, creating animations, reviewing PRs)
|
||||||
|
3. Whether this is a common enough task that a skill likely exists
|
||||||
|
|
||||||
|
### Step 2: Check the Leaderboard First
|
||||||
|
|
||||||
|
Before running a CLI search, check the [skills.sh leaderboard](https://skills.sh/) to see if a well-known skill already exists for the domain. The leaderboard ranks skills by total installs, surfacing the most popular and battle-tested options.
|
||||||
|
|
||||||
|
For example, top skills for web development include:
|
||||||
|
- `vercel-labs/agent-skills` — React, Next.js, web design (100K+ installs each)
|
||||||
|
- `anthropics/skills` — Frontend design, document processing (100K+ installs)
|
||||||
|
|
||||||
|
### Step 3: Search for Skills
|
||||||
|
|
||||||
|
If the leaderboard doesn't cover the user's need, run the find command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx skills find [query] [--owner <owner>]
|
||||||
|
```
|
||||||
|
|
||||||
|
For example:
|
||||||
|
|
||||||
|
- User asks "how do I make my React app faster?" → `npx skills find react performance`
|
||||||
|
- User asks "can you help me with PR reviews?" → `npx skills find pr review`
|
||||||
|
- User asks "I need to create a changelog" → `npx skills find changelog`
|
||||||
|
|
||||||
|
### Step 4: Verify Quality Before Recommending
|
||||||
|
|
||||||
|
**Do not recommend a skill based solely on search results.** Always verify:
|
||||||
|
|
||||||
|
1. **Install count** — Prefer skills with 1K+ installs. Be cautious with anything under 100.
|
||||||
|
2. **Source reputation** — Official sources (`vercel-labs`, `anthropics`, `microsoft`) are more trustworthy than unknown authors.
|
||||||
|
3. **GitHub stars** — Check the source repository. A skill from a repo with <100 stars should be treated with skepticism.
|
||||||
|
|
||||||
|
### Step 5: Present Options to the User
|
||||||
|
|
||||||
|
When you find relevant skills, present them to the user with:
|
||||||
|
|
||||||
|
1. The skill name and what it does
|
||||||
|
2. The install count and source
|
||||||
|
3. The install command they can run
|
||||||
|
4. A link to learn more at skills.sh
|
||||||
|
|
||||||
|
Example response:
|
||||||
|
|
||||||
|
```
|
||||||
|
I found a skill that might help! The "react-best-practices" skill provides
|
||||||
|
React and Next.js performance optimization guidelines from Vercel Engineering.
|
||||||
|
(185K installs)
|
||||||
|
|
||||||
|
To install it:
|
||||||
|
npx skills add vercel-labs/agent-skills@react-best-practices
|
||||||
|
|
||||||
|
Learn more: https://skills.sh/vercel-labs/agent-skills/react-best-practices
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 6: Offer to Install
|
||||||
|
|
||||||
|
If the user wants to proceed, you can install the skill for them:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx skills add <owner/repo@skill> -g -y
|
||||||
|
```
|
||||||
|
|
||||||
|
The `-g` flag installs globally (user-level) and `-y` skips confirmation prompts.
|
||||||
|
|
||||||
|
## Common Skill Categories
|
||||||
|
|
||||||
|
When searching, consider these common categories:
|
||||||
|
|
||||||
|
| Category | Example Queries |
|
||||||
|
| --------------- | ---------------------------------------- |
|
||||||
|
| Web Development | react, nextjs, typescript, css, tailwind |
|
||||||
|
| Testing | testing, jest, playwright, e2e |
|
||||||
|
| DevOps | deploy, docker, kubernetes, ci-cd |
|
||||||
|
| Documentation | docs, readme, changelog, api-docs |
|
||||||
|
| Code Quality | review, lint, refactor, best-practices |
|
||||||
|
| Design | ui, ux, design-system, accessibility |
|
||||||
|
| Productivity | workflow, automation, git |
|
||||||
|
|
||||||
|
## Tips for Effective Searches
|
||||||
|
|
||||||
|
1. **Use specific keywords**: "react testing" is better than just "testing"
|
||||||
|
2. **Try alternative terms**: If "deploy" doesn't work, try "deployment" or "ci-cd"
|
||||||
|
3. **Check popular sources**: Many skills come from `vercel-labs/agent-skills` or `ComposioHQ/awesome-claude-skills`
|
||||||
|
|
||||||
|
## When No Skills Are Found
|
||||||
|
|
||||||
|
If no relevant skills exist:
|
||||||
|
|
||||||
|
1. Acknowledge that no existing skill was found
|
||||||
|
2. Offer to help with the task directly using your general capabilities
|
||||||
|
3. Suggest the user could create their own skill with `npx skills init`
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```
|
||||||
|
I searched for skills related to "xyz" but didn't find any matches.
|
||||||
|
I can still help you with this task directly! Would you like me to proceed?
|
||||||
|
|
||||||
|
If this is something you do often, you could create your own skill:
|
||||||
|
npx skills init my-xyz-skill
|
||||||
|
```
|
||||||
328
.agents/skills/frontend-ui-engineering/SKILL.md
Normal file
328
.agents/skills/frontend-ui-engineering/SKILL.md
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
---
|
||||||
|
name: frontend-ui-engineering
|
||||||
|
description: Builds production-quality, accessible, responsive user-facing UIs. Use when building or modifying interfaces and pages, creating components, implementing layouts, meeting WCAG accessibility requirements, managing state, or when the output needs to look and feel production-quality rather than AI-generated.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Frontend UI Engineering
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Build production-quality user interfaces that are accessible, performant, and visually polished. The goal is UI that looks like it was built by a design-aware engineer at a top company — not like it was generated by an AI. This means real design system adherence, proper accessibility, thoughtful interaction patterns, and no generic "AI aesthetic."
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
- Building new UI components or pages
|
||||||
|
- Modifying existing user-facing interfaces
|
||||||
|
- Implementing responsive layouts
|
||||||
|
- Adding interactivity or state management
|
||||||
|
- Fixing visual or UX issues
|
||||||
|
|
||||||
|
## Component Architecture
|
||||||
|
|
||||||
|
### File Structure
|
||||||
|
|
||||||
|
Colocate everything related to a component:
|
||||||
|
|
||||||
|
```
|
||||||
|
src/components/
|
||||||
|
TaskList/
|
||||||
|
TaskList.tsx # Component implementation
|
||||||
|
TaskList.test.tsx # Tests
|
||||||
|
TaskList.stories.tsx # Storybook stories (if using)
|
||||||
|
use-task-list.ts # Custom hook (if complex state)
|
||||||
|
types.ts # Component-specific types (if needed)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Component Patterns
|
||||||
|
|
||||||
|
**Prefer composition over configuration:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Good: Composable
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Tasks</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
<TaskList tasks={tasks} />
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
// Avoid: Over-configured
|
||||||
|
<Card
|
||||||
|
title="Tasks"
|
||||||
|
headerVariant="large"
|
||||||
|
bodyPadding="md"
|
||||||
|
content={<TaskList tasks={tasks} />}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Keep components focused:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Good: Does one thing
|
||||||
|
export function TaskItem({ task, onToggle, onDelete }: TaskItemProps) {
|
||||||
|
return (
|
||||||
|
<li className="flex items-center gap-3 p-3">
|
||||||
|
<Checkbox checked={task.done} onChange={() => onToggle(task.id)} />
|
||||||
|
<span className={task.done ? 'line-through text-muted' : ''}>{task.title}</span>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => onDelete(task.id)}>
|
||||||
|
<TrashIcon />
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Separate data fetching from presentation:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Container: handles data
|
||||||
|
export function TaskListContainer() {
|
||||||
|
const { tasks, isLoading, error } = useTasks();
|
||||||
|
|
||||||
|
if (isLoading) return <TaskListSkeleton />;
|
||||||
|
if (error) return <ErrorState message="Failed to load tasks" retry={refetch} />;
|
||||||
|
if (tasks.length === 0) return <EmptyState message="No tasks yet" />;
|
||||||
|
|
||||||
|
return <TaskList tasks={tasks} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Presentation: handles rendering
|
||||||
|
export function TaskList({ tasks }: { tasks: Task[] }) {
|
||||||
|
return (
|
||||||
|
<ul role="list" className="divide-y">
|
||||||
|
{tasks.map(task => <TaskItem key={task.id} task={task} />)}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## State Management
|
||||||
|
|
||||||
|
**Choose the simplest approach that works:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Local state (useState) → Component-specific UI state
|
||||||
|
Lifted state → Shared between 2-3 sibling components
|
||||||
|
Context → Theme, auth, locale (read-heavy, write-rare)
|
||||||
|
URL state (searchParams) → Filters, pagination, shareable UI state
|
||||||
|
Server state (React Query, SWR) → Remote data with caching
|
||||||
|
Global store (Zustand, Redux) → Complex client state shared app-wide
|
||||||
|
```
|
||||||
|
|
||||||
|
**Avoid prop drilling deeper than 3 levels.** If you're passing props through components that don't use them, introduce context or restructure the component tree.
|
||||||
|
|
||||||
|
## Design System Adherence
|
||||||
|
|
||||||
|
### Avoid the AI Aesthetic
|
||||||
|
|
||||||
|
AI-generated UI has recognizable patterns. Avoid all of them:
|
||||||
|
|
||||||
|
| AI Default | Why It Is a Problem | Production Quality |
|
||||||
|
|---|---|---|
|
||||||
|
| Purple/indigo everything | Models default to visually "safe" palettes, making every app look identical | Use the project's actual color palette |
|
||||||
|
| Excessive gradients | Gradients add visual noise and clash with most design systems | Flat or subtle gradients matching the design system |
|
||||||
|
| Rounded everything (rounded-2xl) | Maximum rounding signals "friendly" but ignores the hierarchy of corner radii in real designs | Consistent border-radius from the design system |
|
||||||
|
| Generic hero sections | Template-driven layout with no connection to the actual content or user need | Content-first layouts |
|
||||||
|
| Lorem ipsum-style copy | Placeholder text hides layout problems that real content reveals (length, wrapping, overflow) | Realistic placeholder content |
|
||||||
|
| Oversized padding everywhere | Equal generous padding destroys visual hierarchy and wastes screen space | Consistent spacing scale |
|
||||||
|
| Stock card grids | Uniform grids are a layout shortcut that ignores information priority and scanning patterns | Purpose-driven layouts |
|
||||||
|
| Shadow-heavy design | Layered shadows add depth that competes with content and slows rendering on low-end devices | Subtle or no shadows unless the design system specifies |
|
||||||
|
|
||||||
|
### Spacing and Layout
|
||||||
|
|
||||||
|
Use a consistent spacing scale. Don't invent values:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Use the scale: 0.25rem increments (or whatever the project uses) */
|
||||||
|
/* Good */ padding: 1rem; /* 16px */
|
||||||
|
/* Good */ gap: 0.75rem; /* 12px */
|
||||||
|
/* Bad */ padding: 13px; /* Not on any scale */
|
||||||
|
/* Bad */ margin-top: 2.3rem; /* Not on any scale */
|
||||||
|
```
|
||||||
|
|
||||||
|
### Typography
|
||||||
|
|
||||||
|
Respect the type hierarchy:
|
||||||
|
|
||||||
|
```
|
||||||
|
h1 → Page title (one per page)
|
||||||
|
h2 → Section title
|
||||||
|
h3 → Subsection title
|
||||||
|
body → Default text
|
||||||
|
small → Secondary/helper text
|
||||||
|
```
|
||||||
|
|
||||||
|
Don't skip heading levels. Don't use heading styles for non-heading content.
|
||||||
|
|
||||||
|
### Color
|
||||||
|
|
||||||
|
- Use semantic color tokens: `text-primary`, `bg-surface`, `border-default` — not raw hex values
|
||||||
|
- Ensure sufficient contrast (4.5:1 for normal text, 3:1 for large text)
|
||||||
|
- Don't rely solely on color to convey information (use icons, text, or patterns too)
|
||||||
|
|
||||||
|
## Accessibility (WCAG 2.1 AA)
|
||||||
|
|
||||||
|
Every component must meet these standards:
|
||||||
|
|
||||||
|
### Keyboard Navigation
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Every interactive element must be keyboard accessible
|
||||||
|
<button onClick={handleClick}>Click me</button> // ✓ Focusable by default
|
||||||
|
<div onClick={handleClick}>Click me</div> // ✗ Not focusable
|
||||||
|
<div role="button" tabIndex={0} onClick={handleClick} // ✓ But prefer <button>
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter') handleClick();
|
||||||
|
if (e.key === ' ') e.preventDefault();
|
||||||
|
}}
|
||||||
|
onKeyUp={e => {
|
||||||
|
if (e.key === ' ') handleClick();
|
||||||
|
}}>
|
||||||
|
Click me
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### ARIA Labels
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Label interactive elements that lack visible text
|
||||||
|
<button aria-label="Close dialog"><XIcon /></button>
|
||||||
|
|
||||||
|
// Label form inputs
|
||||||
|
<label htmlFor="email">Email</label>
|
||||||
|
<input id="email" type="email" />
|
||||||
|
|
||||||
|
// Or use aria-label when no visible label exists
|
||||||
|
<input aria-label="Search tasks" type="search" />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Focus Management
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Move focus when content changes
|
||||||
|
function Dialog({ isOpen, onClose }: DialogProps) {
|
||||||
|
const closeRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) closeRef.current?.focus();
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// Trap focus inside dialog when open
|
||||||
|
return (
|
||||||
|
<dialog open={isOpen}>
|
||||||
|
<button ref={closeRef} onClick={onClose}>Close</button>
|
||||||
|
{/* dialog content */}
|
||||||
|
</dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Meaningful Empty and Error States
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Don't show blank screens
|
||||||
|
function TaskList({ tasks }: { tasks: Task[] }) {
|
||||||
|
if (tasks.length === 0) {
|
||||||
|
return (
|
||||||
|
<div role="status" className="text-center py-12">
|
||||||
|
<TasksEmptyIcon className="mx-auto h-12 w-12 text-muted" />
|
||||||
|
<h3 className="mt-2 text-sm font-medium">No tasks</h3>
|
||||||
|
<p className="mt-1 text-sm text-muted">Get started by creating a new task.</p>
|
||||||
|
<Button className="mt-4" onClick={onCreateTask}>Create Task</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <ul role="list">...</ul>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Responsive Design
|
||||||
|
|
||||||
|
Design for mobile first, then expand:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Tailwind: mobile-first responsive
|
||||||
|
<div className="
|
||||||
|
grid grid-cols-1 /* Mobile: single column */
|
||||||
|
sm:grid-cols-2 /* Small: 2 columns */
|
||||||
|
lg:grid-cols-3 /* Large: 3 columns */
|
||||||
|
gap-4
|
||||||
|
">
|
||||||
|
```
|
||||||
|
|
||||||
|
Test at these breakpoints: 320px, 768px, 1024px, 1440px.
|
||||||
|
|
||||||
|
## Loading and Transitions
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Skeleton loading (not spinners for content)
|
||||||
|
function TaskListSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3" aria-busy="true" aria-label="Loading tasks">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-12 bg-muted animate-pulse rounded" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optimistic updates for perceived speed
|
||||||
|
function useToggleTask() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: toggleTask,
|
||||||
|
onMutate: async (taskId) => {
|
||||||
|
await queryClient.cancelQueries({ queryKey: ['tasks'] });
|
||||||
|
const previous = queryClient.getQueryData(['tasks']);
|
||||||
|
|
||||||
|
queryClient.setQueryData(['tasks'], (old: Task[]) =>
|
||||||
|
old.map(t => t.id === taskId ? { ...t, done: !t.done } : t)
|
||||||
|
);
|
||||||
|
|
||||||
|
return { previous };
|
||||||
|
},
|
||||||
|
onError: (_err, _taskId, context) => {
|
||||||
|
queryClient.setQueryData(['tasks'], context?.previous);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
For detailed accessibility requirements and testing tools, see `references/accessibility-checklist.md`.
|
||||||
|
|
||||||
|
## Common Rationalizations
|
||||||
|
|
||||||
|
| Rationalization | Reality |
|
||||||
|
|---|---|
|
||||||
|
| "Accessibility is a nice-to-have" | It's a legal requirement in many jurisdictions and an engineering quality standard. |
|
||||||
|
| "We'll make it responsive later" | Retrofitting responsive design is 3x harder than building it from the start. |
|
||||||
|
| "The design isn't final, so I'll skip styling" | Use the design system defaults. Unstyled UI creates a broken first impression for reviewers. |
|
||||||
|
| "This is just a prototype" | Prototypes become production code. Build the foundation right. |
|
||||||
|
| "The AI aesthetic is fine for now" | It signals low quality. Use the project's actual design system from the start. |
|
||||||
|
|
||||||
|
## Red Flags
|
||||||
|
|
||||||
|
- Components with more than 200 lines (split them)
|
||||||
|
- Inline styles or arbitrary pixel values
|
||||||
|
- Missing error states, loading states, or empty states
|
||||||
|
- No keyboard navigation testing
|
||||||
|
- Color as the sole indicator of state (red/green without text or icons)
|
||||||
|
- Generic "AI look" (purple gradients, oversized cards, stock layouts)
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
After building UI:
|
||||||
|
|
||||||
|
- [ ] Component renders without console errors
|
||||||
|
- [ ] All interactive elements are keyboard accessible (Tab through the page)
|
||||||
|
- [ ] Screen reader can convey the page's content and structure
|
||||||
|
- [ ] Responsive: works at 320px, 768px, 1024px, 1440px
|
||||||
|
- [ ] Loading, error, and empty states all handled
|
||||||
|
- [ ] Follows the project's design system (spacing, colors, typography)
|
||||||
|
- [ ] No accessibility warnings in dev tools or axe-core
|
||||||
197
.agents/skills/prototype/PICKER.md
Normal file
197
.agents/skills/prototype/PICKER.md
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
# The Picker
|
||||||
|
|
||||||
|
The picker's appearance is **not a design decision** — it is this spec. Copy the markup, CSS, and wiring below verbatim; the only values that change per run are the variant names and count. It stays identical across every project so it always reads as harness chrome, never as part of the design being judged. Do not restyle it with the project's tokens, fonts, or colors.
|
||||||
|
|
||||||
|
It is a floating dark pill, bottom-center. Dark glass works on top of any page — light or dark — which is why it is not theme-aware.
|
||||||
|
|
||||||
|
## Markup
|
||||||
|
|
||||||
|
The sliding highlight span first, one button per variant, a hairline divider, then the replay button (only when at least one variant has motion to re-trigger):
|
||||||
|
|
||||||
|
```html
|
||||||
|
<nav class="proto-picker" aria-label="Prototype variants">
|
||||||
|
<span class="proto-picker-highlight" aria-hidden="true"></span>
|
||||||
|
<button class="proto-picker-item" data-active aria-current="true">Quiet</button>
|
||||||
|
<button class="proto-picker-item">Editorial</button>
|
||||||
|
<button class="proto-picker-item">Playful</button>
|
||||||
|
<span class="proto-picker-divider" aria-hidden="true"></span>
|
||||||
|
<button class="proto-picker-item proto-picker-replay" aria-label="Replay animation (R)">↻</button>
|
||||||
|
</nav>
|
||||||
|
```
|
||||||
|
|
||||||
|
In a framework, keep the class names and structure; only the rendering syntax changes.
|
||||||
|
|
||||||
|
## Styles
|
||||||
|
|
||||||
|
```css
|
||||||
|
.proto-picker {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 24px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 2147483647;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(10, 10, 10, 0.82);
|
||||||
|
-webkit-backdrop-filter: blur(12px) saturate(1.4);
|
||||||
|
backdrop-filter: blur(12px) saturate(1.4);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px rgba(255, 255, 255, 0.08) inset,
|
||||||
|
0 8px 24px rgba(0, 0, 0, 0.24),
|
||||||
|
0 2px 6px rgba(0, 0, 0, 0.12);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proto-picker-highlight {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
left: 0;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The slide is enabled only after first paint (data-ready), so load doesn't animate. */
|
||||||
|
.proto-picker[data-ready] .proto-picker-highlight {
|
||||||
|
transition:
|
||||||
|
transform 250ms cubic-bezier(0.23, 1, 0.32, 1),
|
||||||
|
width 250ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.proto-picker[data-ready] .proto-picker-highlight { transition: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.proto-picker-item {
|
||||||
|
position: relative; /* sits above the highlight */
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: transparent;
|
||||||
|
color: rgba(255, 255, 255, 0.55);
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 150ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proto-picker-item:hover {
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.proto-picker-item:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
.proto-picker-item:focus-visible {
|
||||||
|
outline: 2px solid rgba(255, 255, 255, 0.4);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proto-picker-item[data-active] {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proto-picker-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 16px;
|
||||||
|
margin: 0 4px;
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.proto-picker-replay {
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proto-picker[data-position="top"] {
|
||||||
|
bottom: auto;
|
||||||
|
top: 24px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- **Verbatim.** These values are the spec. No project fonts, no brand colors, no theme switching, no extra shadows or borders.
|
||||||
|
- **The highlight slides; the variant swap stays instant.** The active pill animates between buttons (250ms, strong ease-out) as spatial feedback on the picker itself — but the variant being previewed still switches with no transition. The `width` transition is a deliberate exception to the transform/opacity rule: the element is 28px tall, absolutely positioned, and has no layout dependents, so the paint cost is negligible.
|
||||||
|
- **One allowed modification:** if a variant occupies the bottom-center of the screen (a toast stack, a bottom sheet, a dock), set `data-position="top"` so the picker never covers the work. Nothing else about it may move or change.
|
||||||
|
- **Replay is conditional.** Render the replay button and its divider only when at least one variant has an entrance or state animation worth re-triggering; a static comparison gets a shorter pill.
|
||||||
|
|
||||||
|
## Behavior contract
|
||||||
|
|
||||||
|
The contract is fixed regardless of how the harness renders:
|
||||||
|
|
||||||
|
- Number keys `1–N` and `←`/`→` switch variants; `R` replays. Ignore key events when focus is in an input, textarea, select, or contenteditable, or when a modifier is held.
|
||||||
|
- Clicking an item switches to it; exactly one item carries `data-active` and `aria-current="true"` at all times, and the highlight slides to it.
|
||||||
|
- Selection persists across reload via a URL param (`?v=2`), falling back to variant 1. The highlight takes its initial position without animating (`data-ready` is added after first paint).
|
||||||
|
- Switching re-mounts the variant (so entrance animations re-run); the replay key re-mounts without switching.
|
||||||
|
|
||||||
|
## Reference wiring
|
||||||
|
|
||||||
|
Verbatim for the standalone-HTML branch; in a framework, keep the same behavior but express it idiomatically (state instead of `innerHTML`, a keyed re-mount instead of `requestAnimationFrame`, refs + a layout effect for the highlight measurement).
|
||||||
|
|
||||||
|
```js
|
||||||
|
// `variants` is an array of render functions, one per variant, in picker order.
|
||||||
|
const stage = document.getElementById('stage');
|
||||||
|
const picker = document.querySelector('.proto-picker');
|
||||||
|
const highlight = picker.querySelector('.proto-picker-highlight');
|
||||||
|
const items = [...picker.querySelectorAll('.proto-picker-item:not(.proto-picker-replay)')];
|
||||||
|
const replay = picker.querySelector('.proto-picker-replay');
|
||||||
|
let current = 0;
|
||||||
|
|
||||||
|
function moveHighlight() {
|
||||||
|
const el = items[current];
|
||||||
|
highlight.style.width = el.offsetWidth + 'px';
|
||||||
|
highlight.style.transform = `translateX(${el.offsetLeft}px)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mount(i) {
|
||||||
|
stage.innerHTML = '';
|
||||||
|
// Clear first, render next frame, so entrance animations re-run.
|
||||||
|
requestAnimationFrame(() => { stage.innerHTML = variants[i](); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActive(i) {
|
||||||
|
if (i < 0 || i >= variants.length) return;
|
||||||
|
current = i;
|
||||||
|
items.forEach((el, j) => {
|
||||||
|
el.toggleAttribute('data-active', j === i);
|
||||||
|
if (j === i) el.setAttribute('aria-current', 'true');
|
||||||
|
else el.removeAttribute('aria-current');
|
||||||
|
});
|
||||||
|
moveHighlight();
|
||||||
|
const url = new URL(location);
|
||||||
|
url.searchParams.set('v', i + 1);
|
||||||
|
history.replaceState(null, '', url);
|
||||||
|
mount(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
items.forEach((el, i) => el.addEventListener('click', () => setActive(i)));
|
||||||
|
replay?.addEventListener('click', () => mount(current));
|
||||||
|
window.addEventListener('resize', moveHighlight);
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (/^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName) || e.target.isContentEditable) return;
|
||||||
|
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||||
|
const num = parseInt(e.key, 10);
|
||||||
|
if (num >= 1 && num <= variants.length) setActive(num - 1);
|
||||||
|
else if (e.key === 'ArrowRight') setActive((current + 1) % variants.length);
|
||||||
|
else if (e.key === 'ArrowLeft') setActive((current - 1 + variants.length) % variants.length);
|
||||||
|
else if (e.key === 'r' || e.key === 'R') mount(current);
|
||||||
|
});
|
||||||
|
|
||||||
|
setActive((parseInt(new URLSearchParams(location.search).get('v'), 10) || 1) - 1);
|
||||||
|
// Enable the slide only after first paint, so load doesn't animate.
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => picker.setAttribute('data-ready', '')));
|
||||||
|
```
|
||||||
90
.agents/skills/prototype/SKILL.md
Normal file
90
.agents/skills/prototype/SKILL.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
---
|
||||||
|
name: prototype
|
||||||
|
description: Build multiple genuinely different versions of a UI piece you describe, rendered behind a visual picker so you can flip through them live and promote the one that feels right. Only runs when explicitly invoked; it does not trigger on its own.
|
||||||
|
disable-model-invocation: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Prototyping Variants
|
||||||
|
|
||||||
|
A divergence skill. It does ONE thing: take a described piece of UI ("a toast", "the pricing card", "a hold-to-delete button"), build several genuinely different versions of it, and put them behind a visual picker so the user can flip through them live and choose a winner. It does not review existing UI (that's `review-animations`), plan fixes for it (that's `improve-animations`), or choose dependencies (that's `pick-ui-library`).
|
||||||
|
|
||||||
|
## Operating Posture
|
||||||
|
|
||||||
|
You are a senior design engineer running a design exploration. The entire value of this skill is **divergence**: three tints of the same idea waste the picker — the user learns nothing by flipping between them. Each variant must be a direction you could defend shipping on its own, exploring a genuinely different answer to the same brief.
|
||||||
|
|
||||||
|
Divergence is not an excuse to drop the craft bar. Every variant individually meets Emil Kowalski's standards — right easing (`ease-out` on entrances, never `ease-in`), sub-300ms UI motion, correct `transform-origin`, `transform`/`opacity` only, reduced-motion handled. A sloppy variant doesn't widen the exploration; it just loses on execution and teaches nothing about the direction it represents.
|
||||||
|
|
||||||
|
## Hard Rules
|
||||||
|
|
||||||
|
1. **Never touch production code during exploration.** Everything lives in an isolated prototype surface (see Phase 4). Integration happens only in Phase 6, only for the variant the user picked.
|
||||||
|
2. **Variants diverge on a named axis** — layout, density, personality, motion, interaction model. Before building, you must be able to state each variant's axis in a phrase. Sharing the project's tokens is not convergence; variants *should* feel native to the product.
|
||||||
|
3. **Every variant fully works.** Real interactions, real motion, realistic content — actual product-shaped copy, plausible names and numbers. No lorem ipsum, no dead buttons, no "imagine this part".
|
||||||
|
4. **The picker is chrome, not a contestant.** Its exact markup, styles, and behavior are specified in [PICKER.md](PICKER.md) — copy them verbatim. Its look is not a design decision and never adapts to the project.
|
||||||
|
5. **Clean up after the choice.** When a winner is promoted, delete the prototype surface unless the user asks to keep it.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
### Phase 1 — Scope
|
||||||
|
|
||||||
|
One thing per run. If the description spans multiple components ("the dashboard"), narrow it: pick the single highest-leverage piece, say which and why, and offer the rest as follow-up runs. Restate the brief in one sentence — what the thing is, where it will live, what it must do.
|
||||||
|
|
||||||
|
### Phase 2 — Recon
|
||||||
|
|
||||||
|
Before designing anything, map the ground the variants must stand on:
|
||||||
|
|
||||||
|
- **Stack**: framework, styling system (Tailwind, CSS modules, vanilla), motion library if any.
|
||||||
|
- **Tokens**: colors, radii, spacing, fonts, easing/duration variables. Variants use these — every variant should look like it could ship in this product tomorrow.
|
||||||
|
- **Personality**: playful consumer app or crisp dashboard? This bounds how far the boldest variant may go.
|
||||||
|
- **Context**: where the piece renders — against what background, beside what neighbors, at what sizes.
|
||||||
|
|
||||||
|
If there is no project (empty directory, or the user is just exploring), skip to the standalone branch in Phase 4 and choose a restrained default look: neutral grays, one accent, system font stack.
|
||||||
|
|
||||||
|
### Phase 3 — Choose directions
|
||||||
|
|
||||||
|
Default **3 variants**; up to 5 when the user asks or the design space is genuinely wide. More than 5 dilutes the comparison.
|
||||||
|
|
||||||
|
Before writing any code, list the set: a name and an axis for each. Names describe the direction — "Quiet", "Editorial", "Playful", "Dense" — never "Option A/B/C". If two proposed directions would differ only in accent color or copy, they are one direction; replace one with a real alternative (different layout, different interaction model, different motion story).
|
||||||
|
|
||||||
|
**Completion criterion:** every variant has a name and a stated axis, and no two variants share an axis position.
|
||||||
|
|
||||||
|
### Phase 4 — Build the picker harness
|
||||||
|
|
||||||
|
Two branches, by what exists:
|
||||||
|
|
||||||
|
- **In a project with a dev server** — an isolated route or page (`/prototypes/<slug>`, or the framework's equivalent), one file per variant plus a small harness file. Nothing imports from the prototype surface into production code.
|
||||||
|
- **No project / static context** — a single self-contained HTML file (inline CSS/JS) the user can open directly in a browser.
|
||||||
|
|
||||||
|
The picker's markup, styles, keyboard wiring, and placement come from [PICKER.md](PICKER.md), verbatim — load it now and build exactly that. Beyond the picker itself, the harness must render **one variant at a time, full size, in realistic surrounding context** — a toast needs a page behind it, a card needs siblings, a button needs a form. Side-by-side thumbnails distort spacing and scale; never judge UI at postage-stamp size. Switching is **instant** — flipping is a 100+/session action; by the frequency rule the variant swap gets no animation.
|
||||||
|
|
||||||
|
### Phase 5 — Verify and hand off
|
||||||
|
|
||||||
|
Run the harness. Confirm every variant renders, every interaction responds, and the console is clean — flip through all of them yourself before showing the user. If browser tooling is available, screenshot each variant.
|
||||||
|
|
||||||
|
Then present the set and **stop — the choice belongs to the user**:
|
||||||
|
|
||||||
|
| # | Variant | Axis | When it's the right choice | Its cost |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 1 | Quiet | Minimal motion, borders over shadows | The product is a daily-use tool | Least memorable |
|
||||||
|
| 2 | Editorial | Large type, generous whitespace | The moment deserves weight | Eats vertical space |
|
||||||
|
|
||||||
|
Close with where the picker is running (URL or file path) and the keys to flip.
|
||||||
|
|
||||||
|
**Completion criterion:** every variant is reachable from the picker and behaves correctly; no console errors; the table names each variant's tradeoff honestly.
|
||||||
|
|
||||||
|
### Phase 6 — Promote on selection
|
||||||
|
|
||||||
|
When the user picks: integrate that variant where it belongs, following the project's existing conventions (file layout, naming, token usage), then delete the prototype surface per Hard Rule 5. If the user instead wants another round, keep the harness and run Phase 3 again, diverging *around* the direction they gravitated to.
|
||||||
|
|
||||||
|
## Invocation Variants
|
||||||
|
|
||||||
|
| Invocation | Behavior |
|
||||||
|
| --- | --- |
|
||||||
|
| `<description>` | Full workflow: scope → recon → 3 variants → picker → wait for choice |
|
||||||
|
| `<description> x5` | Same, with that many variants (capped at 5) |
|
||||||
|
| `riff <variant>` | New round: keep the harness, generate a fresh set diverging around the named variant's direction |
|
||||||
|
| `keep <variant>` | Promote that variant into the codebase and delete the prototype surface |
|
||||||
|
| `keep <variant>, leave the picker` | Promote, but keep the prototype surface around |
|
||||||
|
|
||||||
|
## Tone
|
||||||
|
|
||||||
|
Sell each variant honestly — one line on when it wins, one on what it costs. Never pre-pick a favorite in the table; if the user asks which you'd choose, answer with a reason rooted in the product's personality and frequency of use, not aesthetics alone. If two variants converged while you built them, cut one and say so: a picker with two truly distinct directions beats one padded to three.
|
||||||
3810
.agents/skills/vercel-react-best-practices/AGENTS.md
Normal file
3810
.agents/skills/vercel-react-best-practices/AGENTS.md
Normal file
File diff suppressed because it is too large
Load Diff
123
.agents/skills/vercel-react-best-practices/README.md
Normal file
123
.agents/skills/vercel-react-best-practices/README.md
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
# React Best Practices
|
||||||
|
|
||||||
|
A structured repository for creating and maintaining React Best Practices optimized for agents and LLMs.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
- `rules/` - Individual rule files (one per rule)
|
||||||
|
- `_sections.md` - Section metadata (titles, impacts, descriptions)
|
||||||
|
- `_template.md` - Template for creating new rules
|
||||||
|
- `area-description.md` - Individual rule files
|
||||||
|
- `src/` - Build scripts and utilities
|
||||||
|
- `metadata.json` - Document metadata (version, organization, abstract)
|
||||||
|
- __`AGENTS.md`__ - Compiled output (generated)
|
||||||
|
- __`test-cases.json`__ - Test cases for LLM evaluation (generated)
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
1. Install dependencies:
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Build AGENTS.md from rules:
|
||||||
|
```bash
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Validate rule files:
|
||||||
|
```bash
|
||||||
|
pnpm validate
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Extract test cases:
|
||||||
|
```bash
|
||||||
|
pnpm extract-tests
|
||||||
|
```
|
||||||
|
|
||||||
|
## Creating a New Rule
|
||||||
|
|
||||||
|
1. Copy `rules/_template.md` to `rules/area-description.md`
|
||||||
|
2. Choose the appropriate area prefix:
|
||||||
|
- `async-` for Eliminating Waterfalls (Section 1)
|
||||||
|
- `bundle-` for Bundle Size Optimization (Section 2)
|
||||||
|
- `server-` for Server-Side Performance (Section 3)
|
||||||
|
- `client-` for Client-Side Data Fetching (Section 4)
|
||||||
|
- `rerender-` for Re-render Optimization (Section 5)
|
||||||
|
- `rendering-` for Rendering Performance (Section 6)
|
||||||
|
- `js-` for JavaScript Performance (Section 7)
|
||||||
|
- `advanced-` for Advanced Patterns (Section 8)
|
||||||
|
3. Fill in the frontmatter and content
|
||||||
|
4. Ensure you have clear examples with explanations
|
||||||
|
5. Run `pnpm build` to regenerate AGENTS.md and test-cases.json
|
||||||
|
|
||||||
|
## Rule File Structure
|
||||||
|
|
||||||
|
Each rule file should follow this structure:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
title: Rule Title Here
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: Optional description
|
||||||
|
tags: tag1, tag2, tag3
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rule Title Here
|
||||||
|
|
||||||
|
Brief explanation of the rule and why it matters.
|
||||||
|
|
||||||
|
**Incorrect (description of what's wrong):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Bad code example
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (description of what's right):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Good code example
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional explanatory text after examples.
|
||||||
|
|
||||||
|
Reference: [Link](https://example.com)
|
||||||
|
|
||||||
|
## File Naming Convention
|
||||||
|
|
||||||
|
- Files starting with `_` are special (excluded from build)
|
||||||
|
- Rule files: `area-description.md` (e.g., `async-parallel.md`)
|
||||||
|
- Section is automatically inferred from filename prefix
|
||||||
|
- Rules are sorted alphabetically by title within each section
|
||||||
|
- IDs (e.g., 1.1, 1.2) are auto-generated during build
|
||||||
|
|
||||||
|
## Impact Levels
|
||||||
|
|
||||||
|
- `CRITICAL` - Highest priority, major performance gains
|
||||||
|
- `HIGH` - Significant performance improvements
|
||||||
|
- `MEDIUM-HIGH` - Moderate-high gains
|
||||||
|
- `MEDIUM` - Moderate performance improvements
|
||||||
|
- `LOW-MEDIUM` - Low-medium gains
|
||||||
|
- `LOW` - Incremental improvements
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
- `pnpm build` - Compile rules into AGENTS.md
|
||||||
|
- `pnpm validate` - Validate all rule files
|
||||||
|
- `pnpm extract-tests` - Extract test cases for LLM evaluation
|
||||||
|
- `pnpm dev` - Build and validate
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
When adding or modifying rules:
|
||||||
|
|
||||||
|
1. Use the correct filename prefix for your section
|
||||||
|
2. Follow the `_template.md` structure
|
||||||
|
3. Include clear bad/good examples with explanations
|
||||||
|
4. Add appropriate tags
|
||||||
|
5. Run `pnpm build` to regenerate AGENTS.md and test-cases.json
|
||||||
|
6. Rules are automatically sorted by title - no need to manage numbers!
|
||||||
|
|
||||||
|
## Acknowledgments
|
||||||
|
|
||||||
|
Originally created by [@shuding](https://x.com/shuding) at [Vercel](https://vercel.com).
|
||||||
149
.agents/skills/vercel-react-best-practices/SKILL.md
Normal file
149
.agents/skills/vercel-react-best-practices/SKILL.md
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
---
|
||||||
|
name: vercel-react-best-practices
|
||||||
|
description: React and Next.js performance optimization guidelines from Vercel Engineering. This skill should be used when writing, reviewing, or refactoring React/Next.js code to ensure optimal performance patterns. Triggers on tasks involving React components, Next.js pages, data fetching, bundle optimization, or performance improvements.
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
author: vercel
|
||||||
|
version: "1.0.0"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Vercel React Best Practices
|
||||||
|
|
||||||
|
Comprehensive performance optimization guide for React and Next.js applications, maintained by Vercel. Contains 70 rules across 8 categories, prioritized by impact to guide automated refactoring and code generation.
|
||||||
|
|
||||||
|
## When to Apply
|
||||||
|
|
||||||
|
Reference these guidelines when:
|
||||||
|
- Writing new React components or Next.js pages
|
||||||
|
- Implementing data fetching (client or server-side)
|
||||||
|
- Reviewing code for performance issues
|
||||||
|
- Refactoring existing React/Next.js code
|
||||||
|
- Optimizing bundle size or load times
|
||||||
|
|
||||||
|
## Rule Categories by Priority
|
||||||
|
|
||||||
|
| Priority | Category | Impact | Prefix |
|
||||||
|
|----------|----------|--------|--------|
|
||||||
|
| 1 | Eliminating Waterfalls | CRITICAL | `async-` |
|
||||||
|
| 2 | Bundle Size Optimization | CRITICAL | `bundle-` |
|
||||||
|
| 3 | Server-Side Performance | HIGH | `server-` |
|
||||||
|
| 4 | Client-Side Data Fetching | MEDIUM-HIGH | `client-` |
|
||||||
|
| 5 | Re-render Optimization | MEDIUM | `rerender-` |
|
||||||
|
| 6 | Rendering Performance | MEDIUM | `rendering-` |
|
||||||
|
| 7 | JavaScript Performance | LOW-MEDIUM | `js-` |
|
||||||
|
| 8 | Advanced Patterns | LOW | `advanced-` |
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
### 1. Eliminating Waterfalls (CRITICAL)
|
||||||
|
|
||||||
|
- `async-cheap-condition-before-await` - Check cheap sync conditions before awaiting flags or remote values
|
||||||
|
- `async-defer-await` - Move await into branches where actually used
|
||||||
|
- `async-parallel` - Use Promise.all() for independent operations
|
||||||
|
- `async-dependencies` - Use better-all for partial dependencies
|
||||||
|
- `async-api-routes` - Start promises early, await late in API routes
|
||||||
|
- `async-suspense-boundaries` - Use Suspense to stream content
|
||||||
|
|
||||||
|
### 2. Bundle Size Optimization (CRITICAL)
|
||||||
|
|
||||||
|
- `bundle-barrel-imports` - Import directly, avoid barrel files
|
||||||
|
- `bundle-analyzable-paths` - Prefer statically analyzable import and file-system paths to avoid broad bundles and traces
|
||||||
|
- `bundle-dynamic-imports` - Use next/dynamic for heavy components
|
||||||
|
- `bundle-defer-third-party` - Load analytics/logging after hydration
|
||||||
|
- `bundle-conditional` - Load modules only when feature is activated
|
||||||
|
- `bundle-preload` - Preload on hover/focus for perceived speed
|
||||||
|
|
||||||
|
### 3. Server-Side Performance (HIGH)
|
||||||
|
|
||||||
|
- `server-auth-actions` - Authenticate server actions like API routes
|
||||||
|
- `server-cache-react` - Use React.cache() for per-request deduplication
|
||||||
|
- `server-cache-lru` - Use LRU cache for cross-request caching
|
||||||
|
- `server-dedup-props` - Avoid duplicate serialization in RSC props
|
||||||
|
- `server-hoist-static-io` - Hoist static I/O (fonts, logos) to module level
|
||||||
|
- `server-no-shared-module-state` - Avoid module-level mutable request state in RSC/SSR
|
||||||
|
- `server-serialization` - Minimize data passed to client components
|
||||||
|
- `server-parallel-fetching` - Restructure components to parallelize fetches
|
||||||
|
- `server-parallel-nested-fetching` - Chain nested fetches per item in Promise.all
|
||||||
|
- `server-after-nonblocking` - Use after() for non-blocking operations
|
||||||
|
|
||||||
|
### 4. Client-Side Data Fetching (MEDIUM-HIGH)
|
||||||
|
|
||||||
|
- `client-swr-dedup` - Use SWR for automatic request deduplication
|
||||||
|
- `client-event-listeners` - Deduplicate global event listeners
|
||||||
|
- `client-passive-event-listeners` - Use passive listeners for scroll
|
||||||
|
- `client-localstorage-schema` - Version and minimize localStorage data
|
||||||
|
|
||||||
|
### 5. Re-render Optimization (MEDIUM)
|
||||||
|
|
||||||
|
- `rerender-defer-reads` - Don't subscribe to state only used in callbacks
|
||||||
|
- `rerender-memo` - Extract expensive work into memoized components
|
||||||
|
- `rerender-memo-with-default-value` - Hoist default non-primitive props
|
||||||
|
- `rerender-dependencies` - Use primitive dependencies in effects
|
||||||
|
- `rerender-derived-state` - Subscribe to derived booleans, not raw values
|
||||||
|
- `rerender-derived-state-no-effect` - Derive state during render, not effects
|
||||||
|
- `rerender-functional-setstate` - Use functional setState for stable callbacks
|
||||||
|
- `rerender-lazy-state-init` - Pass function to useState for expensive values
|
||||||
|
- `rerender-simple-expression-in-memo` - Avoid memo for simple primitives
|
||||||
|
- `rerender-split-combined-hooks` - Split hooks with independent dependencies
|
||||||
|
- `rerender-move-effect-to-event` - Put interaction logic in event handlers
|
||||||
|
- `rerender-transitions` - Use startTransition for non-urgent updates
|
||||||
|
- `rerender-use-deferred-value` - Defer expensive renders to keep input responsive
|
||||||
|
- `rerender-use-ref-transient-values` - Use refs for transient frequent values
|
||||||
|
- `rerender-no-inline-components` - Don't define components inside components
|
||||||
|
|
||||||
|
### 6. Rendering Performance (MEDIUM)
|
||||||
|
|
||||||
|
- `rendering-animate-svg-wrapper` - Animate div wrapper, not SVG element
|
||||||
|
- `rendering-content-visibility` - Use content-visibility for long lists
|
||||||
|
- `rendering-hoist-jsx` - Extract static JSX outside components
|
||||||
|
- `rendering-svg-precision` - Reduce SVG coordinate precision
|
||||||
|
- `rendering-hydration-no-flicker` - Use inline script for client-only data
|
||||||
|
- `rendering-hydration-suppress-warning` - Suppress expected mismatches
|
||||||
|
- `rendering-activity` - Use Activity component for show/hide
|
||||||
|
- `rendering-conditional-render` - Use ternary, not && for conditionals
|
||||||
|
- `rendering-usetransition-loading` - Prefer useTransition for loading state
|
||||||
|
- `rendering-resource-hints` - Use React DOM resource hints for preloading
|
||||||
|
- `rendering-script-defer-async` - Use defer or async on script tags
|
||||||
|
|
||||||
|
### 7. JavaScript Performance (LOW-MEDIUM)
|
||||||
|
|
||||||
|
- `js-batch-dom-css` - Group CSS changes via classes or cssText
|
||||||
|
- `js-index-maps` - Build Map for repeated lookups
|
||||||
|
- `js-cache-property-access` - Cache object properties in loops
|
||||||
|
- `js-cache-function-results` - Cache function results in module-level Map
|
||||||
|
- `js-cache-storage` - Cache localStorage/sessionStorage reads
|
||||||
|
- `js-combine-iterations` - Combine multiple filter/map into one loop
|
||||||
|
- `js-length-check-first` - Check array length before expensive comparison
|
||||||
|
- `js-early-exit` - Return early from functions
|
||||||
|
- `js-hoist-regexp` - Hoist RegExp creation outside loops
|
||||||
|
- `js-min-max-loop` - Use loop for min/max instead of sort
|
||||||
|
- `js-set-map-lookups` - Use Set/Map for O(1) lookups
|
||||||
|
- `js-tosorted-immutable` - Use toSorted() for immutability
|
||||||
|
- `js-flatmap-filter` - Use flatMap to map and filter in one pass
|
||||||
|
- `js-request-idle-callback` - Defer non-critical work to browser idle time
|
||||||
|
|
||||||
|
### 8. Advanced Patterns (LOW)
|
||||||
|
|
||||||
|
- `advanced-effect-event-deps` - Don't put `useEffectEvent` results in effect deps
|
||||||
|
- `advanced-event-handler-refs` - Store event handlers in refs
|
||||||
|
- `advanced-init-once` - Initialize app once per app load
|
||||||
|
- `advanced-use-latest` - useLatest for stable callback refs
|
||||||
|
|
||||||
|
## How to Use
|
||||||
|
|
||||||
|
Read individual rule files for detailed explanations and code examples:
|
||||||
|
|
||||||
|
```
|
||||||
|
rules/async-parallel.md
|
||||||
|
rules/bundle-barrel-imports.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Each rule file contains:
|
||||||
|
- Brief explanation of why it matters
|
||||||
|
- Incorrect code example with explanation
|
||||||
|
- Correct code example with explanation
|
||||||
|
- Additional context and references
|
||||||
|
|
||||||
|
## Full Compiled Document
|
||||||
|
|
||||||
|
For the complete guide with all rules expanded: `AGENTS.md`
|
||||||
15
.agents/skills/vercel-react-best-practices/metadata.json
Normal file
15
.agents/skills/vercel-react-best-practices/metadata.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"version": "1.0.0",
|
||||||
|
"organization": "Vercel Engineering",
|
||||||
|
"date": "January 2026",
|
||||||
|
"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.",
|
||||||
|
"references": [
|
||||||
|
"https://react.dev",
|
||||||
|
"https://nextjs.org",
|
||||||
|
"https://swr.vercel.app",
|
||||||
|
"https://github.com/shuding/better-all",
|
||||||
|
"https://github.com/isaacs/node-lru-cache",
|
||||||
|
"https://vercel.com/blog/how-we-optimized-package-imports-in-next-js",
|
||||||
|
"https://vercel.com/blog/how-we-made-the-vercel-dashboard-twice-as-fast"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Sections
|
||||||
|
|
||||||
|
This file defines all sections, their ordering, impact levels, and descriptions.
|
||||||
|
The section ID (in parentheses) is the filename prefix used to group rules.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Eliminating Waterfalls (async)
|
||||||
|
|
||||||
|
**Impact:** CRITICAL
|
||||||
|
**Description:** Waterfalls are the #1 performance killer. Each sequential await adds full network latency. Eliminating them yields the largest gains.
|
||||||
|
|
||||||
|
## 2. Bundle Size Optimization (bundle)
|
||||||
|
|
||||||
|
**Impact:** CRITICAL
|
||||||
|
**Description:** Reducing initial bundle size improves Time to Interactive and Largest Contentful Paint.
|
||||||
|
|
||||||
|
## 3. Server-Side Performance (server)
|
||||||
|
|
||||||
|
**Impact:** HIGH
|
||||||
|
**Description:** Optimizing server-side rendering and data fetching eliminates server-side waterfalls and reduces response times.
|
||||||
|
|
||||||
|
## 4. Client-Side Data Fetching (client)
|
||||||
|
|
||||||
|
**Impact:** MEDIUM-HIGH
|
||||||
|
**Description:** Automatic deduplication and efficient data fetching patterns reduce redundant network requests.
|
||||||
|
|
||||||
|
## 5. Re-render Optimization (rerender)
|
||||||
|
|
||||||
|
**Impact:** MEDIUM
|
||||||
|
**Description:** Reducing unnecessary re-renders minimizes wasted computation and improves UI responsiveness.
|
||||||
|
|
||||||
|
## 6. Rendering Performance (rendering)
|
||||||
|
|
||||||
|
**Impact:** MEDIUM
|
||||||
|
**Description:** Optimizing the rendering process reduces the work the browser needs to do.
|
||||||
|
|
||||||
|
## 7. JavaScript Performance (js)
|
||||||
|
|
||||||
|
**Impact:** LOW-MEDIUM
|
||||||
|
**Description:** Micro-optimizations for hot paths can add up to meaningful improvements.
|
||||||
|
|
||||||
|
## 8. Advanced Patterns (advanced)
|
||||||
|
|
||||||
|
**Impact:** LOW
|
||||||
|
**Description:** Advanced patterns for specific cases that require careful implementation.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
title: Rule Title Here
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: Optional description of impact (e.g., "20-50% improvement")
|
||||||
|
tags: tag1, tag2
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rule Title Here
|
||||||
|
|
||||||
|
**Impact: MEDIUM (optional impact description)**
|
||||||
|
|
||||||
|
Brief explanation of the rule and why it matters. This should be clear and concise, explaining the performance implications.
|
||||||
|
|
||||||
|
**Incorrect (description of what's wrong):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Bad code example here
|
||||||
|
const bad = example()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (description of what's right):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Good code example here
|
||||||
|
const good = example()
|
||||||
|
```
|
||||||
|
|
||||||
|
Reference: [Link to documentation or resource](https://example.com)
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
---
|
||||||
|
title: Do Not Put Effect Events in Dependency Arrays
|
||||||
|
impact: LOW
|
||||||
|
impactDescription: avoids unnecessary effect re-runs and lint errors
|
||||||
|
tags: advanced, hooks, useEffectEvent, dependencies, effects
|
||||||
|
---
|
||||||
|
|
||||||
|
## Do Not Put Effect Events in Dependency Arrays
|
||||||
|
|
||||||
|
Effect Event functions do not have a stable identity. Their identity intentionally changes on every render. Do not include the function returned by `useEffectEvent` in a `useEffect` dependency array. Keep the actual reactive values as dependencies and call the Effect Event from inside the effect body or subscriptions created by that effect.
|
||||||
|
|
||||||
|
**Incorrect (Effect Event added as a dependency):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useEffect, useEffectEvent } from 'react'
|
||||||
|
|
||||||
|
function ChatRoom({ roomId, onConnected }: {
|
||||||
|
roomId: string
|
||||||
|
onConnected: () => void
|
||||||
|
}) {
|
||||||
|
const handleConnected = useEffectEvent(onConnected)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const connection = createConnection(roomId)
|
||||||
|
connection.on('connected', handleConnected)
|
||||||
|
connection.connect()
|
||||||
|
|
||||||
|
return () => connection.disconnect()
|
||||||
|
}, [roomId, handleConnected])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Including the Effect Event in dependencies makes the effect re-run every render and triggers the React Hooks lint rule.
|
||||||
|
|
||||||
|
**Correct (depend on reactive values, not the Effect Event):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useEffect, useEffectEvent } from 'react'
|
||||||
|
|
||||||
|
function ChatRoom({ roomId, onConnected }: {
|
||||||
|
roomId: string
|
||||||
|
onConnected: () => void
|
||||||
|
}) {
|
||||||
|
const handleConnected = useEffectEvent(onConnected)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const connection = createConnection(roomId)
|
||||||
|
connection.on('connected', handleConnected)
|
||||||
|
connection.connect()
|
||||||
|
|
||||||
|
return () => connection.disconnect()
|
||||||
|
}, [roomId])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Reference: [React useEffectEvent: Effect Event in deps](https://react.dev/reference/react/useEffectEvent#effect-event-in-deps)
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
---
|
||||||
|
title: Store Event Handlers in Refs
|
||||||
|
impact: LOW
|
||||||
|
impactDescription: stable subscriptions
|
||||||
|
tags: advanced, hooks, refs, event-handlers, optimization
|
||||||
|
---
|
||||||
|
|
||||||
|
## Store Event Handlers in Refs
|
||||||
|
|
||||||
|
Store callbacks in refs when used in effects that shouldn't re-subscribe on callback changes.
|
||||||
|
|
||||||
|
**Incorrect (re-subscribes on every render):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function useWindowEvent(event: string, handler: (e) => void) {
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener(event, handler)
|
||||||
|
return () => window.removeEventListener(event, handler)
|
||||||
|
}, [event, handler])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (stable subscription):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function useWindowEvent(event: string, handler: (e) => void) {
|
||||||
|
const handlerRef = useRef(handler)
|
||||||
|
useEffect(() => {
|
||||||
|
handlerRef.current = handler
|
||||||
|
}, [handler])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const listener = (e) => handlerRef.current(e)
|
||||||
|
window.addEventListener(event, listener)
|
||||||
|
return () => window.removeEventListener(event, listener)
|
||||||
|
}, [event])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Alternative: use `useEffectEvent` if you're on latest React:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useEffectEvent } from 'react'
|
||||||
|
|
||||||
|
function useWindowEvent(event: string, handler: (e) => void) {
|
||||||
|
const onEvent = useEffectEvent(handler)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener(event, onEvent)
|
||||||
|
return () => window.removeEventListener(event, onEvent)
|
||||||
|
}, [event])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`useEffectEvent` provides a cleaner API for the same pattern: it creates a stable function reference that always calls the latest version of the handler.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
title: Initialize App Once, Not Per Mount
|
||||||
|
impact: LOW-MEDIUM
|
||||||
|
impactDescription: avoids duplicate init in development
|
||||||
|
tags: initialization, useEffect, app-startup, side-effects
|
||||||
|
---
|
||||||
|
|
||||||
|
## Initialize App Once, Not Per Mount
|
||||||
|
|
||||||
|
Do not put app-wide initialization that must run once per app load inside `useEffect([])` of a component. Components can remount and effects will re-run. Use a module-level guard or top-level init in the entry module instead.
|
||||||
|
|
||||||
|
**Incorrect (runs twice in dev, re-runs on remount):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function Comp() {
|
||||||
|
useEffect(() => {
|
||||||
|
loadFromStorage()
|
||||||
|
checkAuthToken()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (once per app load):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
let didInit = false
|
||||||
|
|
||||||
|
function Comp() {
|
||||||
|
useEffect(() => {
|
||||||
|
if (didInit) return
|
||||||
|
didInit = true
|
||||||
|
loadFromStorage()
|
||||||
|
checkAuthToken()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Reference: [Initializing the application](https://react.dev/learn/you-might-not-need-an-effect#initializing-the-application)
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
---
|
||||||
|
title: useEffectEvent for Stable Callback Refs
|
||||||
|
impact: LOW
|
||||||
|
impactDescription: prevents effect re-runs
|
||||||
|
tags: advanced, hooks, useEffectEvent, refs, optimization
|
||||||
|
---
|
||||||
|
|
||||||
|
## useEffectEvent for Stable Callback Refs
|
||||||
|
|
||||||
|
Access latest values in callbacks without adding them to dependency arrays. Prevents effect re-runs while avoiding stale closures.
|
||||||
|
|
||||||
|
**Incorrect (effect re-runs on every callback change):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeout = setTimeout(() => onSearch(query), 300)
|
||||||
|
return () => clearTimeout(timeout)
|
||||||
|
}, [query, onSearch])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (using React's useEffectEvent):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useEffectEvent } from 'react';
|
||||||
|
|
||||||
|
function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const onSearchEvent = useEffectEvent(onSearch)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeout = setTimeout(() => onSearchEvent(query), 300)
|
||||||
|
return () => clearTimeout(timeout)
|
||||||
|
}, [query])
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
title: Prevent Waterfall Chains in API Routes
|
||||||
|
impact: CRITICAL
|
||||||
|
impactDescription: 2-10× improvement
|
||||||
|
tags: api-routes, server-actions, waterfalls, parallelization
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prevent Waterfall Chains in API Routes
|
||||||
|
|
||||||
|
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).
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
title: Check Cheap Conditions Before Async Flags
|
||||||
|
impact: HIGH
|
||||||
|
impactDescription: avoids unnecessary async work when a synchronous guard already fails
|
||||||
|
tags: async, await, feature-flags, short-circuit, conditional
|
||||||
|
---
|
||||||
|
|
||||||
|
## Check Cheap Conditions Before Async Flags
|
||||||
|
|
||||||
|
When a branch uses `await` for a flag or remote value and also requires a **cheap synchronous** condition (local props, request metadata, already-loaded state), evaluate the cheap condition **first**. Otherwise you pay for the async call even when the compound condition can never be true.
|
||||||
|
|
||||||
|
This is a specialization of [Defer Await Until Needed](./async-defer-await.md) for `flag && cheapCondition` style checks.
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const someFlag = await getFlag()
|
||||||
|
|
||||||
|
if (someFlag && someCondition) {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
if (someCondition) {
|
||||||
|
const someFlag = await getFlag()
|
||||||
|
if (someFlag) {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This matters when `getFlag` hits the network, a feature-flag service, or `React.cache` / DB work: skipping it when `someCondition` is false removes that cost on the cold path.
|
||||||
|
|
||||||
|
Keep the original order if `someCondition` is expensive, depends on the flag, or you must run side effects in a fixed order.
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
---
|
||||||
|
title: Defer Await Until Needed
|
||||||
|
impact: HIGH
|
||||||
|
impactDescription: avoids blocking unused code paths
|
||||||
|
tags: async, await, conditional, optimization
|
||||||
|
---
|
||||||
|
|
||||||
|
## Defer Await Until Needed
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
For `await getFlag()` combined with a cheap synchronous guard (`flag && someCondition`), see [Check Cheap Conditions Before Async Flags](./async-cheap-condition-before-await.md).
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
title: Dependency-Based Parallelization
|
||||||
|
impact: CRITICAL
|
||||||
|
impactDescription: 2-10× improvement
|
||||||
|
tags: async, parallelization, dependencies, better-all
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependency-Based Parallelization
|
||||||
|
|
||||||
|
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:**
|
||||||
|
|
||||||
|
We can also create all the promises first, and do `Promise.all()` at the end.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const userPromise = fetchUser()
|
||||||
|
const profilePromise = userPromise.then(user => fetchProfile(user.id))
|
||||||
|
|
||||||
|
const [user, config, profile] = await Promise.all([
|
||||||
|
userPromise,
|
||||||
|
fetchConfig(),
|
||||||
|
profilePromise
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
Reference: [https://github.com/shuding/better-all](https://github.com/shuding/better-all)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
title: Promise.all() for Independent Operations
|
||||||
|
impact: CRITICAL
|
||||||
|
impactDescription: 2-10× improvement
|
||||||
|
tags: async, parallelization, promises, waterfalls
|
||||||
|
---
|
||||||
|
|
||||||
|
## Promise.all() for Independent Operations
|
||||||
|
|
||||||
|
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()
|
||||||
|
])
|
||||||
|
```
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
---
|
||||||
|
title: Strategic Suspense Boundaries
|
||||||
|
impact: HIGH
|
||||||
|
impactDescription: faster initial paint
|
||||||
|
tags: async, suspense, streaming, layout-shift
|
||||||
|
---
|
||||||
|
|
||||||
|
## Strategic Suspense Boundaries
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<div>Sidebar</div>
|
||||||
|
<div>Header</div>
|
||||||
|
<div>
|
||||||
|
<DataDisplay data={data} />
|
||||||
|
</div>
|
||||||
|
<div>Footer</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<div>Sidebar</div>
|
||||||
|
<div>Header</div>
|
||||||
|
<div>
|
||||||
|
<Suspense fallback={<Skeleton />}>
|
||||||
|
<DataDisplay />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
<div>Footer</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function DataDisplay() {
|
||||||
|
const data = await fetchData() // Only blocks this component
|
||||||
|
return <div>{data.content}</div>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<div>Sidebar</div>
|
||||||
|
<div>Header</div>
|
||||||
|
<Suspense fallback={<Skeleton />}>
|
||||||
|
<DataDisplay dataPromise={dataPromise} />
|
||||||
|
<DataSummary dataPromise={dataPromise} />
|
||||||
|
</Suspense>
|
||||||
|
<div>Footer</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataDisplay({ dataPromise }: { dataPromise: Promise<Data> }) {
|
||||||
|
const data = use(dataPromise) // Unwraps the promise
|
||||||
|
return <div>{data.content}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataSummary({ dataPromise }: { dataPromise: Promise<Data> }) {
|
||||||
|
const data = use(dataPromise) // Reuses the same promise
|
||||||
|
return <div>{data.summary}</div>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
---
|
||||||
|
title: Prefer Statically Analyzable Paths
|
||||||
|
impact: HIGH
|
||||||
|
impactDescription: avoids accidental broad bundles and file traces
|
||||||
|
tags: bundle, nextjs, vite, webpack, rollup, esbuild, path
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prefer Statically Analyzable Paths
|
||||||
|
|
||||||
|
Build tools work best when import and file-system paths are obvious at build time. If you hide the real path inside a variable or compose it too dynamically, the tool either has to include a broad set of possible files, warn that it cannot analyze the import, or widen file tracing to stay safe.
|
||||||
|
|
||||||
|
Prefer explicit maps or literal paths so the set of reachable files stays narrow and predictable. This is the same rule whether you are choosing modules with `import()` or reading files in server/build code.
|
||||||
|
|
||||||
|
When analysis becomes too broad, the cost is real:
|
||||||
|
- Larger server bundles
|
||||||
|
- Slower builds
|
||||||
|
- Worse cold starts
|
||||||
|
- More memory use
|
||||||
|
|
||||||
|
### Import Paths
|
||||||
|
|
||||||
|
**Incorrect (the bundler cannot tell what may be imported):**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const PAGE_MODULES = {
|
||||||
|
home: './pages/home',
|
||||||
|
settings: './pages/settings',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const Page = await import(PAGE_MODULES[pageName])
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (use an explicit map of allowed modules):**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const PAGE_MODULES = {
|
||||||
|
home: () => import('./pages/home'),
|
||||||
|
settings: () => import('./pages/settings'),
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const Page = await PAGE_MODULES[pageName]()
|
||||||
|
```
|
||||||
|
|
||||||
|
### File-System Paths
|
||||||
|
|
||||||
|
**Incorrect (a 2-value enum still hides the final path from static analysis):**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const baseDir = path.join(process.cwd(), 'content/' + contentKind)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (make each final path literal at the callsite):**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const baseDir =
|
||||||
|
kind === ContentKind.Blog
|
||||||
|
? path.join(process.cwd(), 'content/blog')
|
||||||
|
: path.join(process.cwd(), 'content/docs')
|
||||||
|
```
|
||||||
|
|
||||||
|
In Next.js server code, this matters for output file tracing too. `path.join(process.cwd(), someVar)` can widen the traced file set because Next.js statically analyze `import`, `require`, and `fs` usage.
|
||||||
|
|
||||||
|
Reference: [Next.js output](https://nextjs.org/docs/app/api-reference/config/next-config-js/output), [Next.js dynamic imports](https://nextjs.org/learn/seo/dynamic-imports), [Vite features](https://vite.dev/guide/features.html), [esbuild API](https://esbuild.github.io/api/), [Rollup dynamic import vars](https://www.npmjs.com/package/@rollup/plugin-dynamic-import-vars), [Webpack dependency management](https://webpack.js.org/guides/dependency-management/)
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
---
|
||||||
|
title: Avoid Barrel File Imports
|
||||||
|
impact: CRITICAL
|
||||||
|
impactDescription: 200-800ms import cost, slow builds
|
||||||
|
tags: bundle, imports, tree-shaking, barrel-files, performance
|
||||||
|
---
|
||||||
|
|
||||||
|
## Avoid Barrel File Imports
|
||||||
|
|
||||||
|
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 - Next.js 13.5+ (recommended):**
|
||||||
|
|
||||||
|
```js
|
||||||
|
// next.config.js - automatically optimizes barrel imports at build time
|
||||||
|
module.exports = {
|
||||||
|
experimental: {
|
||||||
|
optimizePackageImports: ['lucide-react', '@mui/material']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Keep the standard imports - Next.js transforms them to direct imports
|
||||||
|
import { Check, X, Menu } from 'lucide-react'
|
||||||
|
// Full TypeScript support, no manual path wrangling
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the recommended approach because it preserves TypeScript type safety and editor autocompletion while still eliminating the barrel import cost.
|
||||||
|
|
||||||
|
**Correct - Direct imports (non-Next.js projects):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import Button from '@mui/material/Button'
|
||||||
|
import TextField from '@mui/material/TextField'
|
||||||
|
// Loads only what you use
|
||||||
|
```
|
||||||
|
|
||||||
|
> **TypeScript warning:** Some libraries (notably `lucide-react`) don't ship `.d.ts` files for their deep import paths. Importing from `lucide-react/dist/esm/icons/check` resolves to an implicit `any` type, causing errors under `strict` or `noImplicitAny`. Prefer `optimizePackageImports` when available, or verify the library exports types for its subpaths before using direct imports.
|
||||||
|
|
||||||
|
These optimizations 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: [How we optimized package imports in Next.js](https://vercel.com/blog/how-we-optimized-package-imports-in-next-js)
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
title: Conditional Module Loading
|
||||||
|
impact: HIGH
|
||||||
|
impactDescription: loads large data only when needed
|
||||||
|
tags: bundle, conditional-loading, lazy-loading
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conditional Module Loading
|
||||||
|
|
||||||
|
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<React.SetStateAction<boolean>> }) {
|
||||||
|
const [frames, setFrames] = useState<Frame[] | null>(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 <Skeleton />
|
||||||
|
return <Canvas frames={frames} />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `typeof window !== 'undefined'` check prevents bundling this module for SSR, optimizing server bundle size and build speed.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
title: Defer Non-Critical Third-Party Libraries
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: loads after hydration
|
||||||
|
tags: bundle, third-party, analytics, defer
|
||||||
|
---
|
||||||
|
|
||||||
|
## Defer Non-Critical Third-Party Libraries
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
{children}
|
||||||
|
<Analytics />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**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 (
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
{children}
|
||||||
|
<Analytics />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
---
|
||||||
|
title: Dynamic Imports for Heavy Components
|
||||||
|
impact: CRITICAL
|
||||||
|
impactDescription: directly affects TTI and LCP
|
||||||
|
tags: bundle, dynamic-import, code-splitting, next-dynamic
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dynamic Imports for Heavy Components
|
||||||
|
|
||||||
|
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 <MonacoEditor value={code} />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**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 <MonacoEditor value={code} />
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
---
|
||||||
|
title: Preload Based on User Intent
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: reduces perceived latency
|
||||||
|
tags: bundle, preload, user-intent, hover
|
||||||
|
---
|
||||||
|
|
||||||
|
## Preload Based on User Intent
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<button
|
||||||
|
onMouseEnter={preload}
|
||||||
|
onFocus={preload}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
Open Editor
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**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 <FlagsContext.Provider value={flags}>
|
||||||
|
{children}
|
||||||
|
</FlagsContext.Provider>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `typeof window !== 'undefined'` check prevents bundling preloaded modules for SSR, optimizing server bundle size and build speed.
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
---
|
||||||
|
title: Deduplicate Global Event Listeners
|
||||||
|
impact: LOW
|
||||||
|
impactDescription: single listener for N components
|
||||||
|
tags: client, swr, event-listeners, subscription
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deduplicate Global Event Listeners
|
||||||
|
|
||||||
|
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<string, Set<() => 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', () => { /* ... */ })
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
---
|
||||||
|
title: Version and Minimize localStorage Data
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: prevents schema conflicts, reduces storage size
|
||||||
|
tags: client, localStorage, storage, versioning, data-minimization
|
||||||
|
---
|
||||||
|
|
||||||
|
## Version and Minimize localStorage Data
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
---
|
||||||
|
title: Use Passive Event Listeners for Scrolling Performance
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: eliminates scroll delay caused by event listeners
|
||||||
|
tags: client, event-listeners, scrolling, performance, touch, wheel
|
||||||
|
---
|
||||||
|
|
||||||
|
## Use Passive Event Listeners for Scrolling Performance
|
||||||
|
|
||||||
|
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()`.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
---
|
||||||
|
title: Use SWR for Automatic Deduplication
|
||||||
|
impact: MEDIUM-HIGH
|
||||||
|
impactDescription: automatic deduplication
|
||||||
|
tags: client, swr, deduplication, data-fetching
|
||||||
|
---
|
||||||
|
|
||||||
|
## Use SWR for 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 <button onClick={() => trigger()}>Update</button>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Reference: [https://swr.vercel.app](https://swr.vercel.app)
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
---
|
||||||
|
title: Avoid Layout Thrashing
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: prevents forced synchronous layouts and reduces performance bottlenecks
|
||||||
|
tags: javascript, dom, css, performance, reflow, layout-thrashing
|
||||||
|
---
|
||||||
|
|
||||||
|
## Avoid Layout Thrashing
|
||||||
|
|
||||||
|
Avoid interleaving style writes with layout reads. When you read a layout property (like `offsetWidth`, `getBoundingClientRect()`, or `getComputedStyle()`) between style changes, the browser is forced to trigger a synchronous reflow.
|
||||||
|
|
||||||
|
**This is OK (browser batches style changes):**
|
||||||
|
```typescript
|
||||||
|
function updateElementStyles(element: HTMLElement) {
|
||||||
|
// Each line invalidates style, but browser batches the recalculation
|
||||||
|
element.style.width = '100px'
|
||||||
|
element.style.height = '200px'
|
||||||
|
element.style.backgroundColor = 'blue'
|
||||||
|
element.style.border = '1px solid black'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Incorrect (interleaved reads and writes force reflows):**
|
||||||
|
```typescript
|
||||||
|
function layoutThrashing(element: HTMLElement) {
|
||||||
|
element.style.width = '100px'
|
||||||
|
const width = element.offsetWidth // Forces reflow
|
||||||
|
element.style.height = '200px'
|
||||||
|
const height = element.offsetHeight // Forces another reflow
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (batch writes, then read once):**
|
||||||
|
```typescript
|
||||||
|
function updateElementStyles(element: HTMLElement) {
|
||||||
|
// Batch all writes together
|
||||||
|
element.style.width = '100px'
|
||||||
|
element.style.height = '200px'
|
||||||
|
element.style.backgroundColor = 'blue'
|
||||||
|
element.style.border = '1px solid black'
|
||||||
|
|
||||||
|
// Read after all writes are done (single reflow)
|
||||||
|
const { width, height } = element.getBoundingClientRect()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (batch reads, then writes):**
|
||||||
|
```typescript
|
||||||
|
function avoidThrashing(element: HTMLElement) {
|
||||||
|
// Read phase - all layout queries first
|
||||||
|
const rect1 = element.getBoundingClientRect()
|
||||||
|
const offsetWidth = element.offsetWidth
|
||||||
|
const offsetHeight = element.offsetHeight
|
||||||
|
|
||||||
|
// Write phase - all style changes after
|
||||||
|
element.style.width = '100px'
|
||||||
|
element.style.height = '200px'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Better: use CSS classes**
|
||||||
|
```css
|
||||||
|
.highlighted-box {
|
||||||
|
width: 100px;
|
||||||
|
height: 200px;
|
||||||
|
background-color: blue;
|
||||||
|
border: 1px solid black;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
```typescript
|
||||||
|
function updateElementStyles(element: HTMLElement) {
|
||||||
|
element.classList.add('highlighted-box')
|
||||||
|
|
||||||
|
const { width, height } = element.getBoundingClientRect()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**React example:**
|
||||||
|
```tsx
|
||||||
|
// Incorrect: interleaving style changes with layout queries
|
||||||
|
function Box({ isHighlighted }: { isHighlighted: boolean }) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (ref.current && isHighlighted) {
|
||||||
|
ref.current.style.width = '100px'
|
||||||
|
const width = ref.current.offsetWidth // Forces layout
|
||||||
|
ref.current.style.height = '200px'
|
||||||
|
}
|
||||||
|
}, [isHighlighted])
|
||||||
|
|
||||||
|
return <div ref={ref}>Content</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Correct: toggle class
|
||||||
|
function Box({ isHighlighted }: { isHighlighted: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className={isHighlighted ? 'highlighted-box' : ''}>
|
||||||
|
Content
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Prefer CSS classes over inline styles when possible. CSS files are cached by the browser, and classes provide better separation of concerns and are easier to maintain.
|
||||||
|
|
||||||
|
See [this gist](https://gist.github.com/paulirish/5d52fb081b3570c81e3a) and [CSS Triggers](https://csstriggers.com/) for more information on layout-forcing operations.
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
---
|
||||||
|
title: Cache Repeated Function Calls
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: avoid redundant computation
|
||||||
|
tags: javascript, cache, memoization, performance
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cache Repeated Function Calls
|
||||||
|
|
||||||
|
Use a module-level Map to cache function results when the same function is called repeatedly with the same inputs during render.
|
||||||
|
|
||||||
|
**Incorrect (redundant computation):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function ProjectList({ projects }: { projects: Project[] }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{projects.map(project => {
|
||||||
|
// slugify() called 100+ times for same project names
|
||||||
|
const slug = slugify(project.name)
|
||||||
|
|
||||||
|
return <ProjectCard key={project.id} slug={slug} />
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (cached results):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Module-level cache
|
||||||
|
const slugifyCache = new Map<string, string>()
|
||||||
|
|
||||||
|
function cachedSlugify(text: string): string {
|
||||||
|
if (slugifyCache.has(text)) {
|
||||||
|
return slugifyCache.get(text)!
|
||||||
|
}
|
||||||
|
const result = slugify(text)
|
||||||
|
slugifyCache.set(text, result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectList({ projects }: { projects: Project[] }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{projects.map(project => {
|
||||||
|
// Computed only once per unique project name
|
||||||
|
const slug = cachedSlugify(project.name)
|
||||||
|
|
||||||
|
return <ProjectCard key={project.id} slug={slug} />
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Simpler pattern for single-value functions:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
let isLoggedInCache: boolean | null = null
|
||||||
|
|
||||||
|
function isLoggedIn(): boolean {
|
||||||
|
if (isLoggedInCache !== null) {
|
||||||
|
return isLoggedInCache
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoggedInCache = document.cookie.includes('auth=')
|
||||||
|
return isLoggedInCache
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear cache when auth changes
|
||||||
|
function onAuthChange() {
|
||||||
|
isLoggedInCache = null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use a Map (not a hook) so it works everywhere: utilities, event handlers, not just React components.
|
||||||
|
|
||||||
|
Reference: [How we made the Vercel Dashboard twice as fast](https://vercel.com/blog/how-we-made-the-vercel-dashboard-twice-as-fast)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
title: Cache Property Access in Loops
|
||||||
|
impact: LOW-MEDIUM
|
||||||
|
impactDescription: reduces lookups
|
||||||
|
tags: javascript, loops, optimization, caching
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cache Property Access in Loops
|
||||||
|
|
||||||
|
Cache object property lookups in hot paths.
|
||||||
|
|
||||||
|
**Incorrect (3 lookups × N iterations):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
for (let i = 0; i < arr.length; i++) {
|
||||||
|
process(obj.config.settings.value)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (1 lookup total):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const value = obj.config.settings.value
|
||||||
|
const len = arr.length
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
process(value)
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
---
|
||||||
|
title: Cache Storage API Calls
|
||||||
|
impact: LOW-MEDIUM
|
||||||
|
impactDescription: reduces expensive I/O
|
||||||
|
tags: javascript, localStorage, storage, caching, performance
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cache Storage API Calls
|
||||||
|
|
||||||
|
`localStorage`, `sessionStorage`, and `document.cookie` are synchronous and expensive. Cache reads in memory.
|
||||||
|
|
||||||
|
**Incorrect (reads storage on every call):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function getTheme() {
|
||||||
|
return localStorage.getItem('theme') ?? 'light'
|
||||||
|
}
|
||||||
|
// Called 10 times = 10 storage reads
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (Map cache):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const storageCache = new Map<string, string | null>()
|
||||||
|
|
||||||
|
function getLocalStorage(key: string) {
|
||||||
|
if (!storageCache.has(key)) {
|
||||||
|
storageCache.set(key, localStorage.getItem(key))
|
||||||
|
}
|
||||||
|
return storageCache.get(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLocalStorage(key: string, value: string) {
|
||||||
|
localStorage.setItem(key, value)
|
||||||
|
storageCache.set(key, value) // keep cache in sync
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use a Map (not a hook) so it works everywhere: utilities, event handlers, not just React components.
|
||||||
|
|
||||||
|
**Cookie caching:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
let cookieCache: Record<string, string> | null = null
|
||||||
|
|
||||||
|
function getCookie(name: string) {
|
||||||
|
if (!cookieCache) {
|
||||||
|
cookieCache = Object.fromEntries(
|
||||||
|
document.cookie.split('; ').map(c => c.split('='))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return cookieCache[name]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important (invalidate on external changes):**
|
||||||
|
|
||||||
|
If storage can change externally (another tab, server-set cookies), invalidate cache:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
window.addEventListener('storage', (e) => {
|
||||||
|
if (e.key) storageCache.delete(e.key)
|
||||||
|
})
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.visibilityState === 'visible') {
|
||||||
|
storageCache.clear()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
---
|
||||||
|
title: Combine Multiple Array Iterations
|
||||||
|
impact: LOW-MEDIUM
|
||||||
|
impactDescription: reduces iterations
|
||||||
|
tags: javascript, arrays, loops, performance
|
||||||
|
---
|
||||||
|
|
||||||
|
## Combine Multiple Array Iterations
|
||||||
|
|
||||||
|
Multiple `.filter()` or `.map()` calls iterate the array multiple times. Combine into one loop.
|
||||||
|
|
||||||
|
**Incorrect (3 iterations):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const admins = users.filter(u => u.isAdmin)
|
||||||
|
const testers = users.filter(u => u.isTester)
|
||||||
|
const inactive = users.filter(u => !u.isActive)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (1 iteration):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const admins: User[] = []
|
||||||
|
const testers: User[] = []
|
||||||
|
const inactive: User[] = []
|
||||||
|
|
||||||
|
for (const user of users) {
|
||||||
|
if (user.isAdmin) admins.push(user)
|
||||||
|
if (user.isTester) testers.push(user)
|
||||||
|
if (!user.isActive) inactive.push(user)
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
---
|
||||||
|
title: Early Return from Functions
|
||||||
|
impact: LOW-MEDIUM
|
||||||
|
impactDescription: avoids unnecessary computation
|
||||||
|
tags: javascript, functions, optimization, early-return
|
||||||
|
---
|
||||||
|
|
||||||
|
## Early Return from Functions
|
||||||
|
|
||||||
|
Return early when result is determined to skip unnecessary processing.
|
||||||
|
|
||||||
|
**Incorrect (processes all items even after finding answer):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function validateUsers(users: User[]) {
|
||||||
|
let hasError = false
|
||||||
|
let errorMessage = ''
|
||||||
|
|
||||||
|
for (const user of users) {
|
||||||
|
if (!user.email) {
|
||||||
|
hasError = true
|
||||||
|
errorMessage = 'Email required'
|
||||||
|
}
|
||||||
|
if (!user.name) {
|
||||||
|
hasError = true
|
||||||
|
errorMessage = 'Name required'
|
||||||
|
}
|
||||||
|
// Continues checking all users even after error found
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasError ? { valid: false, error: errorMessage } : { valid: true }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (returns immediately on first error):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function validateUsers(users: User[]) {
|
||||||
|
for (const user of users) {
|
||||||
|
if (!user.email) {
|
||||||
|
return { valid: false, error: 'Email required' }
|
||||||
|
}
|
||||||
|
if (!user.name) {
|
||||||
|
return { valid: false, error: 'Name required' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true }
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
---
|
||||||
|
title: Use flatMap to Map and Filter in One Pass
|
||||||
|
impact: LOW-MEDIUM
|
||||||
|
impactDescription: eliminates intermediate array
|
||||||
|
tags: javascript, arrays, flatMap, filter, performance
|
||||||
|
---
|
||||||
|
|
||||||
|
## Use flatMap to Map and Filter in One Pass
|
||||||
|
|
||||||
|
**Impact: LOW-MEDIUM (eliminates intermediate array)**
|
||||||
|
|
||||||
|
Chaining `.map().filter(Boolean)` creates an intermediate array and iterates twice. Use `.flatMap()` to transform and filter in a single pass.
|
||||||
|
|
||||||
|
**Incorrect (2 iterations, intermediate array):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const userNames = users
|
||||||
|
.map(user => user.isActive ? user.name : null)
|
||||||
|
.filter(Boolean)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (1 iteration, no intermediate array):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const userNames = users.flatMap(user =>
|
||||||
|
user.isActive ? [user.name] : []
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**More examples:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Extract valid emails from responses
|
||||||
|
// Before
|
||||||
|
const emails = responses
|
||||||
|
.map(r => r.success ? r.data.email : null)
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
// After
|
||||||
|
const emails = responses.flatMap(r =>
|
||||||
|
r.success ? [r.data.email] : []
|
||||||
|
)
|
||||||
|
|
||||||
|
// Parse and filter valid numbers
|
||||||
|
// Before
|
||||||
|
const numbers = strings
|
||||||
|
.map(s => parseInt(s, 10))
|
||||||
|
.filter(n => !isNaN(n))
|
||||||
|
|
||||||
|
// After
|
||||||
|
const numbers = strings.flatMap(s => {
|
||||||
|
const n = parseInt(s, 10)
|
||||||
|
return isNaN(n) ? [] : [n]
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**When to use:**
|
||||||
|
- Transforming items while filtering some out
|
||||||
|
- Conditional mapping where some inputs produce no output
|
||||||
|
- Parsing/validating where invalid inputs should be skipped
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
title: Hoist RegExp Creation
|
||||||
|
impact: LOW-MEDIUM
|
||||||
|
impactDescription: avoids recreation
|
||||||
|
tags: javascript, regexp, optimization, memoization
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hoist RegExp Creation
|
||||||
|
|
||||||
|
Don't create RegExp inside render. Hoist to module scope or memoize with `useMemo()`.
|
||||||
|
|
||||||
|
**Incorrect (new RegExp every render):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function Highlighter({ text, query }: Props) {
|
||||||
|
const regex = new RegExp(`(${query})`, 'gi')
|
||||||
|
const parts = text.split(regex)
|
||||||
|
return <>{parts.map((part, i) => ...)}</>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (memoize or hoist):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
|
||||||
|
function Highlighter({ text, query }: Props) {
|
||||||
|
const regex = useMemo(
|
||||||
|
() => new RegExp(`(${escapeRegex(query)})`, 'gi'),
|
||||||
|
[query]
|
||||||
|
)
|
||||||
|
const parts = text.split(regex)
|
||||||
|
return <>{parts.map((part, i) => ...)}</>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Warning (global regex has mutable state):**
|
||||||
|
|
||||||
|
Global regex (`/g`) has mutable `lastIndex` state:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const regex = /foo/g
|
||||||
|
regex.test('foo') // true, lastIndex = 3
|
||||||
|
regex.test('foo') // false, lastIndex = 0
|
||||||
|
```
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
title: Build Index Maps for Repeated Lookups
|
||||||
|
impact: LOW-MEDIUM
|
||||||
|
impactDescription: 1M ops to 2K ops
|
||||||
|
tags: javascript, map, indexing, optimization, performance
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build Index Maps for Repeated Lookups
|
||||||
|
|
||||||
|
Multiple `.find()` calls by the same key should use a Map.
|
||||||
|
|
||||||
|
**Incorrect (O(n) per lookup):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function processOrders(orders: Order[], users: User[]) {
|
||||||
|
return orders.map(order => ({
|
||||||
|
...order,
|
||||||
|
user: users.find(u => u.id === order.userId)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (O(1) per lookup):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function processOrders(orders: Order[], users: User[]) {
|
||||||
|
const userById = new Map(users.map(u => [u.id, u]))
|
||||||
|
|
||||||
|
return orders.map(order => ({
|
||||||
|
...order,
|
||||||
|
user: userById.get(order.userId)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Build map once (O(n)), then all lookups are O(1).
|
||||||
|
For 1000 orders × 1000 users: 1M ops → 2K ops.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
title: Early Length Check for Array Comparisons
|
||||||
|
impact: MEDIUM-HIGH
|
||||||
|
impactDescription: avoids expensive operations when lengths differ
|
||||||
|
tags: javascript, arrays, performance, optimization, comparison
|
||||||
|
---
|
||||||
|
|
||||||
|
## Early Length Check for Array Comparisons
|
||||||
|
|
||||||
|
When comparing arrays with expensive operations (sorting, deep equality, serialization), check lengths first. If lengths differ, the arrays cannot be equal.
|
||||||
|
|
||||||
|
In real-world applications, this optimization is especially valuable when the comparison runs in hot paths (event handlers, render loops).
|
||||||
|
|
||||||
|
**Incorrect (always runs expensive comparison):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function hasChanges(current: string[], original: string[]) {
|
||||||
|
// Always sorts and joins, even when lengths differ
|
||||||
|
return current.sort().join() !== original.sort().join()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Two O(n log n) sorts run even when `current.length` is 5 and `original.length` is 100. There is also overhead of joining the arrays and comparing the strings.
|
||||||
|
|
||||||
|
**Correct (O(1) length check first):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function hasChanges(current: string[], original: string[]) {
|
||||||
|
// Early return if lengths differ
|
||||||
|
if (current.length !== original.length) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Only sort when lengths match
|
||||||
|
const currentSorted = current.toSorted()
|
||||||
|
const originalSorted = original.toSorted()
|
||||||
|
for (let i = 0; i < currentSorted.length; i++) {
|
||||||
|
if (currentSorted[i] !== originalSorted[i]) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This new approach is more efficient because:
|
||||||
|
- It avoids the overhead of sorting and joining the arrays when lengths differ
|
||||||
|
- It avoids consuming memory for the joined strings (especially important for large arrays)
|
||||||
|
- It avoids mutating the original arrays
|
||||||
|
- It returns early when a difference is found
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
---
|
||||||
|
title: Use Loop for Min/Max Instead of Sort
|
||||||
|
impact: LOW
|
||||||
|
impactDescription: O(n) instead of O(n log n)
|
||||||
|
tags: javascript, arrays, performance, sorting, algorithms
|
||||||
|
---
|
||||||
|
|
||||||
|
## Use Loop for Min/Max Instead of Sort
|
||||||
|
|
||||||
|
Finding the smallest or largest element only requires a single pass through the array. Sorting is wasteful and slower.
|
||||||
|
|
||||||
|
**Incorrect (O(n log n) - sort to find latest):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Project {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
updatedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLatestProject(projects: Project[]) {
|
||||||
|
const sorted = [...projects].sort((a, b) => b.updatedAt - a.updatedAt)
|
||||||
|
return sorted[0]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Sorts the entire array just to find the maximum value.
|
||||||
|
|
||||||
|
**Incorrect (O(n log n) - sort for oldest and newest):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function getOldestAndNewest(projects: Project[]) {
|
||||||
|
const sorted = [...projects].sort((a, b) => a.updatedAt - b.updatedAt)
|
||||||
|
return { oldest: sorted[0], newest: sorted[sorted.length - 1] }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Still sorts unnecessarily when only min/max are needed.
|
||||||
|
|
||||||
|
**Correct (O(n) - single loop):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function getLatestProject(projects: Project[]) {
|
||||||
|
if (projects.length === 0) return null
|
||||||
|
|
||||||
|
let latest = projects[0]
|
||||||
|
|
||||||
|
for (let i = 1; i < projects.length; i++) {
|
||||||
|
if (projects[i].updatedAt > latest.updatedAt) {
|
||||||
|
latest = projects[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return latest
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOldestAndNewest(projects: Project[]) {
|
||||||
|
if (projects.length === 0) return { oldest: null, newest: null }
|
||||||
|
|
||||||
|
let oldest = projects[0]
|
||||||
|
let newest = projects[0]
|
||||||
|
|
||||||
|
for (let i = 1; i < projects.length; i++) {
|
||||||
|
if (projects[i].updatedAt < oldest.updatedAt) oldest = projects[i]
|
||||||
|
if (projects[i].updatedAt > newest.updatedAt) newest = projects[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
return { oldest, newest }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Single pass through the array, no copying, no sorting.
|
||||||
|
|
||||||
|
**Alternative (Math.min/Math.max for small arrays):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const numbers = [5, 2, 8, 1, 9]
|
||||||
|
const min = Math.min(...numbers)
|
||||||
|
const max = Math.max(...numbers)
|
||||||
|
```
|
||||||
|
|
||||||
|
This works for small arrays, but can be slower or just throw an error for very large arrays due to spread operator limitations. Maximal array length is approximately 124000 in Chrome 143 and 638000 in Safari 18; exact numbers may vary - see [the fiddle](https://jsfiddle.net/qw1jabsx/4/). Use the loop approach for reliability.
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
---
|
||||||
|
title: Defer Non-Critical Work with requestIdleCallback
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: keeps UI responsive during background tasks
|
||||||
|
tags: javascript, performance, idle, scheduling, analytics
|
||||||
|
---
|
||||||
|
|
||||||
|
## Defer Non-Critical Work with requestIdleCallback
|
||||||
|
|
||||||
|
**Impact: MEDIUM (keeps UI responsive during background tasks)**
|
||||||
|
|
||||||
|
Use `requestIdleCallback()` to schedule non-critical work during browser idle periods. This keeps the main thread free for user interactions and animations, reducing jank and improving perceived performance.
|
||||||
|
|
||||||
|
**Incorrect (blocks main thread during user interaction):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function handleSearch(query: string) {
|
||||||
|
const results = searchItems(query)
|
||||||
|
setResults(results)
|
||||||
|
|
||||||
|
// These block the main thread immediately
|
||||||
|
analytics.track('search', { query })
|
||||||
|
saveToRecentSearches(query)
|
||||||
|
prefetchTopResults(results.slice(0, 3))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (defers non-critical work to idle time):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function handleSearch(query: string) {
|
||||||
|
const results = searchItems(query)
|
||||||
|
setResults(results)
|
||||||
|
|
||||||
|
// Defer non-critical work to idle periods
|
||||||
|
requestIdleCallback(() => {
|
||||||
|
analytics.track('search', { query })
|
||||||
|
})
|
||||||
|
|
||||||
|
requestIdleCallback(() => {
|
||||||
|
saveToRecentSearches(query)
|
||||||
|
})
|
||||||
|
|
||||||
|
requestIdleCallback(() => {
|
||||||
|
prefetchTopResults(results.slice(0, 3))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**With timeout for required work:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Ensure analytics fires within 2 seconds even if browser stays busy
|
||||||
|
requestIdleCallback(
|
||||||
|
() => analytics.track('page_view', { path: location.pathname }),
|
||||||
|
{ timeout: 2000 }
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Chunking large tasks:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function processLargeDataset(items: Item[]) {
|
||||||
|
let index = 0
|
||||||
|
|
||||||
|
function processChunk(deadline: IdleDeadline) {
|
||||||
|
// Process items while we have idle time (aim for <50ms chunks)
|
||||||
|
while (index < items.length && deadline.timeRemaining() > 0) {
|
||||||
|
processItem(items[index])
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule next chunk if more items remain
|
||||||
|
if (index < items.length) {
|
||||||
|
requestIdleCallback(processChunk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
requestIdleCallback(processChunk)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**With fallback for unsupported browsers:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const scheduleIdleWork = window.requestIdleCallback ?? ((cb: () => void) => setTimeout(cb, 1))
|
||||||
|
|
||||||
|
scheduleIdleWork(() => {
|
||||||
|
// Non-critical work
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**When to use:**
|
||||||
|
|
||||||
|
- Analytics and telemetry
|
||||||
|
- Saving state to localStorage/IndexedDB
|
||||||
|
- Prefetching resources for likely next actions
|
||||||
|
- Processing non-urgent data transformations
|
||||||
|
- Lazy initialization of non-critical features
|
||||||
|
|
||||||
|
**When NOT to use:**
|
||||||
|
|
||||||
|
- User-initiated actions that need immediate feedback
|
||||||
|
- Rendering updates the user is waiting for
|
||||||
|
- Time-sensitive operations
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
title: Use Set/Map for O(1) Lookups
|
||||||
|
impact: LOW-MEDIUM
|
||||||
|
impactDescription: O(n) to O(1)
|
||||||
|
tags: javascript, set, map, data-structures, performance
|
||||||
|
---
|
||||||
|
|
||||||
|
## Use Set/Map for O(1) Lookups
|
||||||
|
|
||||||
|
Convert arrays to Set/Map for repeated membership checks.
|
||||||
|
|
||||||
|
**Incorrect (O(n) per check):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const allowedIds = ['a', 'b', 'c', ...]
|
||||||
|
items.filter(item => allowedIds.includes(item.id))
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (O(1) per check):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const allowedIds = new Set(['a', 'b', 'c', ...])
|
||||||
|
items.filter(item => allowedIds.has(item.id))
|
||||||
|
```
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
---
|
||||||
|
title: Use toSorted() Instead of sort() for Immutability
|
||||||
|
impact: MEDIUM-HIGH
|
||||||
|
impactDescription: prevents mutation bugs in React state
|
||||||
|
tags: javascript, arrays, immutability, react, state, mutation
|
||||||
|
---
|
||||||
|
|
||||||
|
## Use toSorted() Instead of sort() for Immutability
|
||||||
|
|
||||||
|
`.sort()` mutates the array in place, which can cause bugs with React state and props. Use `.toSorted()` to create a new sorted array without mutation.
|
||||||
|
|
||||||
|
**Incorrect (mutates original array):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function UserList({ users }: { users: User[] }) {
|
||||||
|
// Mutates the users prop array!
|
||||||
|
const sorted = useMemo(
|
||||||
|
() => users.sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
|
[users]
|
||||||
|
)
|
||||||
|
return <div>{sorted.map(renderUser)}</div>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (creates new array):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function UserList({ users }: { users: User[] }) {
|
||||||
|
// Creates new sorted array, original unchanged
|
||||||
|
const sorted = useMemo(
|
||||||
|
() => users.toSorted((a, b) => a.name.localeCompare(b.name)),
|
||||||
|
[users]
|
||||||
|
)
|
||||||
|
return <div>{sorted.map(renderUser)}</div>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why this matters in React:**
|
||||||
|
|
||||||
|
1. Props/state mutations break React's immutability model - React expects props and state to be treated as read-only
|
||||||
|
2. Causes stale closure bugs - Mutating arrays inside closures (callbacks, effects) can lead to unexpected behavior
|
||||||
|
|
||||||
|
**Browser support (fallback for older browsers):**
|
||||||
|
|
||||||
|
`.toSorted()` is available in all modern browsers (Chrome 110+, Safari 16+, Firefox 115+, Node.js 20+). For older environments, use spread operator:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Fallback for older browsers
|
||||||
|
const sorted = [...items].sort((a, b) => a.value - b.value)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Other immutable array methods:**
|
||||||
|
|
||||||
|
- `.toSorted()` - immutable sort
|
||||||
|
- `.toReversed()` - immutable reverse
|
||||||
|
- `.toSpliced()` - immutable splice
|
||||||
|
- `.with()` - immutable element replacement
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
title: Use Activity Component for Show/Hide
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: preserves state/DOM
|
||||||
|
tags: rendering, activity, visibility, state-preservation
|
||||||
|
---
|
||||||
|
|
||||||
|
## Use Activity Component for Show/Hide
|
||||||
|
|
||||||
|
Use React's `<Activity>` to preserve state/DOM for expensive components that frequently toggle visibility.
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Activity } from 'react'
|
||||||
|
|
||||||
|
function Dropdown({ isOpen }: Props) {
|
||||||
|
return (
|
||||||
|
<Activity mode={isOpen ? 'visible' : 'hidden'}>
|
||||||
|
<ExpensiveMenu />
|
||||||
|
</Activity>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Avoids expensive re-renders and state loss.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
---
|
||||||
|
title: Animate SVG Wrapper Instead of SVG Element
|
||||||
|
impact: LOW
|
||||||
|
impactDescription: enables hardware acceleration
|
||||||
|
tags: rendering, svg, css, animation, performance
|
||||||
|
---
|
||||||
|
|
||||||
|
## Animate SVG Wrapper Instead of SVG Element
|
||||||
|
|
||||||
|
Many browsers don't have hardware acceleration for CSS3 animations on SVG elements. Wrap SVG in a `<div>` and animate the wrapper instead.
|
||||||
|
|
||||||
|
**Incorrect (animating SVG directly - no hardware acceleration):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function LoadingSpinner() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className="animate-spin"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (animating wrapper div - hardware accelerated):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function LoadingSpinner() {
|
||||||
|
return (
|
||||||
|
<div className="animate-spin">
|
||||||
|
<svg
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This applies to all CSS transforms and transitions (`transform`, `opacity`, `translate`, `scale`, `rotate`). The wrapper div allows browsers to use GPU acceleration for smoother animations.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
title: Use Explicit Conditional Rendering
|
||||||
|
impact: LOW
|
||||||
|
impactDescription: prevents rendering 0 or NaN
|
||||||
|
tags: rendering, conditional, jsx, falsy-values
|
||||||
|
---
|
||||||
|
|
||||||
|
## Use Explicit Conditional Rendering
|
||||||
|
|
||||||
|
Use explicit ternary operators (`? :`) instead of `&&` for conditional rendering when the condition can be `0`, `NaN`, or other falsy values that render.
|
||||||
|
|
||||||
|
**Incorrect (renders "0" when count is 0):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function Badge({ count }: { count: number }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{count && <span className="badge">{count}</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// When count = 0, renders: <div>0</div>
|
||||||
|
// When count = 5, renders: <div><span class="badge">5</span></div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (renders nothing when count is 0):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function Badge({ count }: { count: number }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{count > 0 ? <span className="badge">{count}</span> : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// When count = 0, renders: <div></div>
|
||||||
|
// When count = 5, renders: <div><span class="badge">5</span></div>
|
||||||
|
```
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
title: CSS content-visibility for Long Lists
|
||||||
|
impact: HIGH
|
||||||
|
impactDescription: faster initial render
|
||||||
|
tags: rendering, css, content-visibility, long-lists
|
||||||
|
---
|
||||||
|
|
||||||
|
## CSS content-visibility for Long Lists
|
||||||
|
|
||||||
|
Apply `content-visibility: auto` to defer off-screen rendering.
|
||||||
|
|
||||||
|
**CSS:**
|
||||||
|
|
||||||
|
```css
|
||||||
|
.message-item {
|
||||||
|
content-visibility: auto;
|
||||||
|
contain-intrinsic-size: 0 80px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function MessageList({ messages }: { messages: Message[] }) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-y-auto h-screen">
|
||||||
|
{messages.map(msg => (
|
||||||
|
<div key={msg.id} className="message-item">
|
||||||
|
<Avatar user={msg.author} />
|
||||||
|
<div>{msg.content}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For 1000 messages, browser skips layout/paint for ~990 off-screen items (10× faster initial render).
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
title: Hoist Static JSX Elements
|
||||||
|
impact: LOW
|
||||||
|
impactDescription: avoids re-creation
|
||||||
|
tags: rendering, jsx, static, optimization
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hoist Static JSX Elements
|
||||||
|
|
||||||
|
Extract static JSX outside components to avoid re-creation.
|
||||||
|
|
||||||
|
**Incorrect (recreates element every render):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function LoadingSkeleton() {
|
||||||
|
return <div className="animate-pulse h-20 bg-gray-200" />
|
||||||
|
}
|
||||||
|
|
||||||
|
function Container() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{loading && <LoadingSkeleton />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (reuses same element):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const loadingSkeleton = (
|
||||||
|
<div className="animate-pulse h-20 bg-gray-200" />
|
||||||
|
)
|
||||||
|
|
||||||
|
function Container() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{loading && loadingSkeleton}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This is especially helpful for large and static SVG nodes, which can be expensive to recreate on every render.
|
||||||
|
|
||||||
|
**Note:** If your project has [React Compiler](https://react.dev/learn/react-compiler) enabled, the compiler automatically hoists static JSX elements and optimizes component re-renders, making manual hoisting unnecessary.
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
---
|
||||||
|
title: Prevent Hydration Mismatch Without Flickering
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: avoids visual flicker and hydration errors
|
||||||
|
tags: rendering, ssr, hydration, localStorage, flicker
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prevent Hydration Mismatch Without Flickering
|
||||||
|
|
||||||
|
When rendering content that depends on client-side storage (localStorage, cookies), avoid both SSR breakage and post-hydration flickering by injecting a synchronous script that updates the DOM before React hydrates.
|
||||||
|
|
||||||
|
**Incorrect (breaks SSR):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function ThemeWrapper({ children }: { children: ReactNode }) {
|
||||||
|
// localStorage is not available on server - throws error
|
||||||
|
const theme = localStorage.getItem('theme') || 'light'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={theme}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Server-side rendering will fail because `localStorage` is undefined.
|
||||||
|
|
||||||
|
**Incorrect (visual flickering):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function ThemeWrapper({ children }: { children: ReactNode }) {
|
||||||
|
const [theme, setTheme] = useState('light')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Runs after hydration - causes visible flash
|
||||||
|
const stored = localStorage.getItem('theme')
|
||||||
|
if (stored) {
|
||||||
|
setTheme(stored)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={theme}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Component first renders with default value (`light`), then updates after hydration, causing a visible flash of incorrect content.
|
||||||
|
|
||||||
|
**Correct (no flicker, no hydration mismatch):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function ThemeWrapper({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div id="theme-wrapper">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
<script
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `
|
||||||
|
(function() {
|
||||||
|
try {
|
||||||
|
var theme = localStorage.getItem('theme') || 'light';
|
||||||
|
var el = document.getElementById('theme-wrapper');
|
||||||
|
if (el) el.className = theme;
|
||||||
|
} catch (e) {}
|
||||||
|
})();
|
||||||
|
`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The inline script executes synchronously before showing the element, ensuring the DOM already has the correct value. No flickering, no hydration mismatch.
|
||||||
|
|
||||||
|
This pattern is especially useful for theme toggles, user preferences, authentication states, and any client-only data that should render immediately without flashing default values.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
title: Suppress Expected Hydration Mismatches
|
||||||
|
impact: LOW-MEDIUM
|
||||||
|
impactDescription: avoids noisy hydration warnings for known differences
|
||||||
|
tags: rendering, hydration, ssr, nextjs
|
||||||
|
---
|
||||||
|
|
||||||
|
## Suppress Expected Hydration Mismatches
|
||||||
|
|
||||||
|
In SSR frameworks (e.g., Next.js), some values are intentionally different on server vs client (random IDs, dates, locale/timezone formatting). For these *expected* mismatches, wrap the dynamic text in an element with `suppressHydrationWarning` to prevent noisy warnings. Do not use this to hide real bugs. Don’t overuse it.
|
||||||
|
|
||||||
|
**Incorrect (known mismatch warnings):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function Timestamp() {
|
||||||
|
return <span>{new Date().toLocaleString()}</span>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (suppress expected mismatch only):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function Timestamp() {
|
||||||
|
return (
|
||||||
|
<span suppressHydrationWarning>
|
||||||
|
{new Date().toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
---
|
||||||
|
title: Use React DOM Resource Hints
|
||||||
|
impact: HIGH
|
||||||
|
impactDescription: reduces load time for critical resources
|
||||||
|
tags: rendering, preload, preconnect, prefetch, resource-hints
|
||||||
|
---
|
||||||
|
|
||||||
|
## Use React DOM Resource Hints
|
||||||
|
|
||||||
|
**Impact: HIGH (reduces load time for critical resources)**
|
||||||
|
|
||||||
|
React DOM provides APIs to hint the browser about resources it will need. These are especially useful in server components to start loading resources before the client even receives the HTML.
|
||||||
|
|
||||||
|
- **`prefetchDNS(href)`**: Resolve DNS for a domain you expect to connect to
|
||||||
|
- **`preconnect(href)`**: Establish connection (DNS + TCP + TLS) to a server
|
||||||
|
- **`preload(href, options)`**: Fetch a resource (stylesheet, font, script, image) you'll use soon
|
||||||
|
- **`preloadModule(href)`**: Fetch an ES module you'll use soon
|
||||||
|
- **`preinit(href, options)`**: Fetch and evaluate a stylesheet or script
|
||||||
|
- **`preinitModule(href)`**: Fetch and evaluate an ES module
|
||||||
|
|
||||||
|
**Example (preconnect to third-party APIs):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { preconnect, prefetchDNS } from 'react-dom'
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
prefetchDNS('https://analytics.example.com')
|
||||||
|
preconnect('https://api.example.com')
|
||||||
|
|
||||||
|
return <main>{/* content */}</main>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example (preload critical fonts and styles):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { preload, preinit } from 'react-dom'
|
||||||
|
|
||||||
|
export default function RootLayout({ children }) {
|
||||||
|
// Preload font file
|
||||||
|
preload('/fonts/inter.woff2', { as: 'font', type: 'font/woff2', crossOrigin: 'anonymous' })
|
||||||
|
|
||||||
|
// Fetch and apply critical stylesheet immediately
|
||||||
|
preinit('/styles/critical.css', { as: 'style' })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html>
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example (preload modules for code-split routes):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { preloadModule, preinitModule } from 'react-dom'
|
||||||
|
|
||||||
|
function Navigation() {
|
||||||
|
const preloadDashboard = () => {
|
||||||
|
preloadModule('/dashboard.js', { as: 'script' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav>
|
||||||
|
<a href="/dashboard" onMouseEnter={preloadDashboard}>
|
||||||
|
Dashboard
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**When to use each:**
|
||||||
|
|
||||||
|
| API | Use case |
|
||||||
|
|-----|----------|
|
||||||
|
| `prefetchDNS` | Third-party domains you'll connect to later |
|
||||||
|
| `preconnect` | APIs or CDNs you'll fetch from immediately |
|
||||||
|
| `preload` | Critical resources needed for current page |
|
||||||
|
| `preloadModule` | JS modules for likely next navigation |
|
||||||
|
| `preinit` | Stylesheets/scripts that must execute early |
|
||||||
|
| `preinitModule` | ES modules that must execute early |
|
||||||
|
|
||||||
|
Reference: [React DOM Resource Preloading APIs](https://react.dev/reference/react-dom#resource-preloading-apis)
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
---
|
||||||
|
title: Use defer or async on Script Tags
|
||||||
|
impact: HIGH
|
||||||
|
impactDescription: eliminates render-blocking
|
||||||
|
tags: rendering, script, defer, async, performance
|
||||||
|
---
|
||||||
|
|
||||||
|
## Use defer or async on Script Tags
|
||||||
|
|
||||||
|
**Impact: HIGH (eliminates render-blocking)**
|
||||||
|
|
||||||
|
Script tags without `defer` or `async` block HTML parsing while the script downloads and executes. This delays First Contentful Paint and Time to Interactive.
|
||||||
|
|
||||||
|
- **`defer`**: Downloads in parallel, executes after HTML parsing completes, maintains execution order
|
||||||
|
- **`async`**: Downloads in parallel, executes immediately when ready, no guaranteed order
|
||||||
|
|
||||||
|
Use `defer` for scripts that depend on DOM or other scripts. Use `async` for independent scripts like analytics.
|
||||||
|
|
||||||
|
**Incorrect (blocks rendering):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export default function Document() {
|
||||||
|
return (
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<script src="https://example.com/analytics.js" />
|
||||||
|
<script src="/scripts/utils.js" />
|
||||||
|
</head>
|
||||||
|
<body>{/* content */}</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (non-blocking):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export default function Document() {
|
||||||
|
return (
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
{/* Independent script - use async */}
|
||||||
|
<script src="https://example.com/analytics.js" async />
|
||||||
|
{/* DOM-dependent script - use defer */}
|
||||||
|
<script src="/scripts/utils.js" defer />
|
||||||
|
</head>
|
||||||
|
<body>{/* content */}</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** In Next.js, prefer the `next/script` component with `strategy` prop instead of raw script tags:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import Script from 'next/script'
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Script src="https://example.com/analytics.js" strategy="afterInteractive" />
|
||||||
|
<Script src="/scripts/utils.js" strategy="beforeInteractive" />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Reference: [MDN - Script element](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script#defer)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
title: Optimize SVG Precision
|
||||||
|
impact: LOW
|
||||||
|
impactDescription: reduces file size
|
||||||
|
tags: rendering, svg, optimization, svgo
|
||||||
|
---
|
||||||
|
|
||||||
|
## Optimize SVG Precision
|
||||||
|
|
||||||
|
Reduce SVG coordinate precision to decrease file size. The optimal precision depends on the viewBox size, but in general reducing precision should be considered.
|
||||||
|
|
||||||
|
**Incorrect (excessive precision):**
|
||||||
|
|
||||||
|
```svg
|
||||||
|
<path d="M 10.293847 20.847362 L 30.938472 40.192837" />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (1 decimal place):**
|
||||||
|
|
||||||
|
```svg
|
||||||
|
<path d="M 10.3 20.8 L 30.9 40.2" />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Automate with SVGO:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx svgo --precision=1 --multipass icon.svg
|
||||||
|
```
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
---
|
||||||
|
title: Use useTransition Over Manual Loading States
|
||||||
|
impact: LOW
|
||||||
|
impactDescription: reduces re-renders and improves code clarity
|
||||||
|
tags: rendering, transitions, useTransition, loading, state
|
||||||
|
---
|
||||||
|
|
||||||
|
## Use useTransition Over Manual Loading States
|
||||||
|
|
||||||
|
Use `useTransition` instead of manual `useState` for loading states. This provides built-in `isPending` state and automatically manages transitions.
|
||||||
|
|
||||||
|
**Incorrect (manual loading state):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function SearchResults() {
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [results, setResults] = useState([])
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
|
const handleSearch = async (value: string) => {
|
||||||
|
setIsLoading(true)
|
||||||
|
setQuery(value)
|
||||||
|
const data = await fetchResults(value)
|
||||||
|
setResults(data)
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<input onChange={(e) => handleSearch(e.target.value)} />
|
||||||
|
{isLoading && <Spinner />}
|
||||||
|
<ResultsList results={results} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (useTransition with built-in pending state):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useTransition, useState } from 'react'
|
||||||
|
|
||||||
|
function SearchResults() {
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [results, setResults] = useState([])
|
||||||
|
const [isPending, startTransition] = useTransition()
|
||||||
|
|
||||||
|
const handleSearch = (value: string) => {
|
||||||
|
setQuery(value) // Update input immediately
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
// Fetch and update results
|
||||||
|
const data = await fetchResults(value)
|
||||||
|
setResults(data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<input onChange={(e) => handleSearch(e.target.value)} />
|
||||||
|
{isPending && <Spinner />}
|
||||||
|
<ResultsList results={results} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
|
||||||
|
- **Automatic pending state**: No need to manually manage `setIsLoading(true/false)`
|
||||||
|
- **Error resilience**: Pending state correctly resets even if the transition throws
|
||||||
|
- **Better responsiveness**: Keeps the UI responsive during updates
|
||||||
|
- **Interrupt handling**: New transitions automatically cancel pending ones
|
||||||
|
|
||||||
|
Reference: [useTransition](https://react.dev/reference/react/useTransition)
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
---
|
||||||
|
title: Defer State Reads to Usage Point
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: avoids unnecessary subscriptions
|
||||||
|
tags: rerender, searchParams, localStorage, optimization
|
||||||
|
---
|
||||||
|
|
||||||
|
## Defer State Reads to Usage Point
|
||||||
|
|
||||||
|
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 <button onClick={handleShare}>Share</button>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**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 <button onClick={handleShare}>Share</button>
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
title: Narrow Effect Dependencies
|
||||||
|
impact: LOW
|
||||||
|
impactDescription: minimizes effect re-runs
|
||||||
|
tags: rerender, useEffect, dependencies, optimization
|
||||||
|
---
|
||||||
|
|
||||||
|
## Narrow Effect Dependencies
|
||||||
|
|
||||||
|
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])
|
||||||
|
```
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
title: Calculate Derived State During Rendering
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: avoids redundant renders and state drift
|
||||||
|
tags: rerender, derived-state, useEffect, state
|
||||||
|
---
|
||||||
|
|
||||||
|
## Calculate Derived State During Rendering
|
||||||
|
|
||||||
|
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 <p>{fullName}</p>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (derive during render):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function Form() {
|
||||||
|
const [firstName, setFirstName] = useState('First')
|
||||||
|
const [lastName, setLastName] = useState('Last')
|
||||||
|
const fullName = firstName + ' ' + lastName
|
||||||
|
|
||||||
|
return <p>{fullName}</p>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
References: [You Might Not Need an Effect](https://react.dev/learn/you-might-not-need-an-effect)
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
title: Subscribe to Derived State
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: reduces re-render frequency
|
||||||
|
tags: rerender, derived-state, media-query, optimization
|
||||||
|
---
|
||||||
|
|
||||||
|
## Subscribe to Derived State
|
||||||
|
|
||||||
|
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 <nav className={isMobile ? 'mobile' : 'desktop'} />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (re-renders only when boolean changes):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function Sidebar() {
|
||||||
|
const isMobile = useMediaQuery('(max-width: 767px)')
|
||||||
|
return <nav className={isMobile ? 'mobile' : 'desktop'} />
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
---
|
||||||
|
title: Use Functional setState Updates
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: prevents stale closures and unnecessary callback recreations
|
||||||
|
tags: react, hooks, useState, useCallback, callbacks, closures
|
||||||
|
---
|
||||||
|
|
||||||
|
## Use Functional setState Updates
|
||||||
|
|
||||||
|
When updating state based on the current state value, use the functional update form of setState instead of directly referencing the state variable. This prevents stale closures, eliminates unnecessary dependencies, and creates stable callback references.
|
||||||
|
|
||||||
|
**Incorrect (requires state as dependency):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function TodoList() {
|
||||||
|
const [items, setItems] = useState(initialItems)
|
||||||
|
|
||||||
|
// Callback must depend on items, recreated on every items change
|
||||||
|
const addItems = useCallback((newItems: Item[]) => {
|
||||||
|
setItems([...items, ...newItems])
|
||||||
|
}, [items]) // ❌ items dependency causes recreations
|
||||||
|
|
||||||
|
// Risk of stale closure if dependency is forgotten
|
||||||
|
const removeItem = useCallback((id: string) => {
|
||||||
|
setItems(items.filter(item => item.id !== id))
|
||||||
|
}, []) // ❌ Missing items dependency - will use stale items!
|
||||||
|
|
||||||
|
return <ItemsEditor items={items} onAdd={addItems} onRemove={removeItem} />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The first callback is recreated every time `items` changes, which can cause child components to re-render unnecessarily. The second callback has a stale closure bug—it will always reference the initial `items` value.
|
||||||
|
|
||||||
|
**Correct (stable callbacks, no stale closures):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function TodoList() {
|
||||||
|
const [items, setItems] = useState(initialItems)
|
||||||
|
|
||||||
|
// Stable callback, never recreated
|
||||||
|
const addItems = useCallback((newItems: Item[]) => {
|
||||||
|
setItems(curr => [...curr, ...newItems])
|
||||||
|
}, []) // ✅ No dependencies needed
|
||||||
|
|
||||||
|
// Always uses latest state, no stale closure risk
|
||||||
|
const removeItem = useCallback((id: string) => {
|
||||||
|
setItems(curr => curr.filter(item => item.id !== id))
|
||||||
|
}, []) // ✅ Safe and stable
|
||||||
|
|
||||||
|
return <ItemsEditor items={items} onAdd={addItems} onRemove={removeItem} />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
|
||||||
|
1. **Stable callback references** - Callbacks don't need to be recreated when state changes
|
||||||
|
2. **No stale closures** - Always operates on the latest state value
|
||||||
|
3. **Fewer dependencies** - Simplifies dependency arrays and reduces memory leaks
|
||||||
|
4. **Prevents bugs** - Eliminates the most common source of React closure bugs
|
||||||
|
|
||||||
|
**When to use functional updates:**
|
||||||
|
|
||||||
|
- Any setState that depends on the current state value
|
||||||
|
- Inside useCallback/useMemo when state is needed
|
||||||
|
- Event handlers that reference state
|
||||||
|
- Async operations that update state
|
||||||
|
|
||||||
|
**When direct updates are fine:**
|
||||||
|
|
||||||
|
- Setting state to a static value: `setCount(0)`
|
||||||
|
- Setting state from props/arguments only: `setName(newName)`
|
||||||
|
- State doesn't depend on previous value
|
||||||
|
|
||||||
|
**Note:** If your project has [React Compiler](https://react.dev/learn/react-compiler) enabled, the compiler can automatically optimize some cases, but functional updates are still recommended for correctness and to prevent stale closure bugs.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
---
|
||||||
|
title: Use Lazy State Initialization
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: wasted computation on every render
|
||||||
|
tags: react, hooks, useState, performance, initialization
|
||||||
|
---
|
||||||
|
|
||||||
|
## Use Lazy State Initialization
|
||||||
|
|
||||||
|
Pass a function to `useState` for expensive initial values. Without the function form, the initializer runs on every render even though the value is only used once.
|
||||||
|
|
||||||
|
**Incorrect (runs on every render):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function FilteredList({ items }: { items: Item[] }) {
|
||||||
|
// buildSearchIndex() runs on EVERY render, even after initialization
|
||||||
|
const [searchIndex, setSearchIndex] = useState(buildSearchIndex(items))
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
|
||||||
|
// When query changes, buildSearchIndex runs again unnecessarily
|
||||||
|
return <SearchResults index={searchIndex} query={query} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserProfile() {
|
||||||
|
// JSON.parse runs on every render
|
||||||
|
const [settings, setSettings] = useState(
|
||||||
|
JSON.parse(localStorage.getItem('settings') || '{}')
|
||||||
|
)
|
||||||
|
|
||||||
|
return <SettingsForm settings={settings} onChange={setSettings} />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (runs only once):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function FilteredList({ items }: { items: Item[] }) {
|
||||||
|
// buildSearchIndex() runs ONLY on initial render
|
||||||
|
const [searchIndex, setSearchIndex] = useState(() => buildSearchIndex(items))
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
|
||||||
|
return <SearchResults index={searchIndex} query={query} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserProfile() {
|
||||||
|
// JSON.parse runs only on initial render
|
||||||
|
const [settings, setSettings] = useState(() => {
|
||||||
|
const stored = localStorage.getItem('settings')
|
||||||
|
return stored ? JSON.parse(stored) : {}
|
||||||
|
})
|
||||||
|
|
||||||
|
return <SettingsForm settings={settings} onChange={setSettings} />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use lazy initialization when computing initial values from localStorage/sessionStorage, building data structures (indexes, maps), reading from the DOM, or performing heavy transformations.
|
||||||
|
|
||||||
|
For simple primitives (`useState(0)`), direct references (`useState(props.value)`), or cheap literals (`useState({})`), the function form is unnecessary.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
|
||||||
|
title: Extract Default Non-primitive Parameter Value from Memoized Component to Constant
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: restores memoization by using a constant for default value
|
||||||
|
tags: rerender, memo, optimization
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Extract Default Non-primitive Parameter Value from Memoized Component to Constant
|
||||||
|
|
||||||
|
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
|
||||||
|
<UserAvatar />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (stable default value):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const NOOP = () => {};
|
||||||
|
|
||||||
|
const UserAvatar = memo(function UserAvatar({ onClick = NOOP }: { onClick?: () => void }) {
|
||||||
|
// ...
|
||||||
|
})
|
||||||
|
|
||||||
|
// Used without optional onClick
|
||||||
|
<UserAvatar />
|
||||||
|
```
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
title: Extract to Memoized Components
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: enables early returns
|
||||||
|
tags: rerender, memo, useMemo, optimization
|
||||||
|
---
|
||||||
|
|
||||||
|
## Extract to Memoized Components
|
||||||
|
|
||||||
|
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 <Avatar id={id} />
|
||||||
|
}, [user])
|
||||||
|
|
||||||
|
if (loading) return <Skeleton />
|
||||||
|
return <div>{avatar}</div>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (skips computation when loading):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const UserAvatar = memo(function UserAvatar({ user }: { user: User }) {
|
||||||
|
const id = useMemo(() => computeAvatarId(user), [user])
|
||||||
|
return <Avatar id={id} />
|
||||||
|
})
|
||||||
|
|
||||||
|
function Profile({ user, loading }: Props) {
|
||||||
|
if (loading) return <Skeleton />
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<UserAvatar user={user} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**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.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
title: Put Interaction Logic in Event Handlers
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: avoids effect re-runs and duplicate side effects
|
||||||
|
tags: rerender, useEffect, events, side-effects, dependencies
|
||||||
|
---
|
||||||
|
|
||||||
|
## Put Interaction Logic in Event Handlers
|
||||||
|
|
||||||
|
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 <button onClick={() => setSubmitted(true)}>Submit</button>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (do it in the handler):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function Form() {
|
||||||
|
const theme = useContext(ThemeContext)
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
post('/api/register')
|
||||||
|
showToast('Registered', theme)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <button onClick={handleSubmit}>Submit</button>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Reference: [Should this code move to an event handler?](https://react.dev/learn/removing-effect-dependencies#should-this-code-move-to-an-event-handler)
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
---
|
||||||
|
title: Don't Define Components Inside Components
|
||||||
|
impact: HIGH
|
||||||
|
impactDescription: prevents remount on every render
|
||||||
|
tags: rerender, components, remount, performance
|
||||||
|
---
|
||||||
|
|
||||||
|
## Don't Define Components Inside Components
|
||||||
|
|
||||||
|
**Impact: HIGH (prevents remount on every render)**
|
||||||
|
|
||||||
|
Defining a component inside another component creates a new component type on every render. React sees a different component each time and fully remounts it, destroying all state and DOM.
|
||||||
|
|
||||||
|
A common reason developers do this is to access parent variables without passing props. Always pass props instead.
|
||||||
|
|
||||||
|
**Incorrect (remounts on every render):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function UserProfile({ user, theme }) {
|
||||||
|
// Defined inside to access `theme` - BAD
|
||||||
|
const Avatar = () => (
|
||||||
|
<img
|
||||||
|
src={user.avatarUrl}
|
||||||
|
className={theme === 'dark' ? 'avatar-dark' : 'avatar-light'}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Defined inside to access `user` - BAD
|
||||||
|
const Stats = () => (
|
||||||
|
<div>
|
||||||
|
<span>{user.followers} followers</span>
|
||||||
|
<span>{user.posts} posts</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Avatar />
|
||||||
|
<Stats />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Every time `UserProfile` renders, `Avatar` and `Stats` are new component types. React unmounts the old instances and mounts new ones, losing any internal state, running effects again, and recreating DOM nodes.
|
||||||
|
|
||||||
|
**Correct (pass props instead):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function Avatar({ src, theme }: { src: string; theme: string }) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
className={theme === 'dark' ? 'avatar-dark' : 'avatar-light'}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stats({ followers, posts }: { followers: number; posts: number }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span>{followers} followers</span>
|
||||||
|
<span>{posts} posts</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserProfile({ user, theme }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Avatar src={user.avatarUrl} theme={theme} />
|
||||||
|
<Stats followers={user.followers} posts={user.posts} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Symptoms of this bug:**
|
||||||
|
- Input fields lose focus on every keystroke
|
||||||
|
- Animations restart unexpectedly
|
||||||
|
- `useEffect` cleanup/setup runs on every parent render
|
||||||
|
- Scroll position resets inside the component
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
---
|
||||||
|
title: Do not wrap a simple expression with a primitive result type in useMemo
|
||||||
|
impact: LOW-MEDIUM
|
||||||
|
impactDescription: wasted computation on every render
|
||||||
|
tags: rerender, useMemo, optimization
|
||||||
|
---
|
||||||
|
|
||||||
|
## Do not wrap a simple expression with a primitive result type in useMemo
|
||||||
|
|
||||||
|
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 <Skeleton />
|
||||||
|
// return some markup
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function Header({ user, notifications }: Props) {
|
||||||
|
const isLoading = user.isLoading || notifications.isLoading
|
||||||
|
|
||||||
|
if (isLoading) return <Skeleton />
|
||||||
|
// return some markup
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
---
|
||||||
|
title: Split Combined Hook Computations
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: avoids recomputing independent steps
|
||||||
|
tags: rerender, useMemo, useEffect, dependencies, optimization
|
||||||
|
---
|
||||||
|
|
||||||
|
## Split Combined Hook Computations
|
||||||
|
|
||||||
|
When a hook contains multiple independent tasks with different dependencies, split them into separate hooks. A combined hook reruns all tasks when any dependency changes, even if some tasks don't use the changed value.
|
||||||
|
|
||||||
|
**Incorrect (changing `sortOrder` recomputes filtering):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const sortedProducts = useMemo(() => {
|
||||||
|
const filtered = products.filter((p) => p.category === category)
|
||||||
|
const sorted = filtered.toSorted((a, b) =>
|
||||||
|
sortOrder === "asc" ? a.price - b.price : b.price - a.price
|
||||||
|
)
|
||||||
|
return sorted
|
||||||
|
}, [products, category, sortOrder])
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (filtering only recomputes when products or category change):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const filteredProducts = useMemo(
|
||||||
|
() => products.filter((p) => p.category === category),
|
||||||
|
[products, category]
|
||||||
|
)
|
||||||
|
|
||||||
|
const sortedProducts = useMemo(
|
||||||
|
() =>
|
||||||
|
filteredProducts.toSorted((a, b) =>
|
||||||
|
sortOrder === "asc" ? a.price - b.price : b.price - a.price
|
||||||
|
),
|
||||||
|
[filteredProducts, sortOrder]
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
This pattern also applies to `useEffect` when combining unrelated side effects:
|
||||||
|
|
||||||
|
**Incorrect (both effects run when either dependency changes):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
useEffect(() => {
|
||||||
|
analytics.trackPageView(pathname)
|
||||||
|
document.title = `${pageTitle} | My App`
|
||||||
|
}, [pathname, pageTitle])
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (effects run independently):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
useEffect(() => {
|
||||||
|
analytics.trackPageView(pathname)
|
||||||
|
}, [pathname])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = `${pageTitle} | My App`
|
||||||
|
}, [pageTitle])
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** If your project has [React Compiler](https://react.dev/learn/react-compiler) enabled, it automatically optimizes dependency tracking and may handle some of these cases for you.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
title: Use Transitions for Non-Urgent Updates
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: maintains UI responsiveness
|
||||||
|
tags: rerender, transitions, startTransition, performance
|
||||||
|
---
|
||||||
|
|
||||||
|
## Use Transitions for Non-Urgent Updates
|
||||||
|
|
||||||
|
Mark frequent, non-urgent state updates as transitions to maintain UI responsiveness.
|
||||||
|
|
||||||
|
**Incorrect (blocks UI on every scroll):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function ScrollTracker() {
|
||||||
|
const [scrollY, setScrollY] = useState(0)
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => setScrollY(window.scrollY)
|
||||||
|
window.addEventListener('scroll', handler, { passive: true })
|
||||||
|
return () => window.removeEventListener('scroll', handler)
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (non-blocking updates):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { startTransition } from 'react'
|
||||||
|
|
||||||
|
function ScrollTracker() {
|
||||||
|
const [scrollY, setScrollY] = useState(0)
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => {
|
||||||
|
startTransition(() => setScrollY(window.scrollY))
|
||||||
|
}
|
||||||
|
window.addEventListener('scroll', handler, { passive: true })
|
||||||
|
return () => window.removeEventListener('scroll', handler)
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
---
|
||||||
|
title: Use useDeferredValue for Expensive Derived Renders
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: keeps input responsive during heavy computation
|
||||||
|
tags: rerender, useDeferredValue, optimization, concurrent
|
||||||
|
---
|
||||||
|
|
||||||
|
## Use useDeferredValue for Expensive Derived Renders
|
||||||
|
|
||||||
|
When user input triggers expensive computations or renders, use `useDeferredValue` to keep the input responsive. The deferred value lags behind, allowing React to prioritize the input update and render the expensive result when idle.
|
||||||
|
|
||||||
|
**Incorrect (input feels laggy while filtering):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function Search({ items }: { items: Item[] }) {
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const filtered = items.filter(item => fuzzyMatch(item, query))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<input value={query} onChange={e => setQuery(e.target.value)} />
|
||||||
|
<ResultsList results={filtered} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (input stays snappy, results render when ready):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function Search({ items }: { items: Item[] }) {
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const deferredQuery = useDeferredValue(query)
|
||||||
|
const filtered = useMemo(
|
||||||
|
() => items.filter(item => fuzzyMatch(item, deferredQuery)),
|
||||||
|
[items, deferredQuery]
|
||||||
|
)
|
||||||
|
const isStale = query !== deferredQuery
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<input value={query} onChange={e => setQuery(e.target.value)} />
|
||||||
|
<div style={{ opacity: isStale ? 0.7 : 1 }}>
|
||||||
|
<ResultsList results={filtered} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**When to use:**
|
||||||
|
|
||||||
|
- Filtering/searching large lists
|
||||||
|
- Expensive visualizations (charts, graphs) reacting to input
|
||||||
|
- Any derived state that causes noticeable render delays
|
||||||
|
|
||||||
|
**Note:** Wrap the expensive computation in `useMemo` with the deferred value as a dependency, otherwise it still runs on every render.
|
||||||
|
|
||||||
|
Reference: [React useDeferredValue](https://react.dev/reference/react/useDeferredValue)
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
---
|
||||||
|
title: Use useRef for Transient Values
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: avoids unnecessary re-renders on frequent updates
|
||||||
|
tags: rerender, useref, state, performance
|
||||||
|
---
|
||||||
|
|
||||||
|
## Use useRef for Transient Values
|
||||||
|
|
||||||
|
When a value changes frequently and you don't want a re-render on every update (e.g., mouse trackers, intervals, transient flags), store it in `useRef` instead of `useState`. Keep component state for UI; use refs for temporary DOM-adjacent values. Updating a ref does not trigger a re-render.
|
||||||
|
|
||||||
|
**Incorrect (renders every update):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function Tracker() {
|
||||||
|
const [lastX, setLastX] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onMove = (e: MouseEvent) => setLastX(e.clientX)
|
||||||
|
window.addEventListener('mousemove', onMove)
|
||||||
|
return () => window.removeEventListener('mousemove', onMove)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: lastX,
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
background: 'black',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (no re-render for tracking):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function Tracker() {
|
||||||
|
const lastXRef = useRef(0)
|
||||||
|
const dotRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onMove = (e: MouseEvent) => {
|
||||||
|
lastXRef.current = e.clientX
|
||||||
|
const node = dotRef.current
|
||||||
|
if (node) {
|
||||||
|
node.style.transform = `translateX(${e.clientX}px)`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('mousemove', onMove)
|
||||||
|
return () => window.removeEventListener('mousemove', onMove)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={dotRef}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
background: 'black',
|
||||||
|
transform: 'translateX(0px)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
---
|
||||||
|
title: Use after() for Non-Blocking Operations
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: faster response times
|
||||||
|
tags: server, async, logging, analytics, side-effects
|
||||||
|
---
|
||||||
|
|
||||||
|
## Use after() for Non-Blocking Operations
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
---
|
||||||
|
title: Authenticate Server Actions Like API Routes
|
||||||
|
impact: CRITICAL
|
||||||
|
impactDescription: prevents unauthorized access to server mutations
|
||||||
|
tags: server, server-actions, authentication, security, authorization
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
title: Cross-Request LRU Caching
|
||||||
|
impact: HIGH
|
||||||
|
impactDescription: caches across requests
|
||||||
|
tags: server, cache, lru, cross-request
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cross-Request LRU Caching
|
||||||
|
|
||||||
|
`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<string, any>({
|
||||||
|
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)
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
---
|
||||||
|
title: Per-Request Deduplication with React.cache()
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: deduplicates within request
|
||||||
|
tags: server, cache, react-cache, deduplication
|
||||||
|
---
|
||||||
|
|
||||||
|
## Per-Request Deduplication with React.cache()
|
||||||
|
|
||||||
|
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 getUser = cache(async (uid: number) => {
|
||||||
|
return await db.user.findUnique({ where: { id: uid } })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Primitive args use value equality
|
||||||
|
getUser(1)
|
||||||
|
getUser(1) // Cache hit, returns cached result
|
||||||
|
```
|
||||||
|
|
||||||
|
If you must pass objects, pass the same reference:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const params = { uid: 1 }
|
||||||
|
getUser(params) // Query runs
|
||||||
|
getUser(params) // Cache hit (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: [React.cache documentation](https://react.dev/reference/react/cache)
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
---
|
||||||
|
title: Avoid Duplicate Serialization in RSC Props
|
||||||
|
impact: LOW
|
||||||
|
impactDescription: reduces network payload by avoiding duplicate serialization
|
||||||
|
tags: server, rsc, serialization, props, client-components
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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)
|
||||||
|
<ClientList usernames={usernames} usernamesOrdered={usernames.toSorted()} />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (sends 3 strings):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// RSC: send once
|
||||||
|
<ClientList usernames={usernames} />
|
||||||
|
|
||||||
|
// Client: transform there
|
||||||
|
'use client'
|
||||||
|
const sorted = useMemo(() => [...usernames].sort(), [usernames])
|
||||||
|
```
|
||||||
|
|
||||||
|
**Nested deduplication behavior:**
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
```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)
|
||||||
|
```
|
||||||
|
|
||||||
|
**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
|
||||||
|
<C users={users} active={users.filter(u => u.active)} />
|
||||||
|
<C product={product} productName={product.name} />
|
||||||
|
|
||||||
|
// ✅ Good
|
||||||
|
<C users={users} />
|
||||||
|
<C product={product} />
|
||||||
|
// Do filtering/destructuring in client
|
||||||
|
```
|
||||||
|
|
||||||
|
**Exception:** Pass derived data when transformation is expensive or client doesn't need original.
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
---
|
||||||
|
title: Hoist Static I/O to Module Level
|
||||||
|
impact: HIGH
|
||||||
|
impactDescription: avoids repeated file/network I/O per request
|
||||||
|
tags: server, io, performance, next.js, route-handlers, og-image
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hoist Static I/O to Module Level
|
||||||
|
|
||||||
|
**Impact: HIGH (avoids repeated file/network I/O per request)**
|
||||||
|
|
||||||
|
When loading static assets (fonts, logos, images, config files) in route handlers or server functions, hoist the I/O operation to module level. Module-level code runs once when the module is first imported, not on every request. This eliminates redundant file system reads or network fetches that would otherwise run on every invocation.
|
||||||
|
|
||||||
|
**Incorrect (reads font file on every request):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// app/api/og/route.tsx
|
||||||
|
import { ImageResponse } from 'next/og'
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
// Runs on EVERY request - expensive!
|
||||||
|
const fontData = await fetch(
|
||||||
|
new URL('./fonts/Inter.ttf', import.meta.url)
|
||||||
|
).then(res => res.arrayBuffer())
|
||||||
|
|
||||||
|
const logoData = await fetch(
|
||||||
|
new URL('./images/logo.png', import.meta.url)
|
||||||
|
).then(res => res.arrayBuffer())
|
||||||
|
|
||||||
|
return new ImageResponse(
|
||||||
|
<div style={{ fontFamily: 'Inter' }}>
|
||||||
|
<img src={logoData} />
|
||||||
|
Hello World
|
||||||
|
</div>,
|
||||||
|
{ fonts: [{ name: 'Inter', data: fontData }] }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (loads once at module initialization):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// app/api/og/route.tsx
|
||||||
|
import { ImageResponse } from 'next/og'
|
||||||
|
|
||||||
|
// Module-level: runs ONCE when module is first imported
|
||||||
|
const fontData = fetch(
|
||||||
|
new URL('./fonts/Inter.ttf', import.meta.url)
|
||||||
|
).then(res => res.arrayBuffer())
|
||||||
|
|
||||||
|
const logoData = fetch(
|
||||||
|
new URL('./images/logo.png', import.meta.url)
|
||||||
|
).then(res => res.arrayBuffer())
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
// Await the already-started promises
|
||||||
|
const [font, logo] = await Promise.all([fontData, logoData])
|
||||||
|
|
||||||
|
return new ImageResponse(
|
||||||
|
<div style={{ fontFamily: 'Inter' }}>
|
||||||
|
<img src={logo} />
|
||||||
|
Hello World
|
||||||
|
</div>,
|
||||||
|
{ fonts: [{ name: 'Inter', data: font }] }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (synchronous fs at module level):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// app/api/og/route.tsx
|
||||||
|
import { ImageResponse } from 'next/og'
|
||||||
|
import { readFileSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
|
// Synchronous read at module level - blocks only during module init
|
||||||
|
const fontData = readFileSync(
|
||||||
|
join(process.cwd(), 'public/fonts/Inter.ttf')
|
||||||
|
)
|
||||||
|
|
||||||
|
const logoData = readFileSync(
|
||||||
|
join(process.cwd(), 'public/images/logo.png')
|
||||||
|
)
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
return new ImageResponse(
|
||||||
|
<div style={{ fontFamily: 'Inter' }}>
|
||||||
|
<img src={logoData} />
|
||||||
|
Hello World
|
||||||
|
</div>,
|
||||||
|
{ fonts: [{ name: 'Inter', data: fontData }] }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Incorrect (reads config on every call):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import fs from 'node:fs/promises'
|
||||||
|
|
||||||
|
export async function processRequest(data: Data) {
|
||||||
|
const config = JSON.parse(
|
||||||
|
await fs.readFile('./config.json', 'utf-8')
|
||||||
|
)
|
||||||
|
const template = await fs.readFile('./template.html', 'utf-8')
|
||||||
|
|
||||||
|
return render(template, data, config)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (hoists config and template to module level):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import fs from 'node:fs/promises'
|
||||||
|
|
||||||
|
const configPromise = fs
|
||||||
|
.readFile('./config.json', 'utf-8')
|
||||||
|
.then(JSON.parse)
|
||||||
|
const templatePromise = fs.readFile('./template.html', 'utf-8')
|
||||||
|
|
||||||
|
export async function processRequest(data: Data) {
|
||||||
|
const [config, template] = await Promise.all([
|
||||||
|
configPromise,
|
||||||
|
templatePromise,
|
||||||
|
])
|
||||||
|
|
||||||
|
return render(template, data, config)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When to use this pattern:
|
||||||
|
|
||||||
|
- Loading fonts for OG image generation
|
||||||
|
- Loading static logos, icons, or watermarks
|
||||||
|
- Reading configuration files that don't change at runtime
|
||||||
|
- Loading email templates or other static templates
|
||||||
|
- Any static asset that's the same across all requests
|
||||||
|
|
||||||
|
When not to use this pattern:
|
||||||
|
|
||||||
|
- Assets that vary per request or user
|
||||||
|
- Files that may change during runtime (use caching with TTL instead)
|
||||||
|
- Large files that would consume too much memory if kept loaded
|
||||||
|
- Sensitive data that shouldn't persist in memory
|
||||||
|
|
||||||
|
With Vercel's [Fluid Compute](https://vercel.com/docs/fluid-compute), module-level caching is especially effective because multiple concurrent requests share the same function instance. The static assets stay loaded in memory across requests without cold start penalties.
|
||||||
|
|
||||||
|
In traditional serverless, each cold start re-executes module-level code, but subsequent warm invocations reuse the loaded assets until the instance is recycled.
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
---
|
||||||
|
title: Avoid Shared Module State for Request Data
|
||||||
|
impact: HIGH
|
||||||
|
impactDescription: prevents concurrency bugs and request data leaks
|
||||||
|
tags: server, rsc, ssr, concurrency, security, state
|
||||||
|
---
|
||||||
|
|
||||||
|
## Avoid Shared Module State for Request Data
|
||||||
|
|
||||||
|
For React Server Components and client components rendered during SSR, avoid using mutable module-level variables to share request-scoped data. Server renders can run concurrently in the same process. If one render writes to shared module state and another render reads it, you can get race conditions, cross-request contamination, and security bugs where one user's data appears in another user's response.
|
||||||
|
|
||||||
|
Treat module scope on the server as process-wide shared memory, not request-local state.
|
||||||
|
|
||||||
|
**Incorrect (request data leaks across concurrent renders):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
let currentUser: User | null = null
|
||||||
|
|
||||||
|
export default async function Page() {
|
||||||
|
currentUser = await auth()
|
||||||
|
return <Dashboard />
|
||||||
|
}
|
||||||
|
|
||||||
|
async function Dashboard() {
|
||||||
|
return <div>{currentUser?.name}</div>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If two requests overlap, request A can set `currentUser`, then request B overwrites it before request A finishes rendering `Dashboard`.
|
||||||
|
|
||||||
|
**Correct (keep request data local to the render tree):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export default async function Page() {
|
||||||
|
const user = await auth()
|
||||||
|
return <Dashboard user={user} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function Dashboard({ user }: { user: User | null }) {
|
||||||
|
return <div>{user?.name}</div>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Safe exceptions:
|
||||||
|
|
||||||
|
- Immutable static assets or config loaded once at module scope
|
||||||
|
- Shared caches intentionally designed for cross-request reuse and keyed correctly
|
||||||
|
- Process-wide singletons that do not store request- or user-specific mutable data
|
||||||
|
|
||||||
|
For static assets and config, see [Hoist Static I/O to Module Level](./server-hoist-static-io.md).
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
---
|
||||||
|
title: Parallel Data Fetching with Component Composition
|
||||||
|
impact: CRITICAL
|
||||||
|
impactDescription: eliminates server-side waterfalls
|
||||||
|
tags: server, rsc, parallel-fetching, composition
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Data Fetching with Component Composition
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<div>{header}</div>
|
||||||
|
<Sidebar />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function Sidebar() {
|
||||||
|
const items = await fetchSidebarItems()
|
||||||
|
return <nav>{items.map(renderItem)}</nav>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (both fetch simultaneously):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
async function Header() {
|
||||||
|
const data = await fetchHeader()
|
||||||
|
return <div>{data}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
async function Sidebar() {
|
||||||
|
const items = await fetchSidebarItems()
|
||||||
|
return <nav>{items.map(renderItem)}</nav>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Header />
|
||||||
|
<Sidebar />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Alternative with children prop:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
async function Header() {
|
||||||
|
const data = await fetchHeader()
|
||||||
|
return <div>{data}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
async function Sidebar() {
|
||||||
|
const items = await fetchSidebarItems()
|
||||||
|
return <nav>{items.map(renderItem)}</nav>
|
||||||
|
}
|
||||||
|
|
||||||
|
function Layout({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Header />
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<Sidebar />
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
title: Parallel Nested Data Fetching
|
||||||
|
impact: CRITICAL
|
||||||
|
impactDescription: eliminates server-side waterfalls
|
||||||
|
tags: server, rsc, parallel-fetching, promise-chaining
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Nested Data Fetching
|
||||||
|
|
||||||
|
When fetching nested data in parallel, chain dependent fetches within each item's promise so a slow item doesn't block the rest.
|
||||||
|
|
||||||
|
**Incorrect (a single slow item blocks all nested fetches):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const chats = await Promise.all(
|
||||||
|
chatIds.map(id => getChat(id))
|
||||||
|
)
|
||||||
|
|
||||||
|
const chatAuthors = await Promise.all(
|
||||||
|
chats.map(chat => getUser(chat.author))
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
If one `getChat(id)` out of 100 is extremely slow, the authors of the other 99 chats can't start loading even though their data is ready.
|
||||||
|
|
||||||
|
**Correct (each item chains its own nested fetch):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const chatAuthors = await Promise.all(
|
||||||
|
chatIds.map(id => getChat(id).then(chat => getUser(chat.author)))
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Each item independently chains `getChat` → `getUser`, so a slow chat doesn't block author fetches for the others.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
title: Minimize Serialization at RSC Boundaries
|
||||||
|
impact: HIGH
|
||||||
|
impactDescription: reduces data transfer size
|
||||||
|
tags: server, rsc, serialization, props
|
||||||
|
---
|
||||||
|
|
||||||
|
## Minimize Serialization at RSC Boundaries
|
||||||
|
|
||||||
|
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 <Profile user={user} />
|
||||||
|
}
|
||||||
|
|
||||||
|
'use client'
|
||||||
|
function Profile({ user }: { user: User }) {
|
||||||
|
return <div>{user.name}</div> // uses 1 field
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (serializes only 1 field):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
async function Page() {
|
||||||
|
const user = await fetchUser()
|
||||||
|
return <Profile name={user.name} />
|
||||||
|
}
|
||||||
|
|
||||||
|
'use client'
|
||||||
|
function Profile({ name }: { name: string }) {
|
||||||
|
return <div>{name}</div>
|
||||||
|
}
|
||||||
|
```
|
||||||
20
.claude/settings.json
Normal file
20
.claude/settings.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"enabledPlugins": {
|
||||||
|
"superpowers@superpowers-marketplace": false,
|
||||||
|
"prd-development@pm-skills": false
|
||||||
|
},
|
||||||
|
"extraKnownMarketplaces": {
|
||||||
|
"superpowers-marketplace": {
|
||||||
|
"source": {
|
||||||
|
"source": "github",
|
||||||
|
"repo": "obra/superpowers-marketplace"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pm-skills": {
|
||||||
|
"source": {
|
||||||
|
"source": "github",
|
||||||
|
"repo": "deanpeters/Product-Manager-Skills"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
.claude/skills/find-skills
Symbolic link
1
.claude/skills/find-skills
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../.agents/skills/find-skills
|
||||||
101
.claude/skills/nextjs-app-architecture/SKILL.md
Normal file
101
.claude/skills/nextjs-app-architecture/SKILL.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
---
|
||||||
|
name: nextjs-app-architecture
|
||||||
|
description: Build or audit Next.js 16 App Router apps using a next-beats-style React Server Components architecture. Use when scaffolding a new app, adding a feature, reviewing an existing app, refactoring route-loader-shaped pages into feature-owned async server components, deciding where queries/actions/components live, keeping pages synchronous with `params.then()`, placing Suspense boundaries, choosing the client/server boundary, designing skeletons, preventing CLS, or enabling Cache Components. Also use when the user asks about RSC composition, components receiving IDs instead of route params, `'use cache'`, `cacheTag`, `updateTag`, static-shell prerendering, or making an app easier for AI agents to modify.
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
author: aurorascharff
|
||||||
|
version: "1.3.7"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Next.js App Architecture
|
||||||
|
|
||||||
|
A workflow for building and auditing Next.js 16+ App Router apps so they follow one consistent, feature-sliced RSC architecture like `next-beats`.
|
||||||
|
|
||||||
|
**Follow the workflow below step by step** — it produces the invariants by construction. Load the reference a step names for the decision it depends on. Get framework _mechanics_ (API signatures, config options, hook contracts) from the linked docs — don't restate or improvise them.
|
||||||
|
|
||||||
|
## Prerequisite
|
||||||
|
|
||||||
|
Before changing a Next.js app, make sure the project is set up for AI agents to read version-matched docs. Follow the [AI Coding Agents guide](https://preview.nextjs.org/docs/app/guides/ai-agents): prefer the project's `AGENTS.md` / bundled docs, and create or refresh them when missing. Then use this skill for architecture decisions.
|
||||||
|
|
||||||
|
## Architecture target
|
||||||
|
|
||||||
|
Build pages that describe the loading experience, not pages that act like route loaders:
|
||||||
|
|
||||||
|
- `app/**/page.tsx` and `layout.tsx` are synchronous composition surfaces: static chrome, section headings, `<Suspense>` boundaries, error boundaries, and transition wrappers.
|
||||||
|
- Feature components own their reads on the server. They receive minimal stable inputs (`id`, `slug`, `handle`, parsed filter values) or already-fetched records, never raw `params` / `searchParams`.
|
||||||
|
- Queries and actions live in the feature folder. Components import queries; client leaves import actions directly.
|
||||||
|
- When server tags and client query keys describe the same feature data, a pure feature-local cache contract owns those identities.
|
||||||
|
- Skeletons mirror the component tree and live beside the component they represent.
|
||||||
|
|
||||||
|
## Invariants (what every change must satisfy)
|
||||||
|
|
||||||
|
The non-negotiables. The workflow produces them; the final check verifies them.
|
||||||
|
|
||||||
|
1. **Pages compose, they never fetch.** A page/layout imports feature components and places `<Suspense>`. No queries, no domain logic, no route-specific components defined inline.
|
||||||
|
2. **Pages stay synchronous.** Use `params.then()` / `searchParams.then()`, never `await params` at the top — so chrome paints into the static shell and only data-dependent sections suspend.
|
||||||
|
3. **Feature components receive IDs, not route props.** Resolve `params` / `searchParams` at the page boundary and pass plain values (`id`, `slug`, `query`) into features.
|
||||||
|
4. **Async server component is the default.** `'use client'` only for hooks, event handlers, or browser APIs — and only on leaves, never on parents of server content.
|
||||||
|
5. **The page owns the Suspense boundary; the feature owns the skeleton.** Features never pre-wrap themselves in `<Suspense>`.
|
||||||
|
6. **Skeletons live in the same file as the component**, exported alongside it, defined at the end. `Feed` and `FeedSkeleton` are siblings.
|
||||||
|
7. **Queries live in `<domain>-queries.ts`** (`import 'server-only'`); **actions live in `<domain>-actions.ts`** (`'use server'`). The file name matches the folder, even for sub-concepts.
|
||||||
|
8. **One feature folder per real domain noun.** Sub-concepts (favorite, like, vote, bookmark, search) fold into the parent feature, never their own folder.
|
||||||
|
9. **Client components import actions directly** — never receive a server action as a prop just to call it.
|
||||||
|
10. **Feature-local cache coordination stays with its domain.** Put pure tags/keys in `<domain>-cache.ts`, client query definitions in `<domain>-query-options.ts`, hook wrappers in `hooks/use-*.ts`, and tiny client leaves in `components/`; promote support code only after real cross-feature reuse.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
Run these in order for build-from-scratch, feature work, or audits. Each step names the reference to consult and the check it must pass.
|
||||||
|
|
||||||
|
1. **Choose mode.**
|
||||||
|
- **Build from scratch:** sketch routes, real domain nouns, static shell, and expected loading groups before writing code.
|
||||||
|
- **Audit/refactor:** scan current `app/` pages first; list every async page, page-level query import, route prop leak, missing Suspense boundary, and feature folder mismatch.
|
||||||
|
→ `references/example.md` for the target shape; `references/feature-folders.md` for placement.
|
||||||
|
✓ You know whether you are creating the architecture or converting loader-shaped code into it.
|
||||||
|
2. **Place the work.** Decide the feature folder before writing anything.
|
||||||
|
→ `references/feature-folders.md` (decision tree + merge rules).
|
||||||
|
✓ A real domain, or folded into the right parent.
|
||||||
|
3. **Write the query and, when a client cache shares its data, the cache contract.** Put server reads in `<domain>-queries.ts` with `import 'server-only'`; keep shared tag/key identities in a pure `<domain>-cache.ts`.
|
||||||
|
→ `references/queries-actions.md`; for SWR/TanStack Query → `references/single-page-applications.md`; with `cacheComponents: true`, also → `references/cache-components.md`.
|
||||||
|
✓ Cache identities are defined once; server reads are server-only, cached/tagged/lifetimed under Cache Components, and return domain types rather than ORM rows.
|
||||||
|
4. **Write the action** (if there's a mutation). `features/<domain>/<domain>-actions.ts`, `'use server'` at the top.
|
||||||
|
→ `references/queries-actions.md`.
|
||||||
|
✓ Re-checks auth, validates input, invalidates matching cache tags under Cache Components (`refresh()` only for justified dynamic reads), returns a discriminated union.
|
||||||
|
5. **Build the component + skeleton.** `features/<domain>/components/<name>.tsx`: an async server component that awaits its own query from minimal props; `'use client'` only on interactive leaves.
|
||||||
|
→ `references/components.md`; for a client data library or strict-SPA/CSR feature → `references/single-page-applications.md`.
|
||||||
|
✓ Component receives IDs/handles/parsed filters or already-resolved records, not `params`; skeleton is a sibling export at the end; no alias skeleton wrappers.
|
||||||
|
6. **Compose the page.** `app/<route>/page.tsx`: synchronous, `params.then()`, place `<Suspense fallback={<NameSkeleton />}><Name /></Suspense>`, and wrap fallible sections in an error boundary.
|
||||||
|
→ `references/pages-suspense.md`.
|
||||||
|
✓ The page only composes; the boundary lives here, not in the feature; route props are resolved to plain values before reaching feature components.
|
||||||
|
7. **Add interaction** (if any): optimistic updates, pending state, toasts, confirmation.
|
||||||
|
→ `references/ux-patterns.md`.
|
||||||
|
✓ Feedback isn't doubled; destructive actions confirm; feature-owned client coordination stays with that feature instead of leaking into unrelated domains.
|
||||||
|
8. **Verify** against the checklist below before declaring done.
|
||||||
|
|
||||||
|
## Verify before done
|
||||||
|
|
||||||
|
Inspect the diff against every invariant — each is checkable by reading the changed files:
|
||||||
|
|
||||||
|
- [ ] No page/layout imports a `*-queries` file or defines a route-specific component inline.
|
||||||
|
- [ ] Every page with params is synchronous and uses `params.then()` / `searchParams.then()`.
|
||||||
|
- [ ] Feature components receive plain IDs/handles/parsed filters or resolved records; no feature prop is named `params` or `searchParams`.
|
||||||
|
- [ ] Every `<Suspense>` for page data sits in the page; no feature pre-wraps itself.
|
||||||
|
- [ ] Every component has its real `*Skeleton` in the same file, at the end; no tiny skeleton aliases just to pass props.
|
||||||
|
- [ ] Every `*-queries.ts` starts with `import 'server-only'`; every `*-actions.ts` with `'use server'`.
|
||||||
|
- [ ] With `cacheComponents: true`, reusable reads use `'use cache'` / `cacheTag` / `cacheLife`, or `'use cache: private'` / `'use cache: remote'` when appropriate; any dynamic read is intentional and justified.
|
||||||
|
- [ ] Mutations touching cached reads call `updateTag()` / `revalidateTag(..., 'max')` for the matching tags; `refresh()` is not a substitute for tag invalidation.
|
||||||
|
- [ ] Action files are named `<folder>-actions.ts`; no sub-concept spawned its own folder.
|
||||||
|
- [ ] Features with both server tags and client query keys define them once in a pure `<domain>-cache.ts`; queries, actions, hydration, query options, and hooks import from it.
|
||||||
|
- [ ] Feature-local client-support files sit in the smallest fitting place: query options at the feature root, `use-*` hook wrappers in `hooks/`, leaf components in `components/`, and shared support only after real cross-feature reuse.
|
||||||
|
- [ ] `'use client'` components are leaves — they import actions/hooks/providers, not async server components.
|
||||||
|
- [ ] Mutations validate their input and invalidate the affected data.
|
||||||
|
|
||||||
|
## Reference index
|
||||||
|
|
||||||
|
- **`references/feature-folders.md`** — where code goes: folder layout, cache contracts, naming, and merging sub-concepts.
|
||||||
|
- **`references/queries-actions.md`** — query/action rules: server-only, dedup, validation, invalidation, return shape.
|
||||||
|
- **`references/components.md`** — server/client boundary, skeletons, `use()`, single-use helpers, live data.
|
||||||
|
- **`references/pages-suspense.md`** — page composition, `params.then()`, Suspense placement, CLS, error boundaries, prefetch.
|
||||||
|
- **`references/cache-components.md`** — the `cacheComponents` decisions: which reads to cache, which directive to use, how to invalidate.
|
||||||
|
- **`references/single-page-applications.md`** — client cache decisions: placement, server seeding, Cache Components coordination, hydration, and mutations.
|
||||||
|
- **`references/ux-patterns.md`** — interaction decisions: optimistic vs pending vs inline error, toasts, action-prop, confirmations.
|
||||||
|
- **`references/example.md`** — the next-beats reference app: invariant → file map, for seeing any rule in real code.
|
||||||
20
.claude/skills/nextjs-app-architecture/metadata.json
Normal file
20
.claude/skills/nextjs-app-architecture/metadata.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"version": "1.3.7",
|
||||||
|
"organization": "Aurora Scharff",
|
||||||
|
"date": "July 2026",
|
||||||
|
"abstract": "Build and audit Next.js 16 App Router apps with a next-beats-style RSC architecture: synchronous pages that compose static shells, Suspense, and error boundaries; feature-owned async server components that receive IDs or parsed values; co-located skeletons; server-only queries; server actions; enforced Cache Components practice when enabled; and build/runtime verification of the resulting loading shape. References cover each topic in depth so the agent only loads what's needed for the task.",
|
||||||
|
"references": [
|
||||||
|
"https://preview.nextjs.org/docs/app",
|
||||||
|
"https://preview.nextjs.org/docs/app/guides/ai-agents",
|
||||||
|
"https://preview.nextjs.org/docs/app/api-reference/config/next-config-js/cacheComponents",
|
||||||
|
"https://preview.nextjs.org/docs/app/api-reference/directives/use-cache",
|
||||||
|
"https://preview.nextjs.org/docs/app/api-reference/functions/cacheTag",
|
||||||
|
"https://preview.nextjs.org/docs/app/api-reference/config/next-config-js/cacheLife",
|
||||||
|
"https://preview.nextjs.org/docs/app/api-reference/functions/updateTag",
|
||||||
|
"https://preview.nextjs.org/docs/app/guides/adopting-partial-prefetching",
|
||||||
|
"https://preview.nextjs.org/docs/app/guides/migrating-to-cache-components",
|
||||||
|
"https://preview.nextjs.org/docs/app/guides/interactive-apps",
|
||||||
|
"https://aurorascharff.no/posts/component-architecture-for-react-server-components/",
|
||||||
|
"https://github.com/vercel-labs/next-beats"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# Cache Components
|
||||||
|
|
||||||
|
Decisions for when [`cacheComponents: true`](https://preview.nextjs.org/docs/app/api-reference/config/next-config-js/cacheComponents) is set in `next.config.ts`. This file is about *which reads to cache, which directive to use, and how to invalidate* — for the mechanics of each directive, follow the doc links.
|
||||||
|
|
||||||
|
## When this reference applies
|
||||||
|
|
||||||
|
Use this reference when an app already has `cacheComponents: true`, the user wants this architecture while enabling it, or you are reviewing/refactoring an app that targets Cache Components.
|
||||||
|
|
||||||
|
If the project has not adopted Cache Components yet and the user asks to enable, migrate, or work through adoption blockers, use the `next-cache-components-adoption` skill first. It owns the route-by-route migration loop, opt-out strategy, and build/dev overlay workflow. Then return here for steady-state query/action/component architecture.
|
||||||
|
|
||||||
|
If `cacheComponents` is not enabled and the task is ordinary feature work, follow the core references without adding cache directives. Do not recommend skipping Cache Components based on app category alone; adoption is a migration/project decision, not a per-feature shortcut.
|
||||||
|
|
||||||
|
Adopting these in an existing app: follow [Migrating to Cache Components](https://preview.nextjs.org/docs/app/guides/migrating-to-cache-components) and [Adopting Partial Prefetching](https://preview.nextjs.org/docs/app/guides/adopting-partial-prefetching) — they cover the incremental path (per-route `prefetch = 'partial'`, fixing dynamic-usage build errors) rather than a big-bang switch.
|
||||||
|
|
||||||
|
## The model
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// next.config.ts
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
cacheComponents: true,
|
||||||
|
partialPrefetching: true, // prefetch the static shell of linked routes
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Static shell** — synchronous content, `'use cache'` output, and Suspense fallbacks prerender at build time.
|
||||||
|
- **Dynamic holes** — async work without `'use cache'` streams in behind `<Suspense>` at request time.
|
||||||
|
- **Build constraint** — any async work without `'use cache'` must sit inside `<Suspense>`, or the build fails (wrap it, or add `'use cache'`).
|
||||||
|
|
||||||
|
`cacheComponents` implies Partial Prerendering — it replaced `experimental.ppr` / `dynamicIO` / `useCache`, so don't set those. See [caching](https://preview.nextjs.org/docs/app/getting-started/caching).
|
||||||
|
|
||||||
|
With `cacheComponents: true`, the skill practice is **cache reusable reads**. Do not leave a database/API read dynamic just because Suspense makes the build pass. If a read has a stable key and a mutation can name what changed, give it a cache directive, tags, and a lifetime.
|
||||||
|
|
||||||
|
Dynamic reads are the exception: use them for values that must be recomputed for every request or cannot be invalidated coherently. When you leave a read dynamic, note the reason in the surrounding code/review and invalidate its mutations with `refresh()` because there is no tag to update.
|
||||||
|
|
||||||
|
## Decide what to cache
|
||||||
|
|
||||||
|
| Data | Directive | Notes |
|
||||||
|
| ---- | --------- | ----- |
|
||||||
|
| Cacheable across users (public listings, computed pages) | [`'use cache'`](https://preview.nextjs.org/docs/app/api-reference/directives/use-cache) | Add [`cacheTag`](https://preview.nextjs.org/docs/app/api-reference/functions/cacheTag) (a global + a scoped tag) and a [`cacheLife`](https://preview.nextjs.org/docs/app/api-reference/config/next-config-js/cacheLife) profile. |
|
||||||
|
| Per-user / reads cookies, headers, session | [`'use cache: private'`](https://preview.nextjs.org/docs/app/api-reference/directives/use-cache-private) | Cached in the browser only, doesn't persist across reloads; never stored on the server. |
|
||||||
|
| Remote service, safe across users, worth durable storage | [`'use cache: remote'`](https://preview.nextjs.org/docs/app/api-reference/directives/use-cache-remote) | Protects against rate-limited third-party APIs. |
|
||||||
|
| Genuinely dynamic per request | none | Must be justified. Read inside `<Suspense>`; mutations use `refresh()` because no tag exists. |
|
||||||
|
|
||||||
|
Cache the **query** when its result should be reused across requests. Cache the **component** when rendering is expensive and props are stable (a nav, a trending sidebar). Don't `'use cache'` a component that already calls a `'use cache'` query — double-caching, no benefit.
|
||||||
|
|
||||||
|
## Keep a synchronous value out of the shell
|
||||||
|
|
||||||
|
You usually don't need this. A query that reads `cookies()`/`headers()` or awaits a DB/`fetch` inside `<Suspense>` already stays out of the shell on its own. Only a *synchronous* request-time read (`new Date()`, `Math.random()`, a sync sqlite read) needs help: `await` [`io()`](https://preview.nextjs.org/docs/app/api-reference/functions/io) before it, with the caller inside `<Suspense>`.
|
||||||
|
|
||||||
|
Prefer `io()` over [`connection()`](https://preview.nextjs.org/docs/app/api-reference/functions/connection): both exclude what follows from the shell, but `connection()` blocks prefetches while `io()` stays prefetchable. Reach for `connection()` only when rendering must wait for a real user request.
|
||||||
|
|
||||||
|
## Decide how to invalidate
|
||||||
|
|
||||||
|
- [`updateTag(tag)`](https://preview.nextjs.org/docs/app/api-reference/functions/updateTag) — in **server actions**, when the user should see the result immediately (read-your-own-writes). Requires the query to carry a matching `cacheTag`.
|
||||||
|
- [`revalidateTag(tag, 'max')`](https://preview.nextjs.org/docs/app/api-reference/functions/revalidateTag) — in **route handlers** (webhooks, cron) for stale-while-revalidate. The single-arg `revalidateTag(tag)` form is deprecated.
|
||||||
|
- [`refresh()`](https://preview.nextjs.org/docs/app/api-reference/functions/refresh) — re-render the current route for the current user. Use it for deliberately dynamic reads with no tag; don't use it instead of `updateTag()` for cached reads.
|
||||||
|
|
||||||
|
Tag, cache, invalidate: the `cacheTag` in the query and the `updateTag` in the action use the same string and live in the same feature folder.
|
||||||
|
|
||||||
|
## Coordinate hydrated client data
|
||||||
|
|
||||||
|
When cached server data seeds SWR, TanStack Query, or another browser cache, follow `references/single-page-applications.md`. Server and client freshness policies are independent; hydration adds library-specific constraints.
|
||||||
|
|
||||||
|
## Build failure map
|
||||||
|
|
||||||
|
When `next build` fails under Cache Components, map the error back to an architecture rule instead of patching locally:
|
||||||
|
|
||||||
|
- Async work without `'use cache'` and without an ancestor `<Suspense>` → cache the reusable read, or wrap a justified dynamic read in a page-owned `<Suspense>`.
|
||||||
|
- Request data inside `'use cache'` → switch to `'use cache: private'` when it is per-user cacheable, or keep it dynamic with a documented reason.
|
||||||
|
- `await params` / `await searchParams` at the top of a page → keep the page synchronous and move the read into `params.then()` / `searchParams.then()`.
|
||||||
|
- Sync request-time values (`new Date()`, `Math.random()`, sync storage reads) captured in the shell → cache stable values, or use [`io()`](https://preview.nextjs.org/docs/app/api-reference/functions/io) for per-request values.
|
||||||
|
|
||||||
|
For adoption-wide blocker triage, use `next-cache-components-adoption`. For API-specific recipes, follow the [Migrating to Cache Components guide](https://preview.nextjs.org/docs/app/guides/migrating-to-cache-components).
|
||||||
|
|
||||||
|
## Without Cache Components
|
||||||
|
|
||||||
|
- Don't use `'use cache'` / `cacheTag` / `cacheLife` — they require the flag.
|
||||||
|
- Use React `cache()` only for proven same-request dedup needs; plain `server-only` async queries are the default.
|
||||||
|
- Invalidate with `refresh()` from server actions.
|
||||||
|
- Pages still use `params.then()` in this architecture. Without Cache Components there is no build-time static shell to preserve, but keeping pages synchronous still lets chrome paint before route-specific data resolves and keeps the app consistent.
|
||||||
214
.claude/skills/nextjs-app-architecture/references/components.md
Normal file
214
.claude/skills/nextjs-app-architecture/references/components.md
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
# Components
|
||||||
|
|
||||||
|
How to build server and client components inside a feature folder.
|
||||||
|
|
||||||
|
## Default: async server component
|
||||||
|
|
||||||
|
Server components await their own queries directly — no `useEffect`, no client-side fetching, no manual loading state. See the [Server Components docs](https://preview.nextjs.org/docs/app/getting-started/server-and-client-components) for the model.
|
||||||
|
|
||||||
|
Prefer minimal, stable props: IDs, slugs, handles, parsed filters, or records the parent already fetched. Do not pass raw route `params` or `searchParams` into feature components. Pages resolve those promises and pass plain values.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// features/notifications/components/notifications-badge.tsx
|
||||||
|
import { getUnreadNotificationCount } from "@/features/notifications/notifications-queries";
|
||||||
|
|
||||||
|
export async function NotificationsBadge() {
|
||||||
|
const count = await getUnreadNotificationCount();
|
||||||
|
if (count === 0) return null;
|
||||||
|
return <span aria-label={`${count} unread`}>{count}</span>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The page (not this file) wraps it in `<Suspense fallback={<NotificationsBadgeSkeleton />}>` — see `references/pages-suspense.md`.
|
||||||
|
|
||||||
|
For parameterized routes, the page resolves `params` and the feature receives an ID:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/post/[id]/page.tsx
|
||||||
|
<Suspense fallback={<PostDetailSkeleton />}>
|
||||||
|
{params.then(({ id }) => (
|
||||||
|
<PostDetail id={id} />
|
||||||
|
))}
|
||||||
|
</Suspense>
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// features/post/components/post-detail.tsx
|
||||||
|
export async function PostDetail({ id }: { id: string }) {
|
||||||
|
const post = await getPost(id);
|
||||||
|
return <article>{post.body}</article>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Skeletons live in the same file
|
||||||
|
|
||||||
|
Export the main component and its skeleton from the same file. Pages import both. Define the skeleton **at the end of the file**, below the real component(s) — never above. Function declarations are hoisted, so a skeleton referenced by a component earlier in the file still works when defined last.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export async function Feed({ userId }: { userId: string }) {
|
||||||
|
const posts = await getFeed(userId);
|
||||||
|
return (
|
||||||
|
<ul>
|
||||||
|
{posts.map((p) => (
|
||||||
|
<Post key={p.id} post={p} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FeedSkeleton() {
|
||||||
|
return (
|
||||||
|
<ul>
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<li key={i}>
|
||||||
|
<Skeleton className="h-24" />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Don't export a second skeleton whose whole job is to rename or preconfigure another skeleton:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Wrong — alias wrapper adds an import surface but no behavior
|
||||||
|
export function CompactGridSkeleton() {
|
||||||
|
return <GridSkeleton dense />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Import the real skeleton and pass the prop inline at the `<Suspense>` boundary: `fallback={<GridSkeleton dense />}`.
|
||||||
|
|
||||||
|
### Skeleton design checklist
|
||||||
|
|
||||||
|
1. Match the real component's layout: flex direction, gaps, padding, breakpoints.
|
||||||
|
2. Include all structural elements: avatar circles, action button placeholders, image squares.
|
||||||
|
3. Responsive visibility must match (`hidden sm:block` in the real component → same in the skeleton).
|
||||||
|
4. Show 2–5 placeholders for variable-length lists, not the real count.
|
||||||
|
5. Don't include skeletons for inner Suspense content — those have their own boundaries.
|
||||||
|
6. Reserve the right height. CLS comes from skeletons that are shorter than the real content.
|
||||||
|
|
||||||
|
## Group related components in one file
|
||||||
|
|
||||||
|
A card and its grid live in the same file. For example, `genre-card.tsx` exports `GenrePill`, `GenreCard`, `GenreGrid`, `GenreGridSkeleton`. Variants should reuse that skeleton inline instead of exporting alias skeletons. Don't split shared UI primitives prematurely — wait until three call sites need the same shape before extracting.
|
||||||
|
|
||||||
|
Two sidebar widgets that happen to look similar but render different data shapes are **not** the same component. The visuals diverge as soon as one needs an extra slot.
|
||||||
|
|
||||||
|
### Single-use sub-components stay inlined
|
||||||
|
|
||||||
|
For a metadata strip inside one card, a header used only by one detail view, a list item only rendered by its list — inline them as **non-exported** functions in the same file:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export async function EventDetails({ slug }: { slug: string }) {
|
||||||
|
const event = await getEventBySlug(slug);
|
||||||
|
return (
|
||||||
|
<article>
|
||||||
|
<MetaStrip event={event} />
|
||||||
|
<Speaker speaker={event.speaker} />
|
||||||
|
<p>{event.description}</p>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetaStrip({ event }: { event: Event }) { ... }
|
||||||
|
function Speaker({ speaker }: { speaker: string }) { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
Exports are for things other files will import. Internal structure is for readability inside one file.
|
||||||
|
|
||||||
|
## The server/client boundary
|
||||||
|
|
||||||
|
`'use client'` only when you need:
|
||||||
|
|
||||||
|
- Hooks (`useState`, `useReducer`, `useOptimistic`, `useTransition`, `useEffect`)
|
||||||
|
- Event handlers (`onClick`, `onChange`, `onSubmit`)
|
||||||
|
- Browser APIs (`window`, `localStorage`, refs to DOM)
|
||||||
|
|
||||||
|
If the component needs interactive pieces, keep the server component as the parent and render client leaves:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
async function PostDetail({ id }: { id: string }) {
|
||||||
|
const [post, userState] = await Promise.all([
|
||||||
|
getPost(id),
|
||||||
|
getPostUserState(id),
|
||||||
|
]);
|
||||||
|
return (
|
||||||
|
<article>
|
||||||
|
<PostBody body={post.body} />
|
||||||
|
<PostActions userState={userState} /> {/* 'use client' leaf */}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Server content as children of client components
|
||||||
|
|
||||||
|
Composition crosses the boundary. A client component can accept server-rendered JSX as children or props:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<ComposerForm
|
||||||
|
avatar={
|
||||||
|
<Suspense fallback={<AvatarSkeleton />}>
|
||||||
|
<CurrentUserAvatar />
|
||||||
|
</Suspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
`ComposerForm` is `'use client'`. It doesn't know where the avatar JSX came from. The Suspense boundary streams the avatar in without the form re-rendering.
|
||||||
|
|
||||||
|
### Pass server children resolved values, not promises
|
||||||
|
|
||||||
|
Prefer passing plain values (strings, IDs, resolved data) to a server child. A server component _can_ `await` a promise prop, but resolve route promises in the page instead — pass an unresolved promise down only to a _client_ component that reads it with `use()` (see below). When a parent already has the data from its own query, pass it as a prop instead of having the child refetch.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Right — parent fetches the list, passes each item
|
||||||
|
async function Feed({ userId }: { userId: string }) {
|
||||||
|
const posts = await getFeed(userId);
|
||||||
|
return posts.map((post) => <Post key={post.id} post={post} />);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function Post({ post }: { post: Post }) {
|
||||||
|
return <article>{post.body}</article>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Wrong — child refetches what the parent already had
|
||||||
|
async function Post({ id }: { id: string }) {
|
||||||
|
const post = await getPost(id);
|
||||||
|
return <article>{post.body}</article>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Client components that own their loading state
|
||||||
|
|
||||||
|
When a client component needs server data but should manage its own loading (a sidebar badge, a popover that opens on hover), pass an **unresolved promise** from the server and resolve it with [`use()`](https://react.dev/reference/react/use) on the client. Wrap the consumer in `<Suspense>`.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// page: pass the unresolved promise, wrap in Suspense
|
||||||
|
<Suspense fallback={<TagListSkeleton />}>
|
||||||
|
<TagPicker itemsPromise={getTags()} />
|
||||||
|
</Suspense>
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
"use client";
|
||||||
|
import { use } from "react";
|
||||||
|
|
||||||
|
export function TagPicker({ itemsPromise }: { itemsPromise: Promise<Tag[]> }) {
|
||||||
|
const items = use(itemsPromise);
|
||||||
|
// render interactive UI from items
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The opinionated bit: name promise props with a `Promise` suffix (`itemsPromise`, `userPromise`) so the contract is obvious at the call site.
|
||||||
|
|
||||||
|
### Client data libraries (SWR, TanStack Query)
|
||||||
|
|
||||||
|
Follow `references/single-page-applications.md` when a feature uses a browser data cache or needs externally authored updates. It covers when to use a library, where its files live, server seeding, Cache Components coordination, hydration, and mutations.
|
||||||
|
|
||||||
|
## Mutations
|
||||||
|
|
||||||
|
For client-side reactions to a server mutation (instant feedback, pending state, success/error toasts), see `references/ux-patterns.md`. To cache rendered output across requests, see `references/cache-components.md`.
|
||||||
34
.claude/skills/nextjs-app-architecture/references/example.md
Normal file
34
.claude/skills/nextjs-app-architecture/references/example.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Reference app: next-beats
|
||||||
|
|
||||||
|
A working app that follows this architecture: **<https://github.com/vercel-labs/next-beats>** (Next.js 16, `cacheComponents` + `partialPrefetching`, a music player). Use it to see any invariant in real code rather than restating the code here. Paths are as of this writing — verify against the current repo.
|
||||||
|
|
||||||
|
For the reasoning behind this architecture, read [Component Architecture for React Server Components](https://aurorascharff.no/posts/component-architecture-for-react-server-components/). The skill's target is the same shape: pages describe layout and loading; feature components own server reads; route params become IDs before they reach components.
|
||||||
|
|
||||||
|
## Invariant → where to see it
|
||||||
|
|
||||||
|
| Invariant | File(s) |
|
||||||
|
| --------- | ------- |
|
||||||
|
| 1. Pages compose, never fetch | `app/(app)/search/page.tsx`, `app/(app)/genre/[genre]/page.tsx` — import feature components, place `<Suspense>`, no queries. |
|
||||||
|
| 2. Pages stay synchronous (`params.then` / `searchParams.then`) | `app/(app)/track/[id]/page.tsx`, `app/(app)/genre/[genre]/page.tsx`, `app/(app)/search/page.tsx`. |
|
||||||
|
| 3. Feature components receive IDs, not route props | Track/genre pages resolve `params` / `searchParams` and pass `id`, `genre`, or parsed values into feature components. |
|
||||||
|
| 4. Async server component default; `'use client'` on leaves | Server: `features/track/components/discover.tsx`, `most-played.tsx`. Client leaves: `features/track/components/track-interactions.tsx`, `play-button.tsx`. |
|
||||||
|
| 5. Page owns Suspense; feature owns skeleton | Pages place the boundary (e.g. `app/(app)/genre/[genre]/page.tsx`); features export the skeleton (below). |
|
||||||
|
| 6. Skeleton in the same file, at the end | `features/track/components/track-row.tsx` (`TrackRow` … `TrackListSkeleton`), `features/genre/components/genre-card.tsx`. |
|
||||||
|
| 7. `<domain>-queries.ts` (`server-only`) / `<domain>-actions.ts` (`'use server'`) | `features/track/track-queries.ts`, `features/playlist/playlist-actions.ts`. |
|
||||||
|
| 8. One folder per domain; sub-concepts folded in | `toggleFavorite` in `features/track/track-actions.ts` (no `favorite` folder); `searchTracks` in `features/track/track-queries.ts` (no `search` folder). |
|
||||||
|
| 9. Client components import actions directly | `features/track/components/track-interactions.tsx` imports `toggleFavorite` directly. |
|
||||||
|
|
||||||
|
## Supporting patterns
|
||||||
|
|
||||||
|
| Pattern | File |
|
||||||
|
| ------- | ---- |
|
||||||
|
| Error boundary on `catchError` (`ErrorInfo` `retry`) | `components/ui/error-boundary.tsx` |
|
||||||
|
| `useOptimistic` for an unlikely-to-fail toggle | `features/track/components/track-interactions.tsx` |
|
||||||
|
| Action-prop / `*Action` convention + confirm dialog | `features/playlist/components/playlist-interactions.tsx`, `components/ui/confirm-dialog.tsx` |
|
||||||
|
| `useFormStatus` submit button | `components/ui/button.tsx` |
|
||||||
|
| `useActionState` inline field errors | `features/user/components/sign-in-form.tsx` |
|
||||||
|
| Client-owned live data via a provider (not `<Poller>`) | `providers/player-provider.tsx` → `components/now-playing-bar.tsx` |
|
||||||
|
| `use()` on an unresolved promise prop | `features/playlist/components/add-to-playlist-menu.tsx` |
|
||||||
|
| Purpose-named `components/scripts/` subfolder | `components/scripts/` |
|
||||||
|
|
||||||
|
> The repo is a live app, not a golden reference — spots may drift from the invariants (a page may fetch inline, a route `error.tsx` may lag an API rename). When the app and the invariants disagree, the invariants win; treat the mismatch as a fix for the app.
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
# Feature folders
|
||||||
|
|
||||||
|
How to organize code under `features/` and `app/`.
|
||||||
|
|
||||||
|
## Folder layout
|
||||||
|
|
||||||
|
```
|
||||||
|
features/<domain>/
|
||||||
|
<domain>-cache.ts # Pure server tags + client query keys, when shared
|
||||||
|
<domain>-queries.ts # Server-only queries
|
||||||
|
<domain>-actions.ts # Server actions
|
||||||
|
<domain>-query-options.ts # Client data-library query definitions, when needed
|
||||||
|
components/ # Server + client components, each with its skeleton
|
||||||
|
types/ # Feature-local public types, when needed by multiple files
|
||||||
|
hooks/ # Actual feature-local React hooks and hook wrappers
|
||||||
|
providers/ # Feature-local providers, only when the provider belongs to this domain
|
||||||
|
```
|
||||||
|
|
||||||
|
The folder name **is** the domain. The query and action filenames match the folder.
|
||||||
|
|
||||||
|
## How many features?
|
||||||
|
|
||||||
|
Keep the feature list short. One folder per **domain noun a user would recognize**, not per database table or technical concern.
|
||||||
|
|
||||||
|
A new folder is justified when **all three** are true:
|
||||||
|
|
||||||
|
1. The concept has its own queries.
|
||||||
|
2. The concept has its own pages or routes.
|
||||||
|
3. The concept is referenced from at least two other features.
|
||||||
|
|
||||||
|
If you find yourself making a feature folder with one query, one action, and one button, fold it into the parent feature instead.
|
||||||
|
|
||||||
|
### Merge aggressively
|
||||||
|
|
||||||
|
Concepts that exist only in service of a parent entity belong inside the parent's feature folder:
|
||||||
|
|
||||||
|
- A `favorite` or `bookmark` concept that only attaches to one parent entity (events, posts) → inside that parent's folder.
|
||||||
|
- A `like`, `repost`, `vote`, or `reaction` concept on a piece of content → with that content's feature.
|
||||||
|
- `auth` / `session` / `current user` → a single `user` folder, not split.
|
||||||
|
- A cross-cutting concern like `search` folds into the primary content feature it queries (`searchTracks` in `features/track/`), not a `features/search/` folder — the page composes it.
|
||||||
|
|
||||||
|
Concrete example: `toggleFavorite` is a mutation about events ("I favorite an event"), not its own domain. It lives in `features/event/event-actions.ts`, not `features/favorite/favorite-actions.ts`.
|
||||||
|
|
||||||
|
## File naming
|
||||||
|
|
||||||
|
Filenames inside the folder always start with the folder name:
|
||||||
|
|
||||||
|
```
|
||||||
|
features/event/
|
||||||
|
event-queries.ts
|
||||||
|
event-actions.ts
|
||||||
|
components/
|
||||||
|
event-grid.tsx
|
||||||
|
event-details.tsx
|
||||||
|
favorite-button.tsx ← OK: a component, not a "favorite" feature
|
||||||
|
```
|
||||||
|
|
||||||
|
- `<folder>-queries.ts` — even if the file has only one query.
|
||||||
|
- `<folder>-actions.ts` — even if a mutation is about a sub-concept.
|
||||||
|
- Other `<folder>-*.ts` files are fine when the folder needs them (`playlist-constants.ts`, `<folder>-schema.ts`), as long as they keep the folder-name prefix. Don't put reusable domain types in a root `*-types.ts` file; use `features/<domain>/types/` once a type is imported by multiple files.
|
||||||
|
- Component files use any descriptive name. The component (not the feature) is the unit here.
|
||||||
|
|
||||||
|
## Local vs shared support folders
|
||||||
|
|
||||||
|
Use a local support folder when the code belongs to one feature:
|
||||||
|
|
||||||
|
```
|
||||||
|
features/message/
|
||||||
|
message-cache.ts
|
||||||
|
message-query-options.ts
|
||||||
|
types/
|
||||||
|
message.ts
|
||||||
|
hooks/
|
||||||
|
use-message-mutations.ts
|
||||||
|
use-message-draft.ts
|
||||||
|
providers/
|
||||||
|
message-draft-provider.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
Feature-owned client coordination stays with the feature. Place each file by the shape it exports and the domain it belongs to:
|
||||||
|
|
||||||
|
- Client data-library cache contracts and query definitions follow `references/single-page-applications.md`.
|
||||||
|
- Mutation wrappers that export hooks live in `hooks/use-*.ts` (`use-message-mutations.ts` exporting `useSendMessage`).
|
||||||
|
- Browser-only state helpers live in `hooks/` when their public API is a hook (`use-thread.ts`, `use-message-draft.ts`).
|
||||||
|
- Client leaf components that coordinate a server write live in `components/` next to the UI they support (`mark-activity-read.tsx` posts read activity in the background while the current `/activity` tree stays stable).
|
||||||
|
|
||||||
|
Keep the file prefix aligned with the feature folder when a file exports a grouped feature contract (`workspace-cache.ts` and `workspace-query-options.ts`, not `activity-cache.ts` in `features/workspace/`). Support code for a sub-concept still lives with the parent feature: reactions on messages belong in `features/message/`; unread activity chrome belongs in `features/workspace/`.
|
||||||
|
|
||||||
|
Promote only when there are real cross-feature consumers:
|
||||||
|
|
||||||
|
- `types/` at the project root — shared domain/application types imported across features.
|
||||||
|
- `hooks/` at the project root — shared client hooks used across features.
|
||||||
|
- `app/providers.tsx` or `components/*-provider.tsx` — app-shell providers that wrap the whole app.
|
||||||
|
|
||||||
|
Avoid root-level miscellany like `message-types.ts`, `shared-hooks.ts`, or `common-provider.tsx`; the folder name should explain the scope.
|
||||||
|
|
||||||
|
## What goes in `components/`
|
||||||
|
|
||||||
|
Each component file exports the main component **plus its skeleton**:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// features/event/components/event-grid.tsx
|
||||||
|
export async function EventGrid(...) { ... }
|
||||||
|
export function EventGridSkeleton() { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
Group related components in one file when they're always used together or one is a natural building block for another. A card and its grid live together. For example, `genre-card.tsx` exports `GenrePill`, `GenreCard`, `GenreGrid`, `GenreGridSkeleton`.
|
||||||
|
|
||||||
|
Split into separate files only when:
|
||||||
|
|
||||||
|
- A component is consumed by multiple sibling components (one shared use is not enough — wait until three call sites need it).
|
||||||
|
- A component is `'use client'` and a sibling is a server component (the server/client boundary forbids sharing a file).
|
||||||
|
|
||||||
|
See `references/components.md` for inlining rules and the skeleton design checklist.
|
||||||
|
|
||||||
|
## What pages do
|
||||||
|
|
||||||
|
Pages in `app/` compose feature components with Suspense and transition wrappers. They never:
|
||||||
|
|
||||||
|
- Contain domain logic
|
||||||
|
- Define new components except thin transition wrappers (e.g. `<ViewTransition>`)
|
||||||
|
- Fetch data directly
|
||||||
|
- Inline route-specific components — extract them into the feature folder
|
||||||
|
|
||||||
|
See `references/pages-suspense.md` for page composition details.
|
||||||
|
|
||||||
|
## Top-level layout
|
||||||
|
|
||||||
|
```
|
||||||
|
app/ # Pages and layouts
|
||||||
|
features/ # Domain folders
|
||||||
|
components/ # UI primitives, theme, and app-shell singletons
|
||||||
|
hooks/ # Shared client hooks used across features
|
||||||
|
types/ # Shared cross-feature types only
|
||||||
|
lib/ # Utilities and cohesive non-domain subsystems
|
||||||
|
```
|
||||||
|
|
||||||
|
`lib/` holds flat helpers (`db.ts`, `utils.ts`) but may also group a cohesive non-domain subsystem in its own subfolder (e.g. `lib/audio/` for an audio engine). Cross-feature client hooks live in top-level `hooks/`; a hook used by a single feature co-locates in that feature's `hooks/`. Types follow the same rule: shared types at top-level `types/`, feature-only exported types in `features/<domain>/types/`.
|
||||||
|
|
||||||
|
`components/` holds:
|
||||||
|
|
||||||
|
- **`components/ui/`** — primitives. Low-level building blocks and action-prop components.
|
||||||
|
- **`components/theme/`** — theme provider and toggle, paired.
|
||||||
|
- **Top-level files** (`site-header.tsx`, `auth-gate.tsx`, `poller.tsx`) — app-shell singletons used once each. No `common/` folder — "common" is not a category. If a component is used everywhere it's a primitive (→ `ui/`); if it's used once it lives at the top level.
|
||||||
|
- **Purpose-named subfolders** are fine when several files share a clear technical role — e.g. `components/scripts/` for pre-hydration inline `<script>` seed components. This is distinct from the rejected `common/`: a `scripts/` folder names _what the files are_, not "miscellaneous."
|
||||||
|
|
||||||
|
Conventions for filenames and casing live in the project's `AGENTS.md`. This skill doesn't impose one.
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
# Pages and Suspense
|
||||||
|
|
||||||
|
How to compose pages, place Suspense boundaries, and prevent layout shift.
|
||||||
|
|
||||||
|
## Pages are composition only
|
||||||
|
|
||||||
|
Pages in `app/` import feature components and place `<Suspense>` boundaries. They never:
|
||||||
|
|
||||||
|
- Fetch data directly (queries live in feature folders)
|
||||||
|
- Define new components except thin transition wrappers (e.g. `<ViewTransition>`)
|
||||||
|
- Inline route-specific UI (extract it into the feature folder)
|
||||||
|
- Pass raw `params` / `searchParams` to features
|
||||||
|
|
||||||
|
## Page function signatures
|
||||||
|
|
||||||
|
Type page and layout functions with the auto-generated `PageProps<'/route'>` / `LayoutProps<'/route'>` helpers — no import, regenerated on `next dev` / `next build` / `next typegen`. See [route type helpers](https://preview.nextjs.org/docs/app/api-reference/config/typescript#route-type-helpers).
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export default function PostPage({ params }: PageProps<'/post/[id]'>) { /* ... */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
Don't hand-write `{ params: Promise<{ id: string }> }` — the generated types stay in sync with the route (catch-all, optional segments). Route handlers use `RouteContext<'/api/...'>`. `typedRoutes: true` is a *separate* feature (statically-typed `href`s), not the source of these helpers.
|
||||||
|
|
||||||
|
## Keep pages synchronous
|
||||||
|
|
||||||
|
Use `params.then()` instead of `await params`. Content above the `.then()` pre-renders into the static shell; content inside it suspends.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Suspense } from 'react';
|
||||||
|
import { PostDetail, PostDetailSkeleton } from '@/features/post/components/post-detail';
|
||||||
|
|
||||||
|
export default function PostPage({ params }: PageProps<'/post/[id]'>) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Post</h1>
|
||||||
|
<Suspense fallback={<PostDetailSkeleton />}>
|
||||||
|
{params.then(({ id }) => (
|
||||||
|
<PostDetail id={id} />
|
||||||
|
))}
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `<h1>` sits **above** the `params.then()` so it paints instantly. The `Suspense` fallback covers only the dynamic section.
|
||||||
|
|
||||||
|
Resolve route props to plain values at this boundary. Feature components receive `id`, `slug`, `query`, or parsed filter values — not `params`, `searchParams`, or unresolved server promises.
|
||||||
|
|
||||||
|
### Implicit return inside `.then()`
|
||||||
|
|
||||||
|
Use an implicit-return arrow function when the callback just renders JSX — e.g. `({ id }) => <PostDetail id={id} />`. Only switch to a block body with `return` when you need to do work first (destructure with defaults, parse a `searchParams` value, branch on a condition). This keeps the JSX-in-page shape readable and matches how the resolved tree will look.
|
||||||
|
|
||||||
|
### `searchParams` and combined params
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// searchParams only
|
||||||
|
export default function SearchPage({ searchParams }: PageProps<'/search'>) {
|
||||||
|
return searchParams.then(sp => {
|
||||||
|
const q = typeof sp.q === 'string' ? sp.q : '';
|
||||||
|
return q ? <SearchResults query={q} /> : <EmptyState />;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both params and searchParams
|
||||||
|
export default function ProfilePage({ params, searchParams }: PageProps<'/u/[handle]'>) {
|
||||||
|
return Promise.all([params, searchParams]).then(([{ handle }, sp]) => (
|
||||||
|
<ProfileFeed handle={handle} tab={parseTab(sp.tab)} />
|
||||||
|
));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Metadata, static params, and `notFound()`
|
||||||
|
|
||||||
|
- [`generateMetadata`](https://preview.nextjs.org/docs/app/api-reference/functions/generate-metadata) runs before render, so `await params` is fine there — it's a separate async function, not the page body, so it doesn't make the page dynamic.
|
||||||
|
- Export [`generateStaticParams`](https://preview.nextjs.org/docs/app/api-reference/functions/generate-static-params) from a `[slug]` page/layout to pre-build a known set of slugs; with `cacheComponents` + `'use cache'` they land in the static shell. It does **not** change the page signature — `params` is still a Promise, still consumed with `params.then()`.
|
||||||
|
- A query that can't find its resource calls [`notFound()`](https://preview.nextjs.org/docs/app/api-reference/functions/not-found), which bubbles to the nearest [`not-found.tsx`](https://preview.nextjs.org/docs/app/api-reference/file-conventions/not-found). Don't try/catch it — use [`unstable_rethrow`](https://preview.nextjs.org/docs/app/api-reference/functions/unstable_rethrow) if you must catch nearby.
|
||||||
|
|
||||||
|
## The page owns the Suspense boundary
|
||||||
|
|
||||||
|
The feature exports the async component **and** its skeleton. The page imports both and places the boundary. Don't pre-wrap inside the feature — that hides the boundary and prevents grouping siblings.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// features/post/components/post-detail.tsx
|
||||||
|
export async function PostDetail({ id }: { id: string }) { ... }
|
||||||
|
export function PostDetailSkeleton() { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/post/[id]/page.tsx
|
||||||
|
<Suspense fallback={<PostDetailSkeleton />}>
|
||||||
|
{params.then(({ id }) => (
|
||||||
|
<>
|
||||||
|
<PostDetail id={id} />
|
||||||
|
<ErrorBoundary title="Replies didn't load">
|
||||||
|
<Suspense fallback={<RepliesSkeleton />}>
|
||||||
|
<Replies postId={id} />
|
||||||
|
</Suspense>
|
||||||
|
</ErrorBoundary>
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</Suspense>
|
||||||
|
```
|
||||||
|
|
||||||
|
If a page uses a transition wrapper (e.g. `<ViewTransition>`), place it in the page next to the `<Suspense>` boundary. Feature components render content and skeletons, not transition wrappers.
|
||||||
|
|
||||||
|
## Audit smells
|
||||||
|
|
||||||
|
When auditing an existing app, flag and fix these first:
|
||||||
|
|
||||||
|
- `export default async function Page(...)` that only awaits `params`, `searchParams`, or page-level queries.
|
||||||
|
- `import { getSomething } from '@/features/.../*-queries'` inside `app/**/page.tsx` or `layout.tsx`.
|
||||||
|
- Feature components whose props are `params`, `searchParams`, or a route-shaped object.
|
||||||
|
- Page-local components like `HomeContent`, `PostShell`, or `ResultsSection` that only exist to fetch data or group a Suspense fallback.
|
||||||
|
- `<Suspense>` inside feature components that prevents the page from grouping reveal behavior.
|
||||||
|
|
||||||
|
## Don't create page-local wrapper components
|
||||||
|
|
||||||
|
Avoid components whose only job is to group boundary content, like `HomeLists` or `HomeListsSkeleton`. Keep the resolved JSX and fallback JSX **inline in the page** so the loading shape, headings, and grouped reveal behavior are visible at the boundary.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Wrong — hides the structure behind a wrapper
|
||||||
|
<Suspense fallback={<HomeListsSkeleton />}>
|
||||||
|
<HomeLists searchParams={searchParams} />
|
||||||
|
</Suspense>
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Right — structure visible at the page level
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<>
|
||||||
|
<FeaturedSkeleton />
|
||||||
|
<RecentSkeleton />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{searchParams.then(sp => (
|
||||||
|
<>
|
||||||
|
<Featured filter={sp.filter} />
|
||||||
|
<Recent filter={sp.filter} />
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</Suspense>
|
||||||
|
```
|
||||||
|
|
||||||
|
The same applies to feature-level skeleton aliases. If a variant only passes props to a base skeleton, import the base skeleton and pass those props inline in `fallback={...}`.
|
||||||
|
|
||||||
|
## Suspense boundary placement rules
|
||||||
|
|
||||||
|
1. **First section gets its own Suspense** with a known-height skeleton fallback.
|
||||||
|
2. **Section headings stay outside Suspense** when their final position is stable.
|
||||||
|
3. **Variable-height sections: group everything below them** in the same Suspense, including any headings that would otherwise paint in the wrong vertical position.
|
||||||
|
4. **Fixed-height sections: own boundary is safe.**
|
||||||
|
5. **Variable-length lists: show 2–5 skeleton items**, not the real count.
|
||||||
|
6. **Inner Suspense content stays out of the outer skeleton.** Each boundary owns its own.
|
||||||
|
7. **Never `fallback={null}` for visible UI.** If a boundary covers UI, give it a real shaped fallback, or group it with a sibling boundary that already has the correct fallback.
|
||||||
|
8. **If the top section's final height is unknown, group the following sections** in the same boundary so they reveal together and don't jump underneath.
|
||||||
|
|
||||||
|
## Error boundaries
|
||||||
|
|
||||||
|
Wrap fallible sections in a Next.js-aware error boundary so one failure doesn't take down the page. Build it on [`catchError`](https://preview.nextjs.org/docs/app/api-reference/functions/catchError) from `next/error` (its `ErrorInfo` gives you a `retry()` that re-fetches server data) — it understands Next's control-flow throws (`notFound()`, `redirect()`, `unauthorized()`, `forbidden()`) and won't swallow them. Place the boundary around the suspending section, in the page:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<ErrorBoundary title="Replies didn't load">
|
||||||
|
<Suspense fallback={<RepliesSkeleton />}>
|
||||||
|
<Replies postId={id} />
|
||||||
|
</Suspense>
|
||||||
|
</ErrorBoundary>
|
||||||
|
```
|
||||||
|
|
||||||
|
Why not plain `react-error-boundary`? It catches Next's framework throws (so `notFound()` never reaches `not-found.tsx`), and its reset doesn't re-fetch server data. Background: [Error Handling in Next.js with catchError](https://aurorascharff.no/posts/error-handling-in-nextjs-with-catch-error/).
|
||||||
|
|
||||||
|
Pair component-level boundaries with route-segment [`error.tsx`](https://preview.nextjs.org/docs/app/api-reference/file-conventions/error) for unrecoverable errors; it also receives a `retry` callback.
|
||||||
|
|
||||||
|
## Layout-level Suspense
|
||||||
|
|
||||||
|
Layouts compose feature components the same way pages do. Use `<Suspense>` for slots that fetch data (auth badge, sidebar):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export default function RootLayout({ children }: LayoutProps<'/'>) {
|
||||||
|
return (
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<Suspense>
|
||||||
|
<AuthGate userPromise={getCurrentUser()} />
|
||||||
|
</Suspense>
|
||||||
|
<main>{children}</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`AuthGate` is a client component that resolves the promise with `use()` so the dialog can render conditionally without server-side branching.
|
||||||
|
|
||||||
|
## CLS prevention
|
||||||
|
|
||||||
|
Layout shift happens when:
|
||||||
|
|
||||||
|
- A skeleton is shorter than the real content
|
||||||
|
- A heading sits inside a Suspense boundary whose final height is unknown — it paints in the wrong place, then jumps
|
||||||
|
- A variable-length list streams in without a fallback that reserves space
|
||||||
|
|
||||||
|
Fixes:
|
||||||
|
|
||||||
|
- Match skeleton height to the typical real content height.
|
||||||
|
- Move headings **outside** boundaries when their position depends on data above them.
|
||||||
|
- For unknown-height top sections, group everything below in one boundary so siblings stream together.
|
||||||
|
|
||||||
|
To audit CLS, use React DevTools' Suspense panel to pin each boundary in its loading state and check vertical positions.
|
||||||
|
|
||||||
|
## Runtime prefetch for high-value routes
|
||||||
|
|
||||||
|
With `cacheComponents` + [`partialPrefetching`](https://preview.nextjs.org/docs/app/api-reference/config/next-config-js/partialPrefetching) enabled, a visible `<Link>` prefetches the destination's shared [App Shell](https://preview.nextjs.org/docs/app/glossary#app-shell) — enough to commit navigation instantly, with link-specific content streaming after. The default (`'auto'`) already does this; don't write `prefetch = 'auto'`.
|
||||||
|
|
||||||
|
Use `<Link prefetch={true}>` on high-value links to also resolve the destination's per-link runtime data (`params`, `searchParams`, the full URL) before the click. Each such link can wake the server for a runtime prerender, so reserve it for routes users predictably visit next. See [runtime prefetching](https://preview.nextjs.org/docs/app/guides/runtime-prefetching).
|
||||||
|
|
||||||
|
Can't enable `partialPrefetching` app-wide yet? Opt in per route with `export const prefetch = 'partial'` on the destination, then drop the per-route exports once the global flag is on — see [Adopting Partial Prefetching](https://preview.nextjs.org/docs/app/guides/adopting-partial-prefetching) for the incremental path and [prefetch config](https://preview.nextjs.org/docs/app/api-reference/file-conventions/route-segment-config/prefetch) for the options. To validate navigation feels instant, see the [`instant` config](https://preview.nextjs.org/docs/app/api-reference/file-conventions/route-segment-config/instant) and [Instant Navigation guide](https://preview.nextjs.org/docs/app/guides/instant-navigation).
|
||||||
|
|
||||||
|
## Never wrap the entire page in a Suspense fallback
|
||||||
|
|
||||||
|
Page chrome (header, nav, surrounding layout) should paint instantly. Only data-dependent sections suspend. If you find yourself wrapping `<div>` and everything in it with `<Suspense fallback={<FullPageSkeleton />}>`, restructure: pull static elements out, narrow the boundary to just the dynamic part.
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# Queries and actions
|
||||||
|
|
||||||
|
The data layer. Every feature has both: queries to read, actions to write.
|
||||||
|
|
||||||
|
This page covers the universal data layer that applies to every Next.js App Router app. When `cacheComponents: true` is enabled, follow `references/cache-components.md`: reusable reads are cached/tagged/lifetimed, and mutations update matching tags.
|
||||||
|
|
||||||
|
## Cache identities
|
||||||
|
|
||||||
|
When a server read also seeds a browser data cache, follow `references/single-page-applications.md` for the feature-local cache contract and client-library placement.
|
||||||
|
|
||||||
|
## Queries
|
||||||
|
|
||||||
|
Create `features/<domain>/<domain>-queries.ts`. Mark it `import 'server-only'` — that's the invariant. Default to plain async exports.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import 'server-only';
|
||||||
|
|
||||||
|
export async function getFeed(userId: string) {
|
||||||
|
return db.post.findMany({ where: { userId } });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use [`cache()`](https://react.dev/reference/react/cache) from React only for **request-level deduplication** when the same dynamic query is called multiple times with the same arguments in one render. Highest-value cases: a session/user lookup used by many queries, or a shared expensive read used by metadata + page sections. Don't wrap every query "just in case" — it adds indirection and can hide when data is intentionally dynamic.
|
||||||
|
|
||||||
|
`cache()` dedups within a request; `'use cache'` + `cacheTag` (Cache Components) shares results *across* requests. Don't add React `cache()` to a function only because it already uses `'use cache'`; that is double-caching unless you have a separate, proven same-request duplication problem. See `references/cache-components.md`.
|
||||||
|
|
||||||
|
## Actions
|
||||||
|
|
||||||
|
Create `features/<domain>/<domain>-actions.ts`. Mark with `'use server'` at the top. Always:
|
||||||
|
|
||||||
|
1. Verify auth.
|
||||||
|
2. Validate input with your schema validator.
|
||||||
|
3. Run the mutation.
|
||||||
|
4. Invalidate cached data so the next render sees the new state.
|
||||||
|
5. Return a result (`{ ok }` or `{ error }`).
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
'use server';
|
||||||
|
|
||||||
|
import { refresh } from 'next/cache';
|
||||||
|
|
||||||
|
export async function createPost(formData: FormData) {
|
||||||
|
const user = await verifyUser();
|
||||||
|
const parsed = schema.safeParse({ body: formData.get('body') });
|
||||||
|
if (!parsed.success) {
|
||||||
|
return { ok: false as const, error: parsed.error.issues[0].message };
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.post.create({ data: { body: parsed.data.body, userId: user.id } });
|
||||||
|
refresh();
|
||||||
|
return { ok: true as const };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
[`refresh()`](https://preview.nextjs.org/docs/app/api-reference/functions/refresh) re-renders the current route for the current user. Use it when the affected read is deliberately dynamic and has no tag. With Cache Components enabled, reusable reads should have matching `cacheTag()` calls, so server actions normally call `updateTag()` for read-your-own-writes. See `references/cache-components.md`.
|
||||||
|
|
||||||
|
### Action file naming
|
||||||
|
|
||||||
|
Actions for a feature always go in `<folder>-actions.ts`, matching the folder name — even when the mutation operates on a sub-concept. `toggleFavorite` in `features/event/` lives in `event-actions.ts`, not `favorite-actions.ts`. The folder is the source of truth for the name.
|
||||||
|
|
||||||
|
## Calling actions from client components
|
||||||
|
|
||||||
|
Client components import server actions directly. **Don't** pass an action as a prop just to call it:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Right
|
||||||
|
'use client';
|
||||||
|
import { likePost } from '@/features/post/post-actions';
|
||||||
|
|
||||||
|
export function LikeButton({ postId }: { postId: string }) {
|
||||||
|
return <button onClick={() => likePost(postId)}>Like</button>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Wrong — adds indirection with no benefit
|
||||||
|
async function Post({ id }: { id: string }) {
|
||||||
|
return <LikeButton postId={id} onLike={likePost} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Design components (`<BottomNav>`, `<ToggleGroup>`, `<SubmitButton>`) take this further with the **action-prop pattern** — `action` is a callback wrapped in `useTransition` / `useOptimistic` internally. See `references/ux-patterns.md`.
|
||||||
|
|
||||||
|
## Form actions vs onClick handlers
|
||||||
|
|
||||||
|
Prefer [`<form action={serverAction}>`](https://react.dev/reference/react-dom/components/form#action) for form mutations — React wraps the call in a transition and surfaces pending state automatically.
|
||||||
|
|
||||||
|
For one-off buttons, `onClick={() => action(args)}` is fine. Wrap in [`startTransition`](https://react.dev/reference/react/startTransition) if you need pending state.
|
||||||
|
|
||||||
|
## Return shape
|
||||||
|
|
||||||
|
Return a discriminated union from actions that can fail:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export type ActionResult<T = void> = { ok: true; data?: T } | { ok: false; error: string };
|
||||||
|
```
|
||||||
|
|
||||||
|
Toast on `ok: false` from the client. Skip success toasts when an optimistic UI already shows the result.
|
||||||
|
|
||||||
|
A shared `ActionResult<T>` is optional — a per-action inline union is just as good, and often clearer when the payload has a natural name: `return { ok: true as const, playlist }` reads better than a generic `data`. What matters is that fallible actions return a discriminated union the client can narrow on, not that every action shares one type.
|
||||||
|
|
||||||
|
## Mappers and domain types
|
||||||
|
|
||||||
|
If your DB rows have shapes you don't want to leak to components (extra columns, ORM-specific types), write a mapper inside the query:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export async function getPost(id: string) {
|
||||||
|
const row = await db.post.findUnique({ where: { id }, include: { author: true } });
|
||||||
|
if (!row) notFound();
|
||||||
|
return toPost(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPost(row: PostRow & { author: UserRow }): Post {
|
||||||
|
return { id: row.id, body: row.body, author: row.author.handle };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Components see `Post`, not the ORM row. If that type is imported by multiple files in the feature, put it under `features/<domain>/types/` (for example `features/post/types/post.ts`). Promote it to top-level `types/` only when multiple features import it.
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Single-page application patterns
|
||||||
|
|
||||||
|
Use this reference when a feature adds SWR, TanStack Query, or another browser data cache. For complete library APIs and runnable examples, follow the [Single-page applications guide](https://preview.nextjs.org/docs/app/guides/single-page-applications).
|
||||||
|
|
||||||
|
## Decide whether a client cache is needed
|
||||||
|
|
||||||
|
Use a client data library when the browser needs revalidation, optimistic mutations, request deduplication, or shared live data. If a Client Component only reads server data once, pass a Promise from its Server Component and unwrap it with `use()` instead.
|
||||||
|
|
||||||
|
## Keep ownership with the feature
|
||||||
|
|
||||||
|
```text
|
||||||
|
features/<domain>/
|
||||||
|
<domain>-cache.ts # Pure server tags + client keys
|
||||||
|
<domain>-queries.ts # Server reads and cacheLife
|
||||||
|
<domain>-query-options.ts # Client fetcher/query options
|
||||||
|
hooks/use-*.ts # Client mutations and coordination
|
||||||
|
components/ # Async server owner + client leaves
|
||||||
|
```
|
||||||
|
|
||||||
|
The cache contract imports neither Next.js nor the client library. Queries, actions, route handlers, hydration code, query options, and hooks import identities from it. This prevents a key or tag spelling from drifting between a read and its invalidation.
|
||||||
|
|
||||||
|
Keep behavior in the layer that owns it:
|
||||||
|
|
||||||
|
- Server `cacheLife`, `cacheTag`, and database reads belong in `<domain>-queries.ts`.
|
||||||
|
- Browser freshness and refetch behavior belong in `<domain>-query-options.ts` or the SWR hook.
|
||||||
|
- Optimistic mutation behavior belongs in `hooks/use-*.ts`.
|
||||||
|
- Tiny effect-only or interactive leaves belong in `components/`.
|
||||||
|
|
||||||
|
## Seed from the server
|
||||||
|
|
||||||
|
The async feature component owns the initial read and the library's hydration provider. The page remains a synchronous composition surface and owns the feature's Suspense boundary.
|
||||||
|
|
||||||
|
- With SWR, seed the exact key read by `useSWR`. Use `preload` with `cacheData` when later `mutate(key)` calls must update the seeded entry itself.
|
||||||
|
- With TanStack Query, seed the same query key read by the client query and render the client subtree inside `HydrationBoundary`.
|
||||||
|
|
||||||
|
Do not move the initial read to the browser just because the feature also has a client cache.
|
||||||
|
|
||||||
|
## Coordinate Cache Components
|
||||||
|
|
||||||
|
The server cache and browser cache have independent freshness policies. Do not mirror `cacheLife` into `staleTime`, polling intervals, or SWR revalidation settings. Coordinate identities and invalidation, not durations.
|
||||||
|
|
||||||
|
For tag-driven data, a mutation updates the client cache for immediate feedback and invalidates the same server tag used by the seeded read. For a time-driven server read, choose its `cacheLife` from the server data's freshness requirement.
|
||||||
|
|
||||||
|
TanStack Query hydration adds one coupling: the hydration timestamp must advance whenever the seeded data advances. For tag-driven reads, cache the timestamp with the same tags as the data. For time-driven reads, derive the data and timestamp from the same cached snapshot. Do not cache a `QueryClient` or dehydrated payload.
|
||||||
|
|
||||||
|
## Mutate without drift
|
||||||
|
|
||||||
|
Let the client library own the optimistic browser update, rollback, and authoritative response. Let the write invalidate the server tag only after stored data changes. Do not add polling as a cache-coordination mechanism; add focus revalidation, intervals, SSE, or WebSockets only when the product actually needs external updates to appear automatically.
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# UX patterns
|
||||||
|
|
||||||
|
Interaction decisions on top of the architecture: which feedback mechanism to reach for, and the boundary edge-cases that trip agents up. Hook mechanics live in the React / Next docs — linked, not restated. The deeper end-to-end picture is the [Building interactive apps guide](https://preview.nextjs.org/docs/app/guides/interactive-apps).
|
||||||
|
|
||||||
|
## Choose the feedback mechanism
|
||||||
|
|
||||||
|
| Situation | Reach for | Key rule |
|
||||||
|
| --------- | --------- | -------- |
|
||||||
|
| Mutation unlikely to fail (favorite, vote, follow) | [`useOptimistic`](https://react.dev/reference/react/useOptimistic) | Update immediately, roll back on throw. Set it inside a transition; inside `<form action>` React opens the transition for you. Use a reducer for counters. |
|
||||||
|
| No optimistic fit (filters, sort, navigation) | [`useTransition`](https://react.dev/reference/react/useTransition) + `data-pending` | Put `data-pending` on the pending node; let ancestors react with CSS (`has-data-pending:` for a direct parent, `group-has-data-pending:` further up) so it bubbles without prop drilling. |
|
||||||
|
| Form field validation ("fix this field") | [`useActionState`](https://react.dev/reference/react/useActionState) | Action returns `{ error }`; render inline with `aria-invalid` + `role="alert"`. |
|
||||||
|
| Submit disable + spinner | [`useFormStatus`](https://react.dev/reference/react-dom/hooks/useFormStatus) | Call it from a child of `<form>`, not the form component itself. |
|
||||||
|
| One-shot result with no visible change | toast | See below. |
|
||||||
|
|
||||||
|
`useOptimistic(false)` also works as a transition-scoped **pending flag** that resets automatically when the transition settles — handy when you don't need the `data-pending` bubbling.
|
||||||
|
|
||||||
|
## Toasts
|
||||||
|
|
||||||
|
- **Toast only on error** when an optimistic UI already shows the result — a success toast next to an optimistic checkmark/removal is double feedback, which is noise.
|
||||||
|
- **Toast on success** only for non-visible side effects (email sent, link copied, file uploaded).
|
||||||
|
- **Don't toast for routine navigation** — the page change is the feedback.
|
||||||
|
- **Don't toast inside a server action.** Toasts are client-side; return a result and toast at the call site.
|
||||||
|
|
||||||
|
## View transitions: portaled / floating UI
|
||||||
|
|
||||||
|
Portaled elements (toasts, dialogs, popovers, dropdowns, tooltips) flicker during route transitions unless excluded. Apply `viewTransitionName: 'none'` to the portal root. When the portal also needs stacking control (z-index) or has translucent layers (backdrop-blur), give it a *named* transition and neutralize it in CSS instead — `::view-transition-group(name) { animation: none; z-index: … }` can do things `'none'` can't. See the [React View Transitions skill](https://github.com/vercel-labs/agent-skills/tree/main/skills/react-view-transitions).
|
||||||
|
|
||||||
|
## Destructive actions (delete / leave / unsubscribe)
|
||||||
|
|
||||||
|
Gate behind a confirmation dialog, and mind two edge cases:
|
||||||
|
|
||||||
|
- **Don't `redirect()` inside the action.** It throws, which stops the client from toasting or closing the dialog. Return `{ ok: true }` and navigate with `router.push()`.
|
||||||
|
- **Don't wrap the whole action call in `useTransition`** inside the dialog — with view transitions on, that animates the background UI behind the dialog. Track pending with `useState` / `useOptimistic(false)` and reserve `startTransition` for the post-success navigation only.
|
||||||
|
|
||||||
|
## The action-prop pattern
|
||||||
|
|
||||||
|
A reusable design component (`<ToggleGroup>`, `<BottomNav>`, `<SubmitButton>`) can take an action-style prop and own the async coordination (optimistic update, pending, dimming) so consumers pass a plain callback. Convention: an `action` / `*Action` prop signals "triggers a mutation this component coordinates," versus a plain `onChange` / `onClick` — renaming between them is a contract change. Not every such prop is transition-wrapped: a destructive `confirmAction` is awaited *without* a transition (see above). Transition-wrapping is the default for optimistic/navigation actions, not a rule tied to the name.
|
||||||
|
|
||||||
|
## URL-based pagination
|
||||||
|
|
||||||
|
Drive the page number through `searchParams`, render each page as its own `<Suspense>` boundary so pages stream independently, and add "load more" with `<Link scroll={false}>`. See [linking and navigating](https://preview.nextjs.org/docs/app/getting-started/linking-and-navigating).
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Suspense } from 'react';
|
||||||
|
|
||||||
|
export function Feed({ page = 1 }: { page?: number }) {
|
||||||
|
return (
|
||||||
|
<ul>
|
||||||
|
{Array.from({ length: page }).map((_, i) => {
|
||||||
|
const p = i + 1;
|
||||||
|
return p === 1 ? (
|
||||||
|
<FeedPage key={p} page={p} />
|
||||||
|
) : (
|
||||||
|
<Suspense key={p} fallback={<FeedPageSkeleton />}>
|
||||||
|
<FeedPage page={p} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The first page can render in the parent boundary; later pages get their own fallbacks so "load more" streams only the newly requested page. If the URL update should preserve scroll, use [`<Link scroll={false}>`](https://preview.nextjs.org/docs/app/api-reference/components/link).
|
||||||
|
|
||||||
|
## Global client state
|
||||||
|
|
||||||
|
For truly global client state (audio player, cart, system-reactive theme), wrap a [context provider](https://react.dev/reference/react/createContext) at the root; the provider is `'use client'` but `children` stays server-rendered, and only leaf components read the context. **Don't push server data into it** — server data stays in queries; client state is for ephemeral UI (open menus, playback position, optimistic drafts). See the live-data decision in `references/components.md`.
|
||||||
1
.claude/skills/prototype
Symbolic link
1
.claude/skills/prototype
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../.agents/skills/prototype
|
||||||
1
.claude/skills/vercel-react-best-practices
Symbolic link
1
.claude/skills/vercel-react-best-practices
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../.agents/skills/vercel-react-best-practices
|
||||||
@@ -1,13 +1,27 @@
|
|||||||
node_modules
|
node_modules
|
||||||
.next
|
.next
|
||||||
.git
|
.git
|
||||||
|
.gitea
|
||||||
.agents
|
.agents
|
||||||
.claude
|
.claude
|
||||||
.mcp.json
|
.mcp.json
|
||||||
|
# Yalnızca kök dizindeki .md dosyaları (path ayracını geçmez);
|
||||||
|
# content/rehber/*.md build için gerekli, etkilenmez.
|
||||||
*.md
|
*.md
|
||||||
.env*
|
.env*
|
||||||
!.env.example
|
!.env.example
|
||||||
drizzle/
|
docs/
|
||||||
|
# scripts/ build'de kullanılmaz; istisnalar: entrypoint + standalone link
|
||||||
|
# onarımı + açılış migration runner'ı (drizzle/ da onun için imaja girer)
|
||||||
|
scripts/*
|
||||||
|
!scripts/docker-entrypoint.sh
|
||||||
|
!scripts/standalone-linkleri-onar.sh
|
||||||
|
!scripts/db-goc.mjs
|
||||||
|
package-lock.json
|
||||||
|
skills-lock.json
|
||||||
|
components.json
|
||||||
|
eslint.config.mjs
|
||||||
|
drizzle.config.ts
|
||||||
data/
|
data/
|
||||||
!data/app.db
|
!data/app.db
|
||||||
!data/yokatlas.db
|
!data/yokatlas.db
|
||||||
|
|||||||
@@ -26,10 +26,54 @@ jobs:
|
|||||||
IYZICO_API_KEY=${{ secrets.IYZICO_API_KEY }}
|
IYZICO_API_KEY=${{ secrets.IYZICO_API_KEY }}
|
||||||
IYZICO_SECRET_KEY=${{ secrets.IYZICO_SECRET_KEY }}
|
IYZICO_SECRET_KEY=${{ secrets.IYZICO_SECRET_KEY }}
|
||||||
IYZICO_BASE_URL=${{ secrets.IYZICO_BASE_URL }}
|
IYZICO_BASE_URL=${{ secrets.IYZICO_BASE_URL }}
|
||||||
ANTHROPIC_API_KEY=${{ secrets.ANTHROPIC_API_KEY }}
|
OPENROUTER_API_KEY=${{ secrets.OPENROUTER_API_KEY }}
|
||||||
|
OPENROUTER_MODEL=${{ secrets.OPENROUTER_MODEL }}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
- name: Build & deploy
|
# BuildKit'te "docker build --memory" YOK SAYILIR (moby/buildkit#1362);
|
||||||
|
# gerçek sınır ancak buildkitd'yi kendi cgroup'u olan bir container'da
|
||||||
|
# (buildx docker-container driver) koşturarak konur. Böylece build OOM
|
||||||
|
# olursa yalnızca builder container'ı ölür — uygulama + Gitea ayakta kalır
|
||||||
|
# ve adım 35-90 dk sessiz kilitlenme yerine hızlı, görünür hata verir.
|
||||||
|
#
|
||||||
|
# Sınırlar container İLK oluşturulduğunda donar; değerleri değiştirmek için
|
||||||
|
# önce: docker buildx rm sinirli && docker rm -f buildx_buildkit_sinirli0
|
||||||
|
- name: Sınırlı builder hazırla
|
||||||
run: |
|
run: |
|
||||||
docker compose -p kolaytercih up -d --build --remove-orphans
|
cat > buildkitd.toml <<'EOF'
|
||||||
docker image prune -f
|
[worker.oci]
|
||||||
|
enabled = true
|
||||||
|
max-parallelism = 1
|
||||||
|
EOF
|
||||||
|
# Act container'ı her koşuda sıfırdan geldiği için client kaydı hep
|
||||||
|
# yeniden oluşturulur; host'taki buildx_buildkit_sinirli0 container'ı
|
||||||
|
# (ve build cache'i) varsa bootstrap'ta aynen yeniden kullanılır.
|
||||||
|
docker buildx create --name sinirli \
|
||||||
|
--driver docker-container \
|
||||||
|
--driver-opt memory=2200m \
|
||||||
|
--driver-opt memory-swap=4200m \
|
||||||
|
--driver-opt cpu-quota=150000 \
|
||||||
|
--buildkitd-config ./buildkitd.toml \
|
||||||
|
2>/dev/null || true
|
||||||
|
docker buildx inspect sinirli --bootstrap >/dev/null
|
||||||
|
|
||||||
|
- name: Build (sınırlı builder)
|
||||||
|
run: |
|
||||||
|
docker compose -p kolaytercih build --builder sinirli
|
||||||
|
# docker-container driver'da imajın daemon'a --load ile düştüğünü doğrula;
|
||||||
|
# düşmediyse aynı cache'ten yeniden build edip elle yükle.
|
||||||
|
docker image inspect kolaytercih-app >/dev/null 2>&1 || \
|
||||||
|
docker buildx build --builder sinirli --load -t kolaytercih-app .
|
||||||
|
|
||||||
|
- name: Deploy
|
||||||
|
run: docker compose -p kolaytercih up -d --no-build --remove-orphans
|
||||||
|
|
||||||
|
- name: Temizlik (başarısız build'de de çalışır)
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
docker image prune -f || true
|
||||||
|
# sinirli builder'ın cache'ini 4 GB'ta tut (flag adı buildx sürümüne göre değişti)
|
||||||
|
docker buildx prune --builder sinirli -f --max-used-space 4gb 2>/dev/null || \
|
||||||
|
docker buildx prune --builder sinirli -f --keep-storage 4gb || true
|
||||||
|
# eski varsayılan-builder cache artıkları
|
||||||
|
docker builder prune -f --filter "until=72h" || true
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
|
"rybbit": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://rybbit.kolaytercih.com/api/mcp"
|
||||||
|
},
|
||||||
"shadcn": {
|
"shadcn": {
|
||||||
"command": "npx",
|
"command": "npx",
|
||||||
"args": [
|
"args": [
|
||||||
|
|||||||
@@ -25,5 +25,5 @@ Funnel CTA butonlarında ("Listemi oluştur", "Tercihlerimi belirle", giriş/öd
|
|||||||
|
|
||||||
# Meraklısına'daki mermaid şeması mimariyle senkron kalmalı
|
# Meraklısına'daki mermaid şeması mimariyle senkron kalmalı
|
||||||
|
|
||||||
`/meraklisina` sayfasındaki sihirbaz akış şeması (`src/app/meraklisina/huni-semasi.tsx` içindeki `SEMA` sabiti) ürünün gerçek davranışının belgesidir, dekor değildir. Sihirbaz/listeleme mimarisinde davranış değiştiren her değişiklikte bu şema da aynı PR/commit içinde güncellenmeli: sihirbaz adımlarının sayısı-sırası-içeriği (alan → il → devlet/vakıf), aday havuzu kuralları, 24'lük liste iskeleti (hayal/dengeli/garanti dağılımı), havuz 24'ün altına düşünce filtre gevşetme sırası ve yapay zekânın havuz-dışına-çıkamama kuralı. Şemayı güncellerken çevresindeki unsurları da eşitle: bölümdeki anlatım metni (`src/app/meraklisina/page.tsx`, 4. bölüm), kabın `aria-label`'ı ve figcaption. Şema öğrenci diliyle kalmalı (SQL/prompt/teknik detay yok) ve ürünün risk renk dilini korumalı — kırmızı/sarı/yeşil her zaman metin etiketiyle birlikte, renk tek başına anlam taşımaz.
|
`/meraklisina` sayfasındaki sihirbaz akış şeması (`src/features/pazarlama/components/huni-semasi.tsx` içindeki `SEMA` sabiti) ürünün gerçek davranışının belgesidir, dekor değildir. Sihirbaz/listeleme mimarisinde davranış değiştiren her değişiklikte bu şema da aynı PR/commit içinde güncellenmeli: sihirbaz adımlarının sayısı-sırası-içeriği (alan → il → devlet/vakıf), aday havuzu kuralları, 24'lük liste iskeleti (hayal/dengeli/garanti dağılımı), havuz 24'ün altına düşünce filtre gevşetme sırası ve yapay zekânın havuz-dışına-çıkamama kuralı. Şemayı güncellerken çevresindeki unsurları da eşitle: bölümdeki anlatım metni (`src/features/pazarlama/components/meraklisina-icerik.tsx`, 4. bölüm), kabın `aria-label`'ı ve figcaption. Şema öğrenci diliyle kalmalı (SQL/prompt/teknik detay yok) ve ürünün risk renk dilini korumalı — kırmızı/sarı/yeşil her zaman metin etiketiyle birlikte, renk tek başına anlam taşımaz.
|
||||||
<!-- END:nextjs-agent-rules -->
|
<!-- END:nextjs-agent-rules -->
|
||||||
|
|||||||
51
Dockerfile
51
Dockerfile
@@ -1,6 +1,8 @@
|
|||||||
FROM node:20-alpine AS base
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
# pnpm kurulumu
|
FROM node:20-alpine AS base
|
||||||
|
ENV PNPM_HOME="/pnpm"
|
||||||
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
RUN corepack enable && corepack prepare pnpm@10.8.1 --activate
|
RUN corepack enable && corepack prepare pnpm@10.8.1 --activate
|
||||||
|
|
||||||
# ── Bağımlılık katmanı ──
|
# ── Bağımlılık katmanı ──
|
||||||
@@ -9,17 +11,36 @@ WORKDIR /app
|
|||||||
# better-sqlite3 Alpine'de kaynaktan derleniyor (musl için prebuilt yok)
|
# better-sqlite3 Alpine'de kaynaktan derleniyor (musl için prebuilt yok)
|
||||||
RUN apk add --no-cache python3 make g++
|
RUN apk add --no-cache python3 make g++
|
||||||
COPY package.json pnpm-lock.yaml ./
|
COPY package.json pnpm-lock.yaml ./
|
||||||
RUN pnpm install --frozen-lockfile
|
# pnpm store cache mount'ta yaşar: lockfile değişse bile inen paketler yeniden inmez
|
||||||
|
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
|
||||||
# ── Derleme katmanı ──
|
# ── Derleme katmanı ──
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
# Build girdileri açık listelenir: docs/, scripts/, CI dosyası değişiklikleri
|
||||||
|
# bu katmanı invalide edemez. next build'in okuduğu her şey burada:
|
||||||
|
# content/ (rehber.ts), data/ (db.ts prerender), public/ (opengraph-image).
|
||||||
|
COPY package.json next.config.ts tsconfig.json postcss.config.mjs ./
|
||||||
|
COPY scripts/standalone-linkleri-onar.sh ./scripts/
|
||||||
|
COPY public ./public
|
||||||
|
COPY data ./data
|
||||||
|
COPY content ./content
|
||||||
|
COPY src ./src
|
||||||
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
# data/ .dockerignore'da; libsql build sırasında file:./data/app.db açabilsin diye boş dizin gerekli
|
# V8 heap sınırı ana süreç + statik üretim worker'larına miras kalır.
|
||||||
RUN mkdir -p data && pnpm build
|
# Turbopack'ın Rust tarafı bu sınırın dışında; onun hedefi next.config.ts'te
|
||||||
|
# turbopackMemoryLimit ile verilir (BUILD_KAYNAK_KISITLI bloğu).
|
||||||
|
ENV NODE_OPTIONS="--max-old-space-size=1024"
|
||||||
|
ENV BUILD_KAYNAK_KISITLI=1
|
||||||
|
RUN --mount=type=cache,id=next-cache,target=/app/.next/cache \
|
||||||
|
pnpm build
|
||||||
|
# Turbopack, ESM external'ların (@google/genai vb.) kök node_modules symlink'ini
|
||||||
|
# nft trace'ine yazmıyor; eksik linkler tamamlanmazsa runtime MODULE_NOT_FOUND
|
||||||
|
# ile düşer. Ayrıntı script'in başındaki açıklamada.
|
||||||
|
RUN sh scripts/standalone-linkleri-onar.sh .next/standalone
|
||||||
|
|
||||||
# ── Çalıştırma katmanı ──
|
# ── Çalıştırma katmanı ──
|
||||||
FROM node:20-alpine AS runner
|
FROM node:20-alpine AS runner
|
||||||
@@ -31,20 +52,28 @@ ENV NEXT_TELEMETRY_DISABLED=1
|
|||||||
RUN addgroup --system --gid 1001 nodejs && \
|
RUN addgroup --system --gid 1001 nodejs && \
|
||||||
adduser --system --uid 1001 nextjs
|
adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
# Bağımlılıklar
|
# standalone çıktı, file-tracing ile seçilmiş kendi minimal node_modules'unu
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
# içerir (serverExternalPackages dahil) — tam node_modules kopyalanmaz.
|
||||||
COPY --from=builder /app/.next/standalone ./
|
COPY --from=builder /app/.next/standalone ./
|
||||||
COPY --from=builder /app/.next/static ./.next/static
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder /app/public ./public
|
||||||
|
|
||||||
|
# Trace, data/yokatlas.db'yi de standalone'a koyuyor; runtime'da /app/data
|
||||||
|
# volume'ü bunu gölgelediği için imajdan atılır (~22M).
|
||||||
|
RUN rm -rf /app/data
|
||||||
|
|
||||||
# Seed veritabanları: entrypoint açılışta bunları /app/data volume'üne kopyalar
|
# Seed veritabanları: entrypoint açılışta bunları /app/data volume'üne kopyalar
|
||||||
COPY data/ ./seed/
|
COPY data/ ./seed/
|
||||||
|
# Migration'lar: entrypoint açılışta db-goc.mjs ile volume'deki app.db'ye uygular
|
||||||
|
COPY drizzle ./drizzle
|
||||||
|
COPY scripts/db-goc.mjs ./scripts/db-goc.mjs
|
||||||
COPY scripts/docker-entrypoint.sh ./docker-entrypoint.sh
|
COPY scripts/docker-entrypoint.sh ./docker-entrypoint.sh
|
||||||
|
|
||||||
# SQLite veri dizini
|
# SQLite veri dizini + Next'in runtime'da yazdığı image/fetch cache dizini
|
||||||
|
# (.next'in geri kalanı root'ta kalır; salt-okunur olması sorun değil)
|
||||||
RUN chmod +x /app/docker-entrypoint.sh && \
|
RUN chmod +x /app/docker-entrypoint.sh && \
|
||||||
mkdir -p /app/data && \
|
mkdir -p /app/data /app/.next/cache && \
|
||||||
chown -R nextjs:nodejs /app/data /app/seed
|
chown -R nextjs:nodejs /app/data /app/seed /app/.next/cache
|
||||||
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
|
|||||||
58
content/rehber/ais-tercih-nasil-yapilir-onaylama.md
Normal file
58
content/rehber/ais-tercih-nasil-yapilir-onaylama.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
---
|
||||||
|
baslik: AİS'te Tercih Nasıl Yapılır, Nasıl Onaylanır? (Son Kontrol Listesi)
|
||||||
|
aciklama: ÖSYM AİS tercih ekranı adım adım — kod girme, sıralama, "Tercihleri Bildir" onayı ve göndermeden önceki 5 maddelik son kontrol tek şemada.
|
||||||
|
tarih: 2026-08-05
|
||||||
|
---
|
||||||
|
|
||||||
|
Listeni kurdun; şimdi işin resmî kısmı var: tercihleri ÖSYM'nin sistemine girmek ve **onaylamak**. Her yıl adayların bir kısmı listeyi özenle kurup son adımda takılıyor — tercihler girilir ama bildirilmez, kod yanlış yazılır, değişiklikten sonra yeniden onay unutulur. Bu yazı AİS ekranını adım adım anlatıyor ve göndermeden önceki son kontrol listesini veriyor.
|
||||||
|
|
||||||
|
## Nereden, ne zamana kadar?
|
||||||
|
|
||||||
|
Tercihler yalnızca iki resmî kanaldan girilir: **ais.osym.gov.tr** ve **ÖSYM Aday İşlemleri mobil uygulaması**. Giriş T.C. kimlik numarası ve aday şifresiyle. 2026'da pencere **29 Temmuz - 10 Ağustos, saat 23.59** ([takvimin tamamı](/rehber/yks-tercih-takvimi-2026)). Son saate bırakma: kapanış akşamı sistem yoğunluğu gerçektir ve 23.59'dan sonra hiçbir işlem alınmaz.
|
||||||
|
|
||||||
|
## Adım adım AİS tercih ekranı
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
%% aria: ÖSYM AİS tercih bildirme akış şeması. Adımlar sırayla: aday şifresiyle AİS'e giriş yapılır, 2026 YKS Tercih ekranı açılır, kılavuzdaki program kodları tek tek listeye eklenir, satırlar istek sırasına göre dizilir, ardından son kontrol yapılır. Son kontrolden sonra Tercihleri Bildir butonuna basılır. Kritik doğrulama şudur: ekranda tercihleriniz ÖSYM'ye bildirilmiştir uyarısı görünüyorsa işlem tamamdır ve bu kutu yeşildir; uyarı görünmüyorsa tercihler kaydedilmiş ama bildirilmemiş olabilir, bu durumda tercihlerin geçersiz sayılır ve bu kutu kırmızıdır. Son tarihe kadar listede değişiklik yapılabilir ama her değişiklikten sonra Tercihleri Bildir adımı yeniden yapılmalıdır; bu döngü sarı ile işaretlidir çünkü en sık unutulan adımdır.
|
||||||
|
%% altyazi: Kaydetmek yetmez — ekranda "bildirilmiştir" uyarısını görmeden sistemden çıkma.
|
||||||
|
flowchart TD
|
||||||
|
A("AİS'e gir:<br/>T.C. kimlik + aday şifresi") --> B("2026-YKS Tercih<br/>ekranını aç")
|
||||||
|
B --> C("Program kodlarını<br/>kılavuzdan tek tek ekle")
|
||||||
|
C --> D("Satırları İSTEK sırana<br/>göre diz")
|
||||||
|
D --> E("Son kontrolü yap") --> F("Tercihleri Bildir<br/>butonuna bas")
|
||||||
|
F --> G{"Ekranda 'Tercihleriniz<br/>ÖSYM'ye bildirilmiştir'<br/>uyarısı var mı?"}
|
||||||
|
G -- "evet" --> H("İşlem tamam:<br/>ekran görüntüsü al")
|
||||||
|
G -- "hayır" --> I("Tercihler GEÇERSİZ:<br/>bildirme adımını<br/>tamamla")
|
||||||
|
H --> J{"Son güne kadar<br/>değişiklik yaptın mı?"}
|
||||||
|
J -- "evet" --> F
|
||||||
|
J -- "hayır" --> K("10 Ağustos 23.59'da<br/>liste kilitlenir")
|
||||||
|
|
||||||
|
classDef uyari fill:#fef3c7,stroke:#f59e0b,color:#92400e
|
||||||
|
classDef garanti fill:#d1fae5,stroke:#10b981,color:#065f46
|
||||||
|
classDef hayal fill:#fee2e2,stroke:#ef4444,color:#991b1b
|
||||||
|
class E,G,J uyari
|
||||||
|
class H,K garanti
|
||||||
|
class I hayal
|
||||||
|
```
|
||||||
|
|
||||||
|
**1) Giriş.** ais.osym.gov.tr adresine T.C. kimlik numaran ve aday şifrenle gir. Şifren yoksa/unuttuysan hemen yenile — bunu son güne bırakmak en gereksiz risk.
|
||||||
|
|
||||||
|
**2) Tercih ekranı.** Açılan sayfada "2026-YKS Tercih" seçeneğine tıkla.
|
||||||
|
|
||||||
|
**3) Kodları ekle.** Her programın kılavuzda ve [tercih robotu](/tercih-robotu) çıktısında görünen bir **program kodu** var. Kodu ekleyince ekranda çıkan program adını ve üniversiteyi kendi listenle karşılaştır — tek rakam farkı seni başka şehre, başka bölüme yazar.
|
||||||
|
|
||||||
|
**4) İstek sırana göre diz.** Merkezi yerleştirme listeyi yukarıdan aşağı okur ve seni yerleştirebileceği **en üstteki** satıra yerleştirir ([sistem nasıl çalışır](/rehber/merkezi-yerlestirme-nasil-calisir)). Sıra yerleşme ihtimaline göre değil, *istemene* göre olmalı.
|
||||||
|
|
||||||
|
**5) Bildir ve doğrula.** "Tercihleri Bildir" butonuna bas ve ekranda **"2026-YKS Tercihleriniz ÖSYM'ye bildirilmiştir"** uyarısını gör. Bu uyarıyı görmeden kapattığın oturumda tercihlerin kaydedilmiş ama **bildirilmemiş** olabilir — bildirilmeyen tercih geçersizdir. Uyarı ekranının görüntüsünü al, sakla.
|
||||||
|
|
||||||
|
**6) Değişiklik yaptıysan yeniden bildir.** Son tarihe kadar listeyi istediğin kadar güncelleyebilirsin; ama her değişiklikten sonra bildirme adımı **yeniden** yapılır. Sistemdeki geçerli liste, en son "bildirilmiştir" uyarısını aldığın listedir.
|
||||||
|
|
||||||
|
## Göndermeden önce: 5 maddelik son kontrol
|
||||||
|
|
||||||
|
1. **Her satırın koşulunu okudun mu?** Yaş, cinsiyet, sağlık raporu gibi [özel koşullar](/rehber/ozel-kosullu-programlar) ve [başarı sırası şartları](/rehber/siralama-sartlari-2026) sağlanmıyorsa o satır ya boşa gider ya da yerleşsen bile kayıtta yanar.
|
||||||
|
2. **Üst sıralarda ölü tercih var mı?** Tabanı senden çok iyi programı listenin *sonuna* yazmak satırı çöpe atar ([ölü tercih nedir](/rehber/olu-tercih-nedir)).
|
||||||
|
3. **Listenin sonu gerçekten garanti mi?** Son 4-6 satır, yerleşirsen kayıt yaptıracağın programlar olmalı ([hayal/dengeli/garanti dağılımı](/rehber/tercih-listesi-nasil-yapilir)).
|
||||||
|
4. **Kayıt yaptırmayacağın satır var mı?** Varsa sil: oraya yerleşirsen hem yılın gider hem [ek yerleştirmeye](/rehber/ek-yerlestirme-2026) başvuramazsın.
|
||||||
|
5. **Kodları tek tek doğruladın mı?** Ekrandaki program adı + üniversite + şehir üçlüsünü kendi listenle satır satır karşılaştır.
|
||||||
|
|
||||||
|
Listen henüz oturmadıysa önce evreni gör: [tercih robotuna](/tercih-robotu) sıralamanı gir, sıralamana düşen programları hayal/dengeli/garanti dilimleriyle çıkar, sonra AİS'e kodları bu listeden aktar. AİS'te düşünülmez — AİS'e *bitmiş* liste girilir.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user