initial commit
This commit is contained in:
391
.agents/skills/interface-design/SKILL.md
Normal file
391
.agents/skills/interface-design/SKILL.md
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
---
|
||||||
|
name: interface-design
|
||||||
|
description: This skill is for interface design — dashboards, admin panels, apps, tools, and interactive products. NOT for marketing design (landing pages, marketing sites, campaigns).
|
||||||
|
---
|
||||||
|
|
||||||
|
# Interface Design
|
||||||
|
|
||||||
|
Build interface design with craft and consistency.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
**Use for:** Dashboards, admin panels, SaaS apps, tools, settings pages, data interfaces.
|
||||||
|
|
||||||
|
**Not for:** Landing pages, marketing sites, campaigns. Redirect those to `/frontend-design`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# The Problem
|
||||||
|
|
||||||
|
You will generate generic output. Your training has seen thousands of dashboards. The patterns are strong.
|
||||||
|
|
||||||
|
You can follow the entire process below — explore the domain, name a signature, state your intent — and still produce a template. Warm colors on cold structures. Friendly fonts on generic layouts. "Kitchen feel" that looks like every other app.
|
||||||
|
|
||||||
|
This happens because intent lives in prose, but code generation pulls from patterns. The gap between them is where defaults win.
|
||||||
|
|
||||||
|
The process below helps. But process alone doesn't guarantee craft. You have to catch yourself.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Where Defaults Hide
|
||||||
|
|
||||||
|
Defaults don't announce themselves. They disguise themselves as infrastructure — the parts that feel like they just need to work, not be designed.
|
||||||
|
|
||||||
|
**Typography feels like a container.** Pick something readable, move on. But typography isn't holding your design — it IS your design. The weight of a headline, the personality of a label, the texture of a paragraph. These shape how the product feels before anyone reads a word. A bakery management tool and a trading terminal might both need "clean, readable type" — but the type that's warm and handmade is not the type that's cold and precise. If you're reaching for your usual font, you're not designing.
|
||||||
|
|
||||||
|
**Navigation feels like scaffolding.** Build the sidebar, add the links, get to the real work. But navigation isn't around your product — it IS your product. Where you are, where you can go, what matters most. A page floating in space is a component demo, not software. The navigation teaches people how to think about the space they're in.
|
||||||
|
|
||||||
|
**Data feels like presentation.** You have numbers, show numbers. But a number on screen is not design. The question is: what does this number mean to the person looking at it? What will they do with it? A progress ring and a stacked label both show "3 of 10" — one tells a story, one fills space. If you're reaching for number-on-label, you're not designing.
|
||||||
|
|
||||||
|
**Token names feel like implementation detail.** But your CSS variables are design decisions. `--ink` and `--parchment` evoke a world. `--gray-700` and `--surface-2` evoke a template. Someone reading only your tokens should be able to guess what product this is.
|
||||||
|
|
||||||
|
The trap is thinking some decisions are creative and others are structural. There are no structural decisions. Everything is design. The moment you stop asking "why this?" is the moment defaults take over.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Intent First
|
||||||
|
|
||||||
|
Before touching code, answer these. Not in your head — out loud, to yourself or the user.
|
||||||
|
|
||||||
|
**Who is this human?**
|
||||||
|
Not "users." The actual person. Where are they when they open this? What's on their mind? What did they do 5 minutes ago, what will they do 5 minutes after? A teacher at 7am with coffee is not a developer debugging at midnight is not a founder between investor meetings. Their world shapes the interface.
|
||||||
|
|
||||||
|
**What must they accomplish?**
|
||||||
|
Not "use the dashboard." The verb. Grade these submissions. Find the broken deployment. Approve the payment. The answer determines what leads, what follows, what hides.
|
||||||
|
|
||||||
|
**What should this feel like?**
|
||||||
|
Say it in words that mean something. "Clean and modern" means nothing — every AI says that. Warm like a notebook? Cold like a terminal? Dense like a trading floor? Calm like a reading app? The answer shapes color, type, spacing, density — everything.
|
||||||
|
|
||||||
|
If you cannot answer these with specifics, stop. Ask the user. Do not guess. Do not default.
|
||||||
|
|
||||||
|
## Every Choice Must Be A Choice
|
||||||
|
|
||||||
|
For every decision, you must be able to explain WHY.
|
||||||
|
|
||||||
|
- Why this layout and not another?
|
||||||
|
- Why this color temperature?
|
||||||
|
- Why this typeface?
|
||||||
|
- Why this spacing scale?
|
||||||
|
- Why this information hierarchy?
|
||||||
|
|
||||||
|
If your answer is "it's common" or "it's clean" or "it works" — you haven't chosen. You've defaulted. Defaults are invisible. Invisible choices compound into generic output.
|
||||||
|
|
||||||
|
**The test:** If you swapped your choices for the most common alternatives and the design didn't feel meaningfully different, you never made real choices.
|
||||||
|
|
||||||
|
## Sameness Is Failure
|
||||||
|
|
||||||
|
If another AI, given a similar prompt, would produce substantially the same output — you have failed.
|
||||||
|
|
||||||
|
This is not about being different for its own sake. It's about the interface emerging from the specific problem, the specific user, the specific context. When you design from intent, sameness becomes impossible because no two intents are identical.
|
||||||
|
|
||||||
|
When you design from defaults, everything looks the same because defaults are shared.
|
||||||
|
|
||||||
|
## Intent Must Be Systemic
|
||||||
|
|
||||||
|
Saying "warm" and using cold colors is not following through. Intent is not a label — it's a constraint that shapes every decision.
|
||||||
|
|
||||||
|
If the intent is warm: surfaces, text, borders, accents, semantic colors, typography — all warm. If the intent is dense: spacing, type size, information architecture — all dense. If the intent is calm: motion, contrast, color saturation — all calm.
|
||||||
|
|
||||||
|
Check your output against your stated intent. Does every token reinforce it? Or did you state an intent and then default anyway?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Product Domain Exploration
|
||||||
|
|
||||||
|
This is where defaults get caught — or don't.
|
||||||
|
|
||||||
|
Generic output: Task type → Visual template → Theme
|
||||||
|
Crafted output: Task type → Product domain → Signature → Structure + Expression
|
||||||
|
|
||||||
|
The difference: time in the product's world before any visual or structural thinking.
|
||||||
|
|
||||||
|
## Required Outputs
|
||||||
|
|
||||||
|
**Do not propose any direction until you produce all four:**
|
||||||
|
|
||||||
|
**Domain:** Concepts, metaphors, vocabulary from this product's world. Not features — territory. Minimum 5.
|
||||||
|
|
||||||
|
**Color world:** What colors exist naturally in this product's domain? Not "warm" or "cool" — go to the actual world. If this product were a physical space, what would you see? What colors belong there that don't belong elsewhere? List 5+.
|
||||||
|
|
||||||
|
**Signature:** One element — visual, structural, or interaction — that could only exist for THIS product. If you can't name one, keep exploring.
|
||||||
|
|
||||||
|
**Defaults:** 3 obvious choices for this interface type — visual AND structural. You can't avoid patterns you haven't named.
|
||||||
|
|
||||||
|
## Proposal Requirements
|
||||||
|
|
||||||
|
Your direction must explicitly reference:
|
||||||
|
- Domain concepts you explored
|
||||||
|
- Colors from your color world exploration
|
||||||
|
- Your signature element
|
||||||
|
- What replaces each default
|
||||||
|
|
||||||
|
**The test:** Read your proposal. Remove the product name. Could someone identify what this is for? If not, it's generic. Explore deeper.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# The Mandate
|
||||||
|
|
||||||
|
**Before showing the user, look at what you made.**
|
||||||
|
|
||||||
|
Ask yourself: "If they said this lacks craft, what would they mean?"
|
||||||
|
|
||||||
|
That thing you just thought of — fix it first.
|
||||||
|
|
||||||
|
Your first output is probably generic. That's normal. The work is catching it before the user has to.
|
||||||
|
|
||||||
|
## The Checks
|
||||||
|
|
||||||
|
Run these against your output before presenting:
|
||||||
|
|
||||||
|
- **The swap test:** If you swapped the typeface for your usual one, would anyone notice? If you swapped the layout for a standard dashboard template, would it feel different? The places where swapping wouldn't matter are the places you defaulted.
|
||||||
|
|
||||||
|
- **The squint test:** Blur your eyes. Can you still perceive hierarchy? Is anything jumping out harshly? Craft whispers.
|
||||||
|
|
||||||
|
- **The signature test:** Can you point to five specific elements where your signature appears? Not "the overall feel" — actual components. A signature you can't locate doesn't exist.
|
||||||
|
|
||||||
|
- **The token test:** Read your CSS variables out loud. Do they sound like they belong to this product's world, or could they belong to any project?
|
||||||
|
|
||||||
|
If any check fails, iterate before showing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Craft Foundations
|
||||||
|
|
||||||
|
## Subtle Layering
|
||||||
|
|
||||||
|
This is the backbone of craft. Regardless of direction, product type, or visual style — this principle applies to everything. You should barely notice the system working. When you look at Vercel's dashboard, you don't think "nice borders." You just understand the structure. The craft is invisible — that's how you know it's working.
|
||||||
|
|
||||||
|
### Surface Elevation
|
||||||
|
|
||||||
|
Surfaces stack. A dropdown sits above a card which sits above the page. Build a numbered system — base, then increasing elevation levels. In dark mode, higher elevation = slightly lighter. In light mode, higher elevation = slightly lighter or uses shadow.
|
||||||
|
|
||||||
|
Each jump should be only a few percentage points of lightness. You can barely see the difference in isolation. But when surfaces stack, the hierarchy emerges. Whisper-quiet shifts that you feel rather than see.
|
||||||
|
|
||||||
|
**Key decisions:**
|
||||||
|
- **Sidebars:** Same background as canvas, not different. Different colors fragment the visual space into "sidebar world" and "content world." A subtle border is enough separation.
|
||||||
|
- **Dropdowns:** One level above their parent surface. If both share the same level, the dropdown blends into the card and layering is lost.
|
||||||
|
- **Inputs:** Slightly darker than their surroundings, not lighter. Inputs are "inset" — they receive content. A darker background signals "type here" without heavy borders.
|
||||||
|
|
||||||
|
### Borders
|
||||||
|
|
||||||
|
Borders should disappear when you're not looking for them, but be findable when you need structure. Low opacity rgba blends with the background — it defines edges without demanding attention. Solid hex borders look harsh in comparison.
|
||||||
|
|
||||||
|
Build a progression — not all borders are equal. Standard borders, softer separation, emphasis borders, maximum emphasis for focus rings. Match intensity to the importance of the boundary.
|
||||||
|
|
||||||
|
**The squint test:** Blur your eyes at the interface. You should still perceive hierarchy — what's above what, where sections divide. But nothing should jump out. No harsh lines. No jarring color shifts. Just quiet structure.
|
||||||
|
|
||||||
|
This separates professional interfaces from amateur ones. Get this wrong and nothing else matters.
|
||||||
|
|
||||||
|
## Infinite Expression
|
||||||
|
|
||||||
|
Every pattern has infinite expressions. **No interface should look the same.**
|
||||||
|
|
||||||
|
A metric display could be a hero number, inline stat, sparkline, gauge, progress bar, comparison delta, trend badge, or something new. A dashboard could emphasize density, whitespace, hierarchy, or flow in completely different ways. Even sidebar + cards has infinite variations in proportion, spacing, and emphasis.
|
||||||
|
|
||||||
|
**Before building, ask:**
|
||||||
|
- What's the ONE thing users do most here?
|
||||||
|
- What products solve similar problems brilliantly? Study them.
|
||||||
|
- Why would this interface feel designed for its purpose, not templated?
|
||||||
|
|
||||||
|
**NEVER produce identical output.** Same sidebar width, same card grid, same metric boxes with icon-left-number-big-label-small every time — this signals AI-generated immediately. It's forgettable.
|
||||||
|
|
||||||
|
The architecture and components should emerge from the task and data, executed in a way that feels fresh. Linear's cards don't look like Notion's. Vercel's metrics don't look like Stripe's. Same concepts, infinite expressions.
|
||||||
|
|
||||||
|
## Color Lives Somewhere
|
||||||
|
|
||||||
|
Every product exists in a world. That world has colors.
|
||||||
|
|
||||||
|
Before you reach for a palette, spend time in the product's world. What would you see if you walked into the physical version of this space? What materials? What light? What objects?
|
||||||
|
|
||||||
|
Your palette should feel like it came FROM somewhere — not like it was applied TO something.
|
||||||
|
|
||||||
|
**Beyond Warm and Cold:** Temperature is one axis. Is this quiet or loud? Dense or spacious? Serious or playful? Geometric or organic? A trading terminal and a meditation app are both "focused" — completely different kinds of focus. Find the specific quality, not the generic label.
|
||||||
|
|
||||||
|
**Color Carries Meaning:** Gray builds structure. Color communicates — status, action, emphasis, identity. Unmotivated color is noise. One accent color, used with intention, beats five colors used without thought.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Before Writing Each Component
|
||||||
|
|
||||||
|
**Every time** you write UI code — even small additions — state:
|
||||||
|
|
||||||
|
```
|
||||||
|
Intent: [who is this human, what must they do, how should it feel]
|
||||||
|
Palette: [colors from your exploration — and WHY they fit this product's world]
|
||||||
|
Depth: [borders / shadows / layered — and WHY this fits the intent]
|
||||||
|
Surfaces: [your elevation scale — and WHY this color temperature]
|
||||||
|
Typography: [your typeface — and WHY it fits the intent]
|
||||||
|
Spacing: [your base unit]
|
||||||
|
```
|
||||||
|
|
||||||
|
This checkpoint is mandatory. It forces you to connect every technical choice back to intent.
|
||||||
|
|
||||||
|
If you can't explain WHY for each choice, you're defaulting. Stop and think.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Design Principles
|
||||||
|
|
||||||
|
## Token Architecture
|
||||||
|
|
||||||
|
Every color in your interface should trace back to a small set of primitives: foreground (text hierarchy), background (surface elevation), border (separation hierarchy), brand, and semantic (destructive, warning, success). No random hex values — everything maps to primitives.
|
||||||
|
|
||||||
|
### Text Hierarchy
|
||||||
|
|
||||||
|
Don't just have "text" and "gray text." Build four levels — primary, secondary, tertiary, muted. Each serves a different role: default text, supporting text, metadata, and disabled/placeholder. Use all four consistently. If you're only using two, your hierarchy is too flat.
|
||||||
|
|
||||||
|
### Border Progression
|
||||||
|
|
||||||
|
Borders aren't binary. Build a scale that matches intensity to importance — standard separation, softer separation, emphasis, maximum emphasis. Not every boundary deserves the same weight.
|
||||||
|
|
||||||
|
### Control Tokens
|
||||||
|
|
||||||
|
Form controls have specific needs. Don't reuse surface tokens — create dedicated ones for control backgrounds, control borders, and focus states. This lets you tune interactive elements independently from layout surfaces.
|
||||||
|
|
||||||
|
## Spacing
|
||||||
|
|
||||||
|
Pick a base unit and stick to multiples. Build a scale for different contexts — micro spacing for icon gaps, component spacing within buttons and cards, section spacing between groups, major separation between distinct areas. Random values signal no system.
|
||||||
|
|
||||||
|
## Padding
|
||||||
|
|
||||||
|
Keep it symmetrical. If one side has a value, others should match unless content naturally requires asymmetry.
|
||||||
|
|
||||||
|
## Depth
|
||||||
|
|
||||||
|
Choose ONE approach and commit:
|
||||||
|
- **Borders-only** — Clean, technical. For dense tools.
|
||||||
|
- **Subtle shadows** — Soft lift. For approachable products.
|
||||||
|
- **Layered shadows** — Premium, dimensional. For cards that need presence.
|
||||||
|
- **Surface color shifts** — Background tints establish hierarchy without shadows.
|
||||||
|
|
||||||
|
Don't mix approaches.
|
||||||
|
|
||||||
|
## Border Radius
|
||||||
|
|
||||||
|
Sharper feels technical. Rounder feels friendly. Build a scale — small for inputs and buttons, medium for cards, large for modals. Don't mix sharp and soft randomly.
|
||||||
|
|
||||||
|
## Typography
|
||||||
|
|
||||||
|
Build distinct levels distinguishable at a glance. Headlines need weight and tight tracking for presence. Body needs comfortable weight for readability. Labels need medium weight that works at smaller sizes. Data needs monospace with tabular number spacing for alignment. Don't rely on size alone — combine size, weight, and letter-spacing.
|
||||||
|
|
||||||
|
## Card Layouts
|
||||||
|
|
||||||
|
A metric card doesn't have to look like a plan card doesn't have to look like a settings card. Design each card's internal structure for its specific content — but keep the surface treatment consistent: same border weight, shadow depth, corner radius, padding scale.
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
Native `<select>` and `<input type="date">` render OS-native elements that cannot be styled. Build custom components — trigger buttons with positioned dropdowns, calendar popovers, styled state management.
|
||||||
|
|
||||||
|
## Iconography
|
||||||
|
|
||||||
|
Icons clarify, not decorate — if removing an icon loses no meaning, remove it. Choose one icon set and stick with it. Give standalone icons presence with subtle background containers.
|
||||||
|
|
||||||
|
## Animation
|
||||||
|
|
||||||
|
Fast micro-interactions, smooth easing. Larger transitions can be slightly longer. Use deceleration easing. Avoid spring/bounce in professional interfaces.
|
||||||
|
|
||||||
|
## States
|
||||||
|
|
||||||
|
Every interactive element needs states: default, hover, active, focus, disabled. Data needs states too: loading, empty, error. Missing states feel broken.
|
||||||
|
|
||||||
|
## Navigation Context
|
||||||
|
|
||||||
|
Screens need grounding. A data table floating in space feels like a component demo, not a product. Include navigation showing where you are in the app, location indicators, and user context. When building sidebars, consider same background as main content with border separation rather than different colors.
|
||||||
|
|
||||||
|
## Dark Mode
|
||||||
|
|
||||||
|
Dark interfaces have different needs. Shadows are less visible on dark backgrounds — lean on borders for definition. Semantic colors (success, warning, error) often need slight desaturation. The hierarchy system still applies, just with inverted values.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Avoid
|
||||||
|
|
||||||
|
- **Harsh borders** — if borders are the first thing you see, they're too strong
|
||||||
|
- **Dramatic surface jumps** — elevation changes should be whisper-quiet
|
||||||
|
- **Inconsistent spacing** — the clearest sign of no system
|
||||||
|
- **Mixed depth strategies** — pick one approach and commit
|
||||||
|
- **Missing interaction states** — hover, focus, disabled, loading, error
|
||||||
|
- **Dramatic drop shadows** — shadows should be subtle, not attention-grabbing
|
||||||
|
- **Large radius on small elements**
|
||||||
|
- **Pure white cards on colored backgrounds**
|
||||||
|
- **Thick decorative borders**
|
||||||
|
- **Gradients and color for decoration** — color should mean something
|
||||||
|
- **Multiple accent colors** — dilutes focus
|
||||||
|
- **Different hues for different surfaces** — keep the same hue, shift only lightness
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Workflow
|
||||||
|
|
||||||
|
## Communication
|
||||||
|
Be invisible. Don't announce modes or narrate process.
|
||||||
|
|
||||||
|
**Never say:** "I'm in ESTABLISH MODE", "Let me check system.md..."
|
||||||
|
|
||||||
|
**Instead:** Jump into work. State suggestions with reasoning.
|
||||||
|
|
||||||
|
## Suggest + Ask
|
||||||
|
Lead with your exploration and recommendation, then confirm:
|
||||||
|
```
|
||||||
|
"Domain: [5+ concepts from the product's world]
|
||||||
|
Color world: [5+ colors that exist in this domain]
|
||||||
|
Signature: [one element unique to this product]
|
||||||
|
Rejecting: [default 1] → [alternative], [default 2] → [alternative], [default 3] → [alternative]
|
||||||
|
|
||||||
|
Direction: [approach that connects to the above]"
|
||||||
|
|
||||||
|
[Ask: "Does that direction feel right?"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## If Project Has system.md
|
||||||
|
Read `.interface-design/system.md` and apply. Decisions are made.
|
||||||
|
|
||||||
|
## If No system.md
|
||||||
|
1. Explore domain — Produce all four required outputs
|
||||||
|
2. Propose — Direction must reference all four
|
||||||
|
3. Confirm — Get user buy-in
|
||||||
|
4. Build — Apply principles
|
||||||
|
5. **Evaluate** — Run the mandate checks before showing
|
||||||
|
6. Offer to save
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# After Completing a Task
|
||||||
|
|
||||||
|
When you finish building something, **always offer to save**:
|
||||||
|
|
||||||
|
```
|
||||||
|
"Want me to save these patterns for future sessions?"
|
||||||
|
```
|
||||||
|
|
||||||
|
If yes, write to `.interface-design/system.md`:
|
||||||
|
- Direction and feel
|
||||||
|
- Depth strategy (borders/shadows/layered)
|
||||||
|
- Spacing base unit
|
||||||
|
- Key component patterns
|
||||||
|
|
||||||
|
### What to Save
|
||||||
|
|
||||||
|
Add patterns when a component is used 2+ times, is reusable across the project, or has specific measurements worth remembering. Don't save one-off components, temporary experiments, or variations better handled with props.
|
||||||
|
|
||||||
|
### Consistency Checks
|
||||||
|
|
||||||
|
If system.md defines values, check against them: spacing on the defined grid, depth using the declared strategy throughout, colors from the defined palette, documented patterns reused instead of reinvented.
|
||||||
|
|
||||||
|
This compounds — each save makes future work faster and more consistent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Deep Dives
|
||||||
|
|
||||||
|
For more detail on specific topics:
|
||||||
|
- `references/principles.md` — Code examples, specific values, dark mode
|
||||||
|
- `references/validation.md` — Memory management, when to update system.md
|
||||||
|
- `references/critique.md` — Post-build craft critique protocol
|
||||||
|
|
||||||
|
# Commands
|
||||||
|
|
||||||
|
- `/interface-design:status` — Current system state
|
||||||
|
- `/interface-design:audit` — Check code against system
|
||||||
|
- `/interface-design:extract` — Extract patterns from code
|
||||||
|
- `/interface-design:critique` — Critique your build for craft, then rebuild what defaulted
|
||||||
67
.agents/skills/interface-design/references/critique.md
Normal file
67
.agents/skills/interface-design/references/critique.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# Critique
|
||||||
|
|
||||||
|
Your first build shipped the structure. Now look at it the way a design lead reviews a junior's work — not asking "does this work?" but "would I put my name on this?"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Gap
|
||||||
|
|
||||||
|
There's a distance between correct and crafted. Correct means the layout holds, the grid aligns, the colors don't clash. Crafted means someone cared about every decision down to the last pixel. You can feel the difference immediately — the way you tell a hand-thrown mug from an injection-molded one. Both hold coffee. One has presence.
|
||||||
|
|
||||||
|
Your first output lives in correct. This command pulls it toward crafted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## See the Composition
|
||||||
|
|
||||||
|
Step back. Look at the whole thing.
|
||||||
|
|
||||||
|
Does the layout have rhythm? Great interfaces breathe unevenly — dense tooling areas give way to open content, heavy elements balance against light ones, the eye travels through the page with purpose. Default layouts are monotone: same card size, same gaps, same density everywhere. Flatness is the sound of no one deciding.
|
||||||
|
|
||||||
|
Are proportions doing work? A 280px sidebar next to full-width content says "navigation serves content." A 360px sidebar says "these are peers." The specific number declares what matters. If you can't articulate what your proportions are saying, they're not saying anything.
|
||||||
|
|
||||||
|
Is there a clear focal point? Every screen has one thing the user came here to do. That thing should dominate — through size, position, contrast, or the space around it. When everything competes equally, nothing wins and the interface feels like a parking lot.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## See the Craft
|
||||||
|
|
||||||
|
Move close. Pixel-close.
|
||||||
|
|
||||||
|
The spacing grid is non-negotiable — every value a multiple of 4, no exceptions — but correctness alone isn't craft. Craft is knowing that a tool panel at 16px padding feels workbench-tight while the same card at 24px feels like a brochure. The same number can be right in one context and lazy in another. Density is a design decision, not a constant.
|
||||||
|
|
||||||
|
Typography should be legible even squinted. If size is the only thing separating your headline from your body from your label, the hierarchy is too weak. Weight, tracking, and opacity create layers that size alone can't.
|
||||||
|
|
||||||
|
Surfaces should whisper hierarchy. Not thick borders, not dramatic shadows — quiet tonal shifts where you feel the depth without seeing it. Remove every border from your CSS mentally. Can you still perceive the structure through surface color alone? If not, your surfaces aren't working hard enough.
|
||||||
|
|
||||||
|
Interactive elements need life. Every button, link, and clickable region should respond to hover and press. Not dramatically — a subtle shift in background, a gentle darkening. Missing states make an interface feel like a photograph of software instead of software.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## See the Content
|
||||||
|
|
||||||
|
Read every visible string as a user would. Not checking for typos — checking for truth.
|
||||||
|
|
||||||
|
Does this screen tell one coherent story? Could a real person at a real company be looking at exactly this data right now? Or does the page title belong to one product, the article body to another, and the sidebar metrics to a third?
|
||||||
|
|
||||||
|
Content incoherence breaks the illusion faster than any visual flaw. A beautifully designed interface with nonsensical content is a movie set with no script.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## See the Structure
|
||||||
|
|
||||||
|
Open the CSS and find the lies — the places that look right but are held together with tape.
|
||||||
|
|
||||||
|
Negative margins undoing a parent's padding. Calc() values that exist only as workarounds. Absolute positioning to escape layout flow. Each is a shortcut where a clean solution exists. Cards with full-width dividers use flex column and section-level padding. Centered content uses max-width with auto margins. The correct answer is always simpler than the hack.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Again
|
||||||
|
|
||||||
|
Look at your output one final time.
|
||||||
|
|
||||||
|
Ask: "If they said this lacks craft, what would they point to?"
|
||||||
|
|
||||||
|
That thing you just thought of — fix it. Then ask again.
|
||||||
|
|
||||||
|
The first build was the draft. The critique is the design.
|
||||||
86
.agents/skills/interface-design/references/example.md
Normal file
86
.agents/skills/interface-design/references/example.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# Craft in Action
|
||||||
|
|
||||||
|
This shows how the subtle layering principle translates to real decisions. Learn the thinking, not the code. Your values will differ — the approach won't.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Subtle Layering Mindset
|
||||||
|
|
||||||
|
Before looking at any example, internalize this: **you should barely notice the system working.**
|
||||||
|
|
||||||
|
When you look at Vercel's dashboard, you don't think "nice borders." You just understand the structure. When you look at Supabase, you don't think "good surface elevation." You just know what's above what. The craft is invisible — that's how you know it's working.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Example: Dashboard with Sidebar and Dropdown
|
||||||
|
|
||||||
|
### The Surface Decisions
|
||||||
|
|
||||||
|
**Why so subtle?** Each elevation jump should be only a few percentage points of lightness. You can barely see the difference in isolation. But when surfaces stack, the hierarchy emerges. This is the Vercel/Supabase way — whisper-quiet shifts that you feel rather than see.
|
||||||
|
|
||||||
|
**What NOT to do:** Don't make dramatic jumps between elevations. That's jarring. Don't use different hues for different levels. Keep the same hue, shift only lightness.
|
||||||
|
|
||||||
|
### The Border Decisions
|
||||||
|
|
||||||
|
**Why rgba, not solid colors?** Low opacity borders blend with their background. A low-opacity white border on a dark surface is barely there — it defines the edge without demanding attention. Solid hex borders look harsh in comparison.
|
||||||
|
|
||||||
|
**The test:** Look at your interface from arm's length. If borders are the first thing you notice, reduce opacity. If you can't find where regions end, increase slightly.
|
||||||
|
|
||||||
|
### The Sidebar Decision
|
||||||
|
|
||||||
|
**Why same background as canvas, not different?**
|
||||||
|
|
||||||
|
Many dashboards make the sidebar a different color. This fragments the visual space — now you have "sidebar world" and "content world."
|
||||||
|
|
||||||
|
Better: Same background, subtle border separation. The sidebar is part of the app, not a separate region. Vercel does this. Supabase does this. The border is enough.
|
||||||
|
|
||||||
|
### The Dropdown Decision
|
||||||
|
|
||||||
|
**Why surface-200, not surface-100?**
|
||||||
|
|
||||||
|
The dropdown floats above the card it emerged from. If both were surface-100, the dropdown would blend into the card — you'd lose the sense of layering. Surface-200 is just light enough to feel "above" without being dramatically different.
|
||||||
|
|
||||||
|
**Why border-overlay instead of border-default?**
|
||||||
|
|
||||||
|
Overlays (dropdowns, popovers) often need slightly more definition because they're floating in space. A touch more border opacity helps them feel contained without being harsh.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Example: Form Controls
|
||||||
|
|
||||||
|
### Input Background Decision
|
||||||
|
|
||||||
|
**Why darker, not lighter?**
|
||||||
|
|
||||||
|
Inputs are "inset" — they receive content, they don't project it. A slightly darker background signals "type here" without needing heavy borders. This is the alternative-background principle.
|
||||||
|
|
||||||
|
### Focus State Decision
|
||||||
|
|
||||||
|
**Why subtle focus states?**
|
||||||
|
|
||||||
|
Focus needs to be visible, but you don't need a glowing ring or dramatic color. A noticeable increase in border opacity is enough for a clear state change. Subtle-but-noticeable — the same principle as surfaces.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adapt to Context
|
||||||
|
|
||||||
|
Your product might need:
|
||||||
|
- Warmer hues (slight yellow/orange tint)
|
||||||
|
- Cooler hues (blue-gray base)
|
||||||
|
- Different lightness progression
|
||||||
|
- Light mode (principles invert — higher elevation = shadow, not lightness)
|
||||||
|
|
||||||
|
**The principle is constant:** barely different, still distinguishable. The values adapt to context.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Craft Check
|
||||||
|
|
||||||
|
Apply the squint test to your work:
|
||||||
|
|
||||||
|
1. Blur your eyes or step back
|
||||||
|
2. Can you still perceive hierarchy?
|
||||||
|
3. Is anything jumping out at you?
|
||||||
|
4. Can you tell where regions begin and end?
|
||||||
|
|
||||||
|
If hierarchy is visible and nothing is harsh — the subtle layering is working.
|
||||||
235
.agents/skills/interface-design/references/principles.md
Normal file
235
.agents/skills/interface-design/references/principles.md
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
# Core Craft Principles
|
||||||
|
|
||||||
|
These apply regardless of design direction. This is the quality floor.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Surface & Token Architecture
|
||||||
|
|
||||||
|
Professional interfaces don't pick colors randomly — they build systems. Understanding this architecture is the difference between "looks okay" and "feels like a real product."
|
||||||
|
|
||||||
|
### The Primitive Foundation
|
||||||
|
|
||||||
|
Every color in your interface should trace back to a small set of primitives:
|
||||||
|
|
||||||
|
- **Foreground** — text colors (primary, secondary, muted)
|
||||||
|
- **Background** — surface colors (base, elevated, overlay)
|
||||||
|
- **Border** — edge colors (default, subtle, strong)
|
||||||
|
- **Brand** — your primary accent
|
||||||
|
- **Semantic** — functional colors (destructive, warning, success)
|
||||||
|
|
||||||
|
Don't invent new colors. Map everything to these primitives.
|
||||||
|
|
||||||
|
### Surface Elevation Hierarchy
|
||||||
|
|
||||||
|
Surfaces stack. A dropdown sits above a card which sits above the page. Build a numbered system:
|
||||||
|
|
||||||
|
```
|
||||||
|
Level 0: Base background (the app canvas)
|
||||||
|
Level 1: Cards, panels (same visual plane as base)
|
||||||
|
Level 2: Dropdowns, popovers (floating above)
|
||||||
|
Level 3: Nested dropdowns, stacked overlays
|
||||||
|
Level 4: Highest elevation (rare)
|
||||||
|
```
|
||||||
|
|
||||||
|
In dark mode, higher elevation = slightly lighter. In light mode, higher elevation = slightly lighter or uses shadow. The principle: **elevated surfaces need visual distinction from what's beneath them.**
|
||||||
|
|
||||||
|
### The Subtlety Principle
|
||||||
|
|
||||||
|
This is where most interfaces fail. Study Vercel, Supabase, Linear — their surfaces are **barely different** but still distinguishable. Their borders are **light but not invisible**.
|
||||||
|
|
||||||
|
**For surfaces:** The difference between elevation levels should be subtle — a few percentage points of lightness, not dramatic jumps. In dark mode, surface-100 might be 7% lighter than base, surface-200 might be 9%, surface-300 might be 12%. You can barely see it, but you feel it.
|
||||||
|
|
||||||
|
**For borders:** Borders should define regions without demanding attention. Use low opacity (0.05-0.12 alpha for dark mode, slightly higher for light). The border should disappear when you're not looking for it, but be findable when you need to understand the structure.
|
||||||
|
|
||||||
|
**The test:** Squint at your interface. You should still perceive the hierarchy — what's above what, where regions begin and end. But no single border or surface should jump out at you. If borders are the first thing you notice, they're too strong. If you can't find where one region ends and another begins, they're too subtle.
|
||||||
|
|
||||||
|
**Common AI mistakes to avoid:**
|
||||||
|
- Borders that are too visible (1px solid gray instead of subtle rgba)
|
||||||
|
- Surface jumps that are too dramatic (going from dark to light instead of dark to slightly-less-dark)
|
||||||
|
- Using different hues for different surfaces (gray card on blue background)
|
||||||
|
- Harsh dividers where subtle borders would do
|
||||||
|
|
||||||
|
### Text Hierarchy via Tokens
|
||||||
|
|
||||||
|
Don't just have "text" and "gray text." Build four levels:
|
||||||
|
|
||||||
|
- **Primary** — default text, highest contrast
|
||||||
|
- **Secondary** — supporting text, slightly muted
|
||||||
|
- **Tertiary** — metadata, timestamps, less important
|
||||||
|
- **Muted** — disabled, placeholder, lowest contrast
|
||||||
|
|
||||||
|
Use all four consistently. If you're only using two, your hierarchy is too flat.
|
||||||
|
|
||||||
|
### Border Progression
|
||||||
|
|
||||||
|
Borders aren't binary. Build a scale:
|
||||||
|
|
||||||
|
- **Default** — standard borders
|
||||||
|
- **Subtle/Muted** — softer separation
|
||||||
|
- **Strong** — emphasis, hover states
|
||||||
|
- **Stronger** — maximum emphasis, focus rings
|
||||||
|
|
||||||
|
Match border intensity to the importance of the boundary.
|
||||||
|
|
||||||
|
### Dedicated Control Tokens
|
||||||
|
|
||||||
|
Form controls (inputs, checkboxes, selects) have specific needs. Don't just reuse surface tokens — create dedicated ones:
|
||||||
|
|
||||||
|
- **Control background** — often different from surface backgrounds
|
||||||
|
- **Control border** — needs to feel interactive
|
||||||
|
- **Control focus** — clear focus indication
|
||||||
|
|
||||||
|
This separation lets you tune controls independently from layout surfaces.
|
||||||
|
|
||||||
|
### Context-Aware Bases
|
||||||
|
|
||||||
|
Different areas of your app might need different base surfaces:
|
||||||
|
|
||||||
|
- **Marketing pages** — might use darker/richer backgrounds
|
||||||
|
- **Dashboard/app** — might use neutral working backgrounds
|
||||||
|
- **Sidebar** — might differ from main canvas
|
||||||
|
|
||||||
|
The surface hierarchy works the same way — it just starts from a different base.
|
||||||
|
|
||||||
|
### Alternative Backgrounds for Depth
|
||||||
|
|
||||||
|
Beyond shadows, use contrasting backgrounds to create depth. An "alternative" or "inset" background makes content feel recessed. Useful for:
|
||||||
|
|
||||||
|
- Empty states in data grids
|
||||||
|
- Code blocks
|
||||||
|
- Inset panels
|
||||||
|
- Visual grouping without borders
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Spacing System
|
||||||
|
|
||||||
|
Pick a base unit (4px and 8px are common) and use multiples throughout. The specific number matters less than consistency — every spacing value should be explainable as "X times the base unit."
|
||||||
|
|
||||||
|
Build a scale for different contexts:
|
||||||
|
- Micro spacing (icon gaps, tight element pairs)
|
||||||
|
- Component spacing (within buttons, inputs, cards)
|
||||||
|
- Section spacing (between related groups)
|
||||||
|
- Major separation (between distinct sections)
|
||||||
|
|
||||||
|
## Symmetrical Padding
|
||||||
|
|
||||||
|
TLBR must match. If top padding is 16px, left/bottom/right must also be 16px. Exception: when content naturally creates visual balance.
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Good */
|
||||||
|
padding: 16px;
|
||||||
|
padding: 12px 16px; /* Only when horizontal needs more room */
|
||||||
|
|
||||||
|
/* Bad */
|
||||||
|
padding: 24px 16px 12px 16px;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Border Radius Consistency
|
||||||
|
|
||||||
|
Sharper corners feel technical, rounder corners feel friendly. Pick a scale that fits your product's personality and use it consistently.
|
||||||
|
|
||||||
|
The key is having a system: small radius for inputs and buttons, medium for cards, large for modals or containers. Don't mix sharp and soft randomly — inconsistent radius is as jarring as inconsistent spacing.
|
||||||
|
|
||||||
|
## Depth & Elevation Strategy
|
||||||
|
|
||||||
|
Match your depth approach to your design direction. Choose ONE and commit:
|
||||||
|
|
||||||
|
**Borders-only (flat)** — Clean, technical, dense. Works for utility-focused tools where information density matters more than visual lift. Linear, Raycast, and many developer tools use almost no shadows — just subtle borders to define regions.
|
||||||
|
|
||||||
|
**Subtle single shadows** — Soft lift without complexity. A simple `0 1px 3px rgba(0,0,0,0.08)` can be enough. Works for approachable products that want gentle depth.
|
||||||
|
|
||||||
|
**Layered shadows** — Rich, premium, dimensional. Multiple shadow layers create realistic depth. Stripe and Mercury use this approach. Best for cards that need to feel like physical objects.
|
||||||
|
|
||||||
|
**Surface color shifts** — Background tints establish hierarchy without any shadows. A card at `#fff` on a `#f8fafc` background already feels elevated.
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Borders-only approach */
|
||||||
|
--border: rgba(0, 0, 0, 0.08);
|
||||||
|
--border-subtle: rgba(0, 0, 0, 0.05);
|
||||||
|
border: 0.5px solid var(--border);
|
||||||
|
|
||||||
|
/* Single shadow approach */
|
||||||
|
--shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||||
|
|
||||||
|
/* Layered shadow approach */
|
||||||
|
--shadow-layered:
|
||||||
|
0 0 0 0.5px rgba(0, 0, 0, 0.05),
|
||||||
|
0 1px 2px rgba(0, 0, 0, 0.04),
|
||||||
|
0 2px 4px rgba(0, 0, 0, 0.03),
|
||||||
|
0 4px 8px rgba(0, 0, 0, 0.02);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Card Layouts
|
||||||
|
|
||||||
|
Monotonous card layouts are lazy design. A metric card doesn't have to look like a plan card doesn't have to look like a settings card.
|
||||||
|
|
||||||
|
Design each card's internal structure for its specific content — but keep the surface treatment consistent: same border weight, shadow depth, corner radius, padding scale, typography.
|
||||||
|
|
||||||
|
## Isolated Controls
|
||||||
|
|
||||||
|
UI controls deserve container treatment. Date pickers, filters, dropdowns — these should feel like crafted objects.
|
||||||
|
|
||||||
|
**Never use native form elements for styled UI.** Native `<select>`, `<input type="date">`, and similar elements render OS-native dropdowns that cannot be styled. Build custom components instead:
|
||||||
|
|
||||||
|
- Custom select: trigger button + positioned dropdown menu
|
||||||
|
- Custom date picker: input + calendar popover
|
||||||
|
- Custom checkbox/radio: styled div with state management
|
||||||
|
|
||||||
|
Custom select triggers must use `display: inline-flex` with `white-space: nowrap` to keep text and chevron icons on the same row.
|
||||||
|
|
||||||
|
## Typography Hierarchy
|
||||||
|
|
||||||
|
Build distinct levels that are visually distinguishable at a glance:
|
||||||
|
|
||||||
|
- **Headlines** — heavier weight, tighter letter-spacing for presence
|
||||||
|
- **Body** — comfortable weight for readability
|
||||||
|
- **Labels/UI** — medium weight, works at smaller sizes
|
||||||
|
- **Data** — often monospace, needs `tabular-nums` for alignment
|
||||||
|
|
||||||
|
Don't rely on size alone. Combine size, weight, and letter-spacing to create clear hierarchy. If you squint and can't tell headline from body, the hierarchy is too weak.
|
||||||
|
|
||||||
|
## Monospace for Data
|
||||||
|
|
||||||
|
Numbers, IDs, codes, timestamps belong in monospace. Use `tabular-nums` for columnar alignment. Mono signals "this is data."
|
||||||
|
|
||||||
|
## Iconography
|
||||||
|
|
||||||
|
Icons clarify, not decorate — if removing an icon loses no meaning, remove it. Choose a consistent icon set and stick with it throughout the product.
|
||||||
|
|
||||||
|
Give standalone icons presence with subtle background containers. Icons next to text should align optically, not mathematically.
|
||||||
|
|
||||||
|
## Animation
|
||||||
|
|
||||||
|
Keep it fast and functional. Micro-interactions (hover, focus) should feel instant — around 150ms. Larger transitions (modals, panels) can be slightly longer — 200-250ms.
|
||||||
|
|
||||||
|
Use smooth deceleration easing (ease-out variants). Avoid spring/bounce effects in professional interfaces — they feel playful, not serious.
|
||||||
|
|
||||||
|
## Contrast Hierarchy
|
||||||
|
|
||||||
|
Build a four-level system: foreground (primary) → secondary → muted → faint. Use all four consistently.
|
||||||
|
|
||||||
|
## Color Carries Meaning
|
||||||
|
|
||||||
|
Gray builds structure. Color communicates — status, action, emphasis, identity. Unmotivated color is noise. Color that reinforces the product's world is character.
|
||||||
|
|
||||||
|
## Navigation Context
|
||||||
|
|
||||||
|
Screens need grounding. A data table floating in space feels like a component demo, not a product. Consider including:
|
||||||
|
|
||||||
|
- **Navigation** — sidebar or top nav showing where you are in the app
|
||||||
|
- **Location indicator** — breadcrumbs, page title, or active nav state
|
||||||
|
- **User context** — who's logged in, what workspace/org
|
||||||
|
|
||||||
|
When building sidebars, consider using the same background as the main content area. Rely on a subtle border for separation rather than different background colors.
|
||||||
|
|
||||||
|
## Dark Mode
|
||||||
|
|
||||||
|
Dark interfaces have different needs:
|
||||||
|
|
||||||
|
**Borders over shadows** — Shadows are less visible on dark backgrounds. Lean more on borders for definition.
|
||||||
|
|
||||||
|
**Adjust semantic colors** — Status colors (success, warning, error) often need to be slightly desaturated for dark backgrounds.
|
||||||
|
|
||||||
|
**Same structure, different values** — The hierarchy system still applies, just with inverted values.
|
||||||
48
.agents/skills/interface-design/references/validation.md
Normal file
48
.agents/skills/interface-design/references/validation.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# Memory Management
|
||||||
|
|
||||||
|
When and how to update `.interface-design/system.md`.
|
||||||
|
|
||||||
|
## When to Add Patterns
|
||||||
|
|
||||||
|
Add to system.md when:
|
||||||
|
- Component used 2+ times
|
||||||
|
- Pattern is reusable across the project
|
||||||
|
- Has specific measurements worth remembering
|
||||||
|
|
||||||
|
## Pattern Format
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
### Button Primary
|
||||||
|
- Height: 36px
|
||||||
|
- Padding: 12px 16px
|
||||||
|
- Radius: 6px
|
||||||
|
- Font: 14px, 500 weight
|
||||||
|
```
|
||||||
|
|
||||||
|
## Don't Document
|
||||||
|
|
||||||
|
- One-off components
|
||||||
|
- Temporary experiments
|
||||||
|
- Variations better handled with props
|
||||||
|
|
||||||
|
## Pattern Reuse
|
||||||
|
|
||||||
|
Before creating a component, check system.md:
|
||||||
|
- Pattern exists? Use it.
|
||||||
|
- Need variation? Extend, don't create new.
|
||||||
|
|
||||||
|
Memory compounds: each pattern saved makes future work faster and more consistent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Validation Checks
|
||||||
|
|
||||||
|
If system.md defines specific values, check consistency:
|
||||||
|
|
||||||
|
**Spacing** — All values multiples of the defined base?
|
||||||
|
|
||||||
|
**Depth** — Using the declared strategy throughout? (borders-only means no shadows)
|
||||||
|
|
||||||
|
**Colors** — Using defined palette, not random hex codes?
|
||||||
|
|
||||||
|
**Patterns** — Reusing documented patterns instead of creating new?
|
||||||
148
.agents/skills/make-interfaces-feel-better/SKILL.md
Normal file
148
.agents/skills/make-interfaces-feel-better/SKILL.md
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
---
|
||||||
|
name: make-interfaces-feel-better
|
||||||
|
description: Design engineering principles for making interfaces feel polished. Use when building UI components, reviewing frontend code, implementing animations, hover states, shadows, borders, typography, micro-interactions, enter/exit animations, or any visual detail work. Triggers on UI polish, design details, "make it feel better", "feels off", stagger animations, border radius, optical alignment, font smoothing, tabular numbers, image outlines, box shadows.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Details that make interfaces feel better
|
||||||
|
|
||||||
|
Great interfaces rarely come from a single thing. It's usually a collection of small details that compound into a great experience. Apply these principles when building or reviewing UI code.
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
| Category | When to Use |
|
||||||
|
| --- | --- |
|
||||||
|
| [Typography](typography.md) | Text wrapping, font smoothing, tabular numbers |
|
||||||
|
| [Surfaces](surfaces.md) | Border radius, optical alignment, shadows, image outlines, hit areas |
|
||||||
|
| [Animations](animations.md) | Interruptible animations, enter/exit transitions, icon animations, scale on press |
|
||||||
|
| [Performance](performance.md) | Transition specificity, `will-change` usage |
|
||||||
|
|
||||||
|
## Core Principles
|
||||||
|
|
||||||
|
### 1. Concentric Border Radius
|
||||||
|
|
||||||
|
Outer radius = inner radius + padding. Mismatched radii on nested elements is the most common thing that makes interfaces feel off.
|
||||||
|
|
||||||
|
### 2. Optical Over Geometric Alignment
|
||||||
|
|
||||||
|
When geometric centering looks off, align optically. Buttons with icons, play triangles, and asymmetric icons all need manual adjustment.
|
||||||
|
|
||||||
|
### 3. Shadows Over Borders
|
||||||
|
|
||||||
|
Layer multiple transparent `box-shadow` values for natural depth. Shadows adapt to any background; solid borders don't.
|
||||||
|
|
||||||
|
### 4. Interruptible Animations
|
||||||
|
|
||||||
|
Use CSS transitions for interactive state changes — they can be interrupted mid-animation. Reserve keyframes for staged sequences that run once.
|
||||||
|
|
||||||
|
### 5. Split and Stagger Enter Animations
|
||||||
|
|
||||||
|
Don't animate a single container. Break content into semantic chunks and stagger each with ~100ms delay.
|
||||||
|
|
||||||
|
### 6. Subtle Exit Animations
|
||||||
|
|
||||||
|
Use a small fixed `translateY` instead of full height. Exits should be softer than enters.
|
||||||
|
|
||||||
|
### 7. Contextual Icon Animations
|
||||||
|
|
||||||
|
Animate icons with `opacity`, `scale`, and `blur` instead of toggling visibility. Use exactly these values: scale from `0.25` to `1`, opacity from `0` to `1`, blur from `4px` to `0px`. If the project has `motion` or `framer-motion` in `package.json`, use `transition: { type: "spring", duration: 0.3, bounce: 0 }` — bounce must always be `0`. If no motion library is installed, keep both icons in the DOM (one absolute-positioned) and cross-fade with CSS transitions using `cubic-bezier(0.2, 0, 0, 1)` — this gives both enter and exit animations without any dependency.
|
||||||
|
|
||||||
|
### 8. Font Smoothing
|
||||||
|
|
||||||
|
Apply `-webkit-font-smoothing: antialiased` to the root layout on macOS for crisper text.
|
||||||
|
|
||||||
|
### 9. Tabular Numbers
|
||||||
|
|
||||||
|
Use `font-variant-numeric: tabular-nums` for any dynamically updating numbers to prevent layout shift.
|
||||||
|
|
||||||
|
### 10. Text Wrapping
|
||||||
|
|
||||||
|
Use `text-wrap: balance` on headings. Use `text-wrap: pretty` for body text to avoid orphans.
|
||||||
|
|
||||||
|
### 11. Image Outlines
|
||||||
|
|
||||||
|
Add a subtle `1px` outline with low opacity to images for consistent depth. The color must be pure black in light mode (`rgba(0, 0, 0, 0.1)`) and pure white in dark mode (`rgba(255, 255, 255, 0.1)`) — never a near-black like slate, zinc, or any tinted neutral. A tinted outline picks up the surface color underneath it and reads as dirt on the image edge.
|
||||||
|
|
||||||
|
### 12. Scale on Press
|
||||||
|
|
||||||
|
A subtle `scale(0.96)` on click gives buttons tactile feedback. Always use `0.96`. Never use a value smaller than `0.95` — anything below feels exaggerated. Add a `static` prop to disable it when motion would be distracting.
|
||||||
|
|
||||||
|
### 13. Skip Animation on Page Load
|
||||||
|
|
||||||
|
Use `initial={false}` on `AnimatePresence` to prevent enter animations on first render. Verify it doesn't break intentional entrance animations.
|
||||||
|
|
||||||
|
### 14. Never Use `transition: all`
|
||||||
|
|
||||||
|
Always specify exact properties: `transition-property: scale, opacity`. Tailwind's `transition-transform` covers `transform, translate, scale, rotate`.
|
||||||
|
|
||||||
|
### 15. Use `will-change` Sparingly
|
||||||
|
|
||||||
|
Only for `transform`, `opacity`, `filter` — properties the GPU can composite. Never use `will-change: all`. Only add when you notice first-frame stutter.
|
||||||
|
|
||||||
|
### 16. Minimum Hit Area
|
||||||
|
|
||||||
|
Interactive elements need at least 40×40px hit area. Extend with a pseudo-element if the visible element is smaller. Never let hit areas of two elements overlap.
|
||||||
|
|
||||||
|
## Common Mistakes
|
||||||
|
|
||||||
|
| Mistake | Fix |
|
||||||
|
| --- | --- |
|
||||||
|
| Same border radius on parent and child | Calculate `outerRadius = innerRadius + padding` |
|
||||||
|
| Icons look off-center | Adjust optically with padding or fix SVG directly |
|
||||||
|
| Hard borders between sections | Use layered `box-shadow` with transparency |
|
||||||
|
| Jarring enter/exit animations | Split, stagger, and keep exits subtle |
|
||||||
|
| Numbers cause layout shift | Apply `tabular-nums` |
|
||||||
|
| Heavy text on macOS | Apply `antialiased` to root |
|
||||||
|
| Animation plays on page load | Add `initial={false}` to `AnimatePresence` |
|
||||||
|
| `transition: all` on elements | Specify exact properties |
|
||||||
|
| First-frame animation stutter | Add `will-change: transform` (sparingly) |
|
||||||
|
| Tiny hit areas on small controls | Extend with pseudo-element to 40×40px |
|
||||||
|
|
||||||
|
## Review Output Format
|
||||||
|
|
||||||
|
Always present changes as a markdown table with **Before** and **After** columns. Include every change you made — not just a subset. Never list findings as separate "Before:" / "After:" lines outside of a table. Group changes by principle using a heading above each table, and keep each row focused on a single diff so the reader can scan the whole list quickly.
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
#### Concentric border radius
|
||||||
|
| Before | After |
|
||||||
|
| --- | --- |
|
||||||
|
| `rounded-xl` on card + `rounded-xl` on inner button (`p-2`) | `rounded-2xl` on card (`12 + 8`), `rounded-lg` on inner button |
|
||||||
|
| `border-radius: 16px` on both nested surfaces | Outer `24px`, inner `16px` with `8px` padding |
|
||||||
|
|
||||||
|
#### Tabular numbers
|
||||||
|
| Before | After |
|
||||||
|
| --- | --- |
|
||||||
|
| `<span>{count}</span>` on animated counter | `<span className="tabular-nums">{count}</span>` |
|
||||||
|
| Default numerals on timer | Added `font-variant-numeric: tabular-nums` to root |
|
||||||
|
|
||||||
|
#### Scale on press
|
||||||
|
| Before | After |
|
||||||
|
| --- | --- |
|
||||||
|
| `<button className="...">` | Added `active:scale-[0.96] transition-transform` |
|
||||||
|
| `scale(0.9)` on press | Raised to `scale(0.96)` — anything below `0.95` feels exaggerated |
|
||||||
|
|
||||||
|
Rows should cite the specific file and the specific property that changed when it isn't obvious from the snippet. If a principle was reviewed but nothing needed to change, omit that table entirely — empty tables add noise.
|
||||||
|
|
||||||
|
## Review Checklist
|
||||||
|
|
||||||
|
- [ ] Nested rounded elements use concentric border radius
|
||||||
|
- [ ] Icons are optically centered, not just geometrically
|
||||||
|
- [ ] Shadows used instead of borders where appropriate
|
||||||
|
- [ ] Enter animations are split and staggered
|
||||||
|
- [ ] Exit animations are subtle
|
||||||
|
- [ ] Dynamic numbers use tabular-nums
|
||||||
|
- [ ] Font smoothing is applied
|
||||||
|
- [ ] Headings use text-wrap: balance
|
||||||
|
- [ ] Images have subtle outlines
|
||||||
|
- [ ] Buttons use scale on press where appropriate
|
||||||
|
- [ ] AnimatePresence uses `initial={false}` for default-state elements
|
||||||
|
- [ ] No `transition: all` — only specific properties
|
||||||
|
- [ ] `will-change` only on transform/opacity/filter, never `all`
|
||||||
|
- [ ] Interactive elements have at least 40×40px hit area
|
||||||
|
|
||||||
|
## Reference Files
|
||||||
|
|
||||||
|
- [typography.md](typography.md) — Text wrapping, font smoothing, tabular numbers
|
||||||
|
- [surfaces.md](surfaces.md) — Border radius, optical alignment, shadows, image outlines
|
||||||
|
- [animations.md](animations.md) — Interruptible animations, enter/exit transitions, icon animations, scale on press
|
||||||
|
- [performance.md](performance.md) — Transition specificity, `will-change` usage
|
||||||
379
.agents/skills/make-interfaces-feel-better/animations.md
Normal file
379
.agents/skills/make-interfaces-feel-better/animations.md
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
# Animations
|
||||||
|
|
||||||
|
Interruptible animations, enter/exit transitions, and contextual icon animations.
|
||||||
|
|
||||||
|
## Interruptible Animations
|
||||||
|
|
||||||
|
Users change intent mid-interaction. If animations aren't interruptible, the interface feels broken.
|
||||||
|
|
||||||
|
### CSS Transitions vs. Keyframes
|
||||||
|
|
||||||
|
| | CSS Transitions | CSS Keyframe Animations |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Behavior** | Interpolate toward latest state | Run on a fixed timeline |
|
||||||
|
| **Interruptible** | Yes — retargets mid-animation | No — restarts from beginning |
|
||||||
|
| **Use for** | Interactive state changes (hover, toggle, open/close) | Staged sequences that run once (enter animations, loading) |
|
||||||
|
| **Duration** | Adapts to remaining distance | Fixed regardless of state |
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Good — interruptible transition for a toggle */
|
||||||
|
.drawer {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform 200ms ease-out;
|
||||||
|
}
|
||||||
|
.drawer.open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Clicking again mid-animation smoothly reverses — no jank */
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Bad — keyframe animation for interactive element */
|
||||||
|
.drawer.open {
|
||||||
|
animation: slideIn 200ms ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Closing mid-animation snaps or restarts — feels broken */
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rule:** Always prefer CSS transitions for interactive elements. Reserve keyframes for one-shot sequences.
|
||||||
|
|
||||||
|
## Enter Animations: Split and Stagger
|
||||||
|
|
||||||
|
Don't animate a single large container. Break content into semantic chunks and animate each individually.
|
||||||
|
|
||||||
|
### Step by Step
|
||||||
|
|
||||||
|
1. **Split** into logical groups (title, description, buttons)
|
||||||
|
2. **Stagger** with ~100ms delay between groups
|
||||||
|
3. **For titles**, consider splitting into individual words with ~80ms stagger
|
||||||
|
4. **Combine** `opacity`, `blur`, and `translateY` for the enter effect
|
||||||
|
|
||||||
|
### Code Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Motion (Framer Motion) — staggered enter
|
||||||
|
function PageHeader() {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
variants={{
|
||||||
|
visible: { transition: { staggerChildren: 0.1 } },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<motion.h1
|
||||||
|
variants={{
|
||||||
|
hidden: { opacity: 0, y: 12, filter: "blur(4px)" },
|
||||||
|
visible: { opacity: 1, y: 0, filter: "blur(0px)" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Welcome
|
||||||
|
</motion.h1>
|
||||||
|
|
||||||
|
<motion.p
|
||||||
|
variants={{
|
||||||
|
hidden: { opacity: 0, y: 12, filter: "blur(4px)" },
|
||||||
|
visible: { opacity: 1, y: 0, filter: "blur(0px)" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
A description of the page.
|
||||||
|
</motion.p>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
variants={{
|
||||||
|
hidden: { opacity: 0, y: 12, filter: "blur(4px)" },
|
||||||
|
visible: { opacity: 1, y: 0, filter: "blur(0px)" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button>Get started</Button>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### CSS-Only Stagger
|
||||||
|
|
||||||
|
```css
|
||||||
|
.stagger-item {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(12px);
|
||||||
|
filter: blur(4px);
|
||||||
|
animation: fadeInUp 400ms ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stagger-item:nth-child(1) { animation-delay: 0ms; }
|
||||||
|
.stagger-item:nth-child(2) { animation-delay: 100ms; }
|
||||||
|
.stagger-item:nth-child(3) { animation-delay: 200ms; }
|
||||||
|
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
filter: blur(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Exit Animations
|
||||||
|
|
||||||
|
Exit animations should be softer and less attention-grabbing than enter animations. The user's focus is moving to the next thing — don't fight for attention.
|
||||||
|
|
||||||
|
### Subtle Exit (Recommended)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Small fixed translateY — indicates direction without drama
|
||||||
|
<motion.div
|
||||||
|
exit={{
|
||||||
|
opacity: 0,
|
||||||
|
y: -12,
|
||||||
|
filter: "blur(4px)",
|
||||||
|
transition: { duration: 0.15, ease: "easeIn" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</motion.div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Full Exit (When Context Matters)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Slide fully out — use when spatial context is important
|
||||||
|
// (e.g., a card returning to a list, a drawer closing)
|
||||||
|
<motion.div
|
||||||
|
exit={{
|
||||||
|
opacity: 0,
|
||||||
|
x: "-100%",
|
||||||
|
transition: { duration: 0.2, ease: "easeIn" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</motion.div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Good vs. Bad
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Good — subtle exit */
|
||||||
|
.item-exit {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-12px);
|
||||||
|
transition: opacity 150ms ease-in, transform 150ms ease-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bad — dramatic exit that steals focus */
|
||||||
|
.item-exit {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-100%) scale(0.5);
|
||||||
|
transition: all 400ms ease-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bad — no exit animation at all (element just vanishes) */
|
||||||
|
.item-exit {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key points:**
|
||||||
|
- Use a small fixed `translateY` (e.g., `-12px`) instead of the full container height
|
||||||
|
- Keep some directional movement to indicate where the element went
|
||||||
|
- Exit duration should be shorter than enter duration (150ms vs 300ms)
|
||||||
|
- Don't remove exit animations entirely — subtle motion preserves context
|
||||||
|
|
||||||
|
## Contextual Icon Animations
|
||||||
|
|
||||||
|
When icons appear or disappear contextually (on hover, on state change), animate them with `opacity`, `scale`, and `blur` rather than just toggling visibility.
|
||||||
|
|
||||||
|
### Motion Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
|
|
||||||
|
function IconButton({ isActive, icon: Icon }) {
|
||||||
|
return (
|
||||||
|
<button>
|
||||||
|
<AnimatePresence mode="popLayout">
|
||||||
|
<motion.span
|
||||||
|
key={isActive ? "active" : "inactive"}
|
||||||
|
initial={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
|
||||||
|
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
|
||||||
|
exit={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
|
||||||
|
transition={{ type: "spring", duration: 0.3, bounce: 0 }}
|
||||||
|
>
|
||||||
|
<Icon />
|
||||||
|
</motion.span>
|
||||||
|
</AnimatePresence>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### CSS Transition Approach (No Motion)
|
||||||
|
|
||||||
|
If the project doesn't use Motion (Framer Motion), keep both icons in the DOM and cross-fade them with CSS transitions. Because neither icon unmounts, both enter and exit animate smoothly.
|
||||||
|
|
||||||
|
The trick: one icon is absolutely positioned on top of the other. Toggling state cross-fades them — the entering icon scales up from `0.25` while the exiting icon scales down to `0.25`, both with opacity and blur.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function IconButton({ isActive, ActiveIcon, InactiveIcon }) {
|
||||||
|
return (
|
||||||
|
<button>
|
||||||
|
<div className="relative">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-0 flex items-center justify-center",
|
||||||
|
"transition-[opacity,filter,scale] duration-300",
|
||||||
|
"cubic-bezier(0.2, 0, 0, 1)",
|
||||||
|
isActive
|
||||||
|
? "scale-100 opacity-100 blur-0"
|
||||||
|
: "scale-[0.25] opacity-0 blur-[4px]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ActiveIcon />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"transition-[opacity,filter,scale] duration-300",
|
||||||
|
"cubic-bezier(0.2, 0, 0, 1)",
|
||||||
|
isActive
|
||||||
|
? "scale-[0.25] opacity-0 blur-[4px]"
|
||||||
|
: "scale-100 opacity-100 blur-0"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<InactiveIcon />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The non-absolute icon (InactiveIcon) defines the layout size. The absolute icon (ActiveIcon) overlays it without affecting flow.
|
||||||
|
|
||||||
|
### Choosing Between Motion and CSS
|
||||||
|
|
||||||
|
| | Motion (Framer Motion) | CSS transitions (both icons in DOM) |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Enter animation** | Yes | Yes |
|
||||||
|
| **Exit animation** | Yes (via `AnimatePresence`) | Yes (cross-fade — icon never unmounts) |
|
||||||
|
| **Spring physics** | Yes | No — use `cubic-bezier(0.2, 0, 0, 1)` as approximation |
|
||||||
|
| **When to use** | Project already uses `motion/react` | No motion dependency, or keeping bundle small |
|
||||||
|
|
||||||
|
**Rule:** Check the project's `package.json` for `motion` or `framer-motion`. If present, use the Motion approach. If not, use the CSS cross-fade pattern — don't add a dependency just for icon transitions.
|
||||||
|
|
||||||
|
### When to Animate Icons
|
||||||
|
|
||||||
|
| Animate | Don't animate |
|
||||||
|
| --- | --- |
|
||||||
|
| Icons that appear on hover (action buttons) | Static navigation icons |
|
||||||
|
| State change icons (play → pause, like → liked) | Decorative icons |
|
||||||
|
| Icons in contextual toolbars | Icons that are always visible |
|
||||||
|
| Loading/success state indicators | Icon labels (text next to icon) |
|
||||||
|
|
||||||
|
**Important:** Always use exactly these values for contextual icon animations — do not deviate:
|
||||||
|
- `scale`: `0.25` → `1` (never use `0.5` or `0.6`)
|
||||||
|
- `opacity`: `0` → `1`
|
||||||
|
- `filter`: `"blur(4px)"` → `"blur(0px)"`
|
||||||
|
- `transition`: `{ type: "spring", duration: 0.3, bounce: 0 }` — **bounce must always be `0`**, never `0.1` or any other value
|
||||||
|
|
||||||
|
## Scale on Press
|
||||||
|
|
||||||
|
A subtle scale-down on click gives buttons tactile feedback. Always use `scale(0.96)`. Never use a value smaller than `0.95` — anything below feels exaggerated. Use CSS transitions for interruptibility — if the user releases mid-press, it should smoothly return.
|
||||||
|
|
||||||
|
Not every button needs this. Add a `static` prop to your button component that disables the scale effect when the motion would be distracting.
|
||||||
|
|
||||||
|
### CSS Example
|
||||||
|
|
||||||
|
```css
|
||||||
|
.button {
|
||||||
|
transition-property: scale;
|
||||||
|
transition-duration: 150ms;
|
||||||
|
transition-timing-function: ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:active {
|
||||||
|
scale: 0.96;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tailwind Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<button className="transition-transform duration-150 ease-out active:scale-[0.96]">
|
||||||
|
Click me
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Motion Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<motion.button whileTap={{ scale: 0.96 }}>
|
||||||
|
Click me
|
||||||
|
</motion.button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Static Prop Pattern
|
||||||
|
|
||||||
|
Extract the scale class into a variable and conditionally apply it based on a `static` prop:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const tapScale = "active:not-disabled:scale-[0.96]";
|
||||||
|
|
||||||
|
function Button({ static: isStatic, className, children, ...props }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"transition-transform duration-150 ease-out",
|
||||||
|
!isStatic && tapScale,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
<Button>Click me</Button> {/* scales on press */}
|
||||||
|
<Button static>Submit</Button> {/* no scale */}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Skip Animation on Page Load
|
||||||
|
|
||||||
|
Use `initial={false}` on `AnimatePresence` to prevent enter animations from firing on first render. Elements that are already in their default state shouldn't animate in on page load — only on subsequent state changes.
|
||||||
|
|
||||||
|
### When It Works
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Good — icon doesn't animate in on mount, only on state change
|
||||||
|
<AnimatePresence initial={false} mode="popLayout">
|
||||||
|
<motion.span
|
||||||
|
key={isActive ? "active" : "inactive"}
|
||||||
|
initial={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
|
||||||
|
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
|
||||||
|
exit={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
|
||||||
|
>
|
||||||
|
<Icon />
|
||||||
|
</motion.span>
|
||||||
|
</AnimatePresence>
|
||||||
|
```
|
||||||
|
|
||||||
|
Works well for: icon swaps, toggles, tabs, segmented controls — anything that has a default state on page load.
|
||||||
|
|
||||||
|
### When It Breaks
|
||||||
|
|
||||||
|
Don't use `initial={false}` when the component relies on its `initial` prop to set up a first-time enter animation, like a staggered page hero or a loading state. In those cases, removing the initial animation skips the entire entrance.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Bad — initial={false} would skip the staggered page enter entirely
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
<motion.div initial="hidden" animate="visible" variants={...}>
|
||||||
|
...
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify the component still looks right on a full page refresh before applying this.
|
||||||
88
.agents/skills/make-interfaces-feel-better/performance.md
Normal file
88
.agents/skills/make-interfaces-feel-better/performance.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# Performance
|
||||||
|
|
||||||
|
Transition specificity and GPU compositing hints.
|
||||||
|
|
||||||
|
## Transition Only What Changes
|
||||||
|
|
||||||
|
Never use `transition: all` or Tailwind's `transition` shorthand (which maps to `transition-property: all`). Always specify the exact properties that change.
|
||||||
|
|
||||||
|
### Why
|
||||||
|
|
||||||
|
- `transition: all` forces the browser to watch every property for changes
|
||||||
|
- Causes unexpected transitions on properties you didn't intend to animate (colors, padding, shadows)
|
||||||
|
- Prevents browser optimizations
|
||||||
|
|
||||||
|
### CSS Example
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Good — only transition what changes */
|
||||||
|
.button {
|
||||||
|
transition-property: scale, background-color;
|
||||||
|
transition-duration: 150ms;
|
||||||
|
transition-timing-function: ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bad — transition everything */
|
||||||
|
.button {
|
||||||
|
transition: all 150ms ease-out;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tailwind
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Good — explicit properties
|
||||||
|
<button className="transition-[scale,background-color] duration-150 ease-out">
|
||||||
|
|
||||||
|
// Bad — transition all
|
||||||
|
<button className="transition duration-150 ease-out">
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tailwind `transition-transform` Note
|
||||||
|
|
||||||
|
`transition-transform` in Tailwind maps to `transition-property: transform, translate, scale, rotate` — it covers all transform-related properties, not just `transform`. Use this when you're only animating transforms. For multiple non-transform properties, use the bracket syntax: `transition-[scale,opacity,filter]`.
|
||||||
|
|
||||||
|
## Use `will-change` Sparingly
|
||||||
|
|
||||||
|
`will-change` hints the browser to pre-promote an element to its own GPU compositing layer. Without it, the browser promotes the element only when the animation starts — that one-time layer promotion can cause a micro-stutter on the first frame.
|
||||||
|
|
||||||
|
This particularly helps when an element is changing `scale`, `rotation`, or moving around with `transform`. For other properties, it doesn't help much — the browser can't composite them on the GPU anyway.
|
||||||
|
|
||||||
|
### Rules
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Good — specific property that benefits from GPU compositing */
|
||||||
|
.animated-card {
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Good — multiple compositor-friendly properties */
|
||||||
|
.animated-card {
|
||||||
|
will-change: transform, opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bad — never use will-change: all */
|
||||||
|
.animated-card {
|
||||||
|
will-change: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bad — properties that can't be GPU-composited anyway */
|
||||||
|
.animated-card {
|
||||||
|
will-change: background-color, padding;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Useful Properties
|
||||||
|
|
||||||
|
| Property | GPU-compositable | Worth using `will-change` |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `transform` | Yes | Yes |
|
||||||
|
| `opacity` | Yes | Yes |
|
||||||
|
| `filter` (blur, brightness) | Yes | Yes |
|
||||||
|
| `clip-path` | Yes | Yes |
|
||||||
|
| `top`, `left`, `width`, `height` | No | No |
|
||||||
|
| `background`, `border`, `color` | No | No |
|
||||||
|
|
||||||
|
### When to Skip
|
||||||
|
|
||||||
|
Modern browsers are already good at optimizing on their own. Only add `will-change` when you notice first-frame stutter — Safari in particular benefits from it. Don't add it preemptively to every animated element; each extra compositing layer costs memory.
|
||||||
256
.agents/skills/make-interfaces-feel-better/surfaces.md
Normal file
256
.agents/skills/make-interfaces-feel-better/surfaces.md
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
# Surfaces
|
||||||
|
|
||||||
|
Border radius, optical alignment, shadows, and image outlines.
|
||||||
|
|
||||||
|
## Concentric Border Radius
|
||||||
|
|
||||||
|
When nesting rounded elements, the outer radius must equal the inner radius plus the padding between them:
|
||||||
|
|
||||||
|
```
|
||||||
|
outerRadius = innerRadius + padding
|
||||||
|
```
|
||||||
|
|
||||||
|
This rule is most useful when nested surfaces are close together. If padding is larger than `24px`, treat the layers as separate surfaces and choose each radius independently instead of forcing strict concentric math.
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Good — concentric radii */
|
||||||
|
.card {
|
||||||
|
border-radius: 20px; /* 12 + 8 */
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
.card-inner {
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bad — same radius on both */
|
||||||
|
.card {
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
.card-inner {
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tailwind Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Good — outer radius accounts for padding
|
||||||
|
<div className="rounded-2xl p-2"> {/* 16px radius, 8px padding */}
|
||||||
|
<div className="rounded-lg"> {/* 8px radius = 16 - 8 ✓ */}
|
||||||
|
...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Bad — same radius on both
|
||||||
|
<div className="rounded-xl p-2">
|
||||||
|
<div className="rounded-xl"> {/* same radius, looks off */}
|
||||||
|
...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Mismatched border radii on nested elements is one of the most common things that makes interfaces feel off. Always calculate concentrically.
|
||||||
|
|
||||||
|
## Optical Alignment
|
||||||
|
|
||||||
|
When geometric centering looks off, align optically instead.
|
||||||
|
|
||||||
|
### Buttons with Text + Icon
|
||||||
|
|
||||||
|
Use slightly less padding on the icon side to make the button feel balanced. A reliable rule of thumb is:
|
||||||
|
`icon-side padding = text-side padding - 2px`.
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Good — less padding on icon side */
|
||||||
|
.button-with-icon {
|
||||||
|
padding-left: 16px;
|
||||||
|
padding-right: 14px; /* icon side = text side - 2px */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bad — equal padding looks like icon is pushed too far right */
|
||||||
|
.button-with-icon {
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Tailwind
|
||||||
|
<button className="pl-4 pr-3.5 flex items-center gap-2">
|
||||||
|
<span>Continue</span>
|
||||||
|
<ArrowRightIcon />
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Play Button Triangles
|
||||||
|
|
||||||
|
Play icons are triangular and their geometric center is not their visual center. Shift slightly right:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Good — optically centered */
|
||||||
|
.play-button svg {
|
||||||
|
margin-left: 2px; /* shift right to account for triangle shape */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bad — geometrically centered but looks off */
|
||||||
|
.play-button svg {
|
||||||
|
/* no adjustment */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Asymmetric Icons (Stars, Arrows, Carets)
|
||||||
|
|
||||||
|
Some icons have uneven visual weight. The best fix is adjusting the SVG directly so no extra margin/padding is needed in the component code.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Best — fix in the SVG itself
|
||||||
|
// Adjust the viewBox or path to visually center the icon
|
||||||
|
|
||||||
|
// Fallback — adjust with margin
|
||||||
|
<span className="ml-px">
|
||||||
|
<StarIcon />
|
||||||
|
</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Shadows Instead of Borders
|
||||||
|
|
||||||
|
For **buttons, cards, and containers** that use a border for depth or elevation, prefer replacing it with a subtle `box-shadow`. Shadows adapt to any background since they use transparency; solid borders don't. This also helps when using images or multiple colors as backgrounds — solid border colors don't work well on backgrounds other than the ones they were designed for.
|
||||||
|
|
||||||
|
**Do not apply this to dividers** (`border-b`, `border-t`, side borders) or any border whose purpose is layout separation rather than element depth. Those should stay as borders.
|
||||||
|
|
||||||
|
### Shadow as Border (Light Mode)
|
||||||
|
|
||||||
|
The shadow is comprised of three layers. The first acts as a 1px border ring, the second adds subtle lift, and the third provides ambient depth:
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--shadow-border:
|
||||||
|
0px 0px 0px 1px rgba(0, 0, 0, 0.06),
|
||||||
|
0px 1px 2px -1px rgba(0, 0, 0, 0.06),
|
||||||
|
0px 2px 4px 0px rgba(0, 0, 0, 0.04);
|
||||||
|
--shadow-border-hover:
|
||||||
|
0px 0px 0px 1px rgba(0, 0, 0, 0.08),
|
||||||
|
0px 1px 2px -1px rgba(0, 0, 0, 0.08),
|
||||||
|
0px 2px 4px 0px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Shadow as Border (Dark Mode)
|
||||||
|
|
||||||
|
In dark mode, simplify to a single white ring — layered depth shadows aren't visible on dark backgrounds:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Dark mode — adapt to whatever setup the project uses
|
||||||
|
(prefers-color-scheme, class, data attribute, etc.) */
|
||||||
|
--shadow-border: 0 0 0 1px rgba(255, 255, 255, 0.08);
|
||||||
|
--shadow-border-hover: 0 0 0 1px rgba(255, 255, 255, 0.13);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Usage with Hover Transition
|
||||||
|
|
||||||
|
Apply the variable and add `transition-[box-shadow]` for a smooth hover:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.card {
|
||||||
|
box-shadow: var(--shadow-border);
|
||||||
|
transition-property: box-shadow;
|
||||||
|
transition-duration: 150ms;
|
||||||
|
transition-timing-function: ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
box-shadow: var(--shadow-border-hover);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### When to Use Shadows vs. Borders
|
||||||
|
|
||||||
|
| Use shadows | Use borders |
|
||||||
|
| --- | --- |
|
||||||
|
| Cards, containers with depth | Dividers between list items |
|
||||||
|
| Buttons with bordered styles | Table cell boundaries |
|
||||||
|
| Elevated elements (dropdowns, modals) | Form input outlines (for accessibility) |
|
||||||
|
| Elements on varied backgrounds | Hairline separators in dense UI |
|
||||||
|
| Hover/focus states for lift effect | |
|
||||||
|
|
||||||
|
## Image Outlines
|
||||||
|
|
||||||
|
Add a subtle `1px` outline with low opacity to images. This creates consistent depth, especially in design systems where other elements use borders or shadows.
|
||||||
|
|
||||||
|
### Color rules (non-negotiable)
|
||||||
|
|
||||||
|
- **Light mode**: pure black — `rgba(0, 0, 0, 0.1)`. Exact values: R=0, G=0, B=0.
|
||||||
|
- **Dark mode**: pure white — `rgba(255, 255, 255, 0.1)`. Exact values: R=255, G=255, B=255.
|
||||||
|
- Never use a near-black or near-white from the project palette (e.g. slate-900, zinc-900, `#0a0a0a`, `#111827`, `#f5f5f7`). Tinted outlines pick up the surrounding surface color and read as dirt on the image edge.
|
||||||
|
- Never match the outline to the project's accent or ink color. The outline is a neutral separator, not a themed element.
|
||||||
|
|
||||||
|
### Light Mode
|
||||||
|
|
||||||
|
```css
|
||||||
|
img {
|
||||||
|
outline: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
outline-offset: -1px; /* inset so it doesn't add to layout */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dark Mode
|
||||||
|
|
||||||
|
```css
|
||||||
|
img {
|
||||||
|
outline: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
outline-offset: -1px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tailwind with Dark Mode
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<img
|
||||||
|
className="outline outline-1 -outline-offset-1 outline-black/10 dark:outline-white/10"
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `outline-black/10` and `outline-white/10` specifically — not `outline-slate-*`, `outline-zinc-*`, `outline-neutral-*`, or any tinted scale.
|
||||||
|
|
||||||
|
**Why outline instead of border?** `outline` doesn't affect layout (no added width/height), and `outline-offset: -1px` keeps it inset so images stay their intended size.
|
||||||
|
|
||||||
|
## Minimum Hit Area
|
||||||
|
|
||||||
|
Interactive elements should have a minimum hit area of 44×44px (WCAG) or at least 40×40px. If the visible element is smaller (e.g., a 20×20 checkbox), extend the hit area with a pseudo-element.
|
||||||
|
|
||||||
|
### CSS Example
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Small checkbox with expanded hit area */
|
||||||
|
.checkbox {
|
||||||
|
position: relative;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tailwind Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<button className="relative size-5 after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-1/2">
|
||||||
|
<CheckIcon />
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Collision Rule
|
||||||
|
|
||||||
|
If the extended hit area overlaps another interactive element, shrink the pseudo-element — but make it as large as possible without colliding. Two interactive elements should never have overlapping hit areas.
|
||||||
135
.agents/skills/make-interfaces-feel-better/typography.md
Normal file
135
.agents/skills/make-interfaces-feel-better/typography.md
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
# Typography
|
||||||
|
|
||||||
|
Typography rendering details that make interfaces feel better.
|
||||||
|
|
||||||
|
## Text Wrapping
|
||||||
|
|
||||||
|
### text-wrap: balance
|
||||||
|
|
||||||
|
Distributes text evenly across lines, preventing orphaned words on headings and short text blocks. **Only works on blocks of 6 lines or fewer** (Chromium) or 10 lines or fewer (Firefox) — the balancing algorithm is computationally expensive, so browsers limit it to short text.
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Good — even line lengths on short text */
|
||||||
|
h1, h2, h3 {
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Bad — default wrapping leaves orphans */
|
||||||
|
h1 {
|
||||||
|
/* no text-wrap rule → "Read our
|
||||||
|
blog" instead of balanced lines */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Bad — balance on long paragraphs (silently ignored, wastes intent) */
|
||||||
|
.article-body p {
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tailwind:** `text-balance`
|
||||||
|
|
||||||
|
### text-wrap: pretty
|
||||||
|
|
||||||
|
Prevents orphaned words (a single word dangling on the last line) by adjusting line breaks throughout the paragraph. Unlike `balance`, it doesn't try to equalize line lengths — it just ensures the last line isn't embarrassingly short. Works on text of any length with no line-count limit.
|
||||||
|
|
||||||
|
This should be your **default for short-to-medium text** — paragraphs, descriptions, captions, list items, card text. For very long text (10+ lines), skip both `pretty` and `balance` — the browser's default wrapping is fine and you avoid unnecessary layout cost.
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Good — descriptions, captions, short paragraphs */
|
||||||
|
p, li, figcaption, blockquote {
|
||||||
|
text-wrap: pretty;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Tailwind
|
||||||
|
<p className="text-pretty">
|
||||||
|
A short paragraph that won't leave an orphan on the last line.
|
||||||
|
</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tailwind:** `text-pretty`
|
||||||
|
|
||||||
|
### When to Use Which
|
||||||
|
|
||||||
|
| Scenario | Use |
|
||||||
|
| --- | --- |
|
||||||
|
| Headings, titles where even distribution matters | `text-wrap: balance` |
|
||||||
|
| Short-to-medium text — paragraphs, descriptions, captions, UI text | `text-wrap: pretty` |
|
||||||
|
| Long text (10+ lines), code blocks, pre-formatted text | Neither — leave default |
|
||||||
|
|
||||||
|
## Font Smoothing (macOS)
|
||||||
|
|
||||||
|
On macOS, text renders heavier than intended by default. Apply antialiased smoothing to the root layout so all text renders crisper and thinner.
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* CSS */
|
||||||
|
html {
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Tailwind — apply to root layout
|
||||||
|
<html className="antialiased">
|
||||||
|
```
|
||||||
|
|
||||||
|
### Good vs. Bad
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Good — applied once at the root */
|
||||||
|
html {
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bad — applied per-element, inconsistent */
|
||||||
|
.heading {
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
.body {
|
||||||
|
/* no smoothing → heavier than heading */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** This only affects macOS rendering. Other platforms ignore these properties, so it's safe to apply universally.
|
||||||
|
|
||||||
|
## Tabular Numbers
|
||||||
|
|
||||||
|
When numbers update dynamically (counters, prices, timers, table columns), use tabular-nums to make all digits equal width. This prevents layout shift as values change.
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* CSS */
|
||||||
|
.counter {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Tailwind
|
||||||
|
<span className="tabular-nums">{count}</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
### When to Use
|
||||||
|
|
||||||
|
| Use tabular-nums | Don't use tabular-nums |
|
||||||
|
| --- | --- |
|
||||||
|
| Counters and timers | Static display numbers |
|
||||||
|
| Prices that update | Decorative large numbers |
|
||||||
|
| Table columns with numbers | Phone numbers, zip codes |
|
||||||
|
| Animated number transitions | Version numbers (v2.1.0) |
|
||||||
|
| Scoreboards, dashboards | |
|
||||||
|
|
||||||
|
### Caveat
|
||||||
|
|
||||||
|
Some fonts (like Inter) change the visual appearance of numerals with this property — specifically, the digit `1` becomes wider and centered. This is expected behavior and usually desirable for alignment, but verify it looks right in your specific font.
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* With Inter font:
|
||||||
|
Default: 1234 → proportional, "1" is narrow
|
||||||
|
Tabular: 1234 → all digits equal width, "1" centered */
|
||||||
|
```
|
||||||
260
.agents/skills/shadcn/SKILL.md
Normal file
260
.agents/skills/shadcn/SKILL.md
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
---
|
||||||
|
name: shadcn
|
||||||
|
description: Manages shadcn components and projects — adding, searching, fixing, debugging, styling, and composing UI. Provides project context, component docs, and usage examples. Applies when working with shadcn/ui, component registries, presets, --preset codes, or any project with a components.json file. Also triggers for "shadcn init", "create an app with --preset", or "switch to --preset".
|
||||||
|
user-invocable: false
|
||||||
|
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||||
|
---
|
||||||
|
|
||||||
|
# shadcn/ui
|
||||||
|
|
||||||
|
A framework for building ui, components and design systems. Components are added as source code to the user's project via the CLI.
|
||||||
|
|
||||||
|
> **IMPORTANT:** Run all CLI commands using the project's package runner: `npx shadcn@latest`, `pnpm dlx shadcn@latest`, or `bunx --bun shadcn@latest` — based on the project's `packageManager`. Examples below use `npx shadcn@latest` but substitute the correct runner for the project.
|
||||||
|
|
||||||
|
## Current Project Context
|
||||||
|
|
||||||
|
```json
|
||||||
|
!`npx shadcn@latest info --json`
|
||||||
|
```
|
||||||
|
|
||||||
|
The JSON above contains the project config and installed components. Use `npx shadcn@latest docs <component>` to get documentation and example URLs for any component.
|
||||||
|
|
||||||
|
## Principles
|
||||||
|
|
||||||
|
1. **Use existing components first.** Use `npx shadcn@latest search` to check registries before writing custom UI. Check community registries too.
|
||||||
|
2. **Compose, don't reinvent.** Settings page = Tabs + Card + form controls. Dashboard = Sidebar + Card + Chart + Table.
|
||||||
|
3. **Use built-in variants before custom styles.** `variant="outline"`, `size="sm"`, etc.
|
||||||
|
4. **Use semantic colors.** `bg-primary`, `text-muted-foreground` — never raw values like `bg-blue-500`.
|
||||||
|
|
||||||
|
## Critical Rules
|
||||||
|
|
||||||
|
These rules are **always enforced**. Each links to a file with Incorrect/Correct code pairs.
|
||||||
|
|
||||||
|
### Styling & Tailwind → [styling.md](./rules/styling.md)
|
||||||
|
|
||||||
|
- **`className` for layout, not styling.** Never override component colors or typography.
|
||||||
|
- **No `space-x-*` or `space-y-*`.** Use `flex` with `gap-*`. For vertical stacks, `flex flex-col gap-*`.
|
||||||
|
- **Use `size-*` when width and height are equal.** `size-10` not `w-10 h-10`.
|
||||||
|
- **Use `truncate` shorthand.** Not `overflow-hidden text-ellipsis whitespace-nowrap`.
|
||||||
|
- **No manual `dark:` color overrides.** Use semantic tokens (`bg-background`, `text-muted-foreground`).
|
||||||
|
- **Use `cn()` for conditional classes.** Don't write manual template literal ternaries.
|
||||||
|
- **No manual `z-index` on overlay components.** Dialog, Sheet, Popover, etc. handle their own stacking.
|
||||||
|
|
||||||
|
### Forms & Inputs → [forms.md](./rules/forms.md)
|
||||||
|
|
||||||
|
- **Forms use `FieldGroup` + `Field`.** Never use raw `div` with `space-y-*` or `grid gap-*` for form layout.
|
||||||
|
- **`InputGroup` uses `InputGroupInput`/`InputGroupTextarea`.** Never raw `Input`/`Textarea` inside `InputGroup`.
|
||||||
|
- **Buttons inside inputs use `InputGroup` + `InputGroupAddon`.**
|
||||||
|
- **Option sets (2–7 choices) use `ToggleGroup`.** Don't loop `Button` with manual active state.
|
||||||
|
- **`FieldSet` + `FieldLegend` for grouping related checkboxes/radios.** Don't use a `div` with a heading.
|
||||||
|
- **Field validation uses `data-invalid` + `aria-invalid`.** `data-invalid` on `Field`, `aria-invalid` on the control. For disabled: `data-disabled` on `Field`, `disabled` on the control.
|
||||||
|
|
||||||
|
### Component Structure → [composition.md](./rules/composition.md)
|
||||||
|
|
||||||
|
- **Items always inside their Group.** `SelectItem` → `SelectGroup`. `DropdownMenuItem` → `DropdownMenuGroup`. `CommandItem` → `CommandGroup`.
|
||||||
|
- **Use `asChild` (radix) or `render` (base) for custom triggers.** Check `base` field from `npx shadcn@latest info`. → [base-vs-radix.md](./rules/base-vs-radix.md)
|
||||||
|
- **Dialog, Sheet, and Drawer always need a Title.** `DialogTitle`, `SheetTitle`, `DrawerTitle` required for accessibility. Use `className="sr-only"` if visually hidden.
|
||||||
|
- **Use full Card composition.** `CardHeader`/`CardTitle`/`CardDescription`/`CardContent`/`CardFooter`. Don't dump everything in `CardContent`.
|
||||||
|
- **Button has no `isPending`/`isLoading`.** Compose with `Spinner` + `data-icon` + `disabled`.
|
||||||
|
- **`TabsTrigger` must be inside `TabsList`.** Never render triggers directly in `Tabs`.
|
||||||
|
- **`Avatar` always needs `AvatarFallback`.** For when the image fails to load.
|
||||||
|
|
||||||
|
### Use Components, Not Custom Markup → [composition.md](./rules/composition.md)
|
||||||
|
|
||||||
|
- **Use existing components before custom markup.** Check if a component exists before writing a styled `div`.
|
||||||
|
- **Callouts use `Alert`.** Don't build custom styled divs.
|
||||||
|
- **Empty states use `Empty`.** Don't build custom empty state markup.
|
||||||
|
- **Toast via `sonner`.** Use `toast()` from `sonner`.
|
||||||
|
- **Use `Separator`** instead of `<hr>` or `<div className="border-t">`.
|
||||||
|
- **Use `Skeleton`** for loading placeholders. No custom `animate-pulse` divs.
|
||||||
|
- **Use `Badge`** instead of custom styled spans.
|
||||||
|
|
||||||
|
### Icons → [icons.md](./rules/icons.md)
|
||||||
|
|
||||||
|
- **Icons in `Button` use `data-icon`.** `data-icon="inline-start"` or `data-icon="inline-end"` on the icon.
|
||||||
|
- **No sizing classes on icons inside components.** Components handle icon sizing via CSS. No `size-4` or `w-4 h-4`.
|
||||||
|
- **Pass icons as objects, not string keys.** `icon={CheckIcon}`, not a string lookup.
|
||||||
|
|
||||||
|
### CLI
|
||||||
|
|
||||||
|
- **Never decode preset codes or build preset URLs manually.** Use `npx shadcn@latest preset decode <code>`, `preset url <code>`, or `preset open <code>`. For project-aware preset detection, use `npx shadcn@latest preset resolve`.
|
||||||
|
- **Apply preset codes directly with the CLI.** Use `npx shadcn@latest apply <code>` for existing projects, or `npx shadcn@latest init --preset <code>` when initializing.
|
||||||
|
|
||||||
|
## Key Patterns
|
||||||
|
|
||||||
|
These are the most common patterns that differentiate correct shadcn/ui code. For edge cases, see the linked rule files above.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Form layout: FieldGroup + Field, not div + Label.
|
||||||
|
<FieldGroup>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||||
|
<Input id="email" />
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
|
||||||
|
// Validation: data-invalid on Field, aria-invalid on the control.
|
||||||
|
<Field data-invalid>
|
||||||
|
<FieldLabel>Email</FieldLabel>
|
||||||
|
<Input aria-invalid />
|
||||||
|
<FieldDescription>Invalid email.</FieldDescription>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
// Icons in buttons: data-icon, no sizing classes.
|
||||||
|
<Button>
|
||||||
|
<SearchIcon data-icon="inline-start" />
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
// Spacing: gap-*, not space-y-*.
|
||||||
|
<div className="flex flex-col gap-4"> // correct
|
||||||
|
<div className="space-y-4"> // wrong
|
||||||
|
|
||||||
|
// Equal dimensions: size-*, not w-* h-*.
|
||||||
|
<Avatar className="size-10"> // correct
|
||||||
|
<Avatar className="w-10 h-10"> // wrong
|
||||||
|
|
||||||
|
// Status colors: Badge variants or semantic tokens, not raw colors.
|
||||||
|
<Badge variant="secondary">+20.1%</Badge> // correct
|
||||||
|
<span className="text-emerald-600">+20.1%</span> // wrong
|
||||||
|
```
|
||||||
|
|
||||||
|
## Component Selection
|
||||||
|
|
||||||
|
| Need | Use |
|
||||||
|
| -------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||||
|
| Button/action | `Button` with appropriate variant |
|
||||||
|
| Form inputs | `Input`, `Select`, `Combobox`, `Switch`, `Checkbox`, `RadioGroup`, `Textarea`, `InputOTP`, `Slider` |
|
||||||
|
| Toggle between 2–5 options | `ToggleGroup` + `ToggleGroupItem` |
|
||||||
|
| Data display | `Table`, `Card`, `Badge`, `Avatar` |
|
||||||
|
| Navigation | `Sidebar`, `NavigationMenu`, `Breadcrumb`, `Tabs`, `Pagination` |
|
||||||
|
| Overlays | `Dialog` (modal), `Sheet` (side panel), `Drawer` (bottom sheet), `AlertDialog` (confirmation) |
|
||||||
|
| Feedback | `sonner` (toast), `Alert`, `Progress`, `Skeleton`, `Spinner` |
|
||||||
|
| Command palette | `Command` inside `Dialog` |
|
||||||
|
| Charts | `Chart` (wraps Recharts) |
|
||||||
|
| Layout | `Card`, `Separator`, `Resizable`, `ScrollArea`, `Accordion`, `Collapsible` |
|
||||||
|
| Empty states | `Empty` |
|
||||||
|
| Menus | `DropdownMenu`, `ContextMenu`, `Menubar` |
|
||||||
|
| Tooltips/info | `Tooltip`, `HoverCard`, `Popover` |
|
||||||
|
|
||||||
|
## Key Fields
|
||||||
|
|
||||||
|
The injected project context contains these key fields:
|
||||||
|
|
||||||
|
- **`aliases`** → use the actual alias prefix for imports (e.g. `@/`, `~/`), never hardcode.
|
||||||
|
- **`isRSC`** → when `true`, components using `useState`, `useEffect`, event handlers, or browser APIs need `"use client"` at the top of the file. Always reference this field when advising on the directive.
|
||||||
|
- **`tailwindVersion`** → `"v4"` uses `@theme inline` blocks; `"v3"` uses `tailwind.config.js`.
|
||||||
|
- **`tailwindCssFile`** → the global CSS file where custom CSS variables are defined. Always edit this file, never create a new one.
|
||||||
|
- **`style`** → component visual treatment (e.g. `nova`, `vega`).
|
||||||
|
- **`base`** → primitive library (`radix` or `base`). Affects component APIs and available props.
|
||||||
|
- **`iconLibrary`** → determines icon imports. Use `lucide-react` for `lucide`, `@tabler/icons-react` for `tabler`, etc. Never assume `lucide-react`.
|
||||||
|
- **`resolvedPaths`** → exact file-system destinations for components, utils, hooks, etc.
|
||||||
|
- **`framework`** → routing and file conventions (e.g. Next.js App Router vs Vite SPA).
|
||||||
|
- **`packageManager`** → use this for any non-shadcn dependency installs (e.g. `pnpm add date-fns` vs `npm install date-fns`).
|
||||||
|
- **`preset`** → resolved preset code and values for the current project. Use `npx shadcn@latest preset resolve --json` when you only need preset information.
|
||||||
|
|
||||||
|
See [cli.md — `info` command](./cli.md) for the full field reference.
|
||||||
|
|
||||||
|
## Component Docs, Examples, and Usage
|
||||||
|
|
||||||
|
Run `npx shadcn@latest docs <component>` to get the URLs for a component's documentation, examples, and API reference. Fetch these URLs to get the actual content.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest docs button dialog select
|
||||||
|
```
|
||||||
|
|
||||||
|
**When creating, fixing, debugging, or using a component, always run `npx shadcn@latest docs` and fetch the URLs first.** This ensures you're working with the correct API and usage patterns rather than guessing.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. **Get project context** — already injected above. Run `npx shadcn@latest info` again if you need to refresh.
|
||||||
|
2. **Check installed components first** — before running `add`, always check the `components` list from project context or list the `resolvedPaths.ui` directory. Don't import components that haven't been added, and don't re-add ones already installed.
|
||||||
|
3. **Find components** — `npx shadcn@latest search`.
|
||||||
|
4. **Get docs and examples** — run `npx shadcn@latest docs <component>` to get URLs, then fetch them. Use `npx shadcn@latest view` to browse registry items you haven't installed. To preview changes to installed components, use `npx shadcn@latest add --diff`.
|
||||||
|
5. **Install or update** — `npx shadcn@latest add`. When updating existing components, use `--dry-run` and `--diff` to preview changes first (see [Updating Components](#updating-components) below).
|
||||||
|
6. **Fix imports in third-party components** — After adding components from community registries (e.g. `@bundui`, `@magicui`), check the added non-UI files for hardcoded import paths like `@/components/ui/...`. These won't match the project's actual aliases. Use `npx shadcn@latest info` to get the correct `ui` alias (e.g. `@workspace/ui/components`) and rewrite the imports accordingly. The CLI rewrites imports for its own UI files, but third-party registry components may use default paths that don't match the project.
|
||||||
|
7. **Review added components** — After adding a component or block from any registry, **always read the added files and verify they are correct**. Check for missing sub-components (e.g. `SelectItem` without `SelectGroup`), missing imports, incorrect composition, or violations of the [Critical Rules](#critical-rules). Also replace any icon imports with the project's `iconLibrary` from the project context (e.g. if the registry item uses `lucide-react` but the project uses `hugeicons`, swap the imports and icon names accordingly). Fix all issues before moving on.
|
||||||
|
8. **Registry must be explicit** — When the user asks to add a block or component, **do not guess the registry**. If no registry is specified (e.g. user says "add a login block" without specifying `@shadcn`, `@tailark`, etc.), ask which registry to use. Never default to a registry on behalf of the user.
|
||||||
|
9. **Switching presets** — Ask the user first: **overwrite**, **partial**, **merge**, or **skip**?
|
||||||
|
- **Inspect current preset**: `npx shadcn@latest preset resolve`. Use `--json` when you need structured values.
|
||||||
|
- **Inspect incoming preset**: `npx shadcn@latest preset decode <code>`. Use `preset url <code>` or `preset open <code>` to share or open the preset builder.
|
||||||
|
- **Overwrite**: `npx shadcn@latest apply <code>`. Overwrites detected components, fonts, and CSS variables.
|
||||||
|
- **Partial**: `npx shadcn@latest apply <code> --only theme,font`. Updates only the selected preset parts without reinstalling UI components. Supported values are `theme` and `font`; comma-separated combinations are allowed. `icon` is intentionally not supported, because icon changes may require full component reinstall and transforms.
|
||||||
|
- **Merge**: `npx shadcn@latest init --preset <code> --force --no-reinstall`, then run `npx shadcn@latest info` to list installed components, then for each installed component use `--dry-run` and `--diff` to [smart merge](#updating-components) it individually.
|
||||||
|
- **Skip**: `npx shadcn@latest init --preset <code> --force --no-reinstall`. Only updates config and CSS, leaves components as-is.
|
||||||
|
- **Important**: Always run preset commands inside the user's project directory. `apply` only works in an existing project with a `components.json` file. The CLI automatically preserves the current base (`base` vs `radix`) from `components.json`. If you must use a scratch/temp directory (e.g. for `--dry-run` comparisons), pass `--base <current-base>` explicitly — preset codes do not encode the base.
|
||||||
|
|
||||||
|
## Updating Components
|
||||||
|
|
||||||
|
When the user asks to update a component from upstream while keeping their local changes, use `--dry-run` and `--diff` to intelligently merge. **NEVER fetch raw files from GitHub manually — always use the CLI.**
|
||||||
|
|
||||||
|
1. Run `npx shadcn@latest add <component> --dry-run` to see all files that would be affected.
|
||||||
|
2. For each file, run `npx shadcn@latest add <component> --diff <file>` to see what changed upstream vs local.
|
||||||
|
3. Decide per file based on the diff:
|
||||||
|
- No local changes → safe to overwrite.
|
||||||
|
- Has local changes → read the local file, analyze the diff, and apply upstream updates while preserving local modifications.
|
||||||
|
- User says "just update everything" → use `--overwrite`, but confirm first.
|
||||||
|
4. **Never use `--overwrite` without the user's explicit approval.**
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create a new project.
|
||||||
|
npx shadcn@latest init --name my-app --preset base-nova
|
||||||
|
npx shadcn@latest init --name my-app --preset a2r6bw --template vite
|
||||||
|
|
||||||
|
# Create a monorepo project.
|
||||||
|
npx shadcn@latest init --name my-app --preset base-nova --monorepo
|
||||||
|
npx shadcn@latest init --name my-app --preset base-nova --template next --monorepo
|
||||||
|
|
||||||
|
# Initialize existing project.
|
||||||
|
npx shadcn@latest init --preset base-nova
|
||||||
|
npx shadcn@latest init --defaults # shortcut: --template=next --preset=nova (base style implied)
|
||||||
|
|
||||||
|
# Apply a preset to an existing project.
|
||||||
|
npx shadcn@latest apply a2r6bw
|
||||||
|
npx shadcn@latest apply a2r6bw --only theme
|
||||||
|
npx shadcn@latest apply a2r6bw --only font
|
||||||
|
npx shadcn@latest apply a2r6bw --only theme,font
|
||||||
|
|
||||||
|
# Inspect preset codes and project preset state.
|
||||||
|
npx shadcn@latest preset decode a2r6bw
|
||||||
|
npx shadcn@latest preset url a2r6bw
|
||||||
|
npx shadcn@latest preset open a2r6bw
|
||||||
|
npx shadcn@latest preset resolve
|
||||||
|
npx shadcn@latest preset resolve --json
|
||||||
|
|
||||||
|
# Add components.
|
||||||
|
npx shadcn@latest add button card dialog
|
||||||
|
npx shadcn@latest add @magicui/shimmer-button
|
||||||
|
npx shadcn@latest add --all
|
||||||
|
|
||||||
|
# Preview changes before adding/updating.
|
||||||
|
npx shadcn@latest add button --dry-run
|
||||||
|
npx shadcn@latest add button --diff button.tsx
|
||||||
|
npx shadcn@latest add @acme/form --view button.tsx
|
||||||
|
|
||||||
|
# Search registries.
|
||||||
|
npx shadcn@latest search @shadcn -q "sidebar"
|
||||||
|
npx shadcn@latest search @tailark -q "stats"
|
||||||
|
|
||||||
|
# Get component docs and example URLs.
|
||||||
|
npx shadcn@latest docs button dialog select
|
||||||
|
|
||||||
|
# View registry item details (for items not yet installed).
|
||||||
|
npx shadcn@latest view @shadcn/button
|
||||||
|
```
|
||||||
|
|
||||||
|
**Named presets:** `nova`, `vega`, `maia`, `lyra`, `mira`, `luma`
|
||||||
|
**Templates:** `next`, `vite`, `start`, `react-router`, `astro` (all support `--monorepo`) and `laravel` (not supported for monorepo)
|
||||||
|
**Preset codes:** Version-prefixed base62 strings (e.g. `a2r6bw` or `b0`), from [ui.shadcn.com](https://ui.shadcn.com).
|
||||||
|
|
||||||
|
## Detailed References
|
||||||
|
|
||||||
|
- [rules/forms.md](./rules/forms.md) — FieldGroup, Field, InputGroup, ToggleGroup, FieldSet, validation states
|
||||||
|
- [rules/composition.md](./rules/composition.md) — Groups, overlays, Card, Tabs, Avatar, Alert, Empty, Toast, Separator, Skeleton, Badge, Button loading
|
||||||
|
- [rules/icons.md](./rules/icons.md) — data-icon, icon sizing, passing icons as objects
|
||||||
|
- [rules/styling.md](./rules/styling.md) — Semantic colors, variants, className, spacing, size, truncate, dark mode, cn(), z-index
|
||||||
|
- [rules/base-vs-radix.md](./rules/base-vs-radix.md) — asChild vs render, Select, ToggleGroup, Slider, Accordion
|
||||||
|
- [cli.md](./cli.md) — Commands, flags, presets, templates
|
||||||
|
- [customization.md](./customization.md) — Theming, CSS variables, extending components
|
||||||
5
.agents/skills/shadcn/agents/openai.yml
Normal file
5
.agents/skills/shadcn/agents/openai.yml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
interface:
|
||||||
|
display_name: "shadcn/ui"
|
||||||
|
short_description: "Manages shadcn/ui components — adding, searching, fixing, debugging, styling, and composing UI."
|
||||||
|
icon_small: "./assets/shadcn-small.png"
|
||||||
|
icon_large: "./assets/shadcn.png"
|
||||||
BIN
.agents/skills/shadcn/assets/shadcn-small.png
Normal file
BIN
.agents/skills/shadcn/assets/shadcn-small.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
BIN
.agents/skills/shadcn/assets/shadcn.png
Normal file
BIN
.agents/skills/shadcn/assets/shadcn.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
276
.agents/skills/shadcn/cli.md
Normal file
276
.agents/skills/shadcn/cli.md
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
# shadcn CLI Reference
|
||||||
|
|
||||||
|
Configuration is read from `components.json`.
|
||||||
|
|
||||||
|
> **IMPORTANT:** Always run commands using the project's package runner: `npx shadcn@latest`, `pnpm dlx shadcn@latest`, or `bunx --bun shadcn@latest`. Check `packageManager` from project context to choose the right one. Examples below use `npx shadcn@latest` but substitute the correct runner for the project.
|
||||||
|
|
||||||
|
> **IMPORTANT:** Only use the flags documented below. Do not invent or guess flags — if a flag isn't listed here, it doesn't exist. The CLI auto-detects the package manager from the project's lockfile; there is no `--package-manager` flag.
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- Commands: init, apply, add (dry-run, smart merge), search, view, docs, info, build
|
||||||
|
- Templates: next, vite, start, react-router, astro
|
||||||
|
- Presets: named, code, URL formats and fields
|
||||||
|
- Switching presets
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### `init` — Initialize or create a project
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest init [components...] [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
Initializes shadcn/ui in an existing project or creates a new project (when `--name` is provided). Optionally installs components in the same step.
|
||||||
|
|
||||||
|
| Flag | Short | Description | Default |
|
||||||
|
| ----------------------- | ----- | --------------------------------------------------------- | ------- |
|
||||||
|
| `--template <template>` | `-t` | Template (next, start, vite, next-monorepo, react-router) | — |
|
||||||
|
| `--preset [name]` | `-p` | Preset configuration (named, code, or URL) | — |
|
||||||
|
| `--yes` | `-y` | Skip confirmation prompt | `true` |
|
||||||
|
| `--defaults` | `-d` | Use defaults (`--template=next --preset=base-nova`) | `false` |
|
||||||
|
| `--force` | `-f` | Force overwrite existing configuration | `false` |
|
||||||
|
| `--cwd <cwd>` | `-c` | Working directory | current |
|
||||||
|
| `--name <name>` | `-n` | Name for new project | — |
|
||||||
|
| `--silent` | `-s` | Mute output | `false` |
|
||||||
|
| `--rtl` | | Enable RTL support | — |
|
||||||
|
| `--reinstall` | | Re-install existing UI components | `false` |
|
||||||
|
| `--monorepo` | | Scaffold a monorepo project | — |
|
||||||
|
| `--no-monorepo` | | Skip the monorepo prompt | — |
|
||||||
|
|
||||||
|
`npx shadcn@latest create` is an alias for `npx shadcn@latest init`.
|
||||||
|
|
||||||
|
### `apply` — Apply a preset to an existing project
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest apply [preset] [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
Applies a preset to an existing project, overwriting preset-driven config, fonts, CSS variables, and detected UI components.
|
||||||
|
|
||||||
|
| Flag | Short | Description | Default |
|
||||||
|
| ------------------- | ----- | ------------------------------------------ | ------- |
|
||||||
|
| `--preset <preset>` | — | Preset configuration (named, code, or URL) | — |
|
||||||
|
| `--yes` | `-y` | Skip confirmation prompt | `false` |
|
||||||
|
| `--cwd <cwd>` | `-c` | Working directory | current |
|
||||||
|
| `--silent` | `-s` | Mute output | `false` |
|
||||||
|
|
||||||
|
`[preset]` is a shorthand for `--preset <preset>`. If both are provided, they must match.
|
||||||
|
If no preset is provided, the CLI offers to open the custom preset builder on `ui.shadcn.com/create`.
|
||||||
|
|
||||||
|
### `add` — Add components
|
||||||
|
|
||||||
|
> **IMPORTANT:** To compare local components against upstream or to preview changes, ALWAYS use `npx shadcn@latest add <component> --dry-run`, `--diff`, or `--view`. NEVER fetch raw files from GitHub or other sources manually. The CLI handles registry resolution, file paths, and CSS diffing automatically.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add [components...] [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
Accepts component names, registry-prefixed names (`@magicui/shimmer-button`), URLs, or local paths.
|
||||||
|
|
||||||
|
| Flag | Short | Description | Default |
|
||||||
|
| --------------- | ----- | -------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||||
|
| `--yes` | `-y` | Skip confirmation prompt | `false` |
|
||||||
|
| `--overwrite` | `-o` | Overwrite existing files | `false` |
|
||||||
|
| `--cwd <cwd>` | `-c` | Working directory | current |
|
||||||
|
| `--all` | `-a` | Add all available components | `false` |
|
||||||
|
| `--path <path>` | `-p` | Target path for the component | — |
|
||||||
|
| `--silent` | `-s` | Mute output | `false` |
|
||||||
|
| `--dry-run` | | Preview all changes without writing files | `false` |
|
||||||
|
| `--diff [path]` | | Show diffs. Without a path, shows the first 5 files. With a path, shows that file only (implies `--dry-run`) | — |
|
||||||
|
| `--view [path]` | | Show file contents. Without a path, shows the first 5 files. With a path, shows that file only (implies `--dry-run`) | — |
|
||||||
|
|
||||||
|
#### Dry-Run Mode
|
||||||
|
|
||||||
|
Use `--dry-run` to preview what `add` would do without writing any files. `--diff` and `--view` both imply `--dry-run`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Preview all changes.
|
||||||
|
npx shadcn@latest add button --dry-run
|
||||||
|
|
||||||
|
# Show diffs for all files (top 5).
|
||||||
|
npx shadcn@latest add button --diff
|
||||||
|
|
||||||
|
# Show the diff for a specific file.
|
||||||
|
npx shadcn@latest add button --diff button.tsx
|
||||||
|
|
||||||
|
# Show contents for all files (top 5).
|
||||||
|
npx shadcn@latest add button --view
|
||||||
|
|
||||||
|
# Show the full content of a specific file.
|
||||||
|
npx shadcn@latest add button --view button.tsx
|
||||||
|
|
||||||
|
# Works with URLs too.
|
||||||
|
npx shadcn@latest add https://api.npoint.io/abc123 --dry-run
|
||||||
|
|
||||||
|
# CSS diffs.
|
||||||
|
npx shadcn@latest add button --diff globals.css
|
||||||
|
```
|
||||||
|
|
||||||
|
**When to use dry-run:**
|
||||||
|
|
||||||
|
- When the user asks "what files will this add?" or "what will this change?" — use `--dry-run`.
|
||||||
|
- Before overwriting existing components — use `--diff` to preview the changes first.
|
||||||
|
- When the user wants to inspect component source code without installing — use `--view`.
|
||||||
|
- When checking what CSS changes would be made to `globals.css` — use `--diff globals.css`.
|
||||||
|
- When the user asks to review or audit third-party registry code before installing — use `--view` to inspect the source.
|
||||||
|
|
||||||
|
> **`npx shadcn@latest add --dry-run` vs `npx shadcn@latest view`:** Prefer `npx shadcn@latest add --dry-run/--diff/--view` over `npx shadcn@latest view` when the user wants to preview changes to their project. `npx shadcn@latest view` only shows raw registry metadata. `npx shadcn@latest add --dry-run` shows exactly what would happen in the user's project: resolved file paths, diffs against existing files, and CSS updates. Use `npx shadcn@latest view` only when the user wants to browse registry info without a project context.
|
||||||
|
|
||||||
|
#### Smart Merge from Upstream
|
||||||
|
|
||||||
|
See [Updating Components in SKILL.md](./SKILL.md#updating-components) for the full workflow.
|
||||||
|
|
||||||
|
### `search` — Search registries
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest search <registries...> [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
Fuzzy search across registries. Also aliased as `npx shadcn@latest list`. Without `-q`, lists all items.
|
||||||
|
|
||||||
|
| Flag | Short | Description | Default |
|
||||||
|
| ------------------- | ----- | ---------------------- | ------- |
|
||||||
|
| `--query <query>` | `-q` | Search query | — |
|
||||||
|
| `--limit <number>` | `-l` | Max items per registry | `100` |
|
||||||
|
| `--offset <number>` | `-o` | Items to skip | `0` |
|
||||||
|
| `--cwd <cwd>` | `-c` | Working directory | current |
|
||||||
|
|
||||||
|
### `view` — View item details
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest view <items...> [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
Displays item info including file contents. Example: `npx shadcn@latest view @shadcn/button`.
|
||||||
|
|
||||||
|
### `docs` — Get component documentation URLs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest docs <components...> [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
Outputs resolved URLs for component documentation, examples, and API references. Accepts one or more component names. Fetch the URLs to get the actual content.
|
||||||
|
|
||||||
|
Example output for `npx shadcn@latest docs input button`:
|
||||||
|
|
||||||
|
```
|
||||||
|
base radix
|
||||||
|
|
||||||
|
input
|
||||||
|
docs https://ui.shadcn.com/docs/components/radix/input
|
||||||
|
examples https://raw.githubusercontent.com/.../examples/input-example.tsx
|
||||||
|
|
||||||
|
button
|
||||||
|
docs https://ui.shadcn.com/docs/components/radix/button
|
||||||
|
examples https://raw.githubusercontent.com/.../examples/button-example.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
Some components include an `api` link to the underlying library (e.g. `cmdk` for the command component).
|
||||||
|
|
||||||
|
### `diff` — Check for updates
|
||||||
|
|
||||||
|
Do not use this command. Use `npx shadcn@latest add --diff` instead.
|
||||||
|
|
||||||
|
### `info` — Project information
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest info [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
Displays project info and `components.json` configuration. Run this first to discover the project's framework, aliases, Tailwind version, and resolved paths.
|
||||||
|
|
||||||
|
| Flag | Short | Description | Default |
|
||||||
|
| ------------- | ----- | ----------------- | ------- |
|
||||||
|
| `--cwd <cwd>` | `-c` | Working directory | current |
|
||||||
|
|
||||||
|
**Project Info fields:**
|
||||||
|
|
||||||
|
| Field | Type | Meaning |
|
||||||
|
| -------------------- | --------- | ------------------------------------------------------------------ |
|
||||||
|
| `framework` | `string` | Detected framework (`next`, `vite`, `react-router`, `start`, etc.) |
|
||||||
|
| `frameworkVersion` | `string` | Framework version (e.g. `15.2.4`) |
|
||||||
|
| `isSrcDir` | `boolean` | Whether the project uses a `src/` directory |
|
||||||
|
| `isRSC` | `boolean` | Whether React Server Components are enabled |
|
||||||
|
| `isTsx` | `boolean` | Whether the project uses TypeScript |
|
||||||
|
| `tailwindVersion` | `string` | `"v3"` or `"v4"` |
|
||||||
|
| `tailwindConfigFile` | `string` | Path to the Tailwind config file |
|
||||||
|
| `tailwindCssFile` | `string` | Path to the global CSS file |
|
||||||
|
| `aliasPrefix` | `string` | Import alias prefix (e.g. `@`, `~`, `@/`) |
|
||||||
|
| `packageManager` | `string` | Detected package manager (`npm`, `pnpm`, `yarn`, `bun`) |
|
||||||
|
|
||||||
|
**Components.json fields:**
|
||||||
|
|
||||||
|
| Field | Type | Meaning |
|
||||||
|
| -------------------- | --------- | ------------------------------------------------------------------------------------------ |
|
||||||
|
| `base` | `string` | Primitive library (`radix` or `base`) — determines component APIs and available props |
|
||||||
|
| `style` | `string` | Visual style (e.g. `nova`, `vega`) |
|
||||||
|
| `rsc` | `boolean` | RSC flag from config |
|
||||||
|
| `tsx` | `boolean` | TypeScript flag |
|
||||||
|
| `tailwind.config` | `string` | Tailwind config path |
|
||||||
|
| `tailwind.css` | `string` | Global CSS path — this is where custom CSS variables go |
|
||||||
|
| `iconLibrary` | `string` | Icon library — determines icon import package (e.g. `lucide-react`, `@tabler/icons-react`) |
|
||||||
|
| `aliases.components` | `string` | Component import alias (e.g. `@/components`) |
|
||||||
|
| `aliases.utils` | `string` | Utils import alias (e.g. `@/lib/utils`) |
|
||||||
|
| `aliases.ui` | `string` | UI component alias (e.g. `@/components/ui`) |
|
||||||
|
| `aliases.lib` | `string` | Lib alias (e.g. `@/lib`) |
|
||||||
|
| `aliases.hooks` | `string` | Hooks alias (e.g. `@/hooks`) |
|
||||||
|
| `resolvedPaths` | `object` | Absolute file-system paths for each alias |
|
||||||
|
| `registries` | `object` | Configured custom registries |
|
||||||
|
|
||||||
|
**Links fields:**
|
||||||
|
|
||||||
|
The `info` output includes a **Links** section with templated URLs for component docs, source, and examples. For resolved URLs, use `npx shadcn@latest docs <component>` instead.
|
||||||
|
|
||||||
|
### `build` — Build a custom registry
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest build [registry] [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
Builds `registry.json` into individual JSON files for distribution. Default input: `./registry.json`, default output: `./public/r`.
|
||||||
|
|
||||||
|
| Flag | Short | Description | Default |
|
||||||
|
| ----------------- | ----- | ----------------- | ------------ |
|
||||||
|
| `--output <path>` | `-o` | Output directory | `./public/r` |
|
||||||
|
| `--cwd <cwd>` | `-c` | Working directory | current |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Templates
|
||||||
|
|
||||||
|
| Value | Framework | Monorepo support |
|
||||||
|
| -------------- | -------------- | ---------------- |
|
||||||
|
| `next` | Next.js | Yes |
|
||||||
|
| `vite` | Vite | Yes |
|
||||||
|
| `start` | TanStack Start | Yes |
|
||||||
|
| `react-router` | React Router | Yes |
|
||||||
|
| `astro` | Astro | Yes |
|
||||||
|
| `laravel` | Laravel | No |
|
||||||
|
|
||||||
|
All templates support monorepo scaffolding via the `--monorepo` flag. When passed, the CLI uses a monorepo-specific template directory (e.g. `next-monorepo`, `vite-monorepo`). When neither `--monorepo` nor `--no-monorepo` is passed, the CLI prompts interactively. Laravel does not support monorepo scaffolding.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Presets
|
||||||
|
|
||||||
|
Three ways to specify a preset via `--preset`:
|
||||||
|
|
||||||
|
1. **Named:** `--preset nova` or `--preset lyra`
|
||||||
|
2. **Code:** `--preset a2r6bw` (version-prefixed base62 string, e.g. `a2r6bw` or `b0`)
|
||||||
|
3. **URL:** `--preset "https://ui.shadcn.com/init?base=radix&style=nova&..."`
|
||||||
|
|
||||||
|
> **IMPORTANT:** Never try to decode, fetch, or resolve preset codes manually. Preset codes are opaque — pass them directly to `npx shadcn@latest init --preset <code>` and let the CLI handle resolution.
|
||||||
|
> Use `npx shadcn@latest apply --preset <code>` when overwriting an existing project's preset.
|
||||||
|
|
||||||
|
## Switching Presets
|
||||||
|
|
||||||
|
Ask the user first: **overwrite**, **merge**, or **skip** existing components?
|
||||||
|
|
||||||
|
- **Overwrite / Re-install** → `npx shadcn@latest apply --preset <code>`. Overwrites all detected component files with the new preset styles. Use when the user hasn't customized components.
|
||||||
|
- **Merge** → `npx shadcn@latest init --preset <code> --force --no-reinstall`, then run `npx shadcn@latest info` to get the list of installed components and use the [smart merge workflow](./SKILL.md#updating-components) to update them one by one, preserving local changes. Use when the user has customized components.
|
||||||
|
- **Skip** → `npx shadcn@latest init --preset <code> --force --no-reinstall`. Only updates config and CSS variables, leaves existing components as-is.
|
||||||
|
|
||||||
|
Always run preset commands inside the user's project directory. `apply` only works in an existing project with a `components.json` file. The CLI automatically preserves the current base (`base` vs `radix`) from `components.json`. If you must use a scratch/temp directory (e.g. for `--dry-run` comparisons), pass `--base <current-base>` explicitly — preset codes do not encode the base.
|
||||||
209
.agents/skills/shadcn/customization.md
Normal file
209
.agents/skills/shadcn/customization.md
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
# Customization & Theming
|
||||||
|
|
||||||
|
Components reference semantic CSS variable tokens. Change the variables to change every component.
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- How it works (CSS variables → Tailwind utilities → components)
|
||||||
|
- Color variables and OKLCH format
|
||||||
|
- Dark mode setup
|
||||||
|
- Changing the theme (presets, CSS variables)
|
||||||
|
- Adding custom colors (Tailwind v3 and v4)
|
||||||
|
- Border radius
|
||||||
|
- Customizing components (variants, className, wrappers)
|
||||||
|
- Checking for updates
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
1. CSS variables defined in `:root` (light) and `.dark` (dark mode).
|
||||||
|
2. Tailwind maps them to utilities: `bg-primary`, `text-muted-foreground`, etc.
|
||||||
|
3. Components use these utilities — changing a variable changes all components that reference it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Color Variables
|
||||||
|
|
||||||
|
Every color follows the `name` / `name-foreground` convention. The base variable is for backgrounds, `-foreground` is for text/icons on that background.
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
|
| -------------------------------------------- | -------------------------------- |
|
||||||
|
| `--background` / `--foreground` | Page background and default text |
|
||||||
|
| `--card` / `--card-foreground` | Card surfaces |
|
||||||
|
| `--primary` / `--primary-foreground` | Primary buttons and actions |
|
||||||
|
| `--secondary` / `--secondary-foreground` | Secondary actions |
|
||||||
|
| `--muted` / `--muted-foreground` | Muted/disabled states |
|
||||||
|
| `--accent` / `--accent-foreground` | Hover and accent states |
|
||||||
|
| `--destructive` / `--destructive-foreground` | Error and destructive actions |
|
||||||
|
| `--border` | Default border color |
|
||||||
|
| `--input` | Form input borders |
|
||||||
|
| `--ring` | Focus ring color |
|
||||||
|
| `--chart-1` through `--chart-5` | Chart/data visualization |
|
||||||
|
| `--sidebar-*` | Sidebar-specific colors |
|
||||||
|
| `--surface` / `--surface-foreground` | Secondary surface |
|
||||||
|
|
||||||
|
Colors use OKLCH: `--primary: oklch(0.205 0 0)` where values are lightness (0–1), chroma (0 = gray), and hue (0–360).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dark Mode
|
||||||
|
|
||||||
|
Class-based toggle via `.dark` on the root element. In Next.js, use `next-themes`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ThemeProvider } from "next-themes"
|
||||||
|
|
||||||
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||||
|
{children}
|
||||||
|
</ThemeProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changing the Theme
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Apply a preset code from ui.shadcn.com.
|
||||||
|
npx shadcn@latest apply --preset a2r6bw
|
||||||
|
|
||||||
|
# Positional shorthand also works.
|
||||||
|
npx shadcn@latest apply a2r6bw
|
||||||
|
|
||||||
|
# Switch to a named preset and overwrite existing components.
|
||||||
|
npx shadcn@latest apply --preset nova
|
||||||
|
|
||||||
|
# Preserve existing components instead.
|
||||||
|
npx shadcn@latest init --preset nova --force --no-reinstall
|
||||||
|
|
||||||
|
# Use a custom theme URL.
|
||||||
|
npx shadcn@latest apply --preset "https://ui.shadcn.com/init?base=radix&style=nova&theme=blue&..."
|
||||||
|
```
|
||||||
|
|
||||||
|
Or edit CSS variables directly in `globals.css`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adding Custom Colors
|
||||||
|
|
||||||
|
Add variables to the file at `tailwindCssFile` from `npx shadcn@latest info` (typically `globals.css`). Never create a new CSS file for this.
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* 1. Define in the global CSS file. */
|
||||||
|
:root {
|
||||||
|
--warning: oklch(0.84 0.16 84);
|
||||||
|
--warning-foreground: oklch(0.28 0.07 46);
|
||||||
|
}
|
||||||
|
.dark {
|
||||||
|
--warning: oklch(0.41 0.11 46);
|
||||||
|
--warning-foreground: oklch(0.99 0.02 95);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* 2a. Register with Tailwind v4 (@theme inline). */
|
||||||
|
@theme inline {
|
||||||
|
--color-warning: var(--warning);
|
||||||
|
--color-warning-foreground: var(--warning-foreground);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When `tailwindVersion` is `"v3"` (check via `npx shadcn@latest info`), register in `tailwind.config.js` instead:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// 2b. Register with Tailwind v3 (tailwind.config.js).
|
||||||
|
module.exports = {
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
warning: "oklch(var(--warning) / <alpha-value>)",
|
||||||
|
"warning-foreground":
|
||||||
|
"oklch(var(--warning-foreground) / <alpha-value>)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 3. Use in components.
|
||||||
|
<div className="bg-warning text-warning-foreground">Warning</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Border Radius
|
||||||
|
|
||||||
|
`--radius` controls border radius globally. Components derive values from it (`rounded-lg` = `var(--radius)`, `rounded-md` = `calc(var(--radius) - 2px)`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Customizing Components
|
||||||
|
|
||||||
|
See also: [rules/styling.md](./rules/styling.md) for Incorrect/Correct examples.
|
||||||
|
|
||||||
|
Prefer these approaches in order:
|
||||||
|
|
||||||
|
### 1. Built-in variants
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
Click
|
||||||
|
</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Tailwind classes via `className`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Card className="mx-auto max-w-md">...</Card>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Add a new variant
|
||||||
|
|
||||||
|
Edit the component source to add a variant via `cva`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// components/ui/button.tsx
|
||||||
|
warning: "bg-warning text-warning-foreground hover:bg-warning/90",
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Wrapper components
|
||||||
|
|
||||||
|
Compose shadcn/ui primitives into higher-level components:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export function ConfirmDialog({ title, description, onConfirm, children }) {
|
||||||
|
return (
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>{children}</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={onConfirm}>Confirm</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checking for Updates
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add button --diff
|
||||||
|
```
|
||||||
|
|
||||||
|
To preview exactly what would change before updating, use `--dry-run` and `--diff`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add button --dry-run # see all affected files
|
||||||
|
npx shadcn@latest add button --diff button.tsx # see the diff for a specific file
|
||||||
|
```
|
||||||
|
|
||||||
|
See [Updating Components in SKILL.md](./SKILL.md#updating-components) for the full smart merge workflow.
|
||||||
47
.agents/skills/shadcn/evals/evals.json
Normal file
47
.agents/skills/shadcn/evals/evals.json
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"skill_name": "shadcn",
|
||||||
|
"evals": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"prompt": "I'm building a Next.js app with shadcn/ui (base-nova preset, lucide icons). Create a settings form component with fields for: full name, email address, and notification preferences (email, SMS, push notifications as toggle options). Add validation states for required fields.",
|
||||||
|
"expected_output": "A React component using FieldGroup, Field, ToggleGroup, data-invalid/aria-invalid validation, gap-* spacing, and semantic colors.",
|
||||||
|
"files": [],
|
||||||
|
"expectations": [
|
||||||
|
"Uses FieldGroup and Field components for form layout instead of raw div with space-y",
|
||||||
|
"Uses Switch for independent on/off notification toggles (not looping Button with manual active state)",
|
||||||
|
"Uses data-invalid on Field and aria-invalid on the input control for validation states",
|
||||||
|
"Uses gap-* (e.g. gap-4, gap-6) instead of space-y-* or space-x-* for spacing",
|
||||||
|
"Uses semantic color tokens (e.g. bg-background, text-muted-foreground, text-destructive) instead of raw colors like bg-red-500",
|
||||||
|
"No manual dark: color overrides"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"prompt": "Create a dialog component for editing a user profile. It should have the user's avatar at the top, input fields for name and bio, and Save/Cancel buttons with appropriate icons. Using shadcn/ui with radix-nova preset and tabler icons.",
|
||||||
|
"expected_output": "A React component with DialogTitle, Avatar+AvatarFallback, data-icon on icon buttons, no icon sizing classes, tabler icon imports.",
|
||||||
|
"files": [],
|
||||||
|
"expectations": [
|
||||||
|
"Includes DialogTitle for accessibility (visible or with sr-only class)",
|
||||||
|
"Avatar component includes AvatarFallback",
|
||||||
|
"Icons on buttons use the data-icon attribute (data-icon=\"inline-start\" or data-icon=\"inline-end\")",
|
||||||
|
"No sizing classes on icons inside components (no size-4, w-4, h-4, etc.)",
|
||||||
|
"Uses tabler icons (@tabler/icons-react) instead of lucide-react",
|
||||||
|
"Uses asChild for custom triggers (radix preset)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"prompt": "Create a dashboard component that shows 4 stat cards in a grid. Each card has a title, large number, percentage change badge, and a loading skeleton state. Using shadcn/ui with base-nova preset and lucide icons.",
|
||||||
|
"expected_output": "A React component with full Card composition, Skeleton for loading, Badge for changes, semantic colors, gap-* spacing.",
|
||||||
|
"files": [],
|
||||||
|
"expectations": [
|
||||||
|
"Uses full Card composition with CardHeader, CardTitle, CardContent (not dumping everything into CardContent)",
|
||||||
|
"Uses Skeleton component for loading placeholders instead of custom animate-pulse divs",
|
||||||
|
"Uses Badge component for percentage change instead of custom styled spans",
|
||||||
|
"Uses semantic color tokens instead of raw color values like bg-green-500 or text-red-600",
|
||||||
|
"Uses gap-* instead of space-y-* or space-x-* for spacing",
|
||||||
|
"Uses size-* when width and height are equal instead of separate w-* h-*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
94
.agents/skills/shadcn/mcp.md
Normal file
94
.agents/skills/shadcn/mcp.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# shadcn MCP Server
|
||||||
|
|
||||||
|
The CLI includes an MCP server that lets AI assistants search, browse, view, and install components from registries.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
shadcn mcp # start the MCP server (stdio)
|
||||||
|
shadcn mcp init # write config for your editor
|
||||||
|
```
|
||||||
|
|
||||||
|
Editor config files:
|
||||||
|
|
||||||
|
| Editor | Config file |
|
||||||
|
|--------|------------|
|
||||||
|
| Claude Code | `.mcp.json` |
|
||||||
|
| Cursor | `.cursor/mcp.json` |
|
||||||
|
| VS Code | `.vscode/mcp.json` |
|
||||||
|
| OpenCode | `opencode.json` |
|
||||||
|
| Codex | `~/.codex/config.toml` (manual) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
> **Tip:** MCP tools handle registry operations (search, view, install). For project configuration (aliases, framework, Tailwind version), use `npx shadcn@latest info` — there is no MCP equivalent.
|
||||||
|
|
||||||
|
### `shadcn:get_project_registries`
|
||||||
|
|
||||||
|
Returns registry names from `components.json`. Errors if no `components.json` exists.
|
||||||
|
|
||||||
|
**Input:** none
|
||||||
|
|
||||||
|
### `shadcn:list_items_in_registries`
|
||||||
|
|
||||||
|
Lists all items from one or more registries.
|
||||||
|
|
||||||
|
**Input:** `registries` (string[]), `limit` (number, optional), `offset` (number, optional)
|
||||||
|
|
||||||
|
### `shadcn:search_items_in_registries`
|
||||||
|
|
||||||
|
Fuzzy search across registries.
|
||||||
|
|
||||||
|
**Input:** `registries` (string[]), `query` (string), `limit` (number, optional), `offset` (number, optional)
|
||||||
|
|
||||||
|
### `shadcn:view_items_in_registries`
|
||||||
|
|
||||||
|
View item details including full file contents.
|
||||||
|
|
||||||
|
**Input:** `items` (string[]) — e.g. `["@shadcn/button", "@shadcn/card"]`
|
||||||
|
|
||||||
|
### `shadcn:get_item_examples_from_registries`
|
||||||
|
|
||||||
|
Find usage examples and demos with source code.
|
||||||
|
|
||||||
|
**Input:** `registries` (string[]), `query` (string) — e.g. `"accordion-demo"`, `"button example"`
|
||||||
|
|
||||||
|
### `shadcn:get_add_command_for_items`
|
||||||
|
|
||||||
|
Returns the CLI install command.
|
||||||
|
|
||||||
|
**Input:** `items` (string[]) — e.g. `["@shadcn/button"]`
|
||||||
|
|
||||||
|
### `shadcn:get_audit_checklist`
|
||||||
|
|
||||||
|
Returns a checklist for verifying components (imports, deps, lint, TypeScript).
|
||||||
|
|
||||||
|
**Input:** none
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuring Registries
|
||||||
|
|
||||||
|
Registries are set in `components.json`. The `@shadcn` registry is always built-in.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"registries": {
|
||||||
|
"@acme": "https://acme.com/r/{name}.json",
|
||||||
|
"@private": {
|
||||||
|
"url": "https://private.com/r/{name}.json",
|
||||||
|
"headers": { "Authorization": "Bearer ${MY_TOKEN}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Names must start with `@`.
|
||||||
|
- URLs must contain `{name}`.
|
||||||
|
- `${VAR}` references are resolved from environment variables.
|
||||||
|
|
||||||
|
Community registry index: `https://ui.shadcn.com/r/registries.json`
|
||||||
306
.agents/skills/shadcn/rules/base-vs-radix.md
Normal file
306
.agents/skills/shadcn/rules/base-vs-radix.md
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
# Base vs Radix
|
||||||
|
|
||||||
|
API differences between `base` and `radix`. Check the `base` field from `npx shadcn@latest info`.
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- Composition: asChild vs render
|
||||||
|
- Button / trigger as non-button element
|
||||||
|
- Select (items prop, placeholder, positioning, multiple, object values)
|
||||||
|
- ToggleGroup (type vs multiple)
|
||||||
|
- Slider (scalar vs array)
|
||||||
|
- Accordion (type and defaultValue)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Composition: asChild (radix) vs render (base)
|
||||||
|
|
||||||
|
Radix uses `asChild` to replace the default element. Base uses `render`. Don't wrap triggers in extra elements.
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<DialogTrigger>
|
||||||
|
<div>
|
||||||
|
<Button>Open</Button>
|
||||||
|
</div>
|
||||||
|
</DialogTrigger>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (radix):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>Open</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (base):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<DialogTrigger render={<Button />}>Open</DialogTrigger>
|
||||||
|
```
|
||||||
|
|
||||||
|
This applies to all trigger and close components: `DialogTrigger`, `SheetTrigger`, `AlertDialogTrigger`, `DropdownMenuTrigger`, `PopoverTrigger`, `TooltipTrigger`, `CollapsibleTrigger`, `DialogClose`, `SheetClose`, `NavigationMenuLink`, `BreadcrumbLink`, `SidebarMenuButton`, `Badge`, `Item`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Button / trigger as non-button element (base only)
|
||||||
|
|
||||||
|
When `render` changes an element to a non-button (`<a>`, `<span>`), add `nativeButton={false}`.
|
||||||
|
|
||||||
|
**Incorrect (base):** missing `nativeButton={false}`.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Button render={<a href="/docs" />}>Read the docs</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (base):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Button render={<a href="/docs" />} nativeButton={false}>
|
||||||
|
Read the docs
|
||||||
|
</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (radix):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Button asChild>
|
||||||
|
<a href="/docs">Read the docs</a>
|
||||||
|
</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
Same for triggers whose `render` is not a `Button`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// base.
|
||||||
|
<PopoverTrigger render={<InputGroupAddon />} nativeButton={false}>
|
||||||
|
Pick date
|
||||||
|
</PopoverTrigger>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Select
|
||||||
|
|
||||||
|
**items prop (base only).** Base requires an `items` prop on the root. Radix uses inline JSX only.
|
||||||
|
|
||||||
|
**Incorrect (base):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Select>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select a fruit" /></SelectTrigger>
|
||||||
|
</Select>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (base):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const items = [
|
||||||
|
{ label: "Select a fruit", value: null },
|
||||||
|
{ label: "Apple", value: "apple" },
|
||||||
|
{ label: "Banana", value: "banana" },
|
||||||
|
]
|
||||||
|
|
||||||
|
<Select items={items}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{items.map((item) => (
|
||||||
|
<SelectItem key={item.value} value={item.value}>{item.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (radix):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Select>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a fruit" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectItem value="apple">Apple</SelectItem>
|
||||||
|
<SelectItem value="banana">Banana</SelectItem>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Placeholder.** Base uses a `{ value: null }` item in the items array. Radix uses `<SelectValue placeholder="...">`.
|
||||||
|
|
||||||
|
**Content positioning.** Base uses `alignItemWithTrigger`. Radix uses `position`.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// base.
|
||||||
|
<SelectContent alignItemWithTrigger={false} side="bottom">
|
||||||
|
|
||||||
|
// radix.
|
||||||
|
<SelectContent position="popper">
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Select — multiple selection and object values (base only)
|
||||||
|
|
||||||
|
Base supports `multiple`, render-function children on `SelectValue`, and object values with `itemToStringValue`. Radix is single-select with string values only.
|
||||||
|
|
||||||
|
**Correct (base — multiple selection):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Select items={items} multiple defaultValue={[]}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue>
|
||||||
|
{(value: string[]) => value.length === 0 ? "Select fruits" : `${value.length} selected`}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
...
|
||||||
|
</Select>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (base — object values):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Select defaultValue={plans[0]} itemToStringValue={(plan) => plan.name}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue>{(value) => value.name}</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
...
|
||||||
|
</Select>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ToggleGroup
|
||||||
|
|
||||||
|
Base uses a `multiple` boolean prop. Radix uses `type="single"` or `type="multiple"`.
|
||||||
|
|
||||||
|
**Incorrect (base):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<ToggleGroup type="single" defaultValue="daily">
|
||||||
|
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (base):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Single (no prop needed), defaultValue is always an array.
|
||||||
|
<ToggleGroup defaultValue={["daily"]} spacing={2}>
|
||||||
|
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="weekly">Weekly</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
|
|
||||||
|
// Multi-selection.
|
||||||
|
<ToggleGroup multiple>
|
||||||
|
<ToggleGroupItem value="bold">Bold</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="italic">Italic</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (radix):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Single, defaultValue is a string.
|
||||||
|
<ToggleGroup type="single" defaultValue="daily" spacing={2}>
|
||||||
|
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="weekly">Weekly</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
|
|
||||||
|
// Multi-selection.
|
||||||
|
<ToggleGroup type="multiple">
|
||||||
|
<ToggleGroupItem value="bold">Bold</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="italic">Italic</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Controlled single value:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// base — wrap/unwrap arrays.
|
||||||
|
const [value, setValue] = React.useState("normal")
|
||||||
|
<ToggleGroup value={[value]} onValueChange={(v) => setValue(v[0])}>
|
||||||
|
|
||||||
|
// radix — plain string.
|
||||||
|
const [value, setValue] = React.useState("normal")
|
||||||
|
<ToggleGroup type="single" value={value} onValueChange={setValue}>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slider
|
||||||
|
|
||||||
|
Base accepts a plain number for a single thumb. Radix always requires an array.
|
||||||
|
|
||||||
|
**Incorrect (base):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Slider defaultValue={[50]} max={100} step={1} />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (base):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Slider defaultValue={50} max={100} step={1} />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (radix):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Slider defaultValue={[50]} max={100} step={1} />
|
||||||
|
```
|
||||||
|
|
||||||
|
Both use arrays for range sliders. Controlled `onValueChange` in base may need a cast:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// base.
|
||||||
|
const [value, setValue] = React.useState([0.3, 0.7])
|
||||||
|
<Slider value={value} onValueChange={(v) => setValue(v as number[])} />
|
||||||
|
|
||||||
|
// radix.
|
||||||
|
const [value, setValue] = React.useState([0.3, 0.7])
|
||||||
|
<Slider value={value} onValueChange={setValue} />
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Accordion
|
||||||
|
|
||||||
|
Radix requires `type="single"` or `type="multiple"` and supports `collapsible`. `defaultValue` is a string. Base uses no `type` prop, uses `multiple` boolean, and `defaultValue` is always an array.
|
||||||
|
|
||||||
|
**Incorrect (base):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Accordion type="single" collapsible defaultValue="item-1">
|
||||||
|
<AccordionItem value="item-1">...</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (base):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Accordion defaultValue={["item-1"]}>
|
||||||
|
<AccordionItem value="item-1">...</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
// Multi-select.
|
||||||
|
<Accordion multiple defaultValue={["item-1", "item-2"]}>
|
||||||
|
<AccordionItem value="item-1">...</AccordionItem>
|
||||||
|
<AccordionItem value="item-2">...</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (radix):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Accordion type="single" collapsible defaultValue="item-1">
|
||||||
|
<AccordionItem value="item-1">...</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
```
|
||||||
195
.agents/skills/shadcn/rules/composition.md
Normal file
195
.agents/skills/shadcn/rules/composition.md
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
# Component Composition
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- Items always inside their Group component
|
||||||
|
- Callouts use Alert
|
||||||
|
- Empty states use Empty component
|
||||||
|
- Toast notifications use sonner
|
||||||
|
- Choosing between overlay components
|
||||||
|
- Dialog, Sheet, and Drawer always need a Title
|
||||||
|
- Card structure
|
||||||
|
- Button has no isPending or isLoading prop
|
||||||
|
- TabsTrigger must be inside TabsList
|
||||||
|
- Avatar always needs AvatarFallback
|
||||||
|
- Use Separator instead of raw hr or border divs
|
||||||
|
- Use Skeleton for loading placeholders
|
||||||
|
- Use Badge instead of custom styled spans
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Items always inside their Group component
|
||||||
|
|
||||||
|
Never render items directly inside the content container.
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="apple">Apple</SelectItem>
|
||||||
|
<SelectItem value="banana">Banana</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectItem value="apple">Apple</SelectItem>
|
||||||
|
<SelectItem value="banana">Banana</SelectItem>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
```
|
||||||
|
|
||||||
|
This applies to all group-based components:
|
||||||
|
|
||||||
|
| Item | Group |
|
||||||
|
|------|-------|
|
||||||
|
| `SelectItem`, `SelectLabel` | `SelectGroup` |
|
||||||
|
| `DropdownMenuItem`, `DropdownMenuLabel`, `DropdownMenuSub` | `DropdownMenuGroup` |
|
||||||
|
| `MenubarItem` | `MenubarGroup` |
|
||||||
|
| `ContextMenuItem` | `ContextMenuGroup` |
|
||||||
|
| `CommandItem` | `CommandGroup` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Callouts use Alert
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Alert>
|
||||||
|
<AlertTitle>Warning</AlertTitle>
|
||||||
|
<AlertDescription>Something needs attention.</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Empty states use Empty component
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Empty>
|
||||||
|
<EmptyHeader>
|
||||||
|
<EmptyMedia variant="icon"><FolderIcon /></EmptyMedia>
|
||||||
|
<EmptyTitle>No projects yet</EmptyTitle>
|
||||||
|
<EmptyDescription>Get started by creating a new project.</EmptyDescription>
|
||||||
|
</EmptyHeader>
|
||||||
|
<EmptyContent>
|
||||||
|
<Button>Create Project</Button>
|
||||||
|
</EmptyContent>
|
||||||
|
</Empty>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Toast notifications use sonner
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
toast.success("Changes saved.")
|
||||||
|
toast.error("Something went wrong.")
|
||||||
|
toast("File deleted.", {
|
||||||
|
action: { label: "Undo", onClick: () => undoDelete() },
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Choosing between overlay components
|
||||||
|
|
||||||
|
| Use case | Component |
|
||||||
|
|----------|-----------|
|
||||||
|
| Focused task that requires input | `Dialog` |
|
||||||
|
| Destructive action confirmation | `AlertDialog` |
|
||||||
|
| Side panel with details or filters | `Sheet` |
|
||||||
|
| Mobile-first bottom panel | `Drawer` |
|
||||||
|
| Quick info on hover | `HoverCard` |
|
||||||
|
| Small contextual content on click | `Popover` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dialog, Sheet, and Drawer always need a Title
|
||||||
|
|
||||||
|
`DialogTitle`, `SheetTitle`, `DrawerTitle` are required for accessibility. Use `className="sr-only"` if visually hidden.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Profile</DialogTitle>
|
||||||
|
<DialogDescription>Update your profile.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
...
|
||||||
|
</DialogContent>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Card structure
|
||||||
|
|
||||||
|
Use full composition — don't dump everything into `CardContent`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Team Members</CardTitle>
|
||||||
|
<CardDescription>Manage your team.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>...</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<Button>Invite</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Button has no isPending or isLoading prop
|
||||||
|
|
||||||
|
Compose with `Spinner` + `data-icon` + `disabled`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Button disabled>
|
||||||
|
<Spinner data-icon="inline-start" />
|
||||||
|
Saving...
|
||||||
|
</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TabsTrigger must be inside TabsList
|
||||||
|
|
||||||
|
Never render `TabsTrigger` directly inside `Tabs` — always wrap in `TabsList`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Tabs defaultValue="account">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="account">Account</TabsTrigger>
|
||||||
|
<TabsTrigger value="password">Password</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="account">...</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Avatar always needs AvatarFallback
|
||||||
|
|
||||||
|
Always include `AvatarFallback` for when the image fails to load:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Avatar>
|
||||||
|
<AvatarImage src="/avatar.png" alt="User" />
|
||||||
|
<AvatarFallback>JD</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Use existing components instead of custom markup
|
||||||
|
|
||||||
|
| Instead of | Use |
|
||||||
|
|---|---|
|
||||||
|
| `<hr>` or `<div className="border-t">` | `<Separator />` |
|
||||||
|
| `<div className="animate-pulse">` with styled divs | `<Skeleton className="h-4 w-3/4" />` |
|
||||||
|
| `<span className="rounded-full bg-green-100 ...">` | `<Badge variant="secondary">` |
|
||||||
192
.agents/skills/shadcn/rules/forms.md
Normal file
192
.agents/skills/shadcn/rules/forms.md
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
# Forms & Inputs
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- Forms use FieldGroup + Field
|
||||||
|
- InputGroup requires InputGroupInput/InputGroupTextarea
|
||||||
|
- Buttons inside inputs use InputGroup + InputGroupAddon
|
||||||
|
- Option sets (2–7 choices) use ToggleGroup
|
||||||
|
- FieldSet + FieldLegend for grouping related fields
|
||||||
|
- Field validation and disabled states
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Forms use FieldGroup + Field
|
||||||
|
|
||||||
|
Always use `FieldGroup` + `Field` — never raw `div` with `space-y-*`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<FieldGroup>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||||
|
<Input id="email" type="email" />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="password">Password</FieldLabel>
|
||||||
|
<Input id="password" type="password" />
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `Field orientation="horizontal"` for settings pages. Use `FieldLabel className="sr-only"` for visually hidden labels.
|
||||||
|
|
||||||
|
**Choosing form controls:**
|
||||||
|
|
||||||
|
- Simple text input → `Input`
|
||||||
|
- Dropdown with predefined options → `Select`
|
||||||
|
- Searchable dropdown → `Combobox`
|
||||||
|
- Native HTML select (no JS) → `native-select`
|
||||||
|
- Boolean toggle → `Switch` (for settings) or `Checkbox` (for forms)
|
||||||
|
- Single choice from few options → `RadioGroup`
|
||||||
|
- Toggle between 2–5 options → `ToggleGroup` + `ToggleGroupItem`
|
||||||
|
- OTP/verification code → `InputOTP`
|
||||||
|
- Multi-line text → `Textarea`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## InputGroup requires InputGroupInput/InputGroupTextarea
|
||||||
|
|
||||||
|
Never use raw `Input` or `Textarea` inside an `InputGroup`.
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<InputGroup>
|
||||||
|
<Input placeholder="Search..." />
|
||||||
|
</InputGroup>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { InputGroup, InputGroupInput } from "@/components/ui/input-group"
|
||||||
|
|
||||||
|
<InputGroup>
|
||||||
|
<InputGroupInput placeholder="Search..." />
|
||||||
|
</InputGroup>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Buttons inside inputs use InputGroup + InputGroupAddon
|
||||||
|
|
||||||
|
Never place a `Button` directly inside or adjacent to an `Input` with custom positioning.
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="relative">
|
||||||
|
<Input placeholder="Search..." className="pr-10" />
|
||||||
|
<Button className="absolute right-0 top-0" size="icon">
|
||||||
|
<SearchIcon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { InputGroup, InputGroupInput, InputGroupAddon } from "@/components/ui/input-group"
|
||||||
|
|
||||||
|
<InputGroup>
|
||||||
|
<InputGroupInput placeholder="Search..." />
|
||||||
|
<InputGroupAddon>
|
||||||
|
<Button size="icon">
|
||||||
|
<SearchIcon data-icon="inline-start" />
|
||||||
|
</Button>
|
||||||
|
</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Option sets (2–7 choices) use ToggleGroup
|
||||||
|
|
||||||
|
Don't manually loop `Button` components with active state.
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const [selected, setSelected] = useState("daily")
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{["daily", "weekly", "monthly"].map((option) => (
|
||||||
|
<Button
|
||||||
|
key={option}
|
||||||
|
variant={selected === option ? "default" : "outline"}
|
||||||
|
onClick={() => setSelected(option)}
|
||||||
|
>
|
||||||
|
{option}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
||||||
|
|
||||||
|
<ToggleGroup spacing={2}>
|
||||||
|
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="weekly">Weekly</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="monthly">Monthly</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
|
```
|
||||||
|
|
||||||
|
Combine with `Field` for labelled toggle groups:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Field orientation="horizontal">
|
||||||
|
<FieldTitle id="theme-label">Theme</FieldTitle>
|
||||||
|
<ToggleGroup aria-labelledby="theme-label" spacing={2}>
|
||||||
|
<ToggleGroupItem value="light">Light</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="dark">Dark</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="system">System</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
|
</Field>
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** `defaultValue` and `type`/`multiple` props differ between base and radix. See [base-vs-radix.md](./base-vs-radix.md#togglegroup).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## FieldSet + FieldLegend for grouping related fields
|
||||||
|
|
||||||
|
Use `FieldSet` + `FieldLegend` for related checkboxes, radios, or switches — not `div` with a heading:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<FieldSet>
|
||||||
|
<FieldLegend variant="label">Preferences</FieldLegend>
|
||||||
|
<FieldDescription>Select all that apply.</FieldDescription>
|
||||||
|
<FieldGroup className="gap-3">
|
||||||
|
<Field orientation="horizontal">
|
||||||
|
<Checkbox id="dark" />
|
||||||
|
<FieldLabel htmlFor="dark" className="font-normal">Dark mode</FieldLabel>
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
</FieldSet>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Field validation and disabled states
|
||||||
|
|
||||||
|
Both attributes are needed — `data-invalid`/`data-disabled` styles the field (label, description), while `aria-invalid`/`disabled` styles the control.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Invalid.
|
||||||
|
<Field data-invalid>
|
||||||
|
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||||
|
<Input id="email" aria-invalid />
|
||||||
|
<FieldDescription>Invalid email address.</FieldDescription>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
// Disabled.
|
||||||
|
<Field data-disabled>
|
||||||
|
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||||
|
<Input id="email" disabled />
|
||||||
|
</Field>
|
||||||
|
```
|
||||||
|
|
||||||
|
Works for all controls: `Input`, `Textarea`, `Select`, `Checkbox`, `RadioGroupItem`, `Switch`, `Slider`, `NativeSelect`, `InputOTP`.
|
||||||
101
.agents/skills/shadcn/rules/icons.md
Normal file
101
.agents/skills/shadcn/rules/icons.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# Icons
|
||||||
|
|
||||||
|
**Always use the project's configured `iconLibrary` for imports.** Check the `iconLibrary` field from project context: `lucide` → `lucide-react`, `tabler` → `@tabler/icons-react`, etc. Never assume `lucide-react`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Icons in Button use data-icon attribute
|
||||||
|
|
||||||
|
Add `data-icon="inline-start"` (prefix) or `data-icon="inline-end"` (suffix) to the icon. No sizing classes on the icon.
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Button>
|
||||||
|
<SearchIcon className="mr-2 size-4" />
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Button>
|
||||||
|
<SearchIcon data-icon="inline-start"/>
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button>
|
||||||
|
Next
|
||||||
|
<ArrowRightIcon data-icon="inline-end"/>
|
||||||
|
</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## No sizing classes on icons inside components
|
||||||
|
|
||||||
|
Components handle icon sizing via CSS. Don't add `size-4`, `w-4 h-4`, or other sizing classes to icons inside `Button`, `DropdownMenuItem`, `Alert`, `Sidebar*`, or other shadcn components. Unless the user explicitly asks for custom icon sizes.
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Button>
|
||||||
|
<SearchIcon className="size-4" data-icon="inline-start" />
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<SettingsIcon className="mr-2 size-4" />
|
||||||
|
Settings
|
||||||
|
</DropdownMenuItem>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Button>
|
||||||
|
<SearchIcon data-icon="inline-start" />
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<SettingsIcon />
|
||||||
|
Settings
|
||||||
|
</DropdownMenuItem>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pass icons as component objects, not string keys
|
||||||
|
|
||||||
|
Use `icon={CheckIcon}`, not a string key to a lookup map.
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const iconMap = {
|
||||||
|
check: CheckIcon,
|
||||||
|
alert: AlertIcon,
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ icon }: { icon: string }) {
|
||||||
|
const Icon = iconMap[icon]
|
||||||
|
return <Icon />
|
||||||
|
}
|
||||||
|
|
||||||
|
<StatusBadge icon="check" />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Import from the project's configured iconLibrary (e.g. lucide-react, @tabler/icons-react).
|
||||||
|
import { CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function StatusBadge({ icon: Icon }: { icon: React.ComponentType }) {
|
||||||
|
return <Icon />
|
||||||
|
}
|
||||||
|
|
||||||
|
<StatusBadge icon={CheckIcon} />
|
||||||
|
```
|
||||||
162
.agents/skills/shadcn/rules/styling.md
Normal file
162
.agents/skills/shadcn/rules/styling.md
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
# Styling & Customization
|
||||||
|
|
||||||
|
See [customization.md](../customization.md) for theming, CSS variables, and adding custom colors.
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- Semantic colors
|
||||||
|
- Built-in variants first
|
||||||
|
- className for layout only
|
||||||
|
- No space-x-* / space-y-*
|
||||||
|
- Prefer size-* over w-* h-* when equal
|
||||||
|
- Prefer truncate shorthand
|
||||||
|
- No manual dark: color overrides
|
||||||
|
- Use cn() for conditional classes
|
||||||
|
- No manual z-index on overlay components
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Semantic colors
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="bg-blue-500 text-white">
|
||||||
|
<p className="text-gray-600">Secondary text</p>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="bg-primary text-primary-foreground">
|
||||||
|
<p className="text-muted-foreground">Secondary text</p>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## No raw color values for status/state indicators
|
||||||
|
|
||||||
|
For positive, negative, or status indicators, use Badge variants, semantic tokens like `text-destructive`, or define custom CSS variables — don't reach for raw Tailwind colors.
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<span className="text-emerald-600">+20.1%</span>
|
||||||
|
<span className="text-green-500">Active</span>
|
||||||
|
<span className="text-red-600">-3.2%</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Badge variant="secondary">+20.1%</Badge>
|
||||||
|
<Badge>Active</Badge>
|
||||||
|
<span className="text-destructive">-3.2%</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
If you need a success/positive color that doesn't exist as a semantic token, use a Badge variant or ask the user about adding a custom CSS variable to the theme (see [customization.md](../customization.md)).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Built-in variants first
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Button className="border border-input bg-transparent hover:bg-accent">
|
||||||
|
Click me
|
||||||
|
</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Button variant="outline">Click me</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## className for layout only
|
||||||
|
|
||||||
|
Use `className` for layout (e.g. `max-w-md`, `mx-auto`, `mt-4`), **not** for overriding component colors or typography. To change colors, use semantic tokens, built-in variants, or CSS variables.
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Card className="bg-blue-100 text-blue-900 font-bold">
|
||||||
|
<CardContent>Dashboard</CardContent>
|
||||||
|
</Card>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Card className="max-w-md mx-auto">
|
||||||
|
<CardContent>Dashboard</CardContent>
|
||||||
|
</Card>
|
||||||
|
```
|
||||||
|
|
||||||
|
To customize a component's appearance, prefer these approaches in order:
|
||||||
|
1. **Built-in variants** — `variant="outline"`, `variant="destructive"`, etc.
|
||||||
|
2. **Semantic color tokens** — `bg-primary`, `text-muted-foreground`.
|
||||||
|
3. **CSS variables** — define custom colors in the global CSS file (see [customization.md](../customization.md)).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## No space-x-* / space-y-*
|
||||||
|
|
||||||
|
Use `gap-*` instead. `space-y-4` → `flex flex-col gap-4`. `space-x-2` → `flex gap-2`.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Input />
|
||||||
|
<Input />
|
||||||
|
<Button>Submit</Button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prefer size-* over w-* h-* when equal
|
||||||
|
|
||||||
|
`size-10` not `w-10 h-10`. Applies to icons, avatars, skeletons, etc.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prefer truncate shorthand
|
||||||
|
|
||||||
|
`truncate` not `overflow-hidden text-ellipsis whitespace-nowrap`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## No manual dark: color overrides
|
||||||
|
|
||||||
|
Use semantic tokens — they handle light/dark via CSS variables. `bg-background text-foreground` not `bg-white dark:bg-gray-950`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Use cn() for conditional classes
|
||||||
|
|
||||||
|
Use the `cn()` utility from the project for conditional or merged class names. Don't write manual ternaries in className strings.
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className={`flex items-center ${isActive ? "bg-primary text-primary-foreground" : "bg-muted"}`}>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
<div className={cn("flex items-center", isActive ? "bg-primary text-primary-foreground" : "bg-muted")}>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## No manual z-index on overlay components
|
||||||
|
|
||||||
|
`Dialog`, `Sheet`, `Drawer`, `AlertDialog`, `DropdownMenu`, `Popover`, `Tooltip`, `HoverCard` handle their own stacking. Never add `z-50` or `z-[999]`.
|
||||||
114
.agents/skills/tanstack-query-best-practices/SKILL.md
Normal file
114
.agents/skills/tanstack-query-best-practices/SKILL.md
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
---
|
||||||
|
name: tanstack-query-best-practices
|
||||||
|
description: TanStack Query (React Query) best practices for data fetching, caching, mutations, and server state management. Activate when building data-driven React applications with server state.
|
||||||
|
---
|
||||||
|
|
||||||
|
# TanStack Query Best Practices
|
||||||
|
|
||||||
|
Comprehensive guidelines for implementing TanStack Query (React Query) patterns in React applications. These rules optimize data fetching, caching, mutations, and server state synchronization.
|
||||||
|
|
||||||
|
## When to Apply
|
||||||
|
|
||||||
|
- Creating new data fetching logic
|
||||||
|
- Setting up query configurations
|
||||||
|
- Implementing mutations and optimistic updates
|
||||||
|
- Configuring caching strategies
|
||||||
|
- Integrating with SSR/SSG
|
||||||
|
- Refactoring existing data fetching code
|
||||||
|
|
||||||
|
## Rule Categories by Priority
|
||||||
|
|
||||||
|
| Priority | Category | Rules | Impact |
|
||||||
|
|----------|----------|-------|--------|
|
||||||
|
| CRITICAL | Query Keys | 5 rules | Prevents cache bugs and data inconsistencies |
|
||||||
|
| CRITICAL | Caching | 5 rules | Optimizes performance and data freshness |
|
||||||
|
| HIGH | Mutations | 6 rules | Ensures data integrity and UI consistency |
|
||||||
|
| HIGH | Error Handling | 3 rules | Prevents poor user experiences |
|
||||||
|
| MEDIUM | Prefetching | 4 rules | Improves perceived performance |
|
||||||
|
| MEDIUM | Parallel Queries | 2 rules | Enables dynamic parallel fetching |
|
||||||
|
| MEDIUM | Infinite Queries | 3 rules | Prevents pagination bugs |
|
||||||
|
| MEDIUM | SSR Integration | 4 rules | Enables proper hydration |
|
||||||
|
| LOW | Performance | 4 rules | Reduces unnecessary re-renders |
|
||||||
|
| LOW | Offline Support | 2 rules | Enables offline-first patterns |
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
### Query Keys (Prefix: `qk-`)
|
||||||
|
|
||||||
|
- `qk-array-structure` — Always use arrays for query keys
|
||||||
|
- `qk-include-dependencies` — Include all variables the query depends on
|
||||||
|
- `qk-hierarchical-organization` — Organize keys hierarchically (entity → id → filters)
|
||||||
|
- `qk-factory-pattern` — Use query key factories for complex applications
|
||||||
|
- `qk-serializable` — Ensure all key parts are JSON-serializable
|
||||||
|
|
||||||
|
### Caching (Prefix: `cache-`)
|
||||||
|
|
||||||
|
- `cache-stale-time` — Set appropriate staleTime based on data volatility
|
||||||
|
- `cache-gc-time` — Configure gcTime for inactive query retention
|
||||||
|
- `cache-defaults` — Set sensible defaults at QueryClient level
|
||||||
|
- `cache-invalidation` — Use targeted invalidation over broad patterns
|
||||||
|
- `cache-placeholder-vs-initial` — Understand placeholder vs initial data differences
|
||||||
|
|
||||||
|
### Mutations (Prefix: `mut-`)
|
||||||
|
|
||||||
|
- `mut-invalidate-queries` — Always invalidate related queries after mutations
|
||||||
|
- `mut-optimistic-updates` — Implement optimistic updates for responsive UI
|
||||||
|
- `mut-rollback-context` — Provide rollback context from onMutate
|
||||||
|
- `mut-error-handling` — Handle mutation errors gracefully
|
||||||
|
- `mut-loading-states` — Use isPending for mutation loading states
|
||||||
|
- `mut-mutation-state` — Use useMutationState for cross-component tracking
|
||||||
|
|
||||||
|
### Error Handling (Prefix: `err-`)
|
||||||
|
|
||||||
|
- `err-error-boundaries` — Use error boundaries with useQueryErrorResetBoundary
|
||||||
|
- `err-retry-config` — Configure retry logic appropriately
|
||||||
|
- `err-fallback-data` — Provide fallback data when appropriate
|
||||||
|
|
||||||
|
### Prefetching (Prefix: `pf-`)
|
||||||
|
|
||||||
|
- `pf-intent-prefetch` — Prefetch on user intent (hover, focus)
|
||||||
|
- `pf-route-prefetch` — Prefetch data during route transitions
|
||||||
|
- `pf-stale-time-config` — Set staleTime when prefetching
|
||||||
|
- `pf-ensure-query-data` — Use ensureQueryData for conditional prefetching
|
||||||
|
|
||||||
|
### Infinite Queries (Prefix: `inf-`)
|
||||||
|
|
||||||
|
- `inf-page-params` — Always provide getNextPageParam
|
||||||
|
- `inf-loading-guards` — Check isFetchingNextPage before fetching more
|
||||||
|
- `inf-max-pages` — Consider maxPages for large datasets
|
||||||
|
|
||||||
|
### SSR Integration (Prefix: `ssr-`)
|
||||||
|
|
||||||
|
- `ssr-dehydration` — Use dehydrate/hydrate pattern for SSR
|
||||||
|
- `ssr-client-per-request` — Create QueryClient per request
|
||||||
|
- `ssr-stale-time-server` — Set higher staleTime on server
|
||||||
|
- `ssr-hydration-boundary` — Wrap with HydrationBoundary
|
||||||
|
|
||||||
|
### Parallel Queries (Prefix: `parallel-`)
|
||||||
|
|
||||||
|
- `parallel-use-queries` — Use useQueries for dynamic parallel queries
|
||||||
|
- `query-cancellation` — Implement query cancellation properly
|
||||||
|
|
||||||
|
### Performance (Prefix: `perf-`)
|
||||||
|
|
||||||
|
- `perf-select-transform` — Use select to transform/filter data
|
||||||
|
- `perf-structural-sharing` — Leverage structural sharing
|
||||||
|
- `perf-notify-change-props` — Limit re-renders with notifyOnChangeProps
|
||||||
|
- `perf-placeholder-data` — Use placeholderData for instant UI
|
||||||
|
|
||||||
|
### Offline Support (Prefix: `offline-`)
|
||||||
|
|
||||||
|
- `network-mode` — Configure network mode for offline support
|
||||||
|
- `persist-queries` — Configure query persistence for offline support
|
||||||
|
|
||||||
|
## How to Use
|
||||||
|
|
||||||
|
Each rule file in the `rules/` directory contains:
|
||||||
|
1. **Explanation** — Why this pattern matters
|
||||||
|
2. **Bad Example** — Anti-pattern to avoid
|
||||||
|
3. **Good Example** — Recommended implementation
|
||||||
|
4. **Context** — When to apply or skip this rule
|
||||||
|
|
||||||
|
## Full Reference
|
||||||
|
|
||||||
|
See individual rule files in `rules/` directory for detailed guidance and code examples.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# cache-gc-time: Configure gcTime for Inactive Query Retention
|
||||||
|
|
||||||
|
## Priority: CRITICAL
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
`gcTime` (garbage collection time, formerly `cacheTime`) controls how long inactive queries remain in the cache before being garbage collected. Default is 5 minutes. Configure based on your navigation patterns and memory constraints.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Not considering gcTime for frequently revisited pages
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['dashboard-stats'],
|
||||||
|
queryFn: fetchDashboardStats,
|
||||||
|
// Default gcTime of 5 minutes - might be too short for frequently revisited data
|
||||||
|
})
|
||||||
|
|
||||||
|
// Setting gcTime too high without consideration
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
gcTime: Infinity, // Never garbage collect - potential memory leak
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Setting gcTime to 0 - cache is immediately removed
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['user-data'],
|
||||||
|
queryFn: fetchUserData,
|
||||||
|
gcTime: 0, // Loses cache benefits entirely
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Longer gcTime for frequently revisited data
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['dashboard-stats'],
|
||||||
|
queryFn: fetchDashboardStats,
|
||||||
|
gcTime: 30 * 60 * 1000, // 30 minutes - user returns to dashboard often
|
||||||
|
})
|
||||||
|
|
||||||
|
// Shorter gcTime for rarely revisited large data
|
||||||
|
const { data: report } = useQuery({
|
||||||
|
queryKey: ['detailed-report', reportId],
|
||||||
|
queryFn: () => fetchReport(reportId),
|
||||||
|
gcTime: 2 * 60 * 1000, // 2 minutes - large payload, viewed once
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sensible default with query-specific overrides
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
gcTime: 10 * 60 * 1000, // 10 minutes default
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Understanding gcTime vs staleTime
|
||||||
|
|
||||||
|
```
|
||||||
|
Query Mount → Data Fresh (staleTime) → Data Stale → Query Unmount → gcTime countdown → Garbage Collected
|
||||||
|
|
||||||
|
Timeline example (staleTime: 1min, gcTime: 5min):
|
||||||
|
0:00 - Query mounts, fetches data
|
||||||
|
0:00-1:00 - Data is fresh (no background refetch)
|
||||||
|
1:00+ - Data is stale (background refetch on new mount)
|
||||||
|
5:00 - User navigates away, query unmounts
|
||||||
|
5:00-10:00 - Data in cache but inactive (gcTime countdown)
|
||||||
|
10:00 - Data garbage collected (next mount = full loading state)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Recommended gcTime Values
|
||||||
|
|
||||||
|
| Scenario | gcTime | Rationale |
|
||||||
|
|----------|--------|-----------|
|
||||||
|
| Frequently revisited routes | 15 - 30min | Instant navigation |
|
||||||
|
| Detail pages (viewed once) | 2 - 5min | Memory efficient |
|
||||||
|
| Large payloads | 1 - 2min | Prevent memory bloat |
|
||||||
|
| Critical user data | 30min+ | Offline-like experience |
|
||||||
|
| SSR hydration | >= 2s | Prevent hydration issues |
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- gcTime countdown starts when ALL query observers unmount
|
||||||
|
- Remounting before gcTime expires returns cached data instantly
|
||||||
|
- Setting gcTime < staleTime is rarely useful
|
||||||
|
- For SSR, avoid gcTime: 0 (use minimum 2000ms to allow hydration)
|
||||||
|
- Monitor memory usage in long-running applications
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# cache-invalidation: Use Targeted Invalidation Over Broad Patterns
|
||||||
|
|
||||||
|
## Priority: CRITICAL
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
Query invalidation marks cached data as stale, triggering background refetches. Use targeted invalidation to refresh only affected data. Overly broad invalidation causes unnecessary network requests; too narrow invalidation leaves stale data.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Invalidating everything after a single todo update
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: updateTodo,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries() // Invalidates ENTIRE cache
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Invalidating too broadly
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: updateTodoStatus,
|
||||||
|
onSuccess: () => {
|
||||||
|
// Invalidates all todos including unrelated lists
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Missing invalidation of related queries
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: addComment,
|
||||||
|
onSuccess: () => {
|
||||||
|
// Only invalidates comment list, misses comment count
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['comments', postId] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Targeted invalidation with exact matching
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: updateTodo,
|
||||||
|
onSuccess: (data, variables) => {
|
||||||
|
// Invalidate specific todo and related queries
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos', variables.id] })
|
||||||
|
// Also invalidate lists that might contain this todo
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos', 'list'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Use exact: true when you only want one specific query
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: updateUserProfile,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['user', 'profile'],
|
||||||
|
exact: true, // Only this exact key, not ['user', 'profile', 'settings']
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Invalidate multiple related queries
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: addComment,
|
||||||
|
onSuccess: (data, { postId }) => {
|
||||||
|
// Invalidate all comment-related queries for this post
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['posts', postId, 'comments'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['posts', postId, 'comment-count'] })
|
||||||
|
// Optionally invalidate the post itself if it shows comment count
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['posts', postId] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Predicate-based invalidation for complex scenarios
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
predicate: (query) =>
|
||||||
|
query.queryKey[0] === 'todos' &&
|
||||||
|
query.state.data?.userId === currentUserId,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Invalidation Patterns
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Prefix matching (default) - invalidates all matching prefixes
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos'] })
|
||||||
|
// Matches: ['todos'], ['todos', 1], ['todos', { status: 'done' }]
|
||||||
|
|
||||||
|
// Exact matching - only the exact key
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos'], exact: true })
|
||||||
|
// Matches: ['todos'] only
|
||||||
|
|
||||||
|
// Predicate matching - custom logic
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
predicate: (query) => query.queryKey.includes('user-generated'),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Refetch type control
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['todos'],
|
||||||
|
refetchType: 'active', // Only refetch active queries (default)
|
||||||
|
// refetchType: 'inactive' - Only inactive
|
||||||
|
// refetchType: 'all' - Both
|
||||||
|
// refetchType: 'none' - Mark stale but don't refetch
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Invalidation only marks queries as stale; refetch happens when query is used
|
||||||
|
- `refetchType: 'active'` (default) only refetches queries with active observers
|
||||||
|
- Use hierarchical query keys to enable precise invalidation
|
||||||
|
- Consider `setQueryData` for optimistic updates instead of invalidation
|
||||||
|
- Always test invalidation patterns to ensure all affected queries are refreshed
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
# cache-placeholder-vs-initial: Understand Placeholder vs Initial Data
|
||||||
|
|
||||||
|
## Priority: MEDIUM
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
`placeholderData` and `initialData` both provide data before the fetch completes, but behave differently. `initialData` is treated as real cached data, while `placeholderData` is temporary and doesn't persist to cache. Choose based on whether your fallback data should be cached.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Using initialData when you don't want it cached
|
||||||
|
function PostPreview({ postId, previewData }: Props) {
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['posts', postId],
|
||||||
|
queryFn: () => fetchPost(postId),
|
||||||
|
initialData: previewData, // Wrong: this becomes cached "truth"
|
||||||
|
// If previewData is incomplete, it pollutes the cache
|
||||||
|
// staleTime applies to this data as if it were fetched
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Using placeholderData when you want persistence
|
||||||
|
function UserProfile({ userId }: Props) {
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['users', userId],
|
||||||
|
queryFn: () => fetchUser(userId),
|
||||||
|
placeholderData: cachedUserFromList, // Wrong: won't persist
|
||||||
|
// User navigates away and back - placeholder shown again
|
||||||
|
// No cache entry created until fetch completes
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: placeholderData for Temporary Display
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Show list data while fetching detail
|
||||||
|
function PostDetail({ postId }: { postId: string }) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const { data, isPlaceholderData } = useQuery({
|
||||||
|
queryKey: ['posts', postId],
|
||||||
|
queryFn: () => fetchPost(postId),
|
||||||
|
placeholderData: () => {
|
||||||
|
// Use partial data from list cache as placeholder
|
||||||
|
const posts = queryClient.getQueryData<Post[]>(['posts'])
|
||||||
|
return posts?.find(p => p.id === postId)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className={isPlaceholderData ? 'opacity-50' : ''}>
|
||||||
|
<h1>{data?.title}</h1>
|
||||||
|
{isPlaceholderData ? (
|
||||||
|
<p>Loading full content...</p>
|
||||||
|
) : (
|
||||||
|
<div>{data?.content}</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: initialData for Known Good Data
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// SSR: Data fetched on server should be initial
|
||||||
|
function PostPage({ serverData }: { serverData: Post }) {
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['posts', serverData.id],
|
||||||
|
queryFn: () => fetchPost(serverData.id),
|
||||||
|
initialData: serverData,
|
||||||
|
// Specify when this data was fetched for proper stale calculation
|
||||||
|
initialDataUpdatedAt: serverData.fetchedAt,
|
||||||
|
})
|
||||||
|
|
||||||
|
return <PostContent post={data} />
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-seeding cache with complete data
|
||||||
|
function App() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
// If you have complete, authoritative data
|
||||||
|
useEffect(() => {
|
||||||
|
queryClient.setQueryData(['config'], completeConfigData)
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: keepPreviousData Pattern
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Keep showing old data while fetching new (pagination, filters)
|
||||||
|
function ProductList({ page }: { page: number }) {
|
||||||
|
const { data, isPlaceholderData } = useQuery({
|
||||||
|
queryKey: ['products', page],
|
||||||
|
queryFn: () => fetchProducts(page),
|
||||||
|
placeholderData: keepPreviousData, // Built-in helper
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={isPlaceholderData ? 'opacity-70' : ''}>
|
||||||
|
{data?.map(product => (
|
||||||
|
<ProductCard key={product.id} product={product} />
|
||||||
|
))}
|
||||||
|
{isPlaceholderData && <LoadingOverlay />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Comparison Table
|
||||||
|
|
||||||
|
| Behavior | `initialData` | `placeholderData` |
|
||||||
|
|----------|---------------|-------------------|
|
||||||
|
| Persisted to cache | Yes | No |
|
||||||
|
| `staleTime` applies | Yes | No (always fetches) |
|
||||||
|
| `isPlaceholderData` | `false` | `true` |
|
||||||
|
| Shown to other components | Yes (cached) | No |
|
||||||
|
| Use case | SSR, complete known data | Preview, previous page |
|
||||||
|
| Affects `dataUpdatedAt` | Yes (use `initialDataUpdatedAt`) | No |
|
||||||
|
|
||||||
|
## Good Example: Combining Both
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function PostDetail({ postId, ssrData }: Props) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['posts', postId],
|
||||||
|
queryFn: () => fetchPost(postId),
|
||||||
|
|
||||||
|
// If we have SSR data, use as initial (cached)
|
||||||
|
initialData: ssrData,
|
||||||
|
initialDataUpdatedAt: ssrData?.fetchedAt,
|
||||||
|
|
||||||
|
// If no SSR data, try to use list preview as placeholder
|
||||||
|
placeholderData: () => {
|
||||||
|
if (ssrData) return undefined // Already have initial
|
||||||
|
const posts = queryClient.getQueryData<Post[]>(['posts'])
|
||||||
|
return posts?.find(p => p.id === postId)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- `placeholderData` can be a value or function (lazy evaluation)
|
||||||
|
- `initialData` affects cache immediately on query creation
|
||||||
|
- Use `initialDataUpdatedAt` with `initialData` for proper stale calculations
|
||||||
|
- `keepPreviousData` is a built-in placeholder strategy
|
||||||
|
- Check `isPlaceholderData` to show loading indicators
|
||||||
|
- `placeholderData` is ideal for "instant" UI while fetching
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# cache-stale-time: Set Appropriate staleTime Based on Data Volatility
|
||||||
|
|
||||||
|
## Priority: CRITICAL
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
`staleTime` determines how long data is considered fresh. The default is 0ms, meaning data is immediately stale and will refetch on every new query mount. Set appropriate staleTime based on how often your data actually changes to reduce unnecessary network requests.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Default staleTime of 0 - refetches on every component mount
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['user-profile', userId],
|
||||||
|
queryFn: () => fetchUserProfile(userId),
|
||||||
|
// No staleTime set - always considered stale
|
||||||
|
})
|
||||||
|
|
||||||
|
// User profile probably doesn't change every second
|
||||||
|
// This causes unnecessary API calls on navigation
|
||||||
|
|
||||||
|
// Setting same staleTime everywhere regardless of data type
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 60 * 1000, // 1 minute for everything - too simple
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Match staleTime to data volatility
|
||||||
|
const { data: profile } = useQuery({
|
||||||
|
queryKey: ['user-profile', userId],
|
||||||
|
queryFn: () => fetchUserProfile(userId),
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 minutes - profile rarely changes
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: notifications } = useQuery({
|
||||||
|
queryKey: ['notifications'],
|
||||||
|
queryFn: fetchNotifications,
|
||||||
|
staleTime: 30 * 1000, // 30 seconds - changes more frequently
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: stockPrice } = useQuery({
|
||||||
|
queryKey: ['stock', symbol],
|
||||||
|
queryFn: () => fetchStockPrice(symbol),
|
||||||
|
staleTime: 0, // Real-time data - always refetch
|
||||||
|
})
|
||||||
|
|
||||||
|
// Set sensible defaults, override per-query
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 60 * 1000, // 1 minute default
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Recommended staleTime Values
|
||||||
|
|
||||||
|
| Data Type | staleTime | Rationale |
|
||||||
|
|-----------|-----------|-----------|
|
||||||
|
| Real-time (stocks, live feeds) | 0 | Must always be current |
|
||||||
|
| Frequently changing (notifications) | 30s - 1min | Balance freshness and requests |
|
||||||
|
| User-generated content | 1 - 5min | Changes on user action |
|
||||||
|
| Reference data (categories, config) | 10 - 30min | Rarely changes |
|
||||||
|
| Static content | Infinity | Never changes |
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- `staleTime: 0` (default) triggers background refetch on every mount
|
||||||
|
- `staleTime: Infinity` never considers data stale (manual invalidation only)
|
||||||
|
- Stale data is still returned instantly - refetch happens in background
|
||||||
|
- For SSR, set higher staleTime to avoid immediate client refetch
|
||||||
|
- Consider using `queryOptions` factory to centralize staleTime per data type
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# err-error-boundaries: Use Error Boundaries with useQueryErrorResetBoundary
|
||||||
|
|
||||||
|
## Priority: HIGH
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
When using Suspense with TanStack Query, errors propagate to error boundaries. Use `useQueryErrorResetBoundary` to reset query errors when users retry, preventing stuck error states.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Error boundary without query reset - retry may not work
|
||||||
|
function ErrorBoundary({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<ReactErrorBoundary
|
||||||
|
fallbackRender={({ error, resetErrorBoundary }) => (
|
||||||
|
<div>
|
||||||
|
<p>Error: {error.message}</p>
|
||||||
|
<button onClick={resetErrorBoundary}>Try again</button>
|
||||||
|
{/* resetErrorBoundary alone doesn't reset query state */}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ReactErrorBoundary>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query error persists after retry click
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useQueryErrorResetBoundary } from '@tanstack/react-query'
|
||||||
|
import { ErrorBoundary } from 'react-error-boundary'
|
||||||
|
|
||||||
|
function QueryErrorBoundary({ children }: { children: React.ReactNode }) {
|
||||||
|
const { reset } = useQueryErrorResetBoundary()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ErrorBoundary
|
||||||
|
onReset={reset}
|
||||||
|
fallbackRender={({ error, resetErrorBoundary }) => (
|
||||||
|
<div className="error-container">
|
||||||
|
<h2>Something went wrong</h2>
|
||||||
|
<pre>{error.message}</pre>
|
||||||
|
<button onClick={resetErrorBoundary}>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ErrorBoundary>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage with Suspense
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<QueryErrorBoundary>
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<Posts />
|
||||||
|
</Suspense>
|
||||||
|
</QueryErrorBoundary>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Posts() {
|
||||||
|
// useSuspenseQuery throws on error, caught by boundary
|
||||||
|
const { data } = useSuspenseQuery({
|
||||||
|
queryKey: ['posts'],
|
||||||
|
queryFn: fetchPosts,
|
||||||
|
})
|
||||||
|
|
||||||
|
return <PostList posts={data} />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: With TanStack Router
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Route-level error handling
|
||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { useQueryErrorResetBoundary } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/posts')({
|
||||||
|
loader: ({ context: { queryClient } }) =>
|
||||||
|
queryClient.ensureQueryData(postQueries.list()),
|
||||||
|
|
||||||
|
errorComponent: ({ error, reset }) => {
|
||||||
|
const { reset: resetQuery } = useQueryErrorResetBoundary()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p>Failed to load posts: {error.message}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
resetQuery()
|
||||||
|
reset()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
component: PostsPage,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Boundary Placement Strategy
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Granular error boundaries for isolated failures
|
||||||
|
function Dashboard() {
|
||||||
|
return (
|
||||||
|
<div className="dashboard">
|
||||||
|
{/* Each section can fail independently */}
|
||||||
|
<QueryErrorBoundary>
|
||||||
|
<Suspense fallback={<Skeleton />}>
|
||||||
|
<RecentActivity />
|
||||||
|
</Suspense>
|
||||||
|
</QueryErrorBoundary>
|
||||||
|
|
||||||
|
<QueryErrorBoundary>
|
||||||
|
<Suspense fallback={<Skeleton />}>
|
||||||
|
<Statistics />
|
||||||
|
</Suspense>
|
||||||
|
</QueryErrorBoundary>
|
||||||
|
|
||||||
|
<QueryErrorBoundary>
|
||||||
|
<Suspense fallback={<Skeleton />}>
|
||||||
|
<Notifications />
|
||||||
|
</Suspense>
|
||||||
|
</QueryErrorBoundary>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- `useQueryErrorResetBoundary` clears error state for all queries in the boundary
|
||||||
|
- Always pair Suspense queries with error boundaries
|
||||||
|
- Place boundaries based on failure isolation needs
|
||||||
|
- Consider inline error handling for non-critical data
|
||||||
|
- The reset only affects queries that were in error state
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# inf-page-params: Always Provide getNextPageParam for Infinite Queries
|
||||||
|
|
||||||
|
## Priority: MEDIUM
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
`useInfiniteQuery` requires `getNextPageParam` to determine how to fetch subsequent pages. This function receives the last page's data and must return the next page parameter, or `undefined` when there are no more pages.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Missing getNextPageParam - can't load more pages
|
||||||
|
const { data, fetchNextPage } = useInfiniteQuery({
|
||||||
|
queryKey: ['posts'],
|
||||||
|
queryFn: ({ pageParam }) => fetchPosts(pageParam),
|
||||||
|
initialPageParam: 1,
|
||||||
|
// Missing getNextPageParam - fetchNextPage won't work correctly
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Offset-Based Pagination
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
} = useInfiniteQuery({
|
||||||
|
queryKey: ['posts'],
|
||||||
|
queryFn: ({ pageParam }) => fetchPosts({ page: pageParam, limit: 20 }),
|
||||||
|
initialPageParam: 1,
|
||||||
|
getNextPageParam: (lastPage, allPages) => {
|
||||||
|
// Return next page number, or undefined if no more pages
|
||||||
|
if (lastPage.length < 20) {
|
||||||
|
return undefined // No more pages
|
||||||
|
}
|
||||||
|
return allPages.length + 1
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Cursor-Based Pagination
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface PostsResponse {
|
||||||
|
posts: Post[]
|
||||||
|
nextCursor: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||||
|
queryKey: ['posts'],
|
||||||
|
queryFn: ({ pageParam }): Promise<PostsResponse> =>
|
||||||
|
fetchPosts({ cursor: pageParam }),
|
||||||
|
initialPageParam: undefined as string | undefined,
|
||||||
|
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Bi-directional Pagination
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const { data, fetchNextPage, fetchPreviousPage, hasNextPage, hasPreviousPage } =
|
||||||
|
useInfiniteQuery({
|
||||||
|
queryKey: ['messages', chatId],
|
||||||
|
queryFn: ({ pageParam }) => fetchMessages({ chatId, cursor: pageParam }),
|
||||||
|
initialPageParam: { direction: 'initial' } as PageParam,
|
||||||
|
getNextPageParam: (lastPage) =>
|
||||||
|
lastPage.hasMore ? { cursor: lastPage.nextCursor, direction: 'next' } : undefined,
|
||||||
|
getPreviousPageParam: (firstPage) =>
|
||||||
|
firstPage.hasPrevious
|
||||||
|
? { cursor: firstPage.prevCursor, direction: 'prev' }
|
||||||
|
: undefined,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: With Total Count
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface PaginatedResponse<T> {
|
||||||
|
items: T[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, hasNextPage } = useInfiniteQuery({
|
||||||
|
queryKey: ['products', filters],
|
||||||
|
queryFn: ({ pageParam }) =>
|
||||||
|
fetchProducts({ ...filters, page: pageParam, pageSize: 20 }),
|
||||||
|
initialPageParam: 1,
|
||||||
|
getNextPageParam: (lastPage) => {
|
||||||
|
const totalPages = Math.ceil(lastPage.total / lastPage.pageSize)
|
||||||
|
if (lastPage.page < totalPages) {
|
||||||
|
return lastPage.page + 1
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Accessing Flattened Data
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// data.pages is an array of page responses
|
||||||
|
// Flatten for easier iteration
|
||||||
|
const allPosts = data?.pages.flatMap(page => page.posts) ?? []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{allPosts.map(post => (
|
||||||
|
<PostCard key={post.id} post={post} />
|
||||||
|
))}
|
||||||
|
{hasNextPage && (
|
||||||
|
<button
|
||||||
|
onClick={() => fetchNextPage()}
|
||||||
|
disabled={isFetchingNextPage}
|
||||||
|
>
|
||||||
|
{isFetchingNextPage ? 'Loading...' : 'Load More'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- `getNextPageParam` returning `undefined` sets `hasNextPage` to `false`
|
||||||
|
- For bi-directional scrolling, also provide `getPreviousPageParam`
|
||||||
|
- `initialPageParam` is required and sets the first page parameter
|
||||||
|
- Use `maxPages` option to limit stored pages for memory management
|
||||||
|
- Consider `select` to transform page structure for component consumption
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# mut-invalidate-queries: Always Invalidate Related Queries After Mutations
|
||||||
|
|
||||||
|
## Priority: HIGH
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
After mutations, invalidate all queries whose data might be affected. This ensures the cache stays synchronized with the server. Forgetting to invalidate related queries leads to stale UI data.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// No invalidation - cache remains stale
|
||||||
|
const createTodo = useMutation({
|
||||||
|
mutationFn: (newTodo) => api.createTodo(newTodo),
|
||||||
|
// Missing onSuccess handler - todo list won't show new item
|
||||||
|
})
|
||||||
|
|
||||||
|
// Partial invalidation - misses related queries
|
||||||
|
const deleteTodo = useMutation({
|
||||||
|
mutationFn: (todoId) => api.deleteTodo(todoId),
|
||||||
|
onSuccess: () => {
|
||||||
|
// Only invalidates list, not summary/counts
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos', 'list'] })
|
||||||
|
// Missing: ['todos', 'count'], ['todos', 'completed-count'], etc.
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Comprehensive invalidation
|
||||||
|
const createTodo = useMutation({
|
||||||
|
mutationFn: (newTodo) => api.createTodo(newTodo),
|
||||||
|
onSuccess: () => {
|
||||||
|
// Invalidate all todo-related queries
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Targeted invalidation with all affected queries
|
||||||
|
const updateTodo = useMutation({
|
||||||
|
mutationFn: ({ id, data }) => api.updateTodo(id, data),
|
||||||
|
onSuccess: (data, { id }) => {
|
||||||
|
// Specific todo
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos', id] })
|
||||||
|
// Lists that might contain this todo
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos', 'list'] })
|
||||||
|
// If todo status changed, invalidate filtered views
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos', 'completed'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos', 'active'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Cross-entity invalidation
|
||||||
|
const assignTodoToUser = useMutation({
|
||||||
|
mutationFn: ({ todoId, userId }) => api.assignTodo(todoId, userId),
|
||||||
|
onSuccess: (data, { todoId, userId }) => {
|
||||||
|
// Invalidate the todo
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos', todoId] })
|
||||||
|
// Invalidate user's assigned todos
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['users', userId, 'todos'] })
|
||||||
|
// Invalidate previous assignee's list if available
|
||||||
|
if (data.previousAssignee) {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['users', data.previousAssignee, 'todos'],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pattern: Mutation with Variables Access
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: updatePost,
|
||||||
|
onSuccess: (
|
||||||
|
data, // Server response
|
||||||
|
variables, // What you passed to mutate()
|
||||||
|
context // What onMutate returned
|
||||||
|
) => {
|
||||||
|
// Use variables to know which queries to invalidate
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['posts', variables.id] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['posts', 'list', variables.category] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pattern: Invalidate or Update Directly
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Option 1: Invalidate and refetch
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos'] })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Option 2: Update cache directly (no network request)
|
||||||
|
onSuccess: (newTodo) => {
|
||||||
|
queryClient.setQueryData(['todos'], (old: Todo[]) => [...old, newTodo])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Option 3: Hybrid - update one, invalidate others
|
||||||
|
onSuccess: (newTodo) => {
|
||||||
|
// Immediately add to list
|
||||||
|
queryClient.setQueryData(['todos', 'list'], (old: Todo[]) => [...old, newTodo])
|
||||||
|
// Invalidate counts/summaries for eventual consistency
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos', 'count'] })
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Place invalidation in `onSuccess` for successful mutations
|
||||||
|
- Use `onSettled` if you want to invalidate regardless of success/failure
|
||||||
|
- Think about all UI surfaces that display related data
|
||||||
|
- For complex relationships, consider a centralized invalidation helper
|
||||||
|
- Using hierarchical query keys makes this easier (see `qk-hierarchical-organization`)
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
# mut-mutation-state: Use useMutationState for Cross-Component Mutation Tracking
|
||||||
|
|
||||||
|
## Priority: MEDIUM
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
`useMutationState` allows you to access mutation state from anywhere in your component tree, not just where `useMutation` was called. Use it to show loading indicators, display optimistic updates, or track pending mutations across components.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Prop drilling mutation state
|
||||||
|
function App() {
|
||||||
|
const mutation = useMutation({ mutationFn: createPost })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Header isPending={mutation.isPending} />
|
||||||
|
<Sidebar isPending={mutation.isPending} />
|
||||||
|
<Content mutation={mutation} />
|
||||||
|
<Footer isPending={mutation.isPending} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Or using context for every mutation
|
||||||
|
const MutationContext = createContext<UseMutationResult | null>(null)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Define mutation with a key
|
||||||
|
const useCreatePost = () => useMutation({
|
||||||
|
mutationKey: ['create-post'],
|
||||||
|
mutationFn: createPost,
|
||||||
|
})
|
||||||
|
|
||||||
|
// In the component that triggers mutation
|
||||||
|
function CreatePostButton() {
|
||||||
|
const mutation = useCreatePost()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button onClick={() => mutation.mutate(newPost)}>
|
||||||
|
Create Post
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// In any other component - track mutation state
|
||||||
|
function GlobalLoadingIndicator() {
|
||||||
|
const pendingMutations = useMutationState({
|
||||||
|
filters: { status: 'pending' },
|
||||||
|
select: (mutation) => mutation.state.variables,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (pendingMutations.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="global-loading">
|
||||||
|
Saving {pendingMutations.length} item(s)...
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Optimistic UI in Separate Component
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Mutation defined in form
|
||||||
|
function TodoForm() {
|
||||||
|
const createTodo = useMutation({
|
||||||
|
mutationKey: ['create-todo'],
|
||||||
|
mutationFn: (todo: NewTodo) => api.createTodo(todo),
|
||||||
|
})
|
||||||
|
|
||||||
|
return <form onSubmit={...}>...</form>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optimistic display in list (different component)
|
||||||
|
function TodoList() {
|
||||||
|
const { data: todos } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos })
|
||||||
|
|
||||||
|
// Get pending todo creations
|
||||||
|
const pendingTodos = useMutationState({
|
||||||
|
filters: {
|
||||||
|
mutationKey: ['create-todo'],
|
||||||
|
status: 'pending',
|
||||||
|
},
|
||||||
|
select: (mutation) => mutation.state.variables as NewTodo,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul>
|
||||||
|
{/* Existing todos */}
|
||||||
|
{todos?.map(todo => (
|
||||||
|
<TodoItem key={todo.id} todo={todo} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Optimistic todos (pending creation) */}
|
||||||
|
{pendingTodos.map((todo, index) => (
|
||||||
|
<TodoItem
|
||||||
|
key={`pending-${index}`}
|
||||||
|
todo={{ ...todo, id: `temp-${index}` }}
|
||||||
|
isPending
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Track Specific Mutations
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function PostActions({ postId }: { postId: string }) {
|
||||||
|
// Track if THIS post is being deleted
|
||||||
|
const isDeletingThisPost = useMutationState({
|
||||||
|
filters: {
|
||||||
|
mutationKey: ['delete-post', postId],
|
||||||
|
status: 'pending',
|
||||||
|
},
|
||||||
|
select: () => true,
|
||||||
|
}).length > 0
|
||||||
|
|
||||||
|
// Track if THIS post is being updated
|
||||||
|
const isUpdatingThisPost = useMutationState({
|
||||||
|
filters: {
|
||||||
|
mutationKey: ['update-post', postId],
|
||||||
|
status: 'pending',
|
||||||
|
},
|
||||||
|
select: () => true,
|
||||||
|
}).length > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button disabled={isDeletingThisPost || isUpdatingThisPost}>
|
||||||
|
{isDeletingThisPost ? 'Deleting...' : 'Delete'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Filters Reference
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
useMutationState({
|
||||||
|
filters: {
|
||||||
|
mutationKey: ['key'], // Match mutation key
|
||||||
|
status: 'pending', // 'idle' | 'pending' | 'success' | 'error'
|
||||||
|
predicate: (mutation) => bool, // Custom filter function
|
||||||
|
},
|
||||||
|
select: (mutation) => {
|
||||||
|
// Transform each matching mutation
|
||||||
|
// mutation.state contains: variables, data, error, status, etc.
|
||||||
|
return mutation.state.variables
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Requires `mutationKey` on mutations you want to track
|
||||||
|
- Returns array of selected values from matching mutations
|
||||||
|
- Updates reactively as mutations progress
|
||||||
|
- Use `status` filter to track pending/success/error states
|
||||||
|
- Enables optimistic UI without prop drilling
|
||||||
|
- Pairs with `mutationKey` arrays for granular tracking (e.g., `['delete-post', postId]`)
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# mut-optimistic-updates: Implement Optimistic Updates for Responsive UI
|
||||||
|
|
||||||
|
## Priority: HIGH
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
Optimistic updates immediately reflect changes in the UI before the server confirms them, creating a snappy user experience. Implement them for user-initiated mutations where the expected outcome is predictable.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// No optimistic update - UI waits for server response
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: toggleTodoComplete,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// User clicks checkbox, waits 200-500ms for visual feedback
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Via Cache Manipulation
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: toggleTodoComplete,
|
||||||
|
onMutate: async (todoId) => {
|
||||||
|
// 1. Cancel outgoing refetches to prevent overwriting optimistic update
|
||||||
|
await queryClient.cancelQueries({ queryKey: ['todos'] })
|
||||||
|
|
||||||
|
// 2. Snapshot previous value for potential rollback
|
||||||
|
const previousTodos = queryClient.getQueryData(['todos'])
|
||||||
|
|
||||||
|
// 3. Optimistically update the cache
|
||||||
|
queryClient.setQueryData(['todos'], (old: Todo[]) =>
|
||||||
|
old.map((todo) =>
|
||||||
|
todo.id === todoId ? { ...todo, completed: !todo.completed } : todo
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// 4. Return context for rollback
|
||||||
|
return { previousTodos }
|
||||||
|
},
|
||||||
|
onError: (err, todoId, context) => {
|
||||||
|
// Rollback on error
|
||||||
|
queryClient.setQueryData(['todos'], context?.previousTodos)
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
// Refetch to ensure consistency regardless of success/failure
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Via UI Variables (Simpler)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// When mutation only affects local UI, use mutation state directly
|
||||||
|
function TodoItem({ todo }: { todo: Todo }) {
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: toggleTodoComplete,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Show optimistic state while pending
|
||||||
|
const displayCompleted = mutation.isPending
|
||||||
|
? !todo.completed // Optimistic: show toggled state
|
||||||
|
: todo.completed // Settled: show actual state
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={displayCompleted}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
onChange={() => mutation.mutate(todo.id)}
|
||||||
|
/>
|
||||||
|
<span style={{ opacity: mutation.isPending ? 0.5 : 1 }}>
|
||||||
|
{todo.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Optimistic Create with Temporary ID
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const createTodo = useMutation({
|
||||||
|
mutationFn: (newTodo: CreateTodoInput) => api.createTodo(newTodo),
|
||||||
|
onMutate: async (newTodo) => {
|
||||||
|
await queryClient.cancelQueries({ queryKey: ['todos'] })
|
||||||
|
const previousTodos = queryClient.getQueryData(['todos'])
|
||||||
|
|
||||||
|
// Add with temporary ID
|
||||||
|
const optimisticTodo = {
|
||||||
|
id: `temp-${Date.now()}`,
|
||||||
|
...newTodo,
|
||||||
|
completed: false,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
|
||||||
|
queryClient.setQueryData(['todos'], (old: Todo[]) => [...old, optimisticTodo])
|
||||||
|
|
||||||
|
return { previousTodos, optimisticTodo }
|
||||||
|
},
|
||||||
|
onError: (err, newTodo, context) => {
|
||||||
|
queryClient.setQueryData(['todos'], context?.previousTodos)
|
||||||
|
},
|
||||||
|
onSuccess: (data, variables, context) => {
|
||||||
|
// Replace temp todo with real one
|
||||||
|
queryClient.setQueryData(['todos'], (old: Todo[]) =>
|
||||||
|
old.map((todo) =>
|
||||||
|
todo.id === context?.optimisticTodo.id ? data : todo
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## When to Use Each Approach
|
||||||
|
|
||||||
|
| Approach | Use When |
|
||||||
|
|----------|----------|
|
||||||
|
| Cache Manipulation | Update appears in multiple places, complex data structures |
|
||||||
|
| UI Variables | Update only visible in one component, simpler implementation |
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Always provide rollback logic in `onError`
|
||||||
|
- Cancel queries before optimistic update to prevent race conditions
|
||||||
|
- Call `invalidateQueries` in `onSettled` to sync with server truth
|
||||||
|
- For forms, consider if validation should block optimistic display
|
||||||
|
- Test error scenarios to verify rollback works correctly
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
# network-mode: Configure Network Mode for Offline Support
|
||||||
|
|
||||||
|
## Priority: LOW
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
TanStack Query's `networkMode` controls how queries and mutations behave when there's no network connection. Configure it based on your app's offline requirements: always fetch, pause when offline, or work entirely offline.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Not considering offline behavior
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['todos'],
|
||||||
|
queryFn: fetchTodos,
|
||||||
|
// Default networkMode: 'online'
|
||||||
|
// Query pauses with no feedback when offline
|
||||||
|
})
|
||||||
|
|
||||||
|
// User goes offline, sees stale data with no indication
|
||||||
|
// Mutations silently queue with no UI feedback
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Default Online Mode with Offline UI
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Show clear offline state to users
|
||||||
|
function TodoList() {
|
||||||
|
const { data, fetchStatus, status } = useQuery({
|
||||||
|
queryKey: ['todos'],
|
||||||
|
queryFn: fetchTodos,
|
||||||
|
networkMode: 'online', // Default - pauses when offline
|
||||||
|
})
|
||||||
|
|
||||||
|
// fetchStatus: 'fetching' | 'paused' | 'idle'
|
||||||
|
// 'paused' means waiting for network
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{fetchStatus === 'paused' && (
|
||||||
|
<Banner>You're offline. Showing cached data.</Banner>
|
||||||
|
)}
|
||||||
|
<TodoItems todos={data} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Always Mode for Offline-First
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// App works offline with local data
|
||||||
|
const { data, error } = useQuery({
|
||||||
|
queryKey: ['todos'],
|
||||||
|
queryFn: async () => {
|
||||||
|
// Try network first
|
||||||
|
try {
|
||||||
|
const todos = await fetchTodosFromServer()
|
||||||
|
await saveToLocalDB(todos) // Sync to local
|
||||||
|
return todos
|
||||||
|
} catch (e) {
|
||||||
|
// Fall back to local data
|
||||||
|
return getFromLocalDB()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
networkMode: 'always', // Always runs queryFn, even offline
|
||||||
|
})
|
||||||
|
|
||||||
|
// Or set globally
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
networkMode: 'always',
|
||||||
|
},
|
||||||
|
mutations: {
|
||||||
|
networkMode: 'always',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Offline-First Mode
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Only fetch when online, but don't fail when offline
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['user-preferences'],
|
||||||
|
queryFn: fetchPreferences,
|
||||||
|
networkMode: 'offlineFirst',
|
||||||
|
// Runs queryFn once, then waits for network if it fails
|
||||||
|
// Good for: data that's useful to attempt offline
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Mutation Offline Queue
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function TodoApp() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const addTodo = useMutation({
|
||||||
|
mutationFn: createTodo,
|
||||||
|
networkMode: 'online', // Pauses when offline
|
||||||
|
onMutate: async (newTodo) => {
|
||||||
|
// Optimistic update works offline
|
||||||
|
await queryClient.cancelQueries({ queryKey: ['todos'] })
|
||||||
|
const previous = queryClient.getQueryData(['todos'])
|
||||||
|
queryClient.setQueryData(['todos'], (old: Todo[]) => [...old, newTodo])
|
||||||
|
return { previous }
|
||||||
|
},
|
||||||
|
onError: (err, newTodo, context) => {
|
||||||
|
queryClient.setQueryData(['todos'], context?.previous)
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Track paused mutations
|
||||||
|
const pendingMutations = useMutationState({
|
||||||
|
filters: { status: 'pending' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const pausedMutations = pendingMutations.filter(
|
||||||
|
m => m.state.isPaused
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{pausedMutations.length > 0 && (
|
||||||
|
<Banner>
|
||||||
|
{pausedMutations.length} changes waiting to sync
|
||||||
|
</Banner>
|
||||||
|
)}
|
||||||
|
<TodoList />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Network Mode Comparison
|
||||||
|
|
||||||
|
| Mode | Behavior | Use Case |
|
||||||
|
|------|----------|----------|
|
||||||
|
| `'online'` (default) | Pauses when offline, resumes when online | Most apps, show offline state |
|
||||||
|
| `'always'` | Always runs queryFn regardless of network | Offline-first apps, local-only data |
|
||||||
|
| `'offlineFirst'` | Tries once, then waits for network if fails | Best-effort offline |
|
||||||
|
|
||||||
|
## Good Example: Online Status Detection
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { onlineManager } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
// React to online/offline changes
|
||||||
|
function NetworkStatus() {
|
||||||
|
const isOnline = useSyncExternalStore(
|
||||||
|
onlineManager.subscribe,
|
||||||
|
() => onlineManager.isOnline(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={isOnline ? 'online' : 'offline'}>
|
||||||
|
{isOnline ? 'Connected' : 'Offline'}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manually override online detection (for testing)
|
||||||
|
onlineManager.setOnline(false)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Default `'online'` mode is best for most apps
|
||||||
|
- `fetchStatus: 'paused'` indicates waiting for network
|
||||||
|
- Mutations queue automatically and retry when back online
|
||||||
|
- Use `onlineManager` to detect and control online state
|
||||||
|
- Combine with optimistic updates for seamless offline UX
|
||||||
|
- Consider service workers for true offline support
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
# parallel-use-queries: Use useQueries for Dynamic Parallel Queries
|
||||||
|
|
||||||
|
## Priority: MEDIUM
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
When you need to fetch multiple queries in parallel where the number or identity of queries is dynamic (e.g., fetching details for a list of IDs), use `useQueries`. It handles parallel execution and returns an array of query results.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Sequential fetching with useEffect - waterfall
|
||||||
|
function UserProfiles({ userIds }: { userIds: string[] }) {
|
||||||
|
const [users, setUsers] = useState<User[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchAll() {
|
||||||
|
const results = []
|
||||||
|
for (const id of userIds) {
|
||||||
|
const user = await fetchUser(id) // Sequential!
|
||||||
|
results.push(user)
|
||||||
|
}
|
||||||
|
setUsers(results)
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
fetchAll()
|
||||||
|
}, [userIds])
|
||||||
|
|
||||||
|
// N requests run one after another
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multiple useQuery calls - breaks rules of hooks
|
||||||
|
function UserProfiles({ userIds }: { userIds: string[] }) {
|
||||||
|
// Can't call hooks in a loop!
|
||||||
|
const queries = userIds.map(id => useQuery({
|
||||||
|
queryKey: ['user', id],
|
||||||
|
queryFn: () => fetchUser(id),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useQueries } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
function UserProfiles({ userIds }: { userIds: string[] }) {
|
||||||
|
const userQueries = useQueries({
|
||||||
|
queries: userIds.map(id => ({
|
||||||
|
queryKey: ['users', id],
|
||||||
|
queryFn: () => fetchUser(id),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
|
||||||
|
const isLoading = userQueries.some(q => q.isLoading)
|
||||||
|
const isError = userQueries.some(q => q.isError)
|
||||||
|
const users = userQueries.map(q => q.data).filter(Boolean)
|
||||||
|
|
||||||
|
if (isLoading) return <Loading />
|
||||||
|
if (isError) return <Error />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul>
|
||||||
|
{users.map(user => (
|
||||||
|
<li key={user.id}>{user.name}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: With Combine Option
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function UserProfiles({ userIds }: { userIds: string[] }) {
|
||||||
|
const { data: users, isPending } = useQueries({
|
||||||
|
queries: userIds.map(id => ({
|
||||||
|
queryKey: ['users', id],
|
||||||
|
queryFn: () => fetchUser(id),
|
||||||
|
})),
|
||||||
|
// Combine results into single value
|
||||||
|
combine: (results) => ({
|
||||||
|
data: results.map(r => r.data).filter(Boolean),
|
||||||
|
isPending: results.some(r => r.isPending),
|
||||||
|
isError: results.some(r => r.isError),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isPending) return <Loading />
|
||||||
|
|
||||||
|
return <UserList users={users} />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Dependent Parallel Queries
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function PostsWithAuthors({ postIds }: { postIds: string[] }) {
|
||||||
|
// First: fetch all posts in parallel
|
||||||
|
const postQueries = useQueries({
|
||||||
|
queries: postIds.map(id => ({
|
||||||
|
queryKey: ['posts', id],
|
||||||
|
queryFn: () => fetchPost(id),
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
|
||||||
|
const posts = postQueries.map(q => q.data).filter(Boolean)
|
||||||
|
const authorIds = [...new Set(posts.map(p => p.authorId))]
|
||||||
|
|
||||||
|
// Then: fetch all unique authors in parallel
|
||||||
|
const authorQueries = useQueries({
|
||||||
|
queries: authorIds.map(id => ({
|
||||||
|
queryKey: ['users', id],
|
||||||
|
queryFn: () => fetchUser(id),
|
||||||
|
enabled: posts.length > 0, // Wait for posts
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Combine data...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: With Suspense
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useSuspenseQueries } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
function UserProfiles({ userIds }: { userIds: string[] }) {
|
||||||
|
const userQueries = useSuspenseQueries({
|
||||||
|
queries: userIds.map(id => ({
|
||||||
|
queryKey: ['users', id],
|
||||||
|
queryFn: () => fetchUser(id),
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
|
||||||
|
// All data guaranteed - no loading states needed
|
||||||
|
const users = userQueries.map(q => q.data)
|
||||||
|
|
||||||
|
return <UserList users={users} />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Queries run in parallel, not sequentially
|
||||||
|
- Each query is cached independently
|
||||||
|
- Use `combine` to transform results array into single value
|
||||||
|
- Empty queries array is valid (returns empty results)
|
||||||
|
- Pairs well with `useSuspenseQueries` for guaranteed data
|
||||||
|
- Individual query options (staleTime, etc.) apply per-query
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
# perf-select-transform: Use Select to Transform and Filter Data
|
||||||
|
|
||||||
|
## Priority: LOW
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
The `select` option transforms query data before it reaches your component. Use it for filtering, sorting, or deriving data. Benefits include memoization (re-runs only when data changes) and reduced component re-renders.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Transforming in component - runs on every render
|
||||||
|
function CompletedTodos() {
|
||||||
|
const { data: todos } = useQuery({
|
||||||
|
queryKey: ['todos'],
|
||||||
|
queryFn: fetchTodos,
|
||||||
|
})
|
||||||
|
|
||||||
|
// This filtering runs on every render
|
||||||
|
const completedTodos = todos?.filter(todo => todo.completed) ?? []
|
||||||
|
const sortedTodos = [...completedTodos].sort((a, b) =>
|
||||||
|
new Date(b.completedAt).getTime() - new Date(a.completedAt).getTime()
|
||||||
|
)
|
||||||
|
|
||||||
|
return <TodoList todos={sortedTodos} />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Using select - runs only when data changes
|
||||||
|
function CompletedTodos() {
|
||||||
|
const { data: completedTodos } = useQuery({
|
||||||
|
queryKey: ['todos'],
|
||||||
|
queryFn: fetchTodos,
|
||||||
|
select: (todos) =>
|
||||||
|
todos
|
||||||
|
.filter(todo => todo.completed)
|
||||||
|
.sort((a, b) =>
|
||||||
|
new Date(b.completedAt).getTime() - new Date(a.completedAt).getTime()
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
return <TodoList todos={completedTodos ?? []} />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Selecting Specific Fields
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Derive computed values
|
||||||
|
function TodoStats() {
|
||||||
|
const { data: stats } = useQuery({
|
||||||
|
queryKey: ['todos'],
|
||||||
|
queryFn: fetchTodos,
|
||||||
|
select: (todos) => ({
|
||||||
|
total: todos.length,
|
||||||
|
completed: todos.filter(t => t.completed).length,
|
||||||
|
pending: todos.filter(t => !t.completed).length,
|
||||||
|
completionRate: todos.length
|
||||||
|
? (todos.filter(t => t.completed).length / todos.length) * 100
|
||||||
|
: 0,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span>{stats?.completed} / {stats?.total} completed</span>
|
||||||
|
<span>({stats?.completionRate.toFixed(1)}%)</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Stable Select with useCallback
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// When select depends on external values, stabilize with useCallback
|
||||||
|
function FilteredTodos({ status }: { status: 'all' | 'active' | 'completed' }) {
|
||||||
|
const selectTodos = useCallback(
|
||||||
|
(todos: Todo[]) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'active':
|
||||||
|
return todos.filter(t => !t.completed)
|
||||||
|
case 'completed':
|
||||||
|
return todos.filter(t => t.completed)
|
||||||
|
default:
|
||||||
|
return todos
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[status]
|
||||||
|
)
|
||||||
|
|
||||||
|
const { data: filteredTodos } = useQuery({
|
||||||
|
queryKey: ['todos'],
|
||||||
|
queryFn: fetchTodos,
|
||||||
|
select: selectTodos,
|
||||||
|
})
|
||||||
|
|
||||||
|
return <TodoList todos={filteredTodos ?? []} />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Picking Single Item from List
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Select single item from cached list
|
||||||
|
function useTodoById(id: number) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['todos'],
|
||||||
|
queryFn: fetchTodos,
|
||||||
|
select: (todos) => todos.find(todo => todo.id === id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage - shares cache with list query
|
||||||
|
function TodoDetail({ id }: { id: number }) {
|
||||||
|
const { data: todo } = useTodoById(id)
|
||||||
|
|
||||||
|
if (!todo) return <div>Todo not found</div>
|
||||||
|
return <div>{todo.title}</div>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## When to Use Select
|
||||||
|
|
||||||
|
| Scenario | Use Select? |
|
||||||
|
|----------|-------------|
|
||||||
|
| Filtering list data | Yes |
|
||||||
|
| Sorting data | Yes |
|
||||||
|
| Computing derived values | Yes |
|
||||||
|
| Picking single item from list | Yes |
|
||||||
|
| Heavy transformations | Yes (memoized) |
|
||||||
|
| Simple data pass-through | No |
|
||||||
|
| Transformation needs external state | Yes, with useCallback |
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- `select` leverages structural sharing - only re-runs when data actually changes
|
||||||
|
- Original query data stays cached; transformation applies to consumer
|
||||||
|
- Multiple components can use different `select` on the same query
|
||||||
|
- Avoid unstable function references - use `useCallback` when needed
|
||||||
|
- For complex transformations, consider useMemo in component instead if readability suffers
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
# persist-queries: Configure Query Persistence for Offline Support
|
||||||
|
|
||||||
|
## Priority: LOW
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
TanStack Query can persist the cache to storage (localStorage, IndexedDB, AsyncStorage) and restore it on app load. This enables offline support and faster startup by eliminating initial loading states.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// No persistence - always starts fresh
|
||||||
|
const queryClient = new QueryClient()
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<MyApp />
|
||||||
|
</QueryClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// User refreshes page:
|
||||||
|
// 1. Empty cache
|
||||||
|
// 2. Loading spinners everywhere
|
||||||
|
// 3. Refetch all data
|
||||||
|
// Poor offline experience
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Basic Persistence with localStorage
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { QueryClient } from '@tanstack/react-query'
|
||||||
|
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister'
|
||||||
|
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
gcTime: 1000 * 60 * 60 * 24, // 24 hours - keep cache longer for persistence
|
||||||
|
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const persister = createSyncStoragePersister({
|
||||||
|
storage: window.localStorage,
|
||||||
|
key: 'REACT_QUERY_CACHE',
|
||||||
|
})
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<PersistQueryClientProvider
|
||||||
|
client={queryClient}
|
||||||
|
persistOptions={{
|
||||||
|
persister,
|
||||||
|
maxAge: 1000 * 60 * 60 * 24, // 24 hours max
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MyApp />
|
||||||
|
</PersistQueryClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Async Persistence with IndexedDB
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister'
|
||||||
|
import { get, set, del } from 'idb-keyval'
|
||||||
|
|
||||||
|
const persister = createAsyncStoragePersister({
|
||||||
|
storage: {
|
||||||
|
getItem: async (key) => await get(key),
|
||||||
|
setItem: async (key, value) => await set(key, value),
|
||||||
|
removeItem: async (key) => await del(key),
|
||||||
|
},
|
||||||
|
key: 'REACT_QUERY_CACHE',
|
||||||
|
})
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<PersistQueryClientProvider
|
||||||
|
client={queryClient}
|
||||||
|
persistOptions={{
|
||||||
|
persister,
|
||||||
|
maxAge: 1000 * 60 * 60 * 24 * 7, // 7 days
|
||||||
|
buster: APP_VERSION, // Bust cache on app updates
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MyApp />
|
||||||
|
</PersistQueryClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Selective Persistence
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { persistQueryClient } from '@tanstack/react-query-persist-client'
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
gcTime: 1000 * 60 * 60 * 24,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Only persist certain queries
|
||||||
|
persistQueryClient({
|
||||||
|
queryClient,
|
||||||
|
persister,
|
||||||
|
dehydrateOptions: {
|
||||||
|
shouldDehydrateQuery: (query) => {
|
||||||
|
// Don't persist user-specific sensitive data
|
||||||
|
if (query.queryKey[0] === 'user-session') return false
|
||||||
|
// Don't persist real-time data
|
||||||
|
if (query.queryKey[0] === 'notifications') return false
|
||||||
|
// Don't persist failed queries
|
||||||
|
if (query.state.status !== 'success') return false
|
||||||
|
// Persist everything else
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: React Native with AsyncStorage
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||||
|
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister'
|
||||||
|
|
||||||
|
const persister = createAsyncStoragePersister({
|
||||||
|
storage: AsyncStorage,
|
||||||
|
key: 'app-query-cache',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Usage is the same as web
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Handling Restoration Loading
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<PersistQueryClientProvider
|
||||||
|
client={queryClient}
|
||||||
|
persistOptions={{ persister }}
|
||||||
|
onSuccess={() => {
|
||||||
|
// Cache restored successfully
|
||||||
|
console.log('Cache restored')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Show loading while restoring */}
|
||||||
|
<PersistQueryClientProvider.Consumer>
|
||||||
|
{({ isRestoring }) =>
|
||||||
|
isRestoring ? <SplashScreen /> : <MainApp />
|
||||||
|
}
|
||||||
|
</PersistQueryClientProvider.Consumer>
|
||||||
|
</PersistQueryClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Or use the hook
|
||||||
|
function MainApp() {
|
||||||
|
const { isRestoring } = usePersistQueryClientRestore()
|
||||||
|
|
||||||
|
if (isRestoring) return <SplashScreen />
|
||||||
|
return <App />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Persistence Configuration
|
||||||
|
|
||||||
|
| Option | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `maxAge` | Maximum cache age before considered invalid |
|
||||||
|
| `buster` | String to invalidate cache (use app version) |
|
||||||
|
| `dehydrateOptions.shouldDehydrateQuery` | Filter which queries to persist |
|
||||||
|
| `hydrateOptions.shouldHydrate` | Filter which queries to restore |
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Requires `@tanstack/react-query-persist-client` package
|
||||||
|
- Set `gcTime` higher than default (5 min) for persistence to be useful
|
||||||
|
- Use `buster` option to invalidate cache on app updates
|
||||||
|
- Don't persist sensitive data or real-time data
|
||||||
|
- IndexedDB is better than localStorage for large caches
|
||||||
|
- Restored data is still subject to staleTime checks
|
||||||
|
- Works well with `networkMode: 'offlineFirst'`
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
# pf-intent-prefetch: Prefetch on User Intent (Hover, Focus)
|
||||||
|
|
||||||
|
## Priority: MEDIUM
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
Prefetch data when users show intent to navigate (hover, focus) rather than waiting for click. This eliminates perceived loading time for likely next actions.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// No prefetching - data fetches on click
|
||||||
|
function PostList({ posts }: { posts: Post[] }) {
|
||||||
|
return (
|
||||||
|
<ul>
|
||||||
|
{posts.map(post => (
|
||||||
|
<li key={post.id}>
|
||||||
|
<Link to={`/posts/${post.id}`}>
|
||||||
|
{post.title}
|
||||||
|
</Link>
|
||||||
|
{/* User clicks, waits for data to load */}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { postQueries } from '@/lib/queries'
|
||||||
|
|
||||||
|
function PostList({ posts }: { posts: Post[] }) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const handlePrefetch = (postId: number) => {
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
...postQueries.detail(postId),
|
||||||
|
staleTime: 60 * 1000, // Consider fresh for 1 minute
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul>
|
||||||
|
{posts.map(post => (
|
||||||
|
<li key={post.id}>
|
||||||
|
<Link
|
||||||
|
to={`/posts/${post.id}`}
|
||||||
|
onMouseEnter={() => handlePrefetch(post.id)}
|
||||||
|
onFocus={() => handlePrefetch(post.id)}
|
||||||
|
>
|
||||||
|
{post.title}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: With TanStack Router
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
// TanStack Router has built-in prefetching
|
||||||
|
function PostList({ posts }: { posts: Post[] }) {
|
||||||
|
return (
|
||||||
|
<ul>
|
||||||
|
{posts.map(post => (
|
||||||
|
<li key={post.id}>
|
||||||
|
<Link
|
||||||
|
to="/posts/$postId"
|
||||||
|
params={{ postId: post.id }}
|
||||||
|
preload="intent" // Prefetch on hover/focus
|
||||||
|
>
|
||||||
|
{post.title}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Or set as router default
|
||||||
|
const router = createRouter({
|
||||||
|
routeTree,
|
||||||
|
defaultPreload: 'intent',
|
||||||
|
defaultPreloadDelay: 100, // Wait 100ms before prefetching
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Prefetch with Delay
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function PostLink({ post }: { post: Post }) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const timeoutRef = useRef<NodeJS.Timeout>()
|
||||||
|
|
||||||
|
const handleMouseEnter = () => {
|
||||||
|
// Delay prefetch to avoid unnecessary requests on quick mouse movements
|
||||||
|
timeoutRef.current = setTimeout(() => {
|
||||||
|
queryClient.prefetchQuery(postQueries.detail(post.id))
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={`/posts/${post.id}`}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
>
|
||||||
|
{post.title}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prefetch Triggers
|
||||||
|
|
||||||
|
| Trigger | When to Use |
|
||||||
|
|---------|-------------|
|
||||||
|
| `onMouseEnter` | Desktop, links/buttons user will likely click |
|
||||||
|
| `onFocus` | Keyboard navigation, accessibility |
|
||||||
|
| `onTouchStart` | Mobile, before navigation |
|
||||||
|
| Component mount | Likely next pages, wizard steps |
|
||||||
|
| Intersection Observer | Below-fold content |
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Set appropriate `staleTime` when prefetching to avoid immediate refetch
|
||||||
|
- Consider mobile where hover isn't available
|
||||||
|
- Don't prefetch everything - focus on likely paths
|
||||||
|
- Prefetched data uses `gcTime` for retention
|
||||||
|
- Watch network tab to verify prefetch timing
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# qk-array-structure: Always Use Arrays for Query Keys
|
||||||
|
|
||||||
|
## Priority: CRITICAL
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
Query keys must always be arrays at the top level. This enables proper caching, invalidation matching, and query deduplication. Using non-array keys will cause unexpected behavior and cache misses.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Never use strings or non-array types as query keys
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: 'todos', // Wrong: string instead of array
|
||||||
|
queryFn: fetchTodos,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: user } = useQuery({
|
||||||
|
queryKey: { id: 1, type: 'user' }, // Wrong: object instead of array
|
||||||
|
queryFn: fetchUser,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Always use arrays for query keys
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['todos'],
|
||||||
|
queryFn: fetchTodos,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: user } = useQuery({
|
||||||
|
queryKey: ['user', 1],
|
||||||
|
queryFn: () => fetchUser(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Complex keys with objects inside arrays are fine
|
||||||
|
const { data: filteredTodos } = useQuery({
|
||||||
|
queryKey: ['todos', { status: 'done', page: 1 }],
|
||||||
|
queryFn: () => fetchTodos({ status: 'done', page: 1 }),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Always applicable when defining query keys
|
||||||
|
- Arrays enable prefix-based invalidation (e.g., `invalidateQueries({ queryKey: ['todos'] })` matches all todo queries)
|
||||||
|
- Object property order inside arrays doesn't matter for matching
|
||||||
|
- Array element order does matter: `['todos', 1]` !== `['1', 'todos']`
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# qk-factory-pattern: Use Query Key Factories for Complex Applications
|
||||||
|
|
||||||
|
## Priority: CRITICAL
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
For applications with many queries, centralize query key definitions in factory functions. This ensures consistency, enables autocomplete, prevents typos, and makes refactoring safer. Query key factories are the recommended pattern for production applications.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Scattered, inconsistent key definitions across files
|
||||||
|
// file: components/TodoList.tsx
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['todos', 'list'],
|
||||||
|
queryFn: fetchTodos,
|
||||||
|
})
|
||||||
|
|
||||||
|
// file: components/TodoDetail.tsx
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['todo', id], // Inconsistent: 'todo' vs 'todos'
|
||||||
|
queryFn: () => fetchTodo(id),
|
||||||
|
})
|
||||||
|
|
||||||
|
// file: components/TodoComments.tsx
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['todoComments', todoId], // Different naming convention
|
||||||
|
queryFn: () => fetchComments(todoId),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Invalidation is error-prone
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos'] }) // Misses 'todo' and 'todoComments'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// file: lib/query-keys.ts
|
||||||
|
export const todoKeys = {
|
||||||
|
all: ['todos'] as const,
|
||||||
|
lists: () => [...todoKeys.all, 'list'] as const,
|
||||||
|
list: (filters: TodoFilters) => [...todoKeys.lists(), filters] as const,
|
||||||
|
details: () => [...todoKeys.all, 'detail'] as const,
|
||||||
|
detail: (id: number) => [...todoKeys.details(), id] as const,
|
||||||
|
comments: (id: number) => [...todoKeys.detail(id), 'comments'] as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const userKeys = {
|
||||||
|
all: ['users'] as const,
|
||||||
|
detail: (id: string) => [...userKeys.all, id] as const,
|
||||||
|
posts: (id: string) => [...userKeys.detail(id), 'posts'] as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
// file: components/TodoList.tsx
|
||||||
|
import { todoKeys } from '@/lib/query-keys'
|
||||||
|
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: todoKeys.list({ status: 'active' }),
|
||||||
|
queryFn: () => fetchTodos({ status: 'active' }),
|
||||||
|
})
|
||||||
|
|
||||||
|
// file: components/TodoDetail.tsx
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: todoKeys.detail(id),
|
||||||
|
queryFn: () => fetchTodo(id),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Invalidation is type-safe and predictable
|
||||||
|
queryClient.invalidateQueries({ queryKey: todoKeys.all }) // Invalidates everything
|
||||||
|
queryClient.invalidateQueries({ queryKey: todoKeys.detail(5) }) // Specific todo + comments
|
||||||
|
```
|
||||||
|
|
||||||
|
## Query Options Factory Pattern
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Even better: combine with queryOptions for full type safety
|
||||||
|
import { queryOptions } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
export const todoQueries = {
|
||||||
|
all: () => queryOptions({
|
||||||
|
queryKey: todoKeys.all,
|
||||||
|
queryFn: fetchAllTodos,
|
||||||
|
}),
|
||||||
|
detail: (id: number) => queryOptions({
|
||||||
|
queryKey: todoKeys.detail(id),
|
||||||
|
queryFn: () => fetchTodo(id),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
const { data } = useQuery(todoQueries.detail(5))
|
||||||
|
await queryClient.prefetchQuery(todoQueries.detail(5))
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Essential for applications with 10+ different query types
|
||||||
|
- Enables IDE autocomplete and typo prevention
|
||||||
|
- Makes invalidation patterns discoverable
|
||||||
|
- Pairs well with `queryOptions` for full type inference
|
||||||
|
- Consider the `@lukemorales/query-key-factory` package for standardized implementation
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# qk-hierarchical-organization: Organize Keys Hierarchically
|
||||||
|
|
||||||
|
## Priority: CRITICAL
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
Structure query keys from general to specific: entity type first, then ID, then modifiers/filters. This enables efficient invalidation at any level of specificity and creates predictable cache organization.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Flat, inconsistent key structures
|
||||||
|
const { data: todos } = useQuery({
|
||||||
|
queryKey: ['all-todos-list'],
|
||||||
|
queryFn: fetchTodos,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: todo } = useQuery({
|
||||||
|
queryKey: ['single-todo-5'],
|
||||||
|
queryFn: () => fetchTodo(5),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: comments } = useQuery({
|
||||||
|
queryKey: ['todo-5-comments'],
|
||||||
|
queryFn: () => fetchTodoComments(5),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Can't easily invalidate all todo-related queries
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Hierarchical: entity → id → sub-resource → filters
|
||||||
|
const { data: todos } = useQuery({
|
||||||
|
queryKey: ['todos'],
|
||||||
|
queryFn: fetchTodos,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: todo } = useQuery({
|
||||||
|
queryKey: ['todos', 5],
|
||||||
|
queryFn: () => fetchTodo(5),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: comments } = useQuery({
|
||||||
|
queryKey: ['todos', 5, 'comments'],
|
||||||
|
queryFn: () => fetchTodoComments(5),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: filteredTodos } = useQuery({
|
||||||
|
queryKey: ['todos', { status: 'done', page: 1 }],
|
||||||
|
queryFn: () => fetchTodos({ status: 'done', page: 1 }),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Now we can invalidate at any level:
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos'] }) // All todos
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos', 5] }) // Todo 5 and its sub-resources
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['todos', 5, 'comments'] }) // Just comments
|
||||||
|
```
|
||||||
|
|
||||||
|
## Recommended Hierarchy Pattern
|
||||||
|
|
||||||
|
```
|
||||||
|
['entity'] // List
|
||||||
|
['entity', id] // Single item
|
||||||
|
['entity', id, 'sub-resource'] // Related data
|
||||||
|
['entity', { filters }] // Filtered list
|
||||||
|
['entity', id, 'sub-resource', { filters }] // Filtered sub-resource
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Essential for applications with related data
|
||||||
|
- Enables efficient cache management
|
||||||
|
- Works with prefix-based invalidation
|
||||||
|
- Consider using query key factories (see `qk-factory-pattern`) for consistency
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# qk-include-dependencies: Include All Variables the Query Depends On
|
||||||
|
|
||||||
|
## Priority: CRITICAL
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
If your query function depends on a variable, that variable must be included in the query key. This ensures independent caching per variable combination and automatic refetching when dependencies change. Missing dependencies cause stale data bugs and cache collisions.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function UserPosts({ userId }: { userId: string }) {
|
||||||
|
// Missing userId in query key - all users share the same cache!
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['posts'],
|
||||||
|
queryFn: () => fetchPostsByUser(userId),
|
||||||
|
})
|
||||||
|
|
||||||
|
return <PostList posts={data} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilteredTodos({ status, page }: { status: string; page: number }) {
|
||||||
|
// Missing filter parameters - won't refetch when filters change
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['todos'],
|
||||||
|
queryFn: () => fetchTodos({ status, page }),
|
||||||
|
})
|
||||||
|
|
||||||
|
return <TodoList todos={data} />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function UserPosts({ userId }: { userId: string }) {
|
||||||
|
// userId included - each user has their own cache entry
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['posts', userId],
|
||||||
|
queryFn: () => fetchPostsByUser(userId),
|
||||||
|
})
|
||||||
|
|
||||||
|
return <PostList posts={data} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilteredTodos({ status, page }: { status: string; page: number }) {
|
||||||
|
// All dependencies included - refetches when any change
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['todos', { status, page }],
|
||||||
|
queryFn: () => fetchTodos({ status, page }),
|
||||||
|
})
|
||||||
|
|
||||||
|
return <TodoList todos={data} />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- This is arguably the most important query key rule
|
||||||
|
- Applies whenever query function uses external variables
|
||||||
|
- Prevents subtle bugs where different contexts share cached data
|
||||||
|
- Works in conjunction with staleTime - even with long staleTime, changing keys triggers new fetches
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# qk-serializable: Ensure All Key Parts Are JSON-Serializable
|
||||||
|
|
||||||
|
## Priority: CRITICAL
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
Query keys are hashed using JSON serialization for cache lookups. Non-serializable values (functions, class instances, symbols, circular references) break caching and cause unexpected behavior. All parts of your query key must be JSON-serializable.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Functions are not serializable
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['todos', () => 'active'], // Wrong: function in key
|
||||||
|
queryFn: fetchTodos,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Class instances lose their prototype
|
||||||
|
class Filter {
|
||||||
|
constructor(public status: string) {}
|
||||||
|
isActive() { return this.status === 'active' }
|
||||||
|
}
|
||||||
|
const filter = new Filter('active')
|
||||||
|
const { data: todos } = useQuery({
|
||||||
|
queryKey: ['todos', filter], // Wrong: class instance
|
||||||
|
queryFn: () => fetchTodos(filter),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Dates are technically serializable but become strings
|
||||||
|
const { data: events } = useQuery({
|
||||||
|
queryKey: ['events', new Date()], // Problematic: new Date() each render
|
||||||
|
queryFn: () => fetchEvents(date),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Symbols are not serializable
|
||||||
|
const { data: settings } = useQuery({
|
||||||
|
queryKey: ['settings', Symbol('user')], // Wrong: symbol
|
||||||
|
queryFn: fetchSettings,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Use primitive values and plain objects
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['todos', 'active'],
|
||||||
|
queryFn: fetchTodos,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Plain objects are fine
|
||||||
|
const filters = { status: 'active', priority: 'high' }
|
||||||
|
const { data: todos } = useQuery({
|
||||||
|
queryKey: ['todos', filters],
|
||||||
|
queryFn: () => fetchTodos(filters),
|
||||||
|
})
|
||||||
|
|
||||||
|
// For dates, use stable string representations
|
||||||
|
const dateKey = date.toISOString().split('T')[0] // '2024-01-15'
|
||||||
|
const { data: events } = useQuery({
|
||||||
|
queryKey: ['events', dateKey],
|
||||||
|
queryFn: () => fetchEvents(date),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Arrays of primitives work correctly
|
||||||
|
const { data: users } = useQuery({
|
||||||
|
queryKey: ['users', { ids: [1, 2, 3] }],
|
||||||
|
queryFn: () => fetchUsers([1, 2, 3]),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Serializable Types
|
||||||
|
|
||||||
|
**Safe to use:**
|
||||||
|
- Strings, numbers, booleans, null
|
||||||
|
- Plain objects (no prototype methods)
|
||||||
|
- Arrays of serializable values
|
||||||
|
- undefined (stripped but handled)
|
||||||
|
|
||||||
|
**Avoid:**
|
||||||
|
- Functions
|
||||||
|
- Class instances
|
||||||
|
- Symbols
|
||||||
|
- Date objects (use ISO strings instead)
|
||||||
|
- Map/Set (use arrays/objects instead)
|
||||||
|
- Circular references
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- TanStack Query uses deterministic JSON hashing
|
||||||
|
- Object property order doesn't matter: `{ a: 1, b: 2 }` equals `{ b: 2, a: 1 }`
|
||||||
|
- Keys with `undefined` properties are normalized: `{ a: 1, b: undefined }` equals `{ a: 1 }`
|
||||||
|
- Test serialization: `JSON.stringify(queryKey)` should work without errors
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
# query-cancellation: Implement Query Cancellation Properly
|
||||||
|
|
||||||
|
## Priority: MEDIUM
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
TanStack Query provides an `AbortSignal` to cancel in-flight requests when queries become stale or components unmount. Pass this signal to your fetch calls to prevent memory leaks and wasted bandwidth.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Not using abort signal - requests complete even when unnecessary
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['search', searchTerm],
|
||||||
|
queryFn: async () => {
|
||||||
|
// User types fast: "a", "ab", "abc"
|
||||||
|
// Three requests fire, all complete, wasting bandwidth
|
||||||
|
const response = await fetch(`/api/search?q=${searchTerm}`)
|
||||||
|
return response.json()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Component unmounts but request keeps running
|
||||||
|
function UserProfile({ userId }: { userId: string }) {
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['user', userId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await fetch(`/api/users/${userId}`)
|
||||||
|
return response.json() // Completes even if user navigated away
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Using AbortSignal with Fetch
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['search', searchTerm],
|
||||||
|
queryFn: async ({ signal }) => {
|
||||||
|
const response = await fetch(`/api/search?q=${searchTerm}`, {
|
||||||
|
signal, // Pass abort signal to fetch
|
||||||
|
})
|
||||||
|
return response.json()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Now when user types "a", "ab", "abc" quickly:
|
||||||
|
// - "a" request is cancelled when "ab" starts
|
||||||
|
// - "ab" request is cancelled when "abc" starts
|
||||||
|
// - Only "abc" completes
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: With Axios
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['users', userId],
|
||||||
|
queryFn: async ({ signal }) => {
|
||||||
|
const response = await axios.get(`/api/users/${userId}`, {
|
||||||
|
signal, // Axios supports AbortSignal
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Manual Cancellation
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function SearchResults() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
|
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['search', searchTerm],
|
||||||
|
queryFn: async ({ signal }) => {
|
||||||
|
const response = await fetch(`/api/search?q=${searchTerm}`, { signal })
|
||||||
|
return response.json()
|
||||||
|
},
|
||||||
|
enabled: searchTerm.length > 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Cancel all search queries manually
|
||||||
|
const handleClear = () => {
|
||||||
|
queryClient.cancelQueries({ queryKey: ['search'] })
|
||||||
|
setSearchTerm('')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button onClick={handleClear}>Clear</button>
|
||||||
|
<Results data={data} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: In Mutations (Before Optimistic Update)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const updateTodo = useMutation({
|
||||||
|
mutationFn: (todo: Todo) => api.updateTodo(todo),
|
||||||
|
onMutate: async (newTodo) => {
|
||||||
|
// Cancel outgoing queries to prevent overwriting optimistic update
|
||||||
|
await queryClient.cancelQueries({ queryKey: ['todos'] })
|
||||||
|
await queryClient.cancelQueries({ queryKey: ['todos', newTodo.id] })
|
||||||
|
|
||||||
|
// Proceed with optimistic update...
|
||||||
|
const previousTodos = queryClient.getQueryData(['todos'])
|
||||||
|
queryClient.setQueryData(['todos'], (old) => /* ... */)
|
||||||
|
|
||||||
|
return { previousTodos }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Custom Cancellable Promise
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// For non-fetch APIs that need custom cancellation
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['expensive-computation', params],
|
||||||
|
queryFn: ({ signal }) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// Check if already cancelled
|
||||||
|
if (signal.aborted) {
|
||||||
|
reject(new DOMException('Aborted', 'AbortError'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const worker = new Worker('computation.js')
|
||||||
|
worker.postMessage(params)
|
||||||
|
|
||||||
|
worker.onmessage = (e) => resolve(e.data)
|
||||||
|
worker.onerror = (e) => reject(e)
|
||||||
|
|
||||||
|
// Listen for cancellation
|
||||||
|
signal.addEventListener('abort', () => {
|
||||||
|
worker.terminate()
|
||||||
|
reject(new DOMException('Aborted', 'AbortError'))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## When Queries Are Cancelled
|
||||||
|
|
||||||
|
| Scenario | Cancelled? |
|
||||||
|
|----------|------------|
|
||||||
|
| Query key changes | Yes |
|
||||||
|
| Component unmounts | Yes |
|
||||||
|
| `queryClient.cancelQueries()` called | Yes |
|
||||||
|
| Refetch triggered | Previous request cancelled |
|
||||||
|
| `enabled` becomes false | Yes |
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Always pass `signal` to fetch/axios for automatic cancellation
|
||||||
|
- Cancelled queries don't trigger `onError` - they're silently dropped
|
||||||
|
- Use `queryClient.cancelQueries()` before optimistic updates
|
||||||
|
- AbortError is thrown when cancelled - handle if needed
|
||||||
|
- Cancellation prevents wasted bandwidth and race conditions
|
||||||
|
- Essential for search-as-you-type and fast navigation patterns
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
# ssr-dehydration: Use Dehydrate/Hydrate Pattern for SSR
|
||||||
|
|
||||||
|
## Priority: MEDIUM
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
For server-side rendering, prefetch queries on the server, dehydrate the cache to a serializable format, send it to the client, and hydrate on the client. This prevents content flash and duplicate requests.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// No SSR data passing - client refetches everything
|
||||||
|
// server-side
|
||||||
|
export async function getServerSideProps() {
|
||||||
|
const data = await fetchPosts()
|
||||||
|
return { props: { posts: data } } // Bypasses React Query cache
|
||||||
|
}
|
||||||
|
|
||||||
|
// client-side
|
||||||
|
function PostsPage({ posts }: { posts: Post[] }) {
|
||||||
|
// This doesn't benefit from the server fetch
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['posts'],
|
||||||
|
queryFn: fetchPosts,
|
||||||
|
// Will refetch on client, causing flash
|
||||||
|
})
|
||||||
|
|
||||||
|
return <PostList posts={data ?? posts} /> // Awkward fallback pattern
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Next.js App Router
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/posts/page.tsx
|
||||||
|
import {
|
||||||
|
dehydrate,
|
||||||
|
HydrationBoundary,
|
||||||
|
QueryClient,
|
||||||
|
} from '@tanstack/react-query'
|
||||||
|
import { postQueries } from '@/lib/queries'
|
||||||
|
|
||||||
|
export default async function PostsPage() {
|
||||||
|
const queryClient = new QueryClient()
|
||||||
|
|
||||||
|
await queryClient.prefetchQuery(postQueries.list())
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||||
|
<PostList />
|
||||||
|
</HydrationBoundary>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// components/PostList.tsx
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||||
|
import { postQueries } from '@/lib/queries'
|
||||||
|
|
||||||
|
export function PostList() {
|
||||||
|
const { data: posts } = useSuspenseQuery(postQueries.list())
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul>
|
||||||
|
{posts.map(post => (
|
||||||
|
<li key={post.id}>{post.title}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: TanStack Start/Router
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// routes/posts.tsx
|
||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { postQueries } from '@/lib/queries'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/posts')({
|
||||||
|
loader: async ({ context: { queryClient } }) => {
|
||||||
|
// Prefetch in route loader
|
||||||
|
await queryClient.ensureQueryData(postQueries.list())
|
||||||
|
},
|
||||||
|
component: PostsPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function PostsPage() {
|
||||||
|
const { data: posts } = useSuspenseQuery(postQueries.list())
|
||||||
|
return <PostList posts={posts} />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Manual SSR Setup
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// server.tsx
|
||||||
|
import { dehydrate, QueryClient } from '@tanstack/react-query'
|
||||||
|
import { renderToString } from 'react-dom/server'
|
||||||
|
|
||||||
|
export async function render(url: string) {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 60 * 1000, // Prevent immediate client refetch
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Prefetch required data
|
||||||
|
await queryClient.prefetchQuery({
|
||||||
|
queryKey: ['posts'],
|
||||||
|
queryFn: fetchPosts,
|
||||||
|
})
|
||||||
|
|
||||||
|
const dehydratedState = dehydrate(queryClient)
|
||||||
|
|
||||||
|
const html = renderToString(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<App />
|
||||||
|
</QueryClientProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Serialize safely - JSON.stringify is XSS vulnerable
|
||||||
|
const serializedState = serialize(dehydratedState)
|
||||||
|
|
||||||
|
return `
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<div id="app">${html}</div>
|
||||||
|
<script>window.__DEHYDRATED_STATE__ = ${serializedState}</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
// client.tsx
|
||||||
|
import { hydrate, QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
const queryClient = new QueryClient()
|
||||||
|
hydrate(queryClient, window.__DEHYDRATED_STATE__)
|
||||||
|
|
||||||
|
hydrateRoot(
|
||||||
|
document.getElementById('app'),
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<App />
|
||||||
|
</QueryClientProvider>
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Create new QueryClient per request to prevent data sharing between users
|
||||||
|
- Set `staleTime > 0` on server to prevent immediate client refetch
|
||||||
|
- Use a safe serializer (not JSON.stringify) to prevent XSS
|
||||||
|
- Failed queries aren't dehydrated by default; use `shouldDehydrateQuery` to override
|
||||||
|
- `HydrationBoundary` can be nested for route-level prefetching
|
||||||
113
.agents/skills/tanstack-router-best-practices/SKILL.md
Normal file
113
.agents/skills/tanstack-router-best-practices/SKILL.md
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
---
|
||||||
|
name: tanstack-router-best-practices
|
||||||
|
description: TanStack Router best practices for type-safe routing, data loading, search params, and navigation. Activate when building React applications with complex routing needs.
|
||||||
|
---
|
||||||
|
|
||||||
|
# TanStack Router Best Practices
|
||||||
|
|
||||||
|
Comprehensive guidelines for implementing TanStack Router patterns in React applications. These rules optimize type safety, data loading, navigation, and code organization.
|
||||||
|
|
||||||
|
## When to Apply
|
||||||
|
|
||||||
|
- Setting up application routing
|
||||||
|
- Creating new routes and layouts
|
||||||
|
- Implementing search parameter handling
|
||||||
|
- Configuring data loaders
|
||||||
|
- Setting up code splitting
|
||||||
|
- Integrating with TanStack Query
|
||||||
|
- Refactoring navigation patterns
|
||||||
|
|
||||||
|
## Rule Categories by Priority
|
||||||
|
|
||||||
|
| Priority | Category | Rules | Impact |
|
||||||
|
|----------|----------|-------|--------|
|
||||||
|
| CRITICAL | Type Safety | 4 rules | Prevents runtime errors and enables refactoring |
|
||||||
|
| CRITICAL | Route Organization | 5 rules | Ensures maintainable route structure |
|
||||||
|
| HIGH | Router Config | 1 rule | Global router defaults |
|
||||||
|
| HIGH | Data Loading | 6 rules | Optimizes data fetching and caching |
|
||||||
|
| HIGH | Search Params | 5 rules | Enables type-safe URL state |
|
||||||
|
| HIGH | Error Handling | 1 rule | Handles 404 and errors gracefully |
|
||||||
|
| MEDIUM | Navigation | 5 rules | Improves UX and accessibility |
|
||||||
|
| MEDIUM | Code Splitting | 3 rules | Reduces bundle size |
|
||||||
|
| MEDIUM | Preloading | 3 rules | Improves perceived performance |
|
||||||
|
| LOW | Route Context | 3 rules | Enables dependency injection |
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
### Type Safety (Prefix: `ts-`)
|
||||||
|
|
||||||
|
- `ts-register-router` — Register router type for global inference
|
||||||
|
- `ts-use-from-param` — Use `from` parameter for type narrowing
|
||||||
|
- `ts-route-context-typing` — Type route context with createRootRouteWithContext
|
||||||
|
- `ts-query-options-loader` — Use queryOptions in loaders for type inference
|
||||||
|
|
||||||
|
### Router Config (Prefix: `router-`)
|
||||||
|
|
||||||
|
- `router-default-options` — Configure router defaults (scrollRestoration, defaultErrorComponent, etc.)
|
||||||
|
|
||||||
|
### Route Organization (Prefix: `org-`)
|
||||||
|
|
||||||
|
- `org-file-based-routing` — Prefer file-based routing for conventions
|
||||||
|
- `org-route-tree-structure` — Follow hierarchical route tree patterns
|
||||||
|
- `org-pathless-layouts` — Use pathless routes for shared layouts
|
||||||
|
- `org-index-routes` — Understand index vs layout routes
|
||||||
|
- `org-virtual-routes` — Understand virtual file routes
|
||||||
|
|
||||||
|
### Data Loading (Prefix: `load-`)
|
||||||
|
|
||||||
|
- `load-use-loaders` — Use route loaders for data fetching
|
||||||
|
- `load-loader-deps` — Define loaderDeps for cache control
|
||||||
|
- `load-ensure-query-data` — Use ensureQueryData with TanStack Query
|
||||||
|
- `load-deferred-data` — Split critical and non-critical data
|
||||||
|
- `load-error-handling` — Handle loader errors appropriately
|
||||||
|
- `load-parallel` — Leverage parallel route loading
|
||||||
|
|
||||||
|
### Search Params (Prefix: `search-`)
|
||||||
|
|
||||||
|
- `search-validation` — Always validate search params
|
||||||
|
- `search-type-inheritance` — Leverage parent search param types
|
||||||
|
- `search-middleware` — Use search param middleware
|
||||||
|
- `search-defaults` — Provide sensible defaults
|
||||||
|
- `search-custom-serializer` — Configure custom search param serializers
|
||||||
|
|
||||||
|
### Error Handling (Prefix: `err-`)
|
||||||
|
|
||||||
|
- `err-not-found` — Handle not-found routes properly
|
||||||
|
|
||||||
|
### Navigation (Prefix: `nav-`)
|
||||||
|
|
||||||
|
- `nav-link-component` — Prefer Link component for navigation
|
||||||
|
- `nav-active-states` — Configure active link states
|
||||||
|
- `nav-use-navigate` — Use useNavigate for programmatic navigation
|
||||||
|
- `nav-relative-paths` — Understand relative path navigation
|
||||||
|
- `nav-route-masks` — Use route masks for modal URLs
|
||||||
|
|
||||||
|
### Code Splitting (Prefix: `split-`)
|
||||||
|
|
||||||
|
- `split-lazy-routes` — Use .lazy.tsx for code splitting
|
||||||
|
- `split-critical-path` — Keep critical config in main route file
|
||||||
|
- `split-auto-splitting` — Enable autoCodeSplitting when possible
|
||||||
|
|
||||||
|
### Preloading (Prefix: `preload-`)
|
||||||
|
|
||||||
|
- `preload-intent` — Enable intent-based preloading
|
||||||
|
- `preload-stale-time` — Configure preload stale time
|
||||||
|
- `preload-manual` — Use manual preloading strategically
|
||||||
|
|
||||||
|
### Route Context (Prefix: `ctx-`)
|
||||||
|
|
||||||
|
- `ctx-root-context` — Define context at root route
|
||||||
|
- `ctx-before-load` — Extend context in beforeLoad
|
||||||
|
- `ctx-dependency-injection` — Use context for dependency injection
|
||||||
|
|
||||||
|
## How to Use
|
||||||
|
|
||||||
|
Each rule file in the `rules/` directory contains:
|
||||||
|
1. **Explanation** — Why this pattern matters
|
||||||
|
2. **Bad Example** — Anti-pattern to avoid
|
||||||
|
3. **Good Example** — Recommended implementation
|
||||||
|
4. **Context** — When to apply or skip this rule
|
||||||
|
|
||||||
|
## Full Reference
|
||||||
|
|
||||||
|
See individual rule files in `rules/` directory for detailed guidance and code examples.
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
# ctx-root-context: Define Context at Root Route
|
||||||
|
|
||||||
|
## Priority: LOW
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
Use `createRootRouteWithContext` to define typed context that flows through your entire route tree. This enables dependency injection for things like query clients, auth state, and services.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// No context - importing globals directly
|
||||||
|
// routes/__root.tsx
|
||||||
|
import { createRootRoute } from '@tanstack/react-router'
|
||||||
|
import { queryClient } from '@/lib/query-client' // Global import
|
||||||
|
|
||||||
|
export const Route = createRootRoute({
|
||||||
|
component: RootComponent,
|
||||||
|
})
|
||||||
|
|
||||||
|
// routes/posts.tsx
|
||||||
|
import { queryClient } from '@/lib/query-client' // Import again
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/posts')({
|
||||||
|
loader: async () => {
|
||||||
|
// Using global - harder to test, couples to implementation
|
||||||
|
return queryClient.ensureQueryData(postQueries.list())
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// routes/__root.tsx
|
||||||
|
import { createRootRouteWithContext, Outlet } from '@tanstack/react-router'
|
||||||
|
import { QueryClient } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
// Define the context interface
|
||||||
|
interface RouterContext {
|
||||||
|
queryClient: QueryClient
|
||||||
|
auth: {
|
||||||
|
user: User | null
|
||||||
|
isAuthenticated: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createRootRouteWithContext<RouterContext>()({
|
||||||
|
component: RootComponent,
|
||||||
|
})
|
||||||
|
|
||||||
|
function RootComponent() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header />
|
||||||
|
<main>
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// router.tsx - Provide context when creating router
|
||||||
|
import { createRouter } from '@tanstack/react-router'
|
||||||
|
import { QueryClient } from '@tanstack/react-query'
|
||||||
|
import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query'
|
||||||
|
import { routeTree } from './routeTree.gen'
|
||||||
|
|
||||||
|
export function getRouter(auth: RouterContext['auth'] = { user: null, isAuthenticated: false }) {
|
||||||
|
const queryClient = new QueryClient()
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
routeTree,
|
||||||
|
context: {
|
||||||
|
queryClient,
|
||||||
|
auth,
|
||||||
|
},
|
||||||
|
defaultPreload: 'intent',
|
||||||
|
defaultPreloadStaleTime: 0,
|
||||||
|
scrollRestoration: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
setupRouterSsrQueryIntegration({ router, queryClient })
|
||||||
|
|
||||||
|
return router
|
||||||
|
}
|
||||||
|
|
||||||
|
// routes/posts.tsx - Use context in loaders
|
||||||
|
export const Route = createFileRoute('/posts')({
|
||||||
|
loader: async ({ context: { queryClient } }) => {
|
||||||
|
// Context is typed and injected
|
||||||
|
return queryClient.ensureQueryData(postQueries.list())
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Auth-Protected Routes
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// routes/__root.tsx
|
||||||
|
interface RouterContext {
|
||||||
|
queryClient: QueryClient
|
||||||
|
auth: AuthState
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createRootRouteWithContext<RouterContext>()({
|
||||||
|
component: RootComponent,
|
||||||
|
})
|
||||||
|
|
||||||
|
// routes/_authenticated.tsx - Layout route for protected pages
|
||||||
|
export const Route = createFileRoute('/_authenticated')({
|
||||||
|
beforeLoad: async ({ context, location }) => {
|
||||||
|
if (!context.auth.isAuthenticated) {
|
||||||
|
throw redirect({
|
||||||
|
to: '/login',
|
||||||
|
search: { redirect: location.href },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
component: AuthenticatedLayout,
|
||||||
|
})
|
||||||
|
|
||||||
|
// routes/_authenticated/dashboard.tsx
|
||||||
|
export const Route = createFileRoute('/_authenticated/dashboard')({
|
||||||
|
loader: async ({ context: { queryClient, auth } }) => {
|
||||||
|
// We know user is authenticated from parent beforeLoad
|
||||||
|
return queryClient.ensureQueryData(
|
||||||
|
dashboardQueries.forUser(auth.user!.id)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Extending Context with beforeLoad
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// routes/posts/$postId.tsx
|
||||||
|
export const Route = createFileRoute('/posts/$postId')({
|
||||||
|
beforeLoad: async ({ context, params }) => {
|
||||||
|
// Extend context with route-specific data
|
||||||
|
const post = await fetchPost(params.postId)
|
||||||
|
|
||||||
|
return {
|
||||||
|
post, // Available to this route and children
|
||||||
|
}
|
||||||
|
},
|
||||||
|
loader: async ({ context }) => {
|
||||||
|
// context now includes 'post' from beforeLoad
|
||||||
|
const comments = await fetchComments(context.post.id)
|
||||||
|
return { comments }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context vs. Loader Data
|
||||||
|
|
||||||
|
| Context | Loader Data |
|
||||||
|
|---------|-------------|
|
||||||
|
| Available in beforeLoad, loader, and component | Only available in component |
|
||||||
|
| Set at router creation or in beforeLoad | Returned from loader |
|
||||||
|
| Good for services, clients, auth | Good for route-specific data |
|
||||||
|
| Flows down to all children | Specific to route |
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Type the context interface in `createRootRouteWithContext<T>()`
|
||||||
|
- Provide context when calling `createRouter({ context: {...} })`
|
||||||
|
- Context flows from root to all nested routes
|
||||||
|
- Use `beforeLoad` to extend context for specific subtrees
|
||||||
|
- Enables testability - inject mocks in tests
|
||||||
|
- Avoids global imports and singletons
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
# err-not-found: Handle Not-Found Routes Properly
|
||||||
|
|
||||||
|
## Priority: HIGH
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
Configure `notFoundComponent` to handle 404 errors gracefully. TanStack Router provides not-found handling at multiple levels: root, route-specific, and programmatic via `notFound()`. Proper configuration prevents blank screens and improves UX.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// No not-found handling - shows blank screen or error
|
||||||
|
const router = createRouter({
|
||||||
|
routeTree,
|
||||||
|
// Missing defaultNotFoundComponent
|
||||||
|
})
|
||||||
|
|
||||||
|
// Or throwing generic error
|
||||||
|
export const Route = createFileRoute('/posts/$postId')({
|
||||||
|
loader: async ({ params }) => {
|
||||||
|
const post = await fetchPost(params.postId)
|
||||||
|
if (!post) {
|
||||||
|
throw new Error('Not found') // Generic error, not proper 404
|
||||||
|
}
|
||||||
|
return post
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Root-Level Not Found
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// routes/__root.tsx
|
||||||
|
export const Route = createRootRoute({
|
||||||
|
component: RootComponent,
|
||||||
|
notFoundComponent: GlobalNotFound,
|
||||||
|
})
|
||||||
|
|
||||||
|
function GlobalNotFound() {
|
||||||
|
return (
|
||||||
|
<div className="not-found">
|
||||||
|
<h1>404 - Page Not Found</h1>
|
||||||
|
<p>The page you're looking for doesn't exist.</p>
|
||||||
|
<Link to="/">Go Home</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// router.tsx - Can also set default
|
||||||
|
const router = createRouter({
|
||||||
|
routeTree,
|
||||||
|
defaultNotFoundComponent: () => (
|
||||||
|
<div>
|
||||||
|
<h1>404</h1>
|
||||||
|
<Link to="/">Return Home</Link>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Route-Specific Not Found
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// routes/posts/$postId.tsx
|
||||||
|
import { createFileRoute, notFound } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/posts/$postId')({
|
||||||
|
loader: async ({ params }) => {
|
||||||
|
const post = await fetchPost(params.postId)
|
||||||
|
if (!post) {
|
||||||
|
throw notFound() // Proper 404 handling
|
||||||
|
}
|
||||||
|
return post
|
||||||
|
},
|
||||||
|
notFoundComponent: PostNotFound, // Custom 404 for this route
|
||||||
|
component: PostPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function PostNotFound() {
|
||||||
|
const { postId } = Route.useParams()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Post Not Found</h1>
|
||||||
|
<p>No post exists with ID: {postId}</p>
|
||||||
|
<Link to="/posts">Browse all posts</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Not Found with Data
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export const Route = createFileRoute('/users/$username')({
|
||||||
|
loader: async ({ params }) => {
|
||||||
|
const user = await fetchUser(params.username)
|
||||||
|
if (!user) {
|
||||||
|
throw notFound({
|
||||||
|
// Pass data to notFoundComponent
|
||||||
|
data: {
|
||||||
|
username: params.username,
|
||||||
|
suggestions: await fetchSimilarUsernames(params.username),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return user
|
||||||
|
},
|
||||||
|
notFoundComponent: UserNotFound,
|
||||||
|
})
|
||||||
|
|
||||||
|
function UserNotFound() {
|
||||||
|
const { data } = Route.useMatch()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>User @{data?.username} not found</h1>
|
||||||
|
{data?.suggestions?.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p>Did you mean:</p>
|
||||||
|
<ul>
|
||||||
|
{data.suggestions.map((username) => (
|
||||||
|
<li key={username}>
|
||||||
|
<Link to="/users/$username" params={{ username }}>
|
||||||
|
@{username}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Catch-All Route
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// routes/$.tsx - Catch-all splat route
|
||||||
|
export const Route = createFileRoute('/$')({
|
||||||
|
component: CatchAllNotFound,
|
||||||
|
})
|
||||||
|
|
||||||
|
function CatchAllNotFound() {
|
||||||
|
const { _splat } = Route.useParams()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Page Not Found</h1>
|
||||||
|
<p>No page exists at: /{_splat}</p>
|
||||||
|
<Link to="/">Go to homepage</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Nested Not Found Bubbling
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Not found bubbles up through route tree
|
||||||
|
// routes/posts.tsx
|
||||||
|
export const Route = createFileRoute('/posts')({
|
||||||
|
notFoundComponent: PostsNotFound, // Catches child 404s too
|
||||||
|
})
|
||||||
|
|
||||||
|
// routes/posts/$postId.tsx
|
||||||
|
export const Route = createFileRoute('/posts/$postId')({
|
||||||
|
loader: async ({ params }) => {
|
||||||
|
const post = await fetchPost(params.postId)
|
||||||
|
if (!post) throw notFound()
|
||||||
|
return post
|
||||||
|
},
|
||||||
|
// No notFoundComponent - bubbles to parent
|
||||||
|
})
|
||||||
|
|
||||||
|
// routes/posts/$postId/comments.tsx
|
||||||
|
export const Route = createFileRoute('/posts/$postId/comments')({
|
||||||
|
loader: async ({ params }) => {
|
||||||
|
const comments = await fetchComments(params.postId)
|
||||||
|
if (!comments) throw notFound() // Bubbles to /posts notFoundComponent
|
||||||
|
return comments
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- `notFound()` throws a special error caught by nearest `notFoundComponent`
|
||||||
|
- Not found bubbles up the route tree if not handled locally
|
||||||
|
- Use `defaultNotFoundComponent` on router for global fallback
|
||||||
|
- Pass data to `notFound({ data })` for contextual 404 pages
|
||||||
|
- Catch-all routes (`/$`) can handle truly unknown paths
|
||||||
|
- Different from error boundaries - specifically for 404 cases
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
# load-ensure-query-data: Use ensureQueryData with TanStack Query
|
||||||
|
|
||||||
|
## Priority: HIGH
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
When integrating TanStack Router with TanStack Query, use `queryClient.ensureQueryData()` in loaders instead of `prefetchQuery()`. This respects the cache, awaits data if missing, and returns the data for potential use.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Using prefetchQuery - doesn't return data, can't await stale check
|
||||||
|
export const Route = createFileRoute('/posts/$postId')({
|
||||||
|
loader: async ({ params, context: { queryClient } }) => {
|
||||||
|
// prefetchQuery never throws, swallows errors
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ['posts', params.postId],
|
||||||
|
queryFn: () => fetchPost(params.postId),
|
||||||
|
})
|
||||||
|
// No await - might not complete before render
|
||||||
|
// No return value to use
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Fetching directly - bypasses TanStack Query cache
|
||||||
|
export const Route = createFileRoute('/posts')({
|
||||||
|
loader: async () => {
|
||||||
|
const posts = await fetchPosts() // Not cached
|
||||||
|
return { posts }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Define queryOptions for reuse
|
||||||
|
const postQueryOptions = (postId: string) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ['posts', postId],
|
||||||
|
queryFn: () => fetchPost(postId),
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
})
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/posts/$postId')({
|
||||||
|
loader: async ({ params, context: { queryClient } }) => {
|
||||||
|
// ensureQueryData:
|
||||||
|
// - Returns cached data if fresh
|
||||||
|
// - Fetches and caches if missing or stale
|
||||||
|
// - Awaits completion
|
||||||
|
// - Throws on error (caught by error boundary)
|
||||||
|
await queryClient.ensureQueryData(postQueryOptions(params.postId))
|
||||||
|
},
|
||||||
|
component: PostPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function PostPage() {
|
||||||
|
const { postId } = Route.useParams()
|
||||||
|
|
||||||
|
// Data guaranteed to exist from loader
|
||||||
|
const { data: post } = useSuspenseQuery(postQueryOptions(postId))
|
||||||
|
|
||||||
|
return <PostContent post={post} />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Multiple Parallel Queries
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export const Route = createFileRoute('/dashboard')({
|
||||||
|
loader: async ({ context: { queryClient } }) => {
|
||||||
|
// Parallel data fetching
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.ensureQueryData(statsQueries.overview()),
|
||||||
|
queryClient.ensureQueryData(activityQueries.recent()),
|
||||||
|
queryClient.ensureQueryData(notificationQueries.unread()),
|
||||||
|
])
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Dependent Queries
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export const Route = createFileRoute('/users/$userId/posts')({
|
||||||
|
loader: async ({ params, context: { queryClient } }) => {
|
||||||
|
// First query needed for second
|
||||||
|
const user = await queryClient.ensureQueryData(
|
||||||
|
userQueries.detail(params.userId)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Dependent query uses result
|
||||||
|
await queryClient.ensureQueryData(
|
||||||
|
postQueries.byAuthor(user.id)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Router Configuration for TanStack Query
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// router.tsx
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 60 * 1000, // 1 minute default
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const router = createRouter({
|
||||||
|
routeTree,
|
||||||
|
context: { queryClient },
|
||||||
|
|
||||||
|
// Let TanStack Query manage caching
|
||||||
|
defaultPreloadStaleTime: 0,
|
||||||
|
|
||||||
|
// SSR: Dehydrate query cache
|
||||||
|
dehydrate: () => ({
|
||||||
|
queryClientState: dehydrate(queryClient),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// SSR: Hydrate on client
|
||||||
|
hydrate: (dehydrated) => {
|
||||||
|
hydrate(queryClient, dehydrated.queryClientState)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Wrap with QueryClientProvider
|
||||||
|
Wrap: ({ children }) => (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
{children}
|
||||||
|
</QueryClientProvider>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## ensureQueryData vs prefetchQuery vs fetchQuery
|
||||||
|
|
||||||
|
| Method | Returns | Throws | Awaits | Use Case |
|
||||||
|
|--------|---------|--------|--------|----------|
|
||||||
|
| `ensureQueryData` | Data | Yes | Yes | Route loaders (recommended) |
|
||||||
|
| `prefetchQuery` | void | No | Yes | Background prefetching |
|
||||||
|
| `fetchQuery` | Data | Yes | Yes | When you need data immediately |
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- `ensureQueryData` is the recommended method for route loaders
|
||||||
|
- Respects `staleTime` - won't refetch fresh cached data
|
||||||
|
- Errors propagate to route error boundaries
|
||||||
|
- Use `queryOptions()` factory for type-safe, reusable query definitions
|
||||||
|
- Set `defaultPreloadStaleTime: 0` to let TanStack Query manage cache
|
||||||
|
- Pair with `useSuspenseQuery` in components for guaranteed data
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
# load-parallel: Leverage Parallel Route Loading
|
||||||
|
|
||||||
|
## Priority: MEDIUM
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
TanStack Router loads nested route data in parallel, not sequentially. Structure your routes and loaders to maximize parallelization and avoid creating artificial waterfalls.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Creating waterfall with dependent beforeLoad
|
||||||
|
export const Route = createFileRoute('/dashboard')({
|
||||||
|
beforeLoad: async () => {
|
||||||
|
const user = await fetchUser() // 200ms
|
||||||
|
const permissions = await fetchPermissions(user.id) // 200ms
|
||||||
|
const preferences = await fetchPreferences(user.id) // 200ms
|
||||||
|
// Total: 600ms (sequential)
|
||||||
|
|
||||||
|
return { user, permissions, preferences }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Or nesting data dependencies incorrectly
|
||||||
|
// routes/posts.tsx
|
||||||
|
export const Route = createFileRoute('/posts')({
|
||||||
|
loader: async () => {
|
||||||
|
const posts = await fetchPosts() // 300ms
|
||||||
|
return { posts }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// routes/posts/$postId.tsx
|
||||||
|
export const Route = createFileRoute('/posts/$postId')({
|
||||||
|
loader: async ({ params }) => {
|
||||||
|
// Waits for parent to complete first - waterfall!
|
||||||
|
const post = await fetchPost(params.postId) // +200ms
|
||||||
|
return { post }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Parallel in Single Loader
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export const Route = createFileRoute('/dashboard')({
|
||||||
|
beforeLoad: async () => {
|
||||||
|
// All requests start simultaneously
|
||||||
|
const [user, config] = await Promise.all([
|
||||||
|
fetchUser(), // 200ms
|
||||||
|
fetchAppConfig(), // 150ms
|
||||||
|
])
|
||||||
|
// Total: 200ms (parallel)
|
||||||
|
|
||||||
|
return { user, config }
|
||||||
|
},
|
||||||
|
loader: async ({ context }) => {
|
||||||
|
// These also run in parallel with each other
|
||||||
|
const [stats, activity, notifications] = await Promise.all([
|
||||||
|
fetchDashboardStats(context.user.id),
|
||||||
|
fetchRecentActivity(context.user.id),
|
||||||
|
fetchNotifications(context.user.id),
|
||||||
|
])
|
||||||
|
|
||||||
|
return { stats, activity, notifications }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Parallel Nested Routes
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Parent and child loaders run in PARALLEL
|
||||||
|
// routes/posts.tsx
|
||||||
|
export const Route = createFileRoute('/posts')({
|
||||||
|
loader: async () => {
|
||||||
|
// This runs...
|
||||||
|
const categories = await fetchCategories()
|
||||||
|
return { categories }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// routes/posts/$postId.tsx
|
||||||
|
export const Route = createFileRoute('/posts/$postId')({
|
||||||
|
loader: async ({ params }) => {
|
||||||
|
// ...at the SAME TIME as this!
|
||||||
|
const post = await fetchPost(params.postId)
|
||||||
|
const comments = await fetchComments(params.postId)
|
||||||
|
return { post, comments }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Navigation to /posts/123:
|
||||||
|
// - Both loaders start simultaneously
|
||||||
|
// - Total time = max(categoriesTime, postTime + commentsTime)
|
||||||
|
// - NOT categoriesTime + postTime + commentsTime
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: With TanStack Query
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// routes/posts.tsx
|
||||||
|
export const Route = createFileRoute('/posts')({
|
||||||
|
loader: async ({ context: { queryClient } }) => {
|
||||||
|
// These all start in parallel
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.ensureQueryData(postQueries.list()),
|
||||||
|
queryClient.ensureQueryData(categoryQueries.all()),
|
||||||
|
])
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// routes/posts/$postId.tsx
|
||||||
|
export const Route = createFileRoute('/posts/$postId')({
|
||||||
|
loader: async ({ params, context: { queryClient } }) => {
|
||||||
|
// Runs in parallel with parent loader
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.ensureQueryData(postQueries.detail(params.postId)),
|
||||||
|
queryClient.ensureQueryData(commentQueries.forPost(params.postId)),
|
||||||
|
])
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Streaming Non-Critical Data
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export const Route = createFileRoute('/posts/$postId')({
|
||||||
|
loader: async ({ params, context: { queryClient } }) => {
|
||||||
|
// Critical data - await
|
||||||
|
const post = await queryClient.ensureQueryData(
|
||||||
|
postQueries.detail(params.postId)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Non-critical - start but don't await (stream in later)
|
||||||
|
queryClient.prefetchQuery(commentQueries.forPost(params.postId))
|
||||||
|
queryClient.prefetchQuery(relatedQueries.forPost(params.postId))
|
||||||
|
|
||||||
|
return { post }
|
||||||
|
},
|
||||||
|
component: PostPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function PostPage() {
|
||||||
|
const { post } = Route.useLoaderData()
|
||||||
|
const { postId } = Route.useParams()
|
||||||
|
|
||||||
|
// Critical data ready immediately
|
||||||
|
// Non-critical loads in component with loading state
|
||||||
|
const { data: comments, isLoading } = useQuery(
|
||||||
|
commentQueries.forPost(postId)
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article>
|
||||||
|
<PostContent post={post} />
|
||||||
|
{isLoading ? <CommentsSkeleton /> : <Comments data={comments} />}
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Route Loading Timeline
|
||||||
|
|
||||||
|
```
|
||||||
|
Navigation to /posts/123
|
||||||
|
|
||||||
|
Without parallelization:
|
||||||
|
├─ beforeLoad (parent) ████████
|
||||||
|
├─ loader (parent) ████████
|
||||||
|
├─ beforeLoad (child) ████
|
||||||
|
├─ loader (child) ████████
|
||||||
|
└─ Render █
|
||||||
|
|
||||||
|
With parallelization:
|
||||||
|
├─ beforeLoad (parent) ████████
|
||||||
|
├─ beforeLoad (child) ████
|
||||||
|
├─ loader (parent) ████████
|
||||||
|
├─ loader (child) ████████████
|
||||||
|
└─ Render █
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Nested route loaders run in parallel by default
|
||||||
|
- `beforeLoad` runs before `loader` (for auth, context setup)
|
||||||
|
- Use `Promise.all` for parallel fetches within a single loader
|
||||||
|
- Parent context is available in child loaders (after beforeLoad)
|
||||||
|
- Prefetch non-critical data without awaiting for streaming
|
||||||
|
- Monitor network tab to verify parallelization
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# load-use-loaders: Use Route Loaders for Data Fetching
|
||||||
|
|
||||||
|
## Priority: HIGH
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
Route loaders execute before the route renders, enabling data to be ready when the component mounts. This prevents loading waterfalls, enables preloading, and integrates with the router's caching layer.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Fetching in component - creates waterfall
|
||||||
|
function PostsPage() {
|
||||||
|
const [posts, setPosts] = useState<Post[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Route renders, THEN data fetches, THEN UI updates
|
||||||
|
fetchPosts().then((data) => {
|
||||||
|
setPosts(data)
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (loading) return <Loading />
|
||||||
|
return <PostList posts={posts} />
|
||||||
|
}
|
||||||
|
|
||||||
|
// No preloading possible - user sees loading state on navigation
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// routes/posts.tsx
|
||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/posts')({
|
||||||
|
loader: async () => {
|
||||||
|
const posts = await fetchPosts()
|
||||||
|
return { posts }
|
||||||
|
},
|
||||||
|
component: PostsPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function PostsPage() {
|
||||||
|
// Data is ready when component mounts - no loading state needed
|
||||||
|
const { posts } = Route.useLoaderData()
|
||||||
|
return <PostList posts={posts} />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: With Parameters
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// routes/posts/$postId.tsx
|
||||||
|
export const Route = createFileRoute('/posts/$postId')({
|
||||||
|
loader: async ({ params }) => {
|
||||||
|
// params are type-safe and guaranteed to exist
|
||||||
|
const post = await fetchPost(params.postId)
|
||||||
|
const comments = await fetchComments(params.postId)
|
||||||
|
return { post, comments }
|
||||||
|
},
|
||||||
|
component: PostDetailPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function PostDetailPage() {
|
||||||
|
const { post, comments } = Route.useLoaderData()
|
||||||
|
const { postId } = Route.useParams()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article>
|
||||||
|
<h1>{post.title}</h1>
|
||||||
|
<PostContent content={post.content} />
|
||||||
|
<CommentList comments={comments} />
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: With TanStack Query
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// routes/posts/$postId.tsx
|
||||||
|
import { queryOptions } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
const postQueryOptions = (postId: string) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ['posts', postId],
|
||||||
|
queryFn: () => fetchPost(postId),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/posts/$postId')({
|
||||||
|
loader: async ({ params, context: { queryClient } }) => {
|
||||||
|
// Ensure data is in cache before render
|
||||||
|
await queryClient.ensureQueryData(postQueryOptions(params.postId))
|
||||||
|
},
|
||||||
|
component: PostDetailPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function PostDetailPage() {
|
||||||
|
const { postId } = Route.useParams()
|
||||||
|
// useSuspenseQuery because loader guarantees data exists
|
||||||
|
const { data: post } = useSuspenseQuery(postQueryOptions(postId))
|
||||||
|
|
||||||
|
return <PostContent post={post} />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Loader Context Properties
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export const Route = createFileRoute('/posts')({
|
||||||
|
loader: async ({
|
||||||
|
params, // Route path parameters
|
||||||
|
context, // Route context (queryClient, auth, etc.)
|
||||||
|
abortController, // For cancelling stale requests
|
||||||
|
cause, // 'enter' | 'preload' | 'stay'
|
||||||
|
deps, // Dependencies from loaderDeps
|
||||||
|
preload, // Boolean: true if preloading
|
||||||
|
}) => {
|
||||||
|
// Use abortController for fetch cancellation
|
||||||
|
const response = await fetch('/api/posts', {
|
||||||
|
signal: abortController.signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Different behavior for preload vs navigation
|
||||||
|
if (preload) {
|
||||||
|
// Lighter data for preload
|
||||||
|
return { posts: await response.json() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full data for actual navigation
|
||||||
|
const posts = await response.json()
|
||||||
|
const stats = await fetchStats()
|
||||||
|
return { posts, stats }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Loaders run during route matching, before component render
|
||||||
|
- Supports parallel loading across nested routes
|
||||||
|
- Enables preloading on link hover/focus
|
||||||
|
- Built-in stale-while-revalidate caching
|
||||||
|
- For complex caching needs, integrate with TanStack Query
|
||||||
|
- Use `beforeLoad` for auth checks and redirects
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
# nav-link-component: Prefer Link Component for Navigation
|
||||||
|
|
||||||
|
## Priority: MEDIUM
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
Use the `<Link>` component for navigation instead of `useNavigate()` when possible. Links render proper `<a>` tags with valid `href` attributes, enabling right-click → open in new tab, better SEO, and accessibility.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Using onClick with navigate - loses standard link behavior
|
||||||
|
function PostCard({ post }: { post: Post }) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={() => navigate({ to: '/posts/$postId', params: { postId: post.id } })}
|
||||||
|
className="post-card"
|
||||||
|
>
|
||||||
|
<h2>{post.title}</h2>
|
||||||
|
<p>{post.excerpt}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Problems:
|
||||||
|
// - No right-click → open in new tab
|
||||||
|
// - No cmd/ctrl+click for new tab
|
||||||
|
// - Not announced as link to screen readers
|
||||||
|
// - No valid href for SEO
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
function PostCard({ post }: { post: Post }) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to="/posts/$postId"
|
||||||
|
params={{ postId: post.id }}
|
||||||
|
className="post-card"
|
||||||
|
>
|
||||||
|
<h2>{post.title}</h2>
|
||||||
|
<p>{post.excerpt}</p>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Benefits:
|
||||||
|
// - Renders <a href="/posts/123">
|
||||||
|
// - Right-click menu works
|
||||||
|
// - Cmd/Ctrl+click opens new tab
|
||||||
|
// - Screen readers announce as link
|
||||||
|
// - Preloading works on hover
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: With Search Params
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function FilteredLink() {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to="/products"
|
||||||
|
search={{ category: 'electronics', sort: 'price' }}
|
||||||
|
>
|
||||||
|
View Electronics
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserving existing search params
|
||||||
|
function SortLink({ sort }: { sort: 'asc' | 'desc' }) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to="." // Current route
|
||||||
|
search={(prev) => ({ ...prev, sort })}
|
||||||
|
>
|
||||||
|
Sort {sort === 'asc' ? 'Ascending' : 'Descending'}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: With Active States
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function NavLink({ to, children }: { to: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={to}
|
||||||
|
activeProps={{
|
||||||
|
className: 'nav-link-active',
|
||||||
|
'aria-current': 'page',
|
||||||
|
}}
|
||||||
|
inactiveProps={{
|
||||||
|
className: 'nav-link',
|
||||||
|
}}
|
||||||
|
activeOptions={{
|
||||||
|
exact: true, // Only active on exact match
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Or use render props for more control
|
||||||
|
function CustomNavLink({ to, children }: { to: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Link to={to}>
|
||||||
|
{({ isActive }) => (
|
||||||
|
<span className={isActive ? 'text-blue-600 font-bold' : 'text-gray-600'}>
|
||||||
|
{children}
|
||||||
|
{isActive && <CheckIcon className="ml-2" />}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: With Preloading
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function PostList({ posts }: { posts: Post[] }) {
|
||||||
|
return (
|
||||||
|
<ul>
|
||||||
|
{posts.map(post => (
|
||||||
|
<li key={post.id}>
|
||||||
|
<Link
|
||||||
|
to="/posts/$postId"
|
||||||
|
params={{ postId: post.id }}
|
||||||
|
preload="intent" // Preload on hover/focus
|
||||||
|
preloadDelay={100} // Wait 100ms before preloading
|
||||||
|
>
|
||||||
|
{post.title}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## When to Use useNavigate Instead
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 1. After form submission
|
||||||
|
const createPost = useMutation({
|
||||||
|
mutationFn: submitPost,
|
||||||
|
onSuccess: (data) => {
|
||||||
|
navigate({ to: '/posts/$postId', params: { postId: data.id } })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2. After authentication
|
||||||
|
async function handleLogin(credentials: Credentials) {
|
||||||
|
await login(credentials)
|
||||||
|
navigate({ to: '/dashboard' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Programmatic redirects
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
navigate({ to: '/login', search: { redirect: location.pathname } })
|
||||||
|
}
|
||||||
|
}, [isAuthenticated])
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- `<Link>` renders actual `<a>` tags with proper `href`
|
||||||
|
- Supports all standard link behaviors (middle-click, cmd+click, etc.)
|
||||||
|
- Enables preloading on hover/focus
|
||||||
|
- Better for SEO - crawlers can follow links
|
||||||
|
- Reserve `useNavigate` for side effects and programmatic navigation
|
||||||
|
- Use `<Navigate>` component for immediate redirects on render
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
# nav-route-masks: Use Route Masks for Modal URLs
|
||||||
|
|
||||||
|
## Priority: LOW
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
Route masks let you display one URL while internally routing to another. This is useful for modals, sheets, and overlays where you want a shareable URL that shows the modal, but navigating there directly should show the full page.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Modal without proper URL handling
|
||||||
|
function PostList() {
|
||||||
|
const [selectedPost, setSelectedPost] = useState<string | null>(null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{posts.map(post => (
|
||||||
|
<div key={post.id} onClick={() => setSelectedPost(post.id)}>
|
||||||
|
{post.title}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{selectedPost && (
|
||||||
|
<Modal onClose={() => setSelectedPost(null)}>
|
||||||
|
<PostDetail postId={selectedPost} />
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Problems:
|
||||||
|
// - URL doesn't change when modal opens
|
||||||
|
// - Can't share link to modal
|
||||||
|
// - Back button doesn't close modal
|
||||||
|
// - Refresh loses modal state
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Route Masks for Modal
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// routes/posts.tsx
|
||||||
|
export const Route = createFileRoute('/posts')({
|
||||||
|
component: PostList,
|
||||||
|
})
|
||||||
|
|
||||||
|
function PostList() {
|
||||||
|
const posts = usePosts()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{posts.map(post => (
|
||||||
|
<Link
|
||||||
|
key={post.id}
|
||||||
|
to="/posts/$postId"
|
||||||
|
params={{ postId: post.id }}
|
||||||
|
mask={{
|
||||||
|
to: '/posts',
|
||||||
|
// URL shows /posts but routes to /posts/$postId
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{post.title}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
<Outlet /> {/* Modal renders here */}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// routes/posts/$postId.tsx
|
||||||
|
export const Route = createFileRoute('/posts/$postId')({
|
||||||
|
component: PostModal,
|
||||||
|
})
|
||||||
|
|
||||||
|
function PostModal() {
|
||||||
|
const { postId } = Route.useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal onClose={() => navigate({ to: '/posts' })}>
|
||||||
|
<PostDetail postId={postId} />
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// User clicks post:
|
||||||
|
// - URL stays /posts (masked)
|
||||||
|
// - PostModal renders
|
||||||
|
// - Share link goes to /posts/$postId (real URL)
|
||||||
|
// - Direct navigation to /posts/$postId shows full page (no mask)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: With Search Params
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function PostList() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{posts.map(post => (
|
||||||
|
<Link
|
||||||
|
key={post.id}
|
||||||
|
to="/posts/$postId"
|
||||||
|
params={{ postId: post.id }}
|
||||||
|
mask={{
|
||||||
|
to: '/posts',
|
||||||
|
search: { modal: post.id }, // /posts?modal=123
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{post.title}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Programmatic Navigation with Mask
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function PostCard({ post }: { post: Post }) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const openInModal = () => {
|
||||||
|
navigate({
|
||||||
|
to: '/posts/$postId',
|
||||||
|
params: { postId: post.id },
|
||||||
|
mask: {
|
||||||
|
to: '/posts',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const openFullPage = () => {
|
||||||
|
navigate({
|
||||||
|
to: '/posts/$postId',
|
||||||
|
params: { postId: post.id },
|
||||||
|
// No mask - shows real URL
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3>{post.title}</h3>
|
||||||
|
<button onClick={openInModal}>Quick View</button>
|
||||||
|
<button onClick={openFullPage}>Full Page</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Unmask on Interaction
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function PostModal() {
|
||||||
|
const { postId } = Route.useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const expandToFullPage = () => {
|
||||||
|
// Navigate to real URL, removing mask
|
||||||
|
navigate({
|
||||||
|
to: '/posts/$postId',
|
||||||
|
params: { postId },
|
||||||
|
// No mask = real URL
|
||||||
|
replace: true, // Replace history entry
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal>
|
||||||
|
<PostDetail postId={postId} />
|
||||||
|
<button onClick={expandToFullPage}>
|
||||||
|
Expand to full page
|
||||||
|
</button>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Route Mask Behavior
|
||||||
|
|
||||||
|
| Scenario | URL Shown | Actual Route |
|
||||||
|
|----------|-----------|--------------|
|
||||||
|
| Click masked link | Masked URL | Real route |
|
||||||
|
| Share/copy URL | Real URL | Real route |
|
||||||
|
| Direct navigation | Real URL | Real route |
|
||||||
|
| Browser refresh | Depends on URL in bar | Matches URL |
|
||||||
|
| Back button | Previous URL | Previous route |
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Masks are client-side only - shared URLs are the real route
|
||||||
|
- Direct navigation to real URL bypasses mask (shows full page)
|
||||||
|
- Back button navigates through history correctly
|
||||||
|
- Use for modals, side panels, quick views
|
||||||
|
- Masks can include different search params
|
||||||
|
- Consider UX: users expect shared URLs to work
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# org-virtual-routes: Understand Virtual File Routes
|
||||||
|
|
||||||
|
## Priority: LOW
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
Virtual routes are automatically generated placeholder routes in the route tree when you have a `.lazy.tsx` file without a corresponding main route file. They provide the minimal configuration needed to anchor lazy-loaded components.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Creating unnecessary boilerplate main route files
|
||||||
|
// routes/settings.tsx - Just to have a file
|
||||||
|
export const Route = createFileRoute('/settings')({
|
||||||
|
// Empty - no loader, no beforeLoad, nothing
|
||||||
|
})
|
||||||
|
|
||||||
|
// routes/settings.lazy.tsx - Actual component
|
||||||
|
export const Route = createLazyFileRoute('/settings')({
|
||||||
|
component: SettingsPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
// The main file is unnecessary boilerplate
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Let Virtual Routes Handle It
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Delete routes/settings.tsx entirely!
|
||||||
|
|
||||||
|
// routes/settings.lazy.tsx - Only file needed
|
||||||
|
export const Route = createLazyFileRoute('/settings')({
|
||||||
|
component: SettingsPage,
|
||||||
|
pendingComponent: SettingsLoading,
|
||||||
|
errorComponent: SettingsError,
|
||||||
|
})
|
||||||
|
|
||||||
|
function SettingsPage() {
|
||||||
|
return <div>Settings Content</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// TanStack Router auto-generates a virtual route:
|
||||||
|
// {
|
||||||
|
// path: '/settings',
|
||||||
|
// // Minimal config to anchor the lazy file
|
||||||
|
// }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: When You DO Need Main Route File
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// routes/dashboard.tsx - Need this for loader/beforeLoad
|
||||||
|
export const Route = createFileRoute('/dashboard')({
|
||||||
|
beforeLoad: async ({ context }) => {
|
||||||
|
if (!context.auth.isAuthenticated) {
|
||||||
|
throw redirect({ to: '/login' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
loader: async ({ context: { queryClient } }) => {
|
||||||
|
await queryClient.ensureQueryData(dashboardQueries.stats())
|
||||||
|
},
|
||||||
|
// Component is in lazy file
|
||||||
|
})
|
||||||
|
|
||||||
|
// routes/dashboard.lazy.tsx
|
||||||
|
export const Route = createLazyFileRoute('/dashboard')({
|
||||||
|
component: DashboardPage,
|
||||||
|
pendingComponent: DashboardSkeleton,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Main file IS needed here because we have loader/beforeLoad
|
||||||
|
```
|
||||||
|
|
||||||
|
## Decision Guide
|
||||||
|
|
||||||
|
| Route Has... | Need Main File? | Use Virtual? |
|
||||||
|
|--------------|-----------------|--------------|
|
||||||
|
| Only component | No | Yes |
|
||||||
|
| loader | Yes | No |
|
||||||
|
| beforeLoad | Yes | No |
|
||||||
|
| validateSearch | Yes | No |
|
||||||
|
| loaderDeps | Yes | No |
|
||||||
|
| Just pendingComponent/errorComponent | No | Yes |
|
||||||
|
|
||||||
|
## Good Example: File Structure with Virtual Routes
|
||||||
|
|
||||||
|
```
|
||||||
|
routes/
|
||||||
|
├── __root.tsx # Always needed
|
||||||
|
├── index.tsx # Has loader
|
||||||
|
├── about.lazy.tsx # Virtual route (no main file)
|
||||||
|
├── contact.lazy.tsx # Virtual route (no main file)
|
||||||
|
├── dashboard.tsx # Has beforeLoad (auth)
|
||||||
|
├── dashboard.lazy.tsx # Component
|
||||||
|
├── posts.tsx # Has loader
|
||||||
|
├── posts.lazy.tsx # Component
|
||||||
|
├── posts/
|
||||||
|
│ ├── $postId.tsx # Has loader
|
||||||
|
│ └── $postId.lazy.tsx # Component
|
||||||
|
└── settings/
|
||||||
|
├── index.lazy.tsx # Virtual route
|
||||||
|
├── profile.lazy.tsx # Virtual route
|
||||||
|
└── security.tsx # Has beforeLoad (requires re-auth)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Generated Route Tree
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// routeTree.gen.ts (auto-generated)
|
||||||
|
import { Route as rootRoute } from './routes/__root'
|
||||||
|
import { Route as aboutLazyRoute } from './routes/about.lazy' // Virtual parent
|
||||||
|
|
||||||
|
export const routeTree = rootRoute.addChildren([
|
||||||
|
// Virtual route created for about.lazy.tsx
|
||||||
|
createRoute({
|
||||||
|
path: '/about',
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
}).lazy(() => import('./routes/about.lazy').then(m => m.Route)),
|
||||||
|
|
||||||
|
// Regular route with explicit main file
|
||||||
|
dashboardRoute.addChildren([...]),
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Virtual routes reduce boilerplate for simple pages
|
||||||
|
- Only works with file-based routing
|
||||||
|
- Auto-generated in `routeTree.gen.ts`
|
||||||
|
- Main route file needed for any "critical path" config
|
||||||
|
- Critical: loader, beforeLoad, validateSearch, loaderDeps, context
|
||||||
|
- Non-critical (can be in lazy): component, pendingComponent, errorComponent
|
||||||
|
- Check generated route tree to verify virtual routes
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# preload-intent: Enable Intent-Based Preloading
|
||||||
|
|
||||||
|
## Priority: MEDIUM
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
Configure `defaultPreload: 'intent'` to preload routes when users hover or focus links. This loads data before the click, making navigation feel instant.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// No preloading configured - data loads after click
|
||||||
|
const router = createRouter({
|
||||||
|
routeTree,
|
||||||
|
// No defaultPreload - user waits after every navigation
|
||||||
|
})
|
||||||
|
|
||||||
|
// Each navigation shows loading state
|
||||||
|
function PostList({ posts }: { posts: Post[] }) {
|
||||||
|
return (
|
||||||
|
<ul>
|
||||||
|
{posts.map(post => (
|
||||||
|
<li key={post.id}>
|
||||||
|
<Link to="/posts/$postId" params={{ postId: post.id }}>
|
||||||
|
{post.title}
|
||||||
|
</Link>
|
||||||
|
{/* Click → wait for data → render */}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// router.tsx - Enable preloading by default
|
||||||
|
const router = createRouter({
|
||||||
|
routeTree,
|
||||||
|
defaultPreload: 'intent', // Preload on hover/focus
|
||||||
|
defaultPreloadDelay: 50, // Wait 50ms before starting
|
||||||
|
})
|
||||||
|
|
||||||
|
declare module '@tanstack/react-router' {
|
||||||
|
interface Register {
|
||||||
|
router: typeof router
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Links automatically preload on hover
|
||||||
|
function PostList({ posts }: { posts: Post[] }) {
|
||||||
|
return (
|
||||||
|
<ul>
|
||||||
|
{posts.map(post => (
|
||||||
|
<li key={post.id}>
|
||||||
|
<Link to="/posts/$postId" params={{ postId: post.id }}>
|
||||||
|
{post.title}
|
||||||
|
</Link>
|
||||||
|
{/* Hover → preload starts → click → instant navigation */}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Preload Options
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Router-level defaults
|
||||||
|
const router = createRouter({
|
||||||
|
routeTree,
|
||||||
|
defaultPreload: 'intent', // 'intent' | 'render' | 'viewport' | false
|
||||||
|
defaultPreloadDelay: 50, // ms before preload starts
|
||||||
|
defaultPreloadStaleTime: 30000, // 30s - how long preloaded data stays fresh
|
||||||
|
})
|
||||||
|
|
||||||
|
// Link-level overrides
|
||||||
|
<Link
|
||||||
|
to="/heavy-page"
|
||||||
|
preload={false} // Disable for this specific link
|
||||||
|
>
|
||||||
|
Heavy Page
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
to="/critical-page"
|
||||||
|
preload="render" // Preload immediately when Link renders
|
||||||
|
>
|
||||||
|
Critical Page
|
||||||
|
</Link>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Preload Strategies
|
||||||
|
|
||||||
|
| Strategy | Behavior | Use Case |
|
||||||
|
|----------|----------|----------|
|
||||||
|
| `'intent'` | Preload on hover/focus | Default for most links |
|
||||||
|
| `'render'` | Preload when Link mounts | Critical next pages |
|
||||||
|
| `'viewport'` | Preload when Link enters viewport | Below-fold content |
|
||||||
|
| `false` | No preloading | Heavy, rarely-visited pages |
|
||||||
|
|
||||||
|
## Good Example: With TanStack Query Integration
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// When using TanStack Query, disable router cache
|
||||||
|
const router = createRouter({
|
||||||
|
routeTree,
|
||||||
|
defaultPreload: 'intent',
|
||||||
|
defaultPreloadStaleTime: 0, // Let TanStack Query manage cache
|
||||||
|
context: {
|
||||||
|
queryClient,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Route loader uses TanStack Query
|
||||||
|
export const Route = createFileRoute('/posts/$postId')({
|
||||||
|
loader: async ({ params, context: { queryClient } }) => {
|
||||||
|
// ensureQueryData respects TanStack Query's staleTime
|
||||||
|
await queryClient.ensureQueryData(postQueries.detail(params.postId))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Preloading loads route code AND executes loaders
|
||||||
|
- `preloadDelay` prevents excessive requests on quick mouse movements
|
||||||
|
- Preloaded data is garbage collected after `preloadStaleTime`
|
||||||
|
- Works with both router caching and external caching (TanStack Query)
|
||||||
|
- Mobile: Consider `'viewport'` since hover isn't available
|
||||||
|
- Monitor network tab to verify preloading works correctly
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
# router-default-options: Configure Router Default Options
|
||||||
|
|
||||||
|
## Priority: HIGH
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
TanStack Router's `createRouter` accepts several default options that apply globally. Configure these for consistent behavior across your application including error handling, scroll restoration, and performance optimizations.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Minimal router - missing useful defaults
|
||||||
|
const router = createRouter({
|
||||||
|
routeTree,
|
||||||
|
context: { queryClient },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Each route must handle its own errors
|
||||||
|
// No scroll restoration on navigation
|
||||||
|
// No preloading configured
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Full Configuration
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { QueryClient } from '@tanstack/react-query'
|
||||||
|
import { createRouter } from '@tanstack/react-router'
|
||||||
|
import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query'
|
||||||
|
import { routeTree } from './routeTree.gen'
|
||||||
|
import { DefaultCatchBoundary } from '@/components/DefaultCatchBoundary'
|
||||||
|
import { DefaultNotFound } from '@/components/DefaultNotFound'
|
||||||
|
|
||||||
|
export function getRouter() {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
staleTime: 1000 * 60 * 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
routeTree,
|
||||||
|
context: { queryClient, user: null },
|
||||||
|
|
||||||
|
// Preloading
|
||||||
|
defaultPreload: 'intent', // Preload on hover/focus
|
||||||
|
defaultPreloadStaleTime: 0, // Let Query manage freshness
|
||||||
|
|
||||||
|
// Error handling
|
||||||
|
defaultErrorComponent: DefaultCatchBoundary,
|
||||||
|
defaultNotFoundComponent: DefaultNotFound,
|
||||||
|
|
||||||
|
// UX
|
||||||
|
scrollRestoration: true, // Restore scroll on back/forward
|
||||||
|
|
||||||
|
// Performance
|
||||||
|
defaultStructuralSharing: true, // Optimize re-renders
|
||||||
|
})
|
||||||
|
|
||||||
|
setupRouterSsrQueryIntegration({
|
||||||
|
router,
|
||||||
|
queryClient,
|
||||||
|
})
|
||||||
|
|
||||||
|
return router
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: DefaultCatchBoundary Component
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// components/DefaultCatchBoundary.tsx
|
||||||
|
import { ErrorComponent, useRouter } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
export function DefaultCatchBoundary({ error }: { error: Error }) {
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="error-container">
|
||||||
|
<h1>Something went wrong</h1>
|
||||||
|
<ErrorComponent error={error} />
|
||||||
|
<button onClick={() => router.invalidate()}>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: DefaultNotFound Component
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// components/DefaultNotFound.tsx
|
||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
export function DefaultNotFound() {
|
||||||
|
return (
|
||||||
|
<div className="not-found-container">
|
||||||
|
<h1>404 - Page Not Found</h1>
|
||||||
|
<p>The page you're looking for doesn't exist.</p>
|
||||||
|
<Link to="/">Go home</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Router Options Reference
|
||||||
|
|
||||||
|
| Option | Type | Default | Description |
|
||||||
|
|--------|------|---------|-------------|
|
||||||
|
| `defaultPreload` | `false \| 'intent' \| 'render' \| 'viewport'` | `false` | When to preload routes |
|
||||||
|
| `defaultPreloadStaleTime` | `number` | `30000` | How long preloaded data stays fresh (ms) |
|
||||||
|
| `defaultErrorComponent` | `Component` | Built-in | Global error boundary |
|
||||||
|
| `defaultNotFoundComponent` | `Component` | Built-in | Global 404 page |
|
||||||
|
| `scrollRestoration` | `boolean` | `false` | Restore scroll on navigation |
|
||||||
|
| `defaultStructuralSharing` | `boolean` | `true` | Optimize loader data re-renders |
|
||||||
|
|
||||||
|
## Good Example: Route-Level Overrides
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Routes can override defaults
|
||||||
|
export const Route = createFileRoute('/admin')({
|
||||||
|
// Custom error handling for admin section
|
||||||
|
errorComponent: AdminErrorBoundary,
|
||||||
|
notFoundComponent: AdminNotFound,
|
||||||
|
|
||||||
|
// Disable preload for sensitive routes
|
||||||
|
preload: false,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: With Pending Component
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const router = createRouter({
|
||||||
|
routeTree,
|
||||||
|
context: { queryClient },
|
||||||
|
|
||||||
|
defaultPreload: 'intent',
|
||||||
|
defaultPreloadStaleTime: 0,
|
||||||
|
defaultErrorComponent: DefaultCatchBoundary,
|
||||||
|
defaultNotFoundComponent: DefaultNotFound,
|
||||||
|
scrollRestoration: true,
|
||||||
|
|
||||||
|
// Show during route transitions
|
||||||
|
defaultPendingComponent: () => (
|
||||||
|
<div className="loading-bar" />
|
||||||
|
),
|
||||||
|
defaultPendingMinMs: 200, // Min time to show pending UI
|
||||||
|
defaultPendingMs: 1000, // Delay before showing pending UI
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Set `defaultPreloadStaleTime: 0` when using TanStack Query
|
||||||
|
- `scrollRestoration: true` improves back/forward navigation UX
|
||||||
|
- `defaultStructuralSharing` prevents unnecessary re-renders
|
||||||
|
- Route-level options override router defaults
|
||||||
|
- Error/NotFound components receive route context
|
||||||
|
- Pending components help with perceived performance
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
# search-custom-serializer: Configure Custom Search Param Serializers
|
||||||
|
|
||||||
|
## Priority: LOW
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
By default, TanStack Router serializes search params as JSON. For cleaner URLs or compatibility with external systems, you can provide custom serializers using libraries like `qs`, `query-string`, or your own implementation.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Default JSON serialization creates ugly URLs
|
||||||
|
// URL: /products?filters=%7B%22category%22%3A%22electronics%22%2C%22inStock%22%3Atrue%7D
|
||||||
|
|
||||||
|
// Or manually parsing/serializing inconsistently
|
||||||
|
function ProductList() {
|
||||||
|
const searchParams = new URLSearchParams(window.location.search)
|
||||||
|
const filters = JSON.parse(searchParams.get('filters') || '{}')
|
||||||
|
// Inconsistent with router's handling
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Using JSURL for Compact URLs
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { createRouter } from '@tanstack/react-router'
|
||||||
|
import JSURL from 'jsurl2'
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
routeTree,
|
||||||
|
search: {
|
||||||
|
// Custom serializer for compact, URL-safe encoding
|
||||||
|
serialize: (search) => JSURL.stringify(search),
|
||||||
|
parse: (searchString) => JSURL.parse(searchString) || {},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// URL: /products?~(category~'electronics~inStock~true)
|
||||||
|
// Much shorter than JSON!
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Using query-string for Flat Params
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { createRouter } from '@tanstack/react-router'
|
||||||
|
import queryString from 'query-string'
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
routeTree,
|
||||||
|
search: {
|
||||||
|
serialize: (search) =>
|
||||||
|
queryString.stringify(search, {
|
||||||
|
arrayFormat: 'bracket',
|
||||||
|
skipNull: true,
|
||||||
|
}),
|
||||||
|
parse: (searchString) =>
|
||||||
|
queryString.parse(searchString, {
|
||||||
|
arrayFormat: 'bracket',
|
||||||
|
parseBooleans: true,
|
||||||
|
parseNumbers: true,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// URL: /products?category=electronics&inStock=true&tags[]=sale&tags[]=new
|
||||||
|
// Traditional query string format
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Using qs for Nested Objects
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { createRouter } from '@tanstack/react-router'
|
||||||
|
import qs from 'qs'
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
routeTree,
|
||||||
|
search: {
|
||||||
|
serialize: (search) =>
|
||||||
|
qs.stringify(search, {
|
||||||
|
encodeValuesOnly: true,
|
||||||
|
arrayFormat: 'brackets',
|
||||||
|
}),
|
||||||
|
parse: (searchString) =>
|
||||||
|
qs.parse(searchString, {
|
||||||
|
ignoreQueryPrefix: true,
|
||||||
|
decoder(value) {
|
||||||
|
// Parse booleans and numbers
|
||||||
|
if (value === 'true') return true
|
||||||
|
if (value === 'false') return false
|
||||||
|
if (/^-?\d+$/.test(value)) return parseInt(value, 10)
|
||||||
|
return value
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// URL: /products?filters[category]=electronics&filters[price][min]=100&filters[price][max]=500
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Base64 for Complex State
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { createRouter } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
routeTree,
|
||||||
|
search: {
|
||||||
|
serialize: (search) => {
|
||||||
|
if (Object.keys(search).length === 0) return ''
|
||||||
|
const json = JSON.stringify(search)
|
||||||
|
return btoa(json) // Base64 encode
|
||||||
|
},
|
||||||
|
parse: (searchString) => {
|
||||||
|
if (!searchString) return {}
|
||||||
|
try {
|
||||||
|
return JSON.parse(atob(searchString)) // Base64 decode
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// URL: /products?eyJjYXRlZ29yeSI6ImVsZWN0cm9uaWNzIn0
|
||||||
|
// Opaque but compact
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Hybrid Approach
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Some params as regular query, complex ones as JSON
|
||||||
|
import { createRouter } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
routeTree,
|
||||||
|
search: {
|
||||||
|
serialize: (search) => {
|
||||||
|
const { filters, ...simple } = search
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
|
||||||
|
// Simple values as regular params
|
||||||
|
Object.entries(simple).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined) {
|
||||||
|
params.set(key, String(value))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Complex filters as JSON
|
||||||
|
if (filters && Object.keys(filters).length > 0) {
|
||||||
|
params.set('filters', JSON.stringify(filters))
|
||||||
|
}
|
||||||
|
|
||||||
|
return params.toString()
|
||||||
|
},
|
||||||
|
parse: (searchString) => {
|
||||||
|
const params = new URLSearchParams(searchString)
|
||||||
|
const result: Record<string, unknown> = {}
|
||||||
|
|
||||||
|
params.forEach((value, key) => {
|
||||||
|
if (key === 'filters') {
|
||||||
|
result.filters = JSON.parse(value)
|
||||||
|
} else if (value === 'true') {
|
||||||
|
result[key] = true
|
||||||
|
} else if (value === 'false') {
|
||||||
|
result[key] = false
|
||||||
|
} else if (/^-?\d+$/.test(value)) {
|
||||||
|
result[key] = parseInt(value, 10)
|
||||||
|
} else {
|
||||||
|
result[key] = value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// URL: /products?page=1&sort=price&filters={"category":"electronics","inStock":true}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Serializer Comparison
|
||||||
|
|
||||||
|
| Library | URL Style | Best For |
|
||||||
|
|---------|-----------|----------|
|
||||||
|
| Default (JSON) | `?data=%7B...%7D` | TypeScript safety |
|
||||||
|
| jsurl2 | `?~(key~'value)` | Compact, readable |
|
||||||
|
| query-string | `?key=value&arr[]=1` | Traditional APIs |
|
||||||
|
| qs | `?obj[nested]=value` | Deep nesting |
|
||||||
|
| Base64 | `?eyJrZXkiOiJ2YWx1ZSJ9` | Opaque, compact |
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Custom serializers apply globally to all routes
|
||||||
|
- Route-level `validateSearch` still works after parsing
|
||||||
|
- Consider URL length limits (~2000 chars for safe cross-browser)
|
||||||
|
- SEO: Search engines may not understand custom formats
|
||||||
|
- Bookmarkability: Users can't easily modify opaque URLs
|
||||||
|
- Debugging: JSON is easier to read in browser devtools
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
# search-validation: Always Validate Search Params
|
||||||
|
|
||||||
|
## Priority: HIGH
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
Search params come from the URL - user-controlled input that must be validated. Use `validateSearch` to parse, validate, and provide defaults. This ensures type safety and prevents runtime errors from malformed URLs.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// No validation - trusting URL input directly
|
||||||
|
export const Route = createFileRoute('/products')({
|
||||||
|
component: ProductsPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function ProductsPage() {
|
||||||
|
// Accessing raw search params without validation
|
||||||
|
const searchParams = new URLSearchParams(window.location.search)
|
||||||
|
const page = parseInt(searchParams.get('page') || '1') // Could be NaN
|
||||||
|
const sort = searchParams.get('sort') as 'asc' | 'desc' // Could be anything
|
||||||
|
|
||||||
|
// Runtime errors possible if URL is malformed
|
||||||
|
return <ProductList page={page} sort={sort} />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Manual Validation
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export const Route = createFileRoute('/products')({
|
||||||
|
validateSearch: (search: Record<string, unknown>) => {
|
||||||
|
return {
|
||||||
|
page: Number(search.page) || 1,
|
||||||
|
sort: search.sort === 'desc' ? 'desc' : 'asc',
|
||||||
|
category: typeof search.category === 'string' ? search.category : undefined,
|
||||||
|
minPrice: Number(search.minPrice) || undefined,
|
||||||
|
maxPrice: Number(search.maxPrice) || undefined,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
component: ProductsPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function ProductsPage() {
|
||||||
|
// Fully typed, validated search params
|
||||||
|
const { page, sort, category, minPrice, maxPrice } = Route.useSearch()
|
||||||
|
// page: number (default 1)
|
||||||
|
// sort: 'asc' | 'desc' (default 'asc')
|
||||||
|
// category: string | undefined
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: With Zod
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const productSearchSchema = z.object({
|
||||||
|
page: z.number().min(1).catch(1),
|
||||||
|
limit: z.number().min(1).max(100).catch(20),
|
||||||
|
sort: z.enum(['name', 'price', 'date']).catch('name'),
|
||||||
|
order: z.enum(['asc', 'desc']).catch('asc'),
|
||||||
|
category: z.string().optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
minPrice: z.number().min(0).optional(),
|
||||||
|
maxPrice: z.number().min(0).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
type ProductSearch = z.infer<typeof productSearchSchema>
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/products')({
|
||||||
|
validateSearch: (search) => productSearchSchema.parse(search),
|
||||||
|
component: ProductsPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function ProductsPage() {
|
||||||
|
const search = Route.useSearch()
|
||||||
|
// search: ProductSearch - fully typed with defaults
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProductList
|
||||||
|
page={search.page}
|
||||||
|
limit={search.limit}
|
||||||
|
sort={search.sort}
|
||||||
|
order={search.order}
|
||||||
|
filters={{
|
||||||
|
category: search.category,
|
||||||
|
search: search.search,
|
||||||
|
priceRange: search.minPrice && search.maxPrice
|
||||||
|
? [search.minPrice, search.maxPrice]
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: With Valibot
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import * as v from 'valibot'
|
||||||
|
import { valibotSearchValidator } from '@tanstack/router-valibot-adapter'
|
||||||
|
|
||||||
|
const searchSchema = v.object({
|
||||||
|
page: v.fallback(v.number(), 1),
|
||||||
|
query: v.fallback(v.string(), ''),
|
||||||
|
filters: v.fallback(
|
||||||
|
v.array(v.string()),
|
||||||
|
[]
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/search')({
|
||||||
|
validateSearch: valibotSearchValidator(searchSchema),
|
||||||
|
component: SearchPage,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Updating Search Params
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function ProductFilters() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const search = Route.useSearch()
|
||||||
|
|
||||||
|
const updateFilters = (newFilters: Partial<ProductSearch>) => {
|
||||||
|
navigate({
|
||||||
|
to: '.', // Current route
|
||||||
|
search: (prev) => ({
|
||||||
|
...prev,
|
||||||
|
...newFilters,
|
||||||
|
page: 1, // Reset to page 1 when filters change
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<select
|
||||||
|
value={search.sort}
|
||||||
|
onChange={(e) => updateFilters({ sort: e.target.value as ProductSearch['sort'] })}
|
||||||
|
>
|
||||||
|
<option value="name">Name</option>
|
||||||
|
<option value="price">Price</option>
|
||||||
|
<option value="date">Date</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Search params are user input - never trust them unvalidated
|
||||||
|
- Use `.catch()` in Zod or `fallback()` in Valibot for graceful defaults
|
||||||
|
- Validation runs on every navigation - keep it fast
|
||||||
|
- Search params are inherited by child routes
|
||||||
|
- Use `search` updater function to preserve other params
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# split-lazy-routes: Use .lazy.tsx for Code Splitting
|
||||||
|
|
||||||
|
## Priority: MEDIUM
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
Split route components into `.lazy.tsx` files to reduce initial bundle size. The main route file keeps critical configuration (path, loaders, search validation), while lazy files contain components that load on-demand.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// routes/dashboard.tsx - Everything in one file
|
||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { HeavyChartLibrary } from 'heavy-chart-library'
|
||||||
|
import { ComplexDataGrid } from 'complex-data-grid'
|
||||||
|
import { AnalyticsWidgets } from './components/AnalyticsWidgets'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/dashboard')({
|
||||||
|
loader: async ({ context }) => {
|
||||||
|
return context.queryClient.ensureQueryData(dashboardQueries.stats())
|
||||||
|
},
|
||||||
|
component: DashboardPage, // Entire component in main bundle
|
||||||
|
})
|
||||||
|
|
||||||
|
function DashboardPage() {
|
||||||
|
// Heavy components loaded even if user never visits dashboard
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<HeavyChartLibrary data={useLoaderData()} />
|
||||||
|
<ComplexDataGrid />
|
||||||
|
<AnalyticsWidgets />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// routes/dashboard.tsx - Only critical config
|
||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/dashboard')({
|
||||||
|
loader: async ({ context }) => {
|
||||||
|
return context.queryClient.ensureQueryData(dashboardQueries.stats())
|
||||||
|
},
|
||||||
|
// No component - it's in the lazy file
|
||||||
|
})
|
||||||
|
|
||||||
|
// routes/dashboard.lazy.tsx - Lazy-loaded component
|
||||||
|
import { createLazyFileRoute } from '@tanstack/react-router'
|
||||||
|
import { HeavyChartLibrary } from 'heavy-chart-library'
|
||||||
|
import { ComplexDataGrid } from 'complex-data-grid'
|
||||||
|
import { AnalyticsWidgets } from './components/AnalyticsWidgets'
|
||||||
|
|
||||||
|
export const Route = createLazyFileRoute('/dashboard')({
|
||||||
|
component: DashboardPage,
|
||||||
|
pendingComponent: DashboardSkeleton,
|
||||||
|
errorComponent: DashboardError,
|
||||||
|
})
|
||||||
|
|
||||||
|
function DashboardPage() {
|
||||||
|
const data = Route.useLoaderData()
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<HeavyChartLibrary data={data} />
|
||||||
|
<ComplexDataGrid />
|
||||||
|
<AnalyticsWidgets />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DashboardSkeleton() {
|
||||||
|
return <div className="dashboard-skeleton">Loading dashboard...</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function DashboardError({ error }: { error: Error }) {
|
||||||
|
return <div>Failed to load dashboard: {error.message}</div>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## What Goes Where
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Main route file (routes/example.tsx)
|
||||||
|
// - path configuration (implicit from file location)
|
||||||
|
// - validateSearch
|
||||||
|
// - beforeLoad (auth checks, redirects)
|
||||||
|
// - loader (data fetching)
|
||||||
|
// - loaderDeps
|
||||||
|
// - context manipulation
|
||||||
|
// - Static route data
|
||||||
|
|
||||||
|
// Lazy file (routes/example.lazy.tsx)
|
||||||
|
// - component
|
||||||
|
// - pendingComponent
|
||||||
|
// - errorComponent
|
||||||
|
// - notFoundComponent
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using getRouteApi in Lazy Components
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// routes/posts/$postId.lazy.tsx
|
||||||
|
import { createLazyFileRoute, getRouteApi } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
const route = getRouteApi('/posts/$postId')
|
||||||
|
|
||||||
|
export const Route = createLazyFileRoute('/posts/$postId')({
|
||||||
|
component: PostPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function PostPage() {
|
||||||
|
// Type-safe access without importing main route file
|
||||||
|
const { postId } = route.useParams()
|
||||||
|
const data = route.useLoaderData()
|
||||||
|
|
||||||
|
return <article>{/* ... */}</article>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Automatic Code Splitting
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// vite.config.ts - Enable automatic splitting
|
||||||
|
import { TanStackRouterVite } from '@tanstack/router-plugin/vite'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
TanStackRouterVite({
|
||||||
|
autoCodeSplitting: true, // Automatically splits all route components
|
||||||
|
}),
|
||||||
|
react(),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
// With autoCodeSplitting, you don't need .lazy.tsx files
|
||||||
|
// The plugin handles the splitting automatically
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Lazy loading reduces initial bundle size significantly
|
||||||
|
- Loaders are NOT lazy - they need to run before rendering
|
||||||
|
- `createLazyFileRoute` only accepts component-related options
|
||||||
|
- Use `getRouteApi()` for type-safe hook access in lazy files
|
||||||
|
- Consider `autoCodeSplitting: true` for simpler setup
|
||||||
|
- Virtual routes auto-generate when only .lazy.tsx exists
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# ts-register-router: Register Router Type for Global Inference
|
||||||
|
|
||||||
|
## Priority: CRITICAL
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
Register your router instance with TypeScript's module declaration to enable type inference across your entire application. Without registration, hooks like `useNavigate`, `useParams`, and `useSearch` won't know your route structure.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// router.tsx - Missing type registration
|
||||||
|
import { createRouter, createRootRoute } from '@tanstack/react-router'
|
||||||
|
import { routeTree } from './routeTree.gen'
|
||||||
|
|
||||||
|
export const router = createRouter({ routeTree })
|
||||||
|
|
||||||
|
// components/Navigation.tsx
|
||||||
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
function Navigation() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
// TypeScript doesn't know valid routes - no autocomplete or type checking
|
||||||
|
navigate({ to: '/posts/$postId' }) // No error even if route doesn't exist
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// router.tsx
|
||||||
|
import { createRouter } from '@tanstack/react-router'
|
||||||
|
import { routeTree } from './routeTree.gen'
|
||||||
|
|
||||||
|
export const router = createRouter({ routeTree })
|
||||||
|
|
||||||
|
// Register the router instance for type inference
|
||||||
|
declare module '@tanstack/react-router' {
|
||||||
|
interface Register {
|
||||||
|
router: typeof router
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// components/Navigation.tsx
|
||||||
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
function Navigation() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
// Full type safety - TypeScript knows all valid routes
|
||||||
|
navigate({ to: '/posts/$postId', params: { postId: '123' } })
|
||||||
|
|
||||||
|
// Type error if route doesn't exist
|
||||||
|
navigate({ to: '/invalid-route' }) // Error: Type '"/invalid-route"' is not assignable...
|
||||||
|
|
||||||
|
// Autocomplete for params
|
||||||
|
navigate({
|
||||||
|
to: '/users/$userId/posts/$postId',
|
||||||
|
params: { userId: '1', postId: '2' }, // Both required
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Benefits of Registration
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// After registration, all these get full type inference:
|
||||||
|
|
||||||
|
// 1. Navigation
|
||||||
|
const navigate = useNavigate()
|
||||||
|
navigate({ to: '/posts/$postId', params: { postId: '123' } })
|
||||||
|
|
||||||
|
// 2. Link component
|
||||||
|
<Link to="/posts/$postId" params={{ postId: '123' }}>View Post</Link>
|
||||||
|
|
||||||
|
// 3. useParams hook
|
||||||
|
const { postId } = useParams({ from: '/posts/$postId' }) // postId: string
|
||||||
|
|
||||||
|
// 4. useSearch hook
|
||||||
|
const search = useSearch({ from: '/posts' }) // Knows search param types
|
||||||
|
|
||||||
|
// 5. useLoaderData hook
|
||||||
|
const data = useLoaderData({ from: '/posts/$postId' }) // Knows loader return type
|
||||||
|
```
|
||||||
|
|
||||||
|
## File-Based Routing Setup
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// With file-based routing, routeTree is auto-generated
|
||||||
|
// router.tsx
|
||||||
|
import { createRouter } from '@tanstack/react-router'
|
||||||
|
import { routeTree } from './routeTree.gen' // Generated file
|
||||||
|
|
||||||
|
export const router = createRouter({
|
||||||
|
routeTree,
|
||||||
|
defaultPreload: 'intent',
|
||||||
|
})
|
||||||
|
|
||||||
|
declare module '@tanstack/react-router' {
|
||||||
|
interface Register {
|
||||||
|
router: typeof router
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Must be done once, typically in your router configuration file
|
||||||
|
- Enables IDE autocomplete for routes, params, and search params
|
||||||
|
- Catches invalid routes at compile time
|
||||||
|
- Works with both file-based and code-based routing
|
||||||
|
- Required for full TypeScript benefits of TanStack Router
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# ts-use-from-param: Use `from` Parameter for Type Narrowing
|
||||||
|
|
||||||
|
## Priority: CRITICAL
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
When using hooks like `useParams`, `useSearch`, or `useLoaderData`, provide the `from` parameter to get exact types for that route. Without it, TypeScript returns a union of all possible types across all routes.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Without 'from' - TypeScript doesn't know which route's types to use
|
||||||
|
function PostDetail() {
|
||||||
|
// params could be from ANY route - types are unioned
|
||||||
|
const params = useParams()
|
||||||
|
// params: { postId?: string; userId?: string; categoryId?: string; ... }
|
||||||
|
|
||||||
|
// TypeScript can't guarantee postId exists
|
||||||
|
console.log(params.postId) // postId: string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// Similarly for search params
|
||||||
|
function SearchResults() {
|
||||||
|
const search = useSearch()
|
||||||
|
// search: union of ALL routes' search params
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// With 'from' - exact types for this specific route
|
||||||
|
function PostDetail() {
|
||||||
|
const params = useParams({ from: '/posts/$postId' })
|
||||||
|
// params: { postId: string } - exactly what this route provides
|
||||||
|
|
||||||
|
console.log(params.postId) // postId: string (guaranteed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full path matching
|
||||||
|
function UserPost() {
|
||||||
|
const params = useParams({ from: '/users/$userId/posts/$postId' })
|
||||||
|
// params: { userId: string; postId: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search params with type narrowing
|
||||||
|
function SearchResults() {
|
||||||
|
const search = useSearch({ from: '/search' })
|
||||||
|
// search: exactly the validated search params for /search route
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loader data with type inference
|
||||||
|
function PostPage() {
|
||||||
|
const { post, comments } = useLoaderData({ from: '/posts/$postId' })
|
||||||
|
// Exact types from your loader function
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using Route.fullPath for Type Safety
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// routes/posts/$postId.tsx
|
||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/posts/$postId')({
|
||||||
|
loader: async ({ params }) => {
|
||||||
|
const post = await fetchPost(params.postId)
|
||||||
|
return { post }
|
||||||
|
},
|
||||||
|
component: PostComponent,
|
||||||
|
})
|
||||||
|
|
||||||
|
function PostComponent() {
|
||||||
|
// Use Route.fullPath for guaranteed type matching
|
||||||
|
const params = useParams({ from: Route.fullPath })
|
||||||
|
const { post } = useLoaderData({ from: Route.fullPath })
|
||||||
|
|
||||||
|
// Or use route-specific helper (preferred in same file)
|
||||||
|
const { postId } = Route.useParams()
|
||||||
|
const data = Route.useLoaderData()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using getRouteApi for Code-Split Components
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// components/PostDetail.tsx (separate file from route)
|
||||||
|
import { getRouteApi } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
// Get type-safe access without importing the route
|
||||||
|
const postRoute = getRouteApi('/posts/$postId')
|
||||||
|
|
||||||
|
export function PostDetail() {
|
||||||
|
const params = postRoute.useParams()
|
||||||
|
// params: { postId: string }
|
||||||
|
|
||||||
|
const data = postRoute.useLoaderData()
|
||||||
|
// data: exact loader return type
|
||||||
|
|
||||||
|
const search = postRoute.useSearch()
|
||||||
|
// search: exact search param types
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## When to Use strict: false
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// In shared components that work across multiple routes
|
||||||
|
function Breadcrumbs() {
|
||||||
|
// strict: false returns union types but allows component reuse
|
||||||
|
const params = useParams({ strict: false })
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
// params may or may not have certain values
|
||||||
|
return (
|
||||||
|
<nav>
|
||||||
|
{params.userId && <span>User: {params.userId}</span>}
|
||||||
|
{params.postId && <span>Post: {params.postId}</span>}
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Always use `from` in route-specific components for exact types
|
||||||
|
- Use `Route.useParams()` / `Route.useLoaderData()` within route files
|
||||||
|
- Use `getRouteApi()` in components split from route files
|
||||||
|
- Use `strict: false` only in truly generic, cross-route components
|
||||||
|
- The `from` path must match exactly (including params like `$postId`)
|
||||||
109
.agents/skills/tanstack-start-best-practices/SKILL.md
Normal file
109
.agents/skills/tanstack-start-best-practices/SKILL.md
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
---
|
||||||
|
name: tanstack-start-best-practices
|
||||||
|
description: TanStack Start best practices for full-stack React applications. Server functions, middleware, SSR, authentication, and deployment patterns. Activate when building full-stack apps with TanStack Start.
|
||||||
|
---
|
||||||
|
|
||||||
|
# TanStack Start Best Practices
|
||||||
|
|
||||||
|
Comprehensive guidelines for implementing TanStack Start patterns in full-stack React applications. These rules cover server functions, middleware, SSR, authentication, and deployment.
|
||||||
|
|
||||||
|
## When to Apply
|
||||||
|
|
||||||
|
- Creating server functions for data mutations
|
||||||
|
- Setting up middleware for auth/logging
|
||||||
|
- Configuring SSR and hydration
|
||||||
|
- Implementing authentication flows
|
||||||
|
- Handling errors across client/server boundary
|
||||||
|
- Organizing full-stack code
|
||||||
|
- Deploying to various platforms
|
||||||
|
|
||||||
|
## Rule Categories by Priority
|
||||||
|
|
||||||
|
| Priority | Category | Rules | Impact |
|
||||||
|
|----------|----------|-------|--------|
|
||||||
|
| CRITICAL | Server Functions | 5 rules | Core data mutation patterns |
|
||||||
|
| CRITICAL | Security | 4 rules | Prevents vulnerabilities |
|
||||||
|
| HIGH | Middleware | 4 rules | Request/response handling |
|
||||||
|
| HIGH | Authentication | 4 rules | Secure user sessions |
|
||||||
|
| MEDIUM | API Routes | 1 rule | External endpoint patterns |
|
||||||
|
| MEDIUM | SSR | 6 rules | Server rendering patterns |
|
||||||
|
| MEDIUM | Error Handling | 3 rules | Graceful failure handling |
|
||||||
|
| MEDIUM | Environment | 1 rule | Configuration management |
|
||||||
|
| LOW | File Organization | 3 rules | Maintainable code structure |
|
||||||
|
| LOW | Deployment | 2 rules | Production readiness |
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
### Server Functions (Prefix: `sf-`)
|
||||||
|
|
||||||
|
- `sf-create-server-fn` — Use createServerFn for server-side logic
|
||||||
|
- `sf-input-validation` — Always validate server function inputs
|
||||||
|
- `sf-method-selection` — Choose appropriate HTTP method
|
||||||
|
- `sf-error-handling` — Handle errors in server functions
|
||||||
|
- `sf-response-headers` — Customize response headers when needed
|
||||||
|
|
||||||
|
### Security (Prefix: `sec-`)
|
||||||
|
|
||||||
|
- `sec-validate-inputs` — Validate all user inputs with schemas
|
||||||
|
- `sec-auth-middleware` — Protect routes with auth middleware
|
||||||
|
- `sec-sensitive-data` — Keep secrets server-side only
|
||||||
|
- `sec-csrf-protection` — Implement CSRF protection for mutations
|
||||||
|
|
||||||
|
### Middleware (Prefix: `mw-`)
|
||||||
|
|
||||||
|
- `mw-request-middleware` — Use request middleware for cross-cutting concerns
|
||||||
|
- `mw-function-middleware` — Use function middleware for server functions
|
||||||
|
- `mw-context-flow` — Properly pass context through middleware
|
||||||
|
- `mw-composability` — Compose middleware effectively
|
||||||
|
|
||||||
|
### Authentication (Prefix: `auth-`)
|
||||||
|
|
||||||
|
- `auth-session-management` — Implement secure session handling
|
||||||
|
- `auth-route-protection` — Protect routes with beforeLoad
|
||||||
|
- `auth-server-functions` — Verify auth in server functions
|
||||||
|
- `auth-cookie-security` — Configure secure cookie settings
|
||||||
|
|
||||||
|
### API Routes (Prefix: `api-`)
|
||||||
|
|
||||||
|
- `api-routes` — Create API routes for external consumers
|
||||||
|
|
||||||
|
### SSR (Prefix: `ssr-`)
|
||||||
|
|
||||||
|
- `ssr-data-loading` — Load data appropriately for SSR
|
||||||
|
- `ssr-hydration-safety` — Prevent hydration mismatches
|
||||||
|
- `ssr-streaming` — Implement streaming SSR for faster TTFB
|
||||||
|
- `ssr-selective` — Apply selective SSR when beneficial
|
||||||
|
- `ssr-prerender` — Configure static prerendering and ISR
|
||||||
|
|
||||||
|
### Environment (Prefix: `env-`)
|
||||||
|
|
||||||
|
- `env-functions` — Use environment functions for configuration
|
||||||
|
|
||||||
|
### Error Handling (Prefix: `err-`)
|
||||||
|
|
||||||
|
- `err-server-errors` — Handle server function errors
|
||||||
|
- `err-redirects` — Use redirects appropriately
|
||||||
|
- `err-not-found` — Handle not-found scenarios
|
||||||
|
|
||||||
|
### File Organization (Prefix: `file-`)
|
||||||
|
|
||||||
|
- `file-separation` — Separate server and client code
|
||||||
|
- `file-functions-file` — Use .functions.ts pattern
|
||||||
|
- `file-shared-validation` — Share validation schemas
|
||||||
|
|
||||||
|
### Deployment (Prefix: `deploy-`)
|
||||||
|
|
||||||
|
- `deploy-env-config` — Configure environment variables
|
||||||
|
- `deploy-adapters` — Choose appropriate deployment adapter
|
||||||
|
|
||||||
|
## How to Use
|
||||||
|
|
||||||
|
Each rule file in the `rules/` directory contains:
|
||||||
|
1. **Explanation** — Why this pattern matters
|
||||||
|
2. **Bad Example** — Anti-pattern to avoid
|
||||||
|
3. **Good Example** — Recommended implementation
|
||||||
|
4. **Context** — When to apply or skip this rule
|
||||||
|
|
||||||
|
## Full Reference
|
||||||
|
|
||||||
|
See individual rule files in `rules/` directory for detailed guidance and code examples.
|
||||||
238
.agents/skills/tanstack-start-best-practices/rules/api-routes.md
Normal file
238
.agents/skills/tanstack-start-best-practices/rules/api-routes.md
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
# api-routes: Create Server Routes for External Consumers
|
||||||
|
|
||||||
|
## Priority: MEDIUM
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
While server functions are ideal for internal RPC, server routes provide traditional REST endpoints for external consumers, webhooks, and integrations. Use server routes when you need standard HTTP semantics, custom response formats, or third-party compatibility.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Using server functions for webhook endpoints
|
||||||
|
export const stripeWebhook = createServerFn({ method: 'POST' })
|
||||||
|
.handler(async ({ request }) => {
|
||||||
|
// Server functions aren't designed for raw request handling
|
||||||
|
// No easy access to raw body for signature verification
|
||||||
|
// Response format is JSON by default
|
||||||
|
})
|
||||||
|
|
||||||
|
// Or exposing internal functions to external consumers
|
||||||
|
export const getUsers = createServerFn()
|
||||||
|
.handler(async () => {
|
||||||
|
return db.users.findMany()
|
||||||
|
})
|
||||||
|
// No versioning, no standard REST semantics
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Basic Server Route
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// routes/api/users.ts
|
||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { json } from '@tanstack/react-start'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/api/users')({
|
||||||
|
server: {
|
||||||
|
handlers: {
|
||||||
|
GET: async ({ request }) => {
|
||||||
|
const users = await db.users.findMany({
|
||||||
|
select: { id: true, name: true, email: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
return json(users, {
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'public, max-age=60',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
POST: async ({ request }) => {
|
||||||
|
const body = await request.json()
|
||||||
|
|
||||||
|
// Validate input
|
||||||
|
const parsed = createUserSchema.safeParse(body)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return json({ error: parsed.error.flatten() }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await db.users.create({ data: parsed.data })
|
||||||
|
return json(user, { status: 201 })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Webhook Handler
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// routes/api/webhooks/stripe.ts
|
||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import Stripe from 'stripe'
|
||||||
|
|
||||||
|
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/api/webhooks/stripe')({
|
||||||
|
server: {
|
||||||
|
handlers: {
|
||||||
|
POST: async ({ request }) => {
|
||||||
|
const signature = request.headers.get('stripe-signature')
|
||||||
|
if (!signature) {
|
||||||
|
return new Response('Missing signature', { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get raw body for signature verification
|
||||||
|
const rawBody = await request.text()
|
||||||
|
|
||||||
|
let event: Stripe.Event
|
||||||
|
try {
|
||||||
|
event = stripe.webhooks.constructEvent(
|
||||||
|
rawBody,
|
||||||
|
signature,
|
||||||
|
process.env.STRIPE_WEBHOOK_SECRET!
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Webhook signature verification failed:', err)
|
||||||
|
return new Response('Invalid signature', { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle the event
|
||||||
|
switch (event.type) {
|
||||||
|
case 'checkout.session.completed':
|
||||||
|
await handleCheckoutComplete(event.data.object)
|
||||||
|
break
|
||||||
|
case 'customer.subscription.updated':
|
||||||
|
await handleSubscriptionUpdate(event.data.object)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
console.log(`Unhandled event type: ${event.type}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response('OK', { status: 200 })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: RESTful Resource with Dynamic Params
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// routes/api/posts/$postId.ts
|
||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { json } from '@tanstack/react-start'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/api/posts/$postId')({
|
||||||
|
server: {
|
||||||
|
handlers: {
|
||||||
|
GET: async ({ params }) => {
|
||||||
|
const post = await db.posts.findUnique({
|
||||||
|
where: { id: params.postId },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!post) {
|
||||||
|
return json({ error: 'Post not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return json(post)
|
||||||
|
},
|
||||||
|
|
||||||
|
PUT: async ({ request, params }) => {
|
||||||
|
const body = await request.json()
|
||||||
|
const parsed = updatePostSchema.safeParse(body)
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return json({ error: parsed.error.flatten() }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const post = await db.posts.update({
|
||||||
|
where: { id: params.postId },
|
||||||
|
data: parsed.data,
|
||||||
|
})
|
||||||
|
|
||||||
|
return json(post)
|
||||||
|
},
|
||||||
|
|
||||||
|
DELETE: async ({ params }) => {
|
||||||
|
await db.posts.delete({ where: { id: params.postId } })
|
||||||
|
return new Response(null, { status: 204 })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: With Route-Level Middleware
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// routes/api/protected/data.ts
|
||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { json } from '@tanstack/react-start'
|
||||||
|
import { apiKeyMiddleware } from '@/lib/middleware'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/api/protected/data')({
|
||||||
|
server: {
|
||||||
|
// Middleware applies to all handlers in this route
|
||||||
|
middleware: [apiKeyMiddleware],
|
||||||
|
handlers: {
|
||||||
|
GET: async ({ request, context }) => {
|
||||||
|
// context.client available from middleware
|
||||||
|
const data = await fetchDataForClient(context.client.id)
|
||||||
|
return json(data)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Using createHandlers for Handler-Specific Middleware
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// routes/api/admin/users.ts
|
||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { json } from '@tanstack/react-start'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/api/admin/users')({
|
||||||
|
server: {
|
||||||
|
middleware: [authMiddleware], // All handlers require auth
|
||||||
|
handlers: (createHandlers) => ({
|
||||||
|
GET: createHandlers.GET(async ({ context }) => {
|
||||||
|
const users = await db.users.findMany()
|
||||||
|
return json(users)
|
||||||
|
}),
|
||||||
|
|
||||||
|
// DELETE requires additional admin middleware
|
||||||
|
DELETE: createHandlers.DELETE({
|
||||||
|
middleware: [adminOnlyMiddleware],
|
||||||
|
handler: async ({ request, context }) => {
|
||||||
|
const { userId } = await request.json()
|
||||||
|
await db.users.delete({ where: { id: userId } })
|
||||||
|
return json({ deleted: true })
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Server Functions vs Server Routes
|
||||||
|
|
||||||
|
| Feature | Server Functions | Server Routes |
|
||||||
|
|---------|-----------------|--------------|
|
||||||
|
| Primary use | Internal RPC | External consumers |
|
||||||
|
| Type safety | Full end-to-end | Manual |
|
||||||
|
| Response format | JSON (automatic) | Any (manual) |
|
||||||
|
| Raw request access | Limited | Full |
|
||||||
|
| URL structure | Auto-generated | Explicit paths |
|
||||||
|
| Webhooks | Not ideal | Designed for |
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Server routes use `createFileRoute` with a `server.handlers` property
|
||||||
|
- Support all HTTP methods: GET, POST, PUT, PATCH, DELETE, etc.
|
||||||
|
- Use `json()` helper for JSON responses
|
||||||
|
- Return `Response` objects for custom formats
|
||||||
|
- Handler receives `{ request, params }` object
|
||||||
|
- Ideal for: webhooks, public APIs, file downloads, third-party integrations
|
||||||
|
- Consider versioning: `/api/v1/users` for public APIs
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
# auth-route-protection: Protect Routes with beforeLoad
|
||||||
|
|
||||||
|
## Priority: HIGH
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
Use `beforeLoad` in route definitions to check authentication before the route loads. This prevents unauthorized access, redirects to login, and can extend context with user data for child routes.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Checking auth in component - too late, data may have loaded
|
||||||
|
function DashboardPage() {
|
||||||
|
const user = useAuth()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) {
|
||||||
|
navigate({ to: '/login' }) // Redirect after render
|
||||||
|
}
|
||||||
|
}, [user])
|
||||||
|
|
||||||
|
if (!user) return null // Flash of content possible
|
||||||
|
|
||||||
|
return <Dashboard user={user} />
|
||||||
|
}
|
||||||
|
|
||||||
|
// No protection on route
|
||||||
|
export const Route = createFileRoute('/dashboard')({
|
||||||
|
loader: async () => {
|
||||||
|
// Fetches sensitive data even for unauthenticated users
|
||||||
|
return await fetchDashboardData()
|
||||||
|
},
|
||||||
|
component: DashboardPage,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Route-Level Protection
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// routes/_authenticated.tsx - Layout route for protected area
|
||||||
|
import { createFileRoute, redirect, Outlet } from '@tanstack/react-router'
|
||||||
|
import { getSessionData } from '@/lib/session.server'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_authenticated')({
|
||||||
|
beforeLoad: async ({ location }) => {
|
||||||
|
const session = await getSessionData()
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
throw redirect({
|
||||||
|
to: '/login',
|
||||||
|
search: {
|
||||||
|
redirect: location.href,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extend context with user for all child routes
|
||||||
|
return {
|
||||||
|
user: session,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
component: AuthenticatedLayout,
|
||||||
|
})
|
||||||
|
|
||||||
|
function AuthenticatedLayout() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<AuthenticatedNav />
|
||||||
|
<main>
|
||||||
|
<Outlet /> {/* Child routes render here */}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// routes/_authenticated/dashboard.tsx
|
||||||
|
// This route is automatically protected by parent
|
||||||
|
export const Route = createFileRoute('/_authenticated/dashboard')({
|
||||||
|
loader: async ({ context }) => {
|
||||||
|
// context.user is guaranteed to exist
|
||||||
|
return await fetchDashboardData(context.user.id)
|
||||||
|
},
|
||||||
|
component: DashboardPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function DashboardPage() {
|
||||||
|
const data = Route.useLoaderData()
|
||||||
|
const { user } = Route.useRouteContext()
|
||||||
|
|
||||||
|
return <Dashboard data={data} user={user} />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Role-Based Access
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// routes/_admin.tsx
|
||||||
|
export const Route = createFileRoute('/_admin')({
|
||||||
|
beforeLoad: async ({ context }) => {
|
||||||
|
// context.user comes from parent _authenticated route
|
||||||
|
if (context.user.role !== 'admin') {
|
||||||
|
throw redirect({ to: '/unauthorized' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
component: AdminLayout,
|
||||||
|
})
|
||||||
|
|
||||||
|
// File structure:
|
||||||
|
// routes/
|
||||||
|
// _authenticated.tsx # Requires login
|
||||||
|
// _authenticated/
|
||||||
|
// dashboard.tsx # /dashboard - any authenticated user
|
||||||
|
// settings.tsx # /settings - any authenticated user
|
||||||
|
// _admin.tsx # Admin layout
|
||||||
|
// _admin/
|
||||||
|
// users.tsx # /users - admin only
|
||||||
|
// analytics.tsx # /analytics - admin only
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Preserving Redirect URL
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// routes/login.tsx
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/login')({
|
||||||
|
validateSearch: z.object({
|
||||||
|
redirect: z.string().optional(),
|
||||||
|
}),
|
||||||
|
component: LoginPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function LoginPage() {
|
||||||
|
const { redirect } = Route.useSearch()
|
||||||
|
const loginMutation = useMutation({
|
||||||
|
mutationFn: login,
|
||||||
|
onSuccess: () => {
|
||||||
|
// Redirect to original destination or default
|
||||||
|
navigate({ to: redirect ?? '/dashboard' })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return <LoginForm onSubmit={loginMutation.mutate} />
|
||||||
|
}
|
||||||
|
|
||||||
|
// In protected routes
|
||||||
|
beforeLoad: async ({ location }) => {
|
||||||
|
if (!session) {
|
||||||
|
throw redirect({
|
||||||
|
to: '/login',
|
||||||
|
search: { redirect: location.href },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Conditional Content Based on Auth
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Public route with different content for logged-in users
|
||||||
|
export const Route = createFileRoute('/')({
|
||||||
|
beforeLoad: async () => {
|
||||||
|
const session = await getSessionData()
|
||||||
|
return { user: session?.user ?? null }
|
||||||
|
},
|
||||||
|
component: HomePage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function HomePage() {
|
||||||
|
const { user } = Route.useRouteContext()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Hero />
|
||||||
|
{user ? (
|
||||||
|
<PersonalizedContent user={user} />
|
||||||
|
) : (
|
||||||
|
<SignUpCTA />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- `beforeLoad` runs before route loading begins
|
||||||
|
- Throwing `redirect()` prevents route from loading
|
||||||
|
- Context from `beforeLoad` flows to loader and component
|
||||||
|
- Child routes inherit parent's `beforeLoad` protection
|
||||||
|
- Use pathless layout routes (`_authenticated.tsx`) for grouped protection
|
||||||
|
- Store redirect URL in search params for post-login navigation
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
# auth-session-management: Implement Secure Session Handling
|
||||||
|
|
||||||
|
## Priority: HIGH
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
Sessions maintain user authentication state across requests. Use HTTP-only cookies with secure settings to prevent XSS and CSRF attacks. Never store sensitive data in client-accessible storage.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Storing auth in localStorage - vulnerable to XSS
|
||||||
|
function login(credentials: Credentials) {
|
||||||
|
const token = await authenticate(credentials)
|
||||||
|
localStorage.setItem('authToken', token) // XSS can steal this
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-HTTP-only cookie - JavaScript accessible
|
||||||
|
export const setSession = createServerFn({ method: 'POST' })
|
||||||
|
.handler(async ({ data }) => {
|
||||||
|
setResponseHeader('Set-Cookie', `session=${data.token}`) // Not secure
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Secure Session Cookie
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// lib/session.server.ts
|
||||||
|
import { useSession } from '@tanstack/react-start/server'
|
||||||
|
|
||||||
|
// Configure session with secure defaults
|
||||||
|
export function getSession() {
|
||||||
|
return useSession({
|
||||||
|
password: process.env.SESSION_SECRET!, // At least 32 characters
|
||||||
|
cookie: {
|
||||||
|
name: '__session',
|
||||||
|
httpOnly: true, // Not accessible via JavaScript
|
||||||
|
secure: process.env.NODE_ENV === 'production', // HTTPS only in prod
|
||||||
|
sameSite: 'lax', // CSRF protection
|
||||||
|
maxAge: 60 * 60 * 24 * 7, // 7 days
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage in server function
|
||||||
|
export const login = createServerFn({ method: 'POST' })
|
||||||
|
.validator(loginSchema)
|
||||||
|
.handler(async ({ data }) => {
|
||||||
|
const session = await getSession()
|
||||||
|
|
||||||
|
// Verify credentials
|
||||||
|
const user = await verifyCredentials(data.email, data.password)
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('Invalid credentials')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store only essential data in session
|
||||||
|
await session.update({
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Full Authentication Flow
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// lib/auth.functions.ts
|
||||||
|
import { createServerFn } from '@tanstack/react-start'
|
||||||
|
import { redirect } from '@tanstack/react-router'
|
||||||
|
import { getSession } from './session.server'
|
||||||
|
import { hashPassword, verifyPassword } from './password.server'
|
||||||
|
|
||||||
|
// Login
|
||||||
|
export const login = createServerFn({ method: 'POST' })
|
||||||
|
.validator(z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(1),
|
||||||
|
}))
|
||||||
|
.handler(async ({ data }) => {
|
||||||
|
const user = await db.users.findUnique({
|
||||||
|
where: { email: data.email },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!user || !await verifyPassword(data.password, user.passwordHash)) {
|
||||||
|
throw new Error('Invalid email or password')
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await getSession()
|
||||||
|
await session.update({
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
})
|
||||||
|
|
||||||
|
throw redirect({ to: '/dashboard' })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Logout
|
||||||
|
export const logout = createServerFn({ method: 'POST' })
|
||||||
|
.handler(async () => {
|
||||||
|
const session = await getSession()
|
||||||
|
await session.clear()
|
||||||
|
throw redirect({ to: '/' })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get current user
|
||||||
|
export const getCurrentUser = createServerFn()
|
||||||
|
.handler(async () => {
|
||||||
|
const session = await getSession()
|
||||||
|
const data = await session.data
|
||||||
|
|
||||||
|
if (!data?.userId) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await db.users.findUnique({
|
||||||
|
where: { id: data.userId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
avatar: true,
|
||||||
|
// Don't include passwordHash!
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return user
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Session with Role-Based Access
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// lib/session.server.ts
|
||||||
|
interface SessionData {
|
||||||
|
userId: string
|
||||||
|
email: string
|
||||||
|
role: 'user' | 'admin'
|
||||||
|
createdAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSessionData(): Promise<SessionData | null> {
|
||||||
|
const session = await getSession()
|
||||||
|
const data = await session.data
|
||||||
|
|
||||||
|
if (!data?.userId) return null
|
||||||
|
|
||||||
|
// Validate session age
|
||||||
|
const maxAge = 7 * 24 * 60 * 60 * 1000 // 7 days
|
||||||
|
if (Date.now() - data.createdAt > maxAge) {
|
||||||
|
await session.clear()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return data as SessionData
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middleware for admin-only routes
|
||||||
|
export const requireAdmin = createMiddleware()
|
||||||
|
.server(async ({ next }) => {
|
||||||
|
const session = await getSessionData()
|
||||||
|
|
||||||
|
if (!session || session.role !== 'admin') {
|
||||||
|
throw redirect({ to: '/unauthorized' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return next({ context: { session } })
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Session Security Checklist
|
||||||
|
|
||||||
|
| Setting | Value | Purpose |
|
||||||
|
|---------|-------|---------|
|
||||||
|
| `httpOnly` | `true` | Prevents XSS from accessing cookie |
|
||||||
|
| `secure` | `true` in prod | Requires HTTPS |
|
||||||
|
| `sameSite` | `'lax'` or `'strict'` | CSRF protection |
|
||||||
|
| `maxAge` | Application-specific | Session duration |
|
||||||
|
| `password` | 32+ random chars | Encryption key |
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Always use HTTP-only cookies for session tokens
|
||||||
|
- Generate `SESSION_SECRET` with `openssl rand -base64 32`
|
||||||
|
- Store minimal data in session - fetch user details on demand
|
||||||
|
- Implement session rotation on privilege changes
|
||||||
|
- Consider session invalidation on password change
|
||||||
|
- Use `sameSite: 'strict'` for highest CSRF protection
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
# deploy-adapters: Choose Appropriate Deployment Adapter
|
||||||
|
|
||||||
|
## Priority: LOW
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
TanStack Start uses deployment adapters to target different hosting platforms. Each adapter optimizes the build output for its platform's runtime, edge functions, and static hosting capabilities.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Not configuring adapter - using defaults may not match your host
|
||||||
|
// app.config.ts
|
||||||
|
export default defineConfig({
|
||||||
|
// No adapter specified
|
||||||
|
// May not work correctly on your deployment platform
|
||||||
|
})
|
||||||
|
|
||||||
|
// Or using wrong adapter for platform
|
||||||
|
export default defineConfig({
|
||||||
|
server: {
|
||||||
|
preset: 'node-server', // But deploying to Vercel Edge
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Vercel Deployment
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app.config.ts
|
||||||
|
import { defineConfig } from '@tanstack/react-start/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
server: {
|
||||||
|
preset: 'vercel',
|
||||||
|
// Vercel-specific options
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// vercel.json (optional, for customization)
|
||||||
|
{
|
||||||
|
"framework": null,
|
||||||
|
"buildCommand": "npm run build",
|
||||||
|
"outputDirectory": ".output"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Cloudflare Pages
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app.config.ts
|
||||||
|
import { defineConfig } from '@tanstack/react-start/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
server: {
|
||||||
|
preset: 'cloudflare-pages',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// wrangler.toml
|
||||||
|
name = "my-tanstack-app"
|
||||||
|
compatibility_date = "2024-01-01"
|
||||||
|
pages_build_output_dir = ".output/public"
|
||||||
|
|
||||||
|
// For Cloudflare Workers (full control)
|
||||||
|
export default defineConfig({
|
||||||
|
server: {
|
||||||
|
preset: 'cloudflare',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Netlify
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app.config.ts
|
||||||
|
import { defineConfig } from '@tanstack/react-start/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
server: {
|
||||||
|
preset: 'netlify',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// netlify.toml
|
||||||
|
[build]
|
||||||
|
command = "npm run build"
|
||||||
|
publish = ".output/public"
|
||||||
|
|
||||||
|
[functions]
|
||||||
|
directory = ".output/server"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Node.js Server
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app.config.ts
|
||||||
|
import { defineConfig } from '@tanstack/react-start/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
server: {
|
||||||
|
preset: 'node-server',
|
||||||
|
// Optional: customize port
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Dockerfile
|
||||||
|
FROM node:20-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci --only=production
|
||||||
|
COPY .output .output
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", ".output/server/index.mjs"]
|
||||||
|
|
||||||
|
// Or run directly
|
||||||
|
// node .output/server/index.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Static Export (SPA)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app.config.ts
|
||||||
|
import { defineConfig } from '@tanstack/react-start/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
server: {
|
||||||
|
preset: 'static',
|
||||||
|
prerender: {
|
||||||
|
routes: ['/'],
|
||||||
|
crawlLinks: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Output: .output/public (static files only)
|
||||||
|
// Host anywhere: GitHub Pages, S3, any static host
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: AWS Lambda
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app.config.ts
|
||||||
|
import { defineConfig } from '@tanstack/react-start/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
server: {
|
||||||
|
preset: 'aws-lambda',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Deploy with SST, Serverless Framework, or AWS CDK
|
||||||
|
// serverless.yml example:
|
||||||
|
service: my-tanstack-app
|
||||||
|
provider:
|
||||||
|
name: aws
|
||||||
|
runtime: nodejs20.x
|
||||||
|
functions:
|
||||||
|
app:
|
||||||
|
handler: .output/server/index.handler
|
||||||
|
events:
|
||||||
|
- http: ANY /
|
||||||
|
- http: ANY /{proxy+}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Bun Runtime
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app.config.ts
|
||||||
|
import { defineConfig } from '@tanstack/react-start/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
server: {
|
||||||
|
preset: 'bun',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Run with: bun .output/server/index.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Adapter Comparison
|
||||||
|
|
||||||
|
| Adapter | Runtime | Edge | Static | Best For |
|
||||||
|
|---------|---------|------|--------|----------|
|
||||||
|
| `vercel` | Node/Edge | Yes | Yes | Vercel hosting |
|
||||||
|
| `cloudflare-pages` | Workers | Yes | Yes | Cloudflare Pages |
|
||||||
|
| `cloudflare` | Workers | Yes | No | Cloudflare Workers |
|
||||||
|
| `netlify` | Node | Yes | Yes | Netlify hosting |
|
||||||
|
| `node-server` | Node | No | No | Docker, VPS, self-host |
|
||||||
|
| `static` | None | No | Yes | Any static host |
|
||||||
|
| `aws-lambda` | Node | No | No | AWS serverless |
|
||||||
|
| `bun` | Bun | No | No | Bun runtime |
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Adapters transform output for target platform
|
||||||
|
- Edge adapters have API limitations (no file system, etc.)
|
||||||
|
- Static preset requires all routes to be prerenderable
|
||||||
|
- Test locally with `npm run build && npm run preview`
|
||||||
|
- Check platform docs for runtime-specific constraints
|
||||||
|
- Some platforms auto-detect TanStack Start (no adapter needed)
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
# env-functions: Use Environment Functions for Configuration
|
||||||
|
|
||||||
|
## Priority: MEDIUM
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
Environment functions provide type-safe access to environment variables on the server. They ensure secrets stay server-side, provide validation, and enable different configurations per environment (development, staging, production).
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Accessing env vars directly - no validation, potential leaks
|
||||||
|
export const getApiData = createServerFn()
|
||||||
|
.handler(async () => {
|
||||||
|
// No validation - may be undefined
|
||||||
|
const apiKey = process.env.API_KEY
|
||||||
|
|
||||||
|
// Accidentally exposed in error messages
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error(`Missing API_KEY: ${process.env}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetch(url, { headers: { Authorization: apiKey } })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Or importing env in shared files
|
||||||
|
// lib/config.ts
|
||||||
|
export const config = {
|
||||||
|
apiKey: process.env.API_KEY, // Bundled into client!
|
||||||
|
dbUrl: process.env.DATABASE_URL,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Validated Environment Configuration
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// lib/env.server.ts
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const envSchema = z.object({
|
||||||
|
// Required
|
||||||
|
DATABASE_URL: z.string().url(),
|
||||||
|
SESSION_SECRET: z.string().min(32),
|
||||||
|
|
||||||
|
// API Keys
|
||||||
|
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
|
||||||
|
STRIPE_WEBHOOK_SECRET: z.string().startsWith('whsec_'),
|
||||||
|
|
||||||
|
// Optional with defaults
|
||||||
|
NODE_ENV: z.enum(['development', 'staging', 'production']).default('development'),
|
||||||
|
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
|
||||||
|
|
||||||
|
// Optional
|
||||||
|
SENTRY_DSN: z.string().url().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type Env = z.infer<typeof envSchema>
|
||||||
|
|
||||||
|
function validateEnv(): Env {
|
||||||
|
const parsed = envSchema.safeParse(process.env)
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
console.error('Invalid environment variables:')
|
||||||
|
console.error(parsed.error.flatten().fieldErrors)
|
||||||
|
throw new Error('Invalid environment configuration')
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate once at startup
|
||||||
|
export const env = validateEnv()
|
||||||
|
|
||||||
|
// Usage in server functions
|
||||||
|
export const getPaymentIntent = createServerFn({ method: 'POST' })
|
||||||
|
.handler(async () => {
|
||||||
|
const stripe = new Stripe(env.STRIPE_SECRET_KEY)
|
||||||
|
// Type-safe, validated access
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Public vs Private Config
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// lib/env.server.ts - Server only (secrets)
|
||||||
|
export const serverEnv = {
|
||||||
|
databaseUrl: process.env.DATABASE_URL!,
|
||||||
|
sessionSecret: process.env.SESSION_SECRET!,
|
||||||
|
stripeSecretKey: process.env.STRIPE_SECRET_KEY!,
|
||||||
|
}
|
||||||
|
|
||||||
|
// lib/env.ts - Public config (safe for client)
|
||||||
|
export const publicEnv = {
|
||||||
|
appUrl: process.env.VITE_APP_URL ?? 'http://localhost:3000',
|
||||||
|
stripePublicKey: process.env.VITE_STRIPE_PUBLIC_KEY!,
|
||||||
|
sentryDsn: process.env.VITE_SENTRY_DSN,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vite exposes VITE_ prefixed vars to client
|
||||||
|
// Non-prefixed vars are server-only
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Environment-Specific Behavior
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// lib/env.server.ts
|
||||||
|
export const env = validateEnv()
|
||||||
|
|
||||||
|
export const isDevelopment = env.NODE_ENV === 'development'
|
||||||
|
export const isProduction = env.NODE_ENV === 'production'
|
||||||
|
export const isStaging = env.NODE_ENV === 'staging'
|
||||||
|
|
||||||
|
// lib/logger.server.ts
|
||||||
|
import { env, isDevelopment } from './env.server'
|
||||||
|
|
||||||
|
export function log(level: string, message: string, data?: unknown) {
|
||||||
|
if (isDevelopment) {
|
||||||
|
console.log(`[${level}]`, message, data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Production: send to logging service
|
||||||
|
if (env.SENTRY_DSN) {
|
||||||
|
// Send to Sentry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server function with environment checks
|
||||||
|
export const debugInfo = createServerFn()
|
||||||
|
.handler(async () => {
|
||||||
|
if (isProduction) {
|
||||||
|
throw new Error('Debug endpoint not available in production')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodeVersion: process.version,
|
||||||
|
env: env.NODE_ENV,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Feature Flags via Environment
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// lib/features.server.ts
|
||||||
|
import { env } from './env.server'
|
||||||
|
|
||||||
|
export const features = {
|
||||||
|
newCheckout: env.FEATURE_NEW_CHECKOUT === 'true',
|
||||||
|
betaDashboard: env.FEATURE_BETA_DASHBOARD === 'true',
|
||||||
|
aiAssistant: env.FEATURE_AI_ASSISTANT === 'true',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage in server functions
|
||||||
|
export const getCheckoutUrl = createServerFn()
|
||||||
|
.handler(async () => {
|
||||||
|
if (features.newCheckout) {
|
||||||
|
return '/checkout/v2'
|
||||||
|
}
|
||||||
|
return '/checkout'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Usage in loaders
|
||||||
|
export const Route = createFileRoute('/dashboard')({
|
||||||
|
loader: async () => {
|
||||||
|
return {
|
||||||
|
showBetaFeatures: features.betaDashboard,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Type-Safe env.d.ts
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// env.d.ts - TypeScript declarations for env vars
|
||||||
|
declare namespace NodeJS {
|
||||||
|
interface ProcessEnv {
|
||||||
|
// Required
|
||||||
|
DATABASE_URL: string
|
||||||
|
SESSION_SECRET: string
|
||||||
|
|
||||||
|
// Optional
|
||||||
|
NODE_ENV?: 'development' | 'staging' | 'production'
|
||||||
|
SENTRY_DSN?: string
|
||||||
|
|
||||||
|
// Vite public vars
|
||||||
|
VITE_APP_URL?: string
|
||||||
|
VITE_STRIPE_PUBLIC_KEY: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variable Checklist
|
||||||
|
|
||||||
|
| Variable | Prefix | Accessible On |
|
||||||
|
|----------|--------|---------------|
|
||||||
|
| `DATABASE_URL` | None | Server only |
|
||||||
|
| `SESSION_SECRET` | None | Server only |
|
||||||
|
| `STRIPE_SECRET_KEY` | None | Server only |
|
||||||
|
| `VITE_APP_URL` | `VITE_` | Server + Client |
|
||||||
|
| `VITE_STRIPE_PUBLIC_KEY` | `VITE_` | Server + Client |
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Never import `.server.ts` files in client code
|
||||||
|
- Use `VITE_` prefix for client-accessible variables
|
||||||
|
- Validate at startup to fail fast on misconfiguration
|
||||||
|
- Use Zod or similar for runtime validation
|
||||||
|
- Keep secrets out of error messages and logs
|
||||||
|
- Consider using `.env.local` for local overrides (gitignored)
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
# err-server-errors: Handle Server Function Errors
|
||||||
|
|
||||||
|
## Priority: MEDIUM
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
Server function errors cross the network boundary. Handle them gracefully with appropriate error types, status codes, and user-friendly messages. Avoid exposing internal details in production.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Throwing raw errors - exposes internals
|
||||||
|
export const createUser = createServerFn({ method: 'POST' })
|
||||||
|
.validator(createUserSchema)
|
||||||
|
.handler(async ({ data }) => {
|
||||||
|
const user = await db.users.create({ data }) // May throw DB error
|
||||||
|
return user
|
||||||
|
// Prisma error with stack trace sent to client
|
||||||
|
})
|
||||||
|
|
||||||
|
// Generic error handling - no useful info for client
|
||||||
|
export const getPost = createServerFn()
|
||||||
|
.handler(async ({ data }) => {
|
||||||
|
try {
|
||||||
|
return await fetchPost(data.id)
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error('Something went wrong') // Too vague
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Structured Error Handling
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// lib/errors.ts
|
||||||
|
export class AppError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public code: string,
|
||||||
|
public status: number = 400
|
||||||
|
) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'AppError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NotFoundError extends AppError {
|
||||||
|
constructor(resource: string) {
|
||||||
|
super(`${resource} not found`, 'NOT_FOUND', 404)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UnauthorizedError extends AppError {
|
||||||
|
constructor(message = 'Unauthorized') {
|
||||||
|
super(message, 'UNAUTHORIZED', 401)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ValidationError extends AppError {
|
||||||
|
constructor(message: string, public fields?: Record<string, string>) {
|
||||||
|
super(message, 'VALIDATION_ERROR', 400)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Server Function with Error Handling
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { createServerFn, notFound } from '@tanstack/react-start'
|
||||||
|
import { setResponseStatus } from '@tanstack/react-start/server'
|
||||||
|
|
||||||
|
export const getPost = createServerFn()
|
||||||
|
.validator(z.object({ id: z.string() }))
|
||||||
|
.handler(async ({ data }) => {
|
||||||
|
const post = await db.posts.findUnique({
|
||||||
|
where: { id: data.id },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!post) {
|
||||||
|
// Use built-in notFound for 404s
|
||||||
|
throw notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
return post
|
||||||
|
})
|
||||||
|
|
||||||
|
export const createPost = createServerFn({ method: 'POST' })
|
||||||
|
.validator(createPostSchema)
|
||||||
|
.handler(async ({ data }) => {
|
||||||
|
try {
|
||||||
|
const post = await db.posts.create({ data })
|
||||||
|
return post
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||||
|
if (error.code === 'P2002') {
|
||||||
|
// Unique constraint violation
|
||||||
|
setResponseStatus(409)
|
||||||
|
throw new AppError('A post with this title already exists', 'DUPLICATE', 409)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log full error server-side
|
||||||
|
console.error('Failed to create post:', error)
|
||||||
|
|
||||||
|
// Return sanitized error to client
|
||||||
|
setResponseStatus(500)
|
||||||
|
throw new AppError('Failed to create post', 'INTERNAL_ERROR', 500)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Client-Side Error Handling
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function CreatePostForm() {
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: createPost,
|
||||||
|
onError: (error) => {
|
||||||
|
if (error instanceof AppError) {
|
||||||
|
setError(error.message)
|
||||||
|
} else if (error instanceof ValidationError) {
|
||||||
|
// Handle field-specific errors
|
||||||
|
Object.entries(error.fields ?? {}).forEach(([field, message]) => {
|
||||||
|
form.setError(field, { message })
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setError('An unexpected error occurred')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: (post) => {
|
||||||
|
navigate({ to: '/posts/$postId', params: { postId: post.id } })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
{error && <Alert variant="error">{error}</Alert>}
|
||||||
|
{/* form fields */}
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Using Redirects for Auth Errors
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export const updateProfile = createServerFn({ method: 'POST' })
|
||||||
|
.validator(updateProfileSchema)
|
||||||
|
.handler(async ({ data }) => {
|
||||||
|
const session = await getSessionData()
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
// Redirect to login for auth errors
|
||||||
|
throw redirect({
|
||||||
|
to: '/login',
|
||||||
|
search: { redirect: '/settings' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return await db.users.update({
|
||||||
|
where: { id: session.userId },
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Response Best Practices
|
||||||
|
|
||||||
|
| Scenario | HTTP Status | Response |
|
||||||
|
|----------|-------------|----------|
|
||||||
|
| Validation failed | 400 | Field-specific errors |
|
||||||
|
| Not authenticated | 401 | Redirect to login |
|
||||||
|
| Not authorized | 403 | Generic forbidden message |
|
||||||
|
| Resource not found | 404 | Use `notFound()` |
|
||||||
|
| Conflict (duplicate) | 409 | Specific conflict message |
|
||||||
|
| Server error | 500 | Generic message, log details |
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Use `notFound()` for 404 errors - integrates with router
|
||||||
|
- Use `redirect()` for auth-related errors
|
||||||
|
- Set status codes with `setResponseStatus()`
|
||||||
|
- Log full errors server-side, sanitize for client
|
||||||
|
- Create custom error classes for consistent handling
|
||||||
|
- Validation errors from `.validator()` are automatic
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
# file-separation: Separate Server and Client Code
|
||||||
|
|
||||||
|
## Priority: LOW
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
Organize code by execution context to prevent server code from accidentally bundling into client builds. Use `.server.ts` for server-only code, `.functions.ts` for server function definitions, and standard `.ts` for shared code.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// lib/posts.ts - Mixed server and client code
|
||||||
|
import { db } from './db' // Database - server only
|
||||||
|
import { formatDate } from './utils' // Utility - shared
|
||||||
|
|
||||||
|
export async function getPosts() {
|
||||||
|
// This uses db, so it's server-only
|
||||||
|
// But file might be imported on client
|
||||||
|
return db.posts.findMany()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPostDate(date: Date) {
|
||||||
|
// This could run anywhere
|
||||||
|
return formatDate(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
// routes/posts.tsx
|
||||||
|
import { getPosts, formatPostDate } from '@/lib/posts'
|
||||||
|
// Importing getPosts pulls db into client bundle (error or bloat)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Clear Separation
|
||||||
|
|
||||||
|
```
|
||||||
|
lib/
|
||||||
|
├── posts.ts # Shared types and utilities
|
||||||
|
├── posts.server.ts # Server-only database logic
|
||||||
|
├── posts.functions.ts # Server function definitions
|
||||||
|
└── schemas/
|
||||||
|
└── post.ts # Shared validation schemas
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// lib/posts.ts - Shared (safe to import anywhere)
|
||||||
|
export interface Post {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
createdAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPostDate(date: Date): string {
|
||||||
|
return new Intl.DateTimeFormat('en-US', {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
}).format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
// lib/posts.server.ts - Server only (never import on client)
|
||||||
|
import { db } from './db'
|
||||||
|
import type { Post } from './posts'
|
||||||
|
|
||||||
|
export async function getPostsFromDb(): Promise<Post[]> {
|
||||||
|
return db.posts.findMany({
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPostInDb(data: CreatePostInput): Promise<Post> {
|
||||||
|
return db.posts.create({ data })
|
||||||
|
}
|
||||||
|
|
||||||
|
// lib/posts.functions.ts - Server functions (safe to import anywhere)
|
||||||
|
import { createServerFn } from '@tanstack/react-start'
|
||||||
|
import { getPostsFromDb, createPostInDb } from './posts.server'
|
||||||
|
import { createPostSchema } from './schemas/post'
|
||||||
|
|
||||||
|
export const getPosts = createServerFn()
|
||||||
|
.handler(async () => {
|
||||||
|
return await getPostsFromDb()
|
||||||
|
})
|
||||||
|
|
||||||
|
export const createPost = createServerFn({ method: 'POST' })
|
||||||
|
.validator(createPostSchema)
|
||||||
|
.handler(async ({ data }) => {
|
||||||
|
return await createPostInDb(data)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Using in Components
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// components/PostList.tsx
|
||||||
|
import { getPosts } from '@/lib/posts.functions' // Safe - RPC stub on client
|
||||||
|
import { formatPostDate } from '@/lib/posts' // Safe - shared utility
|
||||||
|
import type { Post } from '@/lib/posts' // Safe - type only
|
||||||
|
|
||||||
|
function PostList() {
|
||||||
|
const postsQuery = useQuery({
|
||||||
|
queryKey: ['posts'],
|
||||||
|
queryFn: () => getPosts(), // Calls server function
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul>
|
||||||
|
{postsQuery.data?.map((post) => (
|
||||||
|
<li key={post.id}>
|
||||||
|
{post.title}
|
||||||
|
<span>{formatPostDate(post.createdAt)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Convention Summary
|
||||||
|
|
||||||
|
| Suffix | Purpose | Safe to Import on Client |
|
||||||
|
|--------|---------|-------------------------|
|
||||||
|
| `.ts` | Shared utilities, types | Yes |
|
||||||
|
| `.server.ts` | Server-only logic (db, secrets) | No |
|
||||||
|
| `.functions.ts` | Server function wrappers | Yes |
|
||||||
|
| `.client.ts` | Client-only code | Yes (client only) |
|
||||||
|
|
||||||
|
## Good Example: Environment Variables
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// lib/config.server.ts - Server secrets
|
||||||
|
export const config = {
|
||||||
|
databaseUrl: process.env.DATABASE_URL!,
|
||||||
|
sessionSecret: process.env.SESSION_SECRET!,
|
||||||
|
stripeSecretKey: process.env.STRIPE_SECRET_KEY!,
|
||||||
|
}
|
||||||
|
|
||||||
|
// lib/config.ts - Public config (safe for client)
|
||||||
|
export const publicConfig = {
|
||||||
|
appName: 'My App',
|
||||||
|
apiUrl: process.env.NEXT_PUBLIC_API_URL,
|
||||||
|
stripePublicKey: process.env.NEXT_PUBLIC_STRIPE_KEY,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Never import config.server.ts on client
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- `.server.ts` files should never be directly imported in client code
|
||||||
|
- Server functions in `.functions.ts` are safe - build replaces with RPC
|
||||||
|
- Types from `.server.ts` are safe if using `import type`
|
||||||
|
- TanStack Start's build process validates proper separation
|
||||||
|
- This pattern enables tree-shaking and smaller client bundles
|
||||||
|
- Use consistent naming convention across your team
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
# mw-request-middleware: Use Request Middleware for Cross-Cutting Concerns
|
||||||
|
|
||||||
|
## Priority: HIGH
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
Request middleware runs before every server request (routes, SSR, server functions). Use it for authentication, logging, rate limiting, and other cross-cutting concerns that apply globally.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Duplicating auth logic in every server function
|
||||||
|
export const getProfile = createServerFn()
|
||||||
|
.handler(async () => {
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session) throw new Error('Unauthorized')
|
||||||
|
// ... rest of handler
|
||||||
|
})
|
||||||
|
|
||||||
|
export const updateProfile = createServerFn({ method: 'POST' })
|
||||||
|
.handler(async ({ data }) => {
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session) throw new Error('Unauthorized')
|
||||||
|
// ... rest of handler
|
||||||
|
})
|
||||||
|
|
||||||
|
export const deleteAccount = createServerFn({ method: 'POST' })
|
||||||
|
.handler(async () => {
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session) throw new Error('Unauthorized')
|
||||||
|
// ... rest of handler
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Authentication Middleware
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// lib/middleware/auth.ts
|
||||||
|
import { createMiddleware } from '@tanstack/react-start'
|
||||||
|
import { getSession } from './session.server'
|
||||||
|
|
||||||
|
export const authMiddleware = createMiddleware()
|
||||||
|
.server(async ({ next }) => {
|
||||||
|
const session = await getSession()
|
||||||
|
|
||||||
|
// Pass session to downstream handlers via context
|
||||||
|
return next({
|
||||||
|
context: {
|
||||||
|
session,
|
||||||
|
user: session?.user ?? null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// lib/middleware/requireAuth.ts
|
||||||
|
export const requireAuthMiddleware = createMiddleware()
|
||||||
|
.middleware([authMiddleware]) // Depends on auth middleware
|
||||||
|
.server(async ({ next, context }) => {
|
||||||
|
if (!context.user) {
|
||||||
|
throw redirect({ to: '/login' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return next({
|
||||||
|
context: {
|
||||||
|
user: context.user, // Now guaranteed to exist
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Logging Middleware
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// lib/middleware/logging.ts
|
||||||
|
export const loggingMiddleware = createMiddleware()
|
||||||
|
.server(async ({ next, request }) => {
|
||||||
|
const start = Date.now()
|
||||||
|
const requestId = crypto.randomUUID()
|
||||||
|
|
||||||
|
console.log(`[${requestId}] ${request.method} ${request.url}`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await next({
|
||||||
|
context: { requestId },
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`[${requestId}] Completed in ${Date.now() - start}ms`)
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[${requestId}] Error:`, error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Global Middleware Configuration
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/start.ts
|
||||||
|
import { createStart } from '@tanstack/react-start/server'
|
||||||
|
import { loggingMiddleware } from './middleware/logging'
|
||||||
|
import { authMiddleware } from './middleware/auth'
|
||||||
|
|
||||||
|
export default createStart({
|
||||||
|
// Request middleware runs for all requests
|
||||||
|
requestMiddleware: [
|
||||||
|
loggingMiddleware,
|
||||||
|
authMiddleware,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Rate Limiting Middleware
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// lib/middleware/rateLimit.ts
|
||||||
|
import { createMiddleware } from '@tanstack/react-start'
|
||||||
|
|
||||||
|
const rateLimitStore = new Map<string, { count: number; resetAt: number }>()
|
||||||
|
|
||||||
|
export const rateLimitMiddleware = createMiddleware()
|
||||||
|
.server(async ({ next, request }) => {
|
||||||
|
const ip = request.headers.get('x-forwarded-for') ?? 'unknown'
|
||||||
|
const now = Date.now()
|
||||||
|
const windowMs = 60 * 1000 // 1 minute
|
||||||
|
const maxRequests = 100
|
||||||
|
|
||||||
|
let record = rateLimitStore.get(ip)
|
||||||
|
|
||||||
|
if (!record || record.resetAt < now) {
|
||||||
|
record = { count: 0, resetAt: now + windowMs }
|
||||||
|
}
|
||||||
|
|
||||||
|
record.count++
|
||||||
|
rateLimitStore.set(ip, record)
|
||||||
|
|
||||||
|
if (record.count > maxRequests) {
|
||||||
|
throw new Response('Too Many Requests', { status: 429 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return next()
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Middleware Execution Order
|
||||||
|
|
||||||
|
```
|
||||||
|
Request → Middleware 1 → Middleware 2 → Handler → Middleware 2 → Middleware 1 → Response
|
||||||
|
|
||||||
|
// Example with timing:
|
||||||
|
loggingMiddleware.server(async ({ next }) => {
|
||||||
|
console.log('Before handler')
|
||||||
|
const result = await next() // Calls next middleware/handler
|
||||||
|
console.log('After handler')
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Request middleware applies to all server requests
|
||||||
|
- Middleware can add to context using `next({ context: {...} })`
|
||||||
|
- Order matters - first middleware wraps the entire chain
|
||||||
|
- Global middleware defined in `app/start.ts`
|
||||||
|
- Route-specific middleware uses `beforeLoad`
|
||||||
|
- Server function middleware uses separate pattern (see `mw-function-middleware`)
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
# sf-create-server-fn: Use createServerFn for Server-Side Logic
|
||||||
|
|
||||||
|
## Priority: CRITICAL
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
`createServerFn()` creates type-safe server functions that can be called from anywhere - loaders, components, or other server functions. The code inside the handler runs only on the server, with automatic RPC for client calls.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Using fetch directly - no type safety, manual serialization
|
||||||
|
async function createPost(data: CreatePostInput) {
|
||||||
|
const response = await fetch('/api/posts', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error('Failed to create post')
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Or using API routes - more boilerplate
|
||||||
|
// api/posts.ts
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const data = await request.json()
|
||||||
|
// No type safety from client
|
||||||
|
const post = await db.posts.create({ data })
|
||||||
|
return new Response(JSON.stringify(post))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// lib/posts.functions.ts
|
||||||
|
import { createServerFn } from '@tanstack/react-start'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { db } from './db.server'
|
||||||
|
|
||||||
|
const createPostSchema = z.object({
|
||||||
|
title: z.string().min(1).max(200),
|
||||||
|
content: z.string().min(1),
|
||||||
|
published: z.boolean().default(false),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const createPost = createServerFn({ method: 'POST' })
|
||||||
|
.inputValidator(createPostSchema)
|
||||||
|
.handler(async ({ data }) => {
|
||||||
|
// This code only runs on the server
|
||||||
|
const post = await db.posts.create({
|
||||||
|
data: {
|
||||||
|
title: data.title,
|
||||||
|
content: data.content,
|
||||||
|
published: data.published,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return post
|
||||||
|
})
|
||||||
|
|
||||||
|
// Usage in component
|
||||||
|
function CreatePostForm() {
|
||||||
|
const createPostMutation = useServerFn(createPost)
|
||||||
|
|
||||||
|
const handleSubmit = async (formData: FormData) => {
|
||||||
|
try {
|
||||||
|
const post = await createPostMutation({
|
||||||
|
data: {
|
||||||
|
title: formData.get('title') as string,
|
||||||
|
content: formData.get('content') as string,
|
||||||
|
published: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// post is fully typed
|
||||||
|
console.log('Created post:', post.id)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create post:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: GET Function for Data Fetching
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// lib/posts.functions.ts
|
||||||
|
export const getPosts = createServerFn() // GET is default
|
||||||
|
.handler(async () => {
|
||||||
|
const posts = await db.posts.findMany({
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 20,
|
||||||
|
})
|
||||||
|
return posts
|
||||||
|
})
|
||||||
|
|
||||||
|
export const getPost = createServerFn()
|
||||||
|
.validator(z.object({ id: z.string() }))
|
||||||
|
.handler(async ({ data }) => {
|
||||||
|
const post = await db.posts.findUnique({
|
||||||
|
where: { id: data.id },
|
||||||
|
})
|
||||||
|
if (!post) {
|
||||||
|
throw notFound()
|
||||||
|
}
|
||||||
|
return post
|
||||||
|
})
|
||||||
|
|
||||||
|
// Usage in route loader
|
||||||
|
export const Route = createFileRoute('/posts/$postId')({
|
||||||
|
loader: async ({ params }) => {
|
||||||
|
return await getPost({ data: { id: params.postId } })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: With Context and Dependencies
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Compose server functions
|
||||||
|
export const getPostWithComments = createServerFn()
|
||||||
|
.inputValidator(z.object({ postId: z.string() }))
|
||||||
|
.handler(async ({ data }) => {
|
||||||
|
const [post, comments] = await Promise.all([
|
||||||
|
getPost({ data: { id: data.postId } }),
|
||||||
|
getComments({ data: { postId: data.postId } }),
|
||||||
|
])
|
||||||
|
|
||||||
|
return { post, comments }
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Benefits
|
||||||
|
|
||||||
|
- **Type safety**: Input/output types flow through client and server
|
||||||
|
- **Automatic serialization**: No manual JSON parsing
|
||||||
|
- **Code splitting**: Server code never reaches client bundle
|
||||||
|
- **Composable**: Call from loaders, components, or other server functions
|
||||||
|
- **Validation**: Built-in input validation with schema libraries
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Default method is GET (idempotent, cacheable)
|
||||||
|
- Use POST for mutations that change data
|
||||||
|
- Server functions are RPC calls under the hood
|
||||||
|
- Validation errors are properly typed and serialized
|
||||||
|
- Import is safe on client - build process replaces with RPC stub
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
# sf-input-validation: Always Validate Server Function Inputs
|
||||||
|
|
||||||
|
## Priority: CRITICAL
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
Server functions receive data across the network boundary. Always validate inputs before processing - never trust client data. Use schema validation libraries like Zod for type-safe validation.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// No validation - trusting client input directly
|
||||||
|
export const updateUser = createServerFn({ method: 'POST' })
|
||||||
|
.handler(async ({ data }) => {
|
||||||
|
// data is unknown/any - no type safety
|
||||||
|
// SQL injection, invalid data, type errors all possible
|
||||||
|
await db.users.update({
|
||||||
|
where: { id: data.id },
|
||||||
|
data: {
|
||||||
|
name: data.name,
|
||||||
|
email: data.email,
|
||||||
|
role: data.role, // Could be set to 'admin' by malicious client!
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Weak validation - type assertion without runtime check
|
||||||
|
export const deletePost = createServerFn({ method: 'POST' })
|
||||||
|
.handler(async ({ data }: { data: { id: string } }) => {
|
||||||
|
// Type assertion doesn't validate at runtime
|
||||||
|
await db.posts.delete({ where: { id: data.id } })
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: With Zod Validation
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { createServerFn } from '@tanstack/react-start'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const updateUserSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
name: z.string().min(1).max(100),
|
||||||
|
email: z.string().email(),
|
||||||
|
// Don't allow role updates from client input!
|
||||||
|
})
|
||||||
|
|
||||||
|
export const updateUser = createServerFn({ method: 'POST' })
|
||||||
|
.validator(updateUserSchema)
|
||||||
|
.handler(async ({ data }) => {
|
||||||
|
// data is fully typed: { id: string; name: string; email: string }
|
||||||
|
const user = await db.users.update({
|
||||||
|
where: { id: data.id },
|
||||||
|
data: {
|
||||||
|
name: data.name,
|
||||||
|
email: data.email,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return user
|
||||||
|
})
|
||||||
|
|
||||||
|
// Validation errors are automatically returned to client
|
||||||
|
// with proper status codes and messages
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Complex Validation
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const createOrderSchema = z.object({
|
||||||
|
items: z.array(z.object({
|
||||||
|
productId: z.string().uuid(),
|
||||||
|
quantity: z.number().int().min(1).max(100),
|
||||||
|
})).min(1).max(50),
|
||||||
|
shippingAddress: z.object({
|
||||||
|
street: z.string().min(1),
|
||||||
|
city: z.string().min(1),
|
||||||
|
state: z.string().length(2),
|
||||||
|
zip: z.string().regex(/^\d{5}(-\d{4})?$/),
|
||||||
|
}),
|
||||||
|
couponCode: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const createOrder = createServerFn({ method: 'POST' })
|
||||||
|
.validator(createOrderSchema)
|
||||||
|
.handler(async ({ data }) => {
|
||||||
|
// All data is validated and typed
|
||||||
|
// Process order safely
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Transform and Refine
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const registrationSchema = z.object({
|
||||||
|
email: z.string().email().toLowerCase(), // Transform to lowercase
|
||||||
|
password: z.string()
|
||||||
|
.min(8, 'Password must be at least 8 characters')
|
||||||
|
.regex(/[A-Z]/, 'Password must contain uppercase letter')
|
||||||
|
.regex(/[0-9]/, 'Password must contain number'),
|
||||||
|
confirmPassword: z.string(),
|
||||||
|
}).refine(
|
||||||
|
(data) => data.password === data.confirmPassword,
|
||||||
|
{ message: 'Passwords must match', path: ['confirmPassword'] }
|
||||||
|
)
|
||||||
|
|
||||||
|
export const register = createServerFn({ method: 'POST' })
|
||||||
|
.validator(registrationSchema)
|
||||||
|
.handler(async ({ data }) => {
|
||||||
|
// Passwords match, email is lowercase
|
||||||
|
// Only password needed (confirmPassword was for validation)
|
||||||
|
const hashedPassword = await hashPassword(data.password)
|
||||||
|
return await createUser({
|
||||||
|
email: data.email,
|
||||||
|
password: hashedPassword,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sharing Schemas Between Client and Server
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// lib/schemas/post.ts - Shared validation schema
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
export const createPostSchema = z.object({
|
||||||
|
title: z.string().min(1).max(200),
|
||||||
|
content: z.string().min(1),
|
||||||
|
tags: z.array(z.string()).max(10).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type CreatePostInput = z.infer<typeof createPostSchema>
|
||||||
|
|
||||||
|
// lib/posts.functions.ts - Server function
|
||||||
|
import { createPostSchema } from './schemas/post'
|
||||||
|
|
||||||
|
export const createPost = createServerFn({ method: 'POST' })
|
||||||
|
.validator(createPostSchema)
|
||||||
|
.handler(async ({ data }) => { /* ... */ })
|
||||||
|
|
||||||
|
// components/CreatePostForm.tsx - Client form validation
|
||||||
|
import { createPostSchema, type CreatePostInput } from '@/lib/schemas/post'
|
||||||
|
|
||||||
|
function CreatePostForm() {
|
||||||
|
const form = useForm<CreatePostInput>({
|
||||||
|
resolver: zodResolver(createPostSchema),
|
||||||
|
})
|
||||||
|
// Same validation client and server side
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Network boundary = trust boundary - always validate
|
||||||
|
- Use `.validator()` before `.handler()` in the chain
|
||||||
|
- Validation errors return proper HTTP status codes
|
||||||
|
- Share schemas between client forms and server functions
|
||||||
|
- Strip or ignore fields clients shouldn't control (like `role`, `isAdmin`)
|
||||||
|
- Consider rate limiting for mutation endpoints
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
# ssr-hydration-safety: Prevent Hydration Mismatches
|
||||||
|
|
||||||
|
## Priority: MEDIUM
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
Hydration errors occur when server-rendered HTML doesn't match what the client expects. This causes React to discard server HTML and re-render, losing SSR benefits. Ensure consistent rendering between server and client.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Using Date.now() - different on server and client
|
||||||
|
function Timestamp() {
|
||||||
|
return <span>Generated at: {Date.now()}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Using Math.random() - always different
|
||||||
|
function RandomGreeting() {
|
||||||
|
const greetings = ['Hello', 'Hi', 'Hey']
|
||||||
|
return <h1>{greetings[Math.floor(Math.random() * 3)]}</h1>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checking window - doesn't exist on server
|
||||||
|
function DeviceInfo() {
|
||||||
|
return <span>Width: {window.innerWidth}px</span> // Error on server
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conditional render based on time
|
||||||
|
function TimeBasedContent() {
|
||||||
|
const hour = new Date().getHours()
|
||||||
|
return hour < 12 ? <Morning /> : <Evening />
|
||||||
|
// Server might render Morning, client renders Evening
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Consistent Server/Client Rendering
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Pass data from server to avoid mismatch
|
||||||
|
export const Route = createFileRoute('/dashboard')({
|
||||||
|
loader: async () => {
|
||||||
|
return {
|
||||||
|
generatedAt: Date.now(),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
component: Dashboard,
|
||||||
|
})
|
||||||
|
|
||||||
|
function Dashboard() {
|
||||||
|
const { generatedAt } = Route.useLoaderData()
|
||||||
|
// Both server and client use same value
|
||||||
|
return <span>Generated at: {generatedAt}</span>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Client-Only Components
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Use lazy loading for client-only features
|
||||||
|
import { lazy, Suspense } from 'react'
|
||||||
|
|
||||||
|
const ClientOnlyMap = lazy(() => import('./Map'))
|
||||||
|
|
||||||
|
function LocationPage() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Our Location</h1>
|
||||||
|
<Suspense fallback={<MapPlaceholder />}>
|
||||||
|
<ClientOnlyMap />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Or use useEffect for client-only state
|
||||||
|
function WindowSize() {
|
||||||
|
const [size, setSize] = useState<{ width: number; height: number } | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSize({
|
||||||
|
width: window.innerWidth,
|
||||||
|
height: window.innerHeight,
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (!size) {
|
||||||
|
return <span>Loading dimensions...</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
return <span>{size.width} x {size.height}</span>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Stable Random Values
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Generate random value on server, pass to client
|
||||||
|
export const Route = createFileRoute('/onboarding')({
|
||||||
|
loader: () => ({
|
||||||
|
welcomeVariant: Math.floor(Math.random() * 3),
|
||||||
|
}),
|
||||||
|
component: Onboarding,
|
||||||
|
})
|
||||||
|
|
||||||
|
function Onboarding() {
|
||||||
|
const { welcomeVariant } = Route.useLoaderData()
|
||||||
|
const messages = ['Welcome aboard!', 'Let's get started!', 'Great to have you!']
|
||||||
|
|
||||||
|
return <h1>{messages[welcomeVariant]}</h1> // Same on server and client
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Handling Time Zones
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Pass formatted date from server
|
||||||
|
export const Route = createFileRoute('/posts/$postId')({
|
||||||
|
loader: async ({ params }) => {
|
||||||
|
const post = await getPost(params.postId)
|
||||||
|
return {
|
||||||
|
...post,
|
||||||
|
// Format on server to avoid timezone mismatch
|
||||||
|
formattedDate: new Intl.DateTimeFormat('en-US', {
|
||||||
|
dateStyle: 'long',
|
||||||
|
timeStyle: 'short',
|
||||||
|
timeZone: 'UTC', // Consistent timezone
|
||||||
|
}).format(post.createdAt),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
component: PostPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Or use client-only formatting
|
||||||
|
function RelativeTime({ date }: { date: Date }) {
|
||||||
|
const [formatted, setFormatted] = useState<string>('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Format in user's timezone after hydration
|
||||||
|
setFormatted(formatDistanceToNow(date, { addSuffix: true }))
|
||||||
|
}, [date])
|
||||||
|
|
||||||
|
// Show absolute date initially (same server/client)
|
||||||
|
return <time dateTime={date.toISOString()}>
|
||||||
|
{formatted || date.toISOString().split('T')[0]}
|
||||||
|
</time>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Hydration Mismatch Causes
|
||||||
|
|
||||||
|
| Issue | Solution |
|
||||||
|
|-------|----------|
|
||||||
|
| `Date.now()` / `new Date()` | Pass timestamp from loader |
|
||||||
|
| `Math.random()` | Generate on server, pass to client |
|
||||||
|
| `window` / `document` | Use useEffect or lazy loading |
|
||||||
|
| User timezone differences | Use UTC or client-only formatting |
|
||||||
|
| Browser-specific APIs | Check `typeof window !== 'undefined'` |
|
||||||
|
| Extension-injected content | Use `suppressHydrationWarning` |
|
||||||
|
|
||||||
|
## Debugging Hydration Errors
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// React 18+ provides detailed hydration error messages
|
||||||
|
// Check the console for:
|
||||||
|
// - "Text content does not match"
|
||||||
|
// - "Hydration failed because"
|
||||||
|
// - The specific DOM element causing the issue
|
||||||
|
|
||||||
|
// For difficult cases, use suppressHydrationWarning sparingly
|
||||||
|
function UserContent({ html }: { html: string }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
suppressHydrationWarning
|
||||||
|
dangerouslySetInnerHTML={{ __html: html }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Hydration compares server HTML with client render
|
||||||
|
- Mismatches force full client re-render (slow, flash)
|
||||||
|
- Use loaders to pass dynamic data consistently
|
||||||
|
- Defer client-only content with useEffect or Suspense
|
||||||
|
- Test SSR by disabling JavaScript and checking render
|
||||||
|
- Development mode shows hydration warnings in console
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
# ssr-prerender: Configure Static Prerendering and ISR
|
||||||
|
|
||||||
|
## Priority: MEDIUM
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
Static prerendering generates HTML at build time for pages that don't require request-time data. Incremental Static Regeneration (ISR) extends this by revalidating cached pages on a schedule. Use these for better performance and lower server costs.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// SSR for completely static content - wasteful
|
||||||
|
export const Route = createFileRoute('/about')({
|
||||||
|
loader: async () => {
|
||||||
|
// Fetching static content on every request
|
||||||
|
const content = await fetchAboutPageContent()
|
||||||
|
return { content }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Or no caching headers for semi-static content
|
||||||
|
export const Route = createFileRoute('/blog/$slug')({
|
||||||
|
loader: async ({ params }) => {
|
||||||
|
const post = await fetchPost(params.slug)
|
||||||
|
return { post }
|
||||||
|
// Every request hits the database
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Static Prerendering
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app.config.ts
|
||||||
|
import { defineConfig } from '@tanstack/react-start/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
server: {
|
||||||
|
prerender: {
|
||||||
|
// Routes to prerender at build time
|
||||||
|
routes: [
|
||||||
|
'/',
|
||||||
|
'/about',
|
||||||
|
'/contact',
|
||||||
|
'/pricing',
|
||||||
|
],
|
||||||
|
// Or crawl from root
|
||||||
|
crawlLinks: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// routes/about.tsx - Will be prerendered
|
||||||
|
export const Route = createFileRoute('/about')({
|
||||||
|
loader: async () => {
|
||||||
|
// Runs at BUILD time, not request time
|
||||||
|
const content = await fetchAboutPageContent()
|
||||||
|
return { content }
|
||||||
|
},
|
||||||
|
component: AboutPage,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Dynamic Prerendering
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app.config.ts
|
||||||
|
export default defineConfig({
|
||||||
|
server: {
|
||||||
|
prerender: {
|
||||||
|
// Generate routes dynamically
|
||||||
|
routes: async () => {
|
||||||
|
const posts = await db.posts.findMany({
|
||||||
|
where: { published: true },
|
||||||
|
select: { slug: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
return [
|
||||||
|
'/',
|
||||||
|
'/blog',
|
||||||
|
...posts.map(p => `/blog/${p.slug}`),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: ISR with Revalidation
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// routes/blog/$slug.tsx
|
||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { setHeaders } from '@tanstack/react-start/server'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/blog/$slug')({
|
||||||
|
loader: async ({ params }) => {
|
||||||
|
const post = await fetchPost(params.slug)
|
||||||
|
|
||||||
|
// ISR: Cache for 60 seconds, then revalidate
|
||||||
|
setHeaders({
|
||||||
|
'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300',
|
||||||
|
})
|
||||||
|
|
||||||
|
return { post }
|
||||||
|
},
|
||||||
|
component: BlogPost,
|
||||||
|
})
|
||||||
|
|
||||||
|
// First request: SSR and cache
|
||||||
|
// Next 60 seconds: Serve cached version
|
||||||
|
// After 60 seconds: Serve stale, revalidate in background
|
||||||
|
// After 300 seconds: Full SSR again
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Hybrid Static/Dynamic
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// routes/products.tsx - Prerendered
|
||||||
|
export const Route = createFileRoute('/products')({
|
||||||
|
loader: async () => {
|
||||||
|
// Featured products - prerendered at build
|
||||||
|
const featured = await fetchFeaturedProducts()
|
||||||
|
return { featured }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// routes/products/$productId.tsx - ISR
|
||||||
|
export const Route = createFileRoute('/products/$productId')({
|
||||||
|
loader: async ({ params }) => {
|
||||||
|
const product = await fetchProduct(params.productId)
|
||||||
|
|
||||||
|
if (!product) throw notFound()
|
||||||
|
|
||||||
|
// Cache product pages for 5 minutes
|
||||||
|
setHeaders({
|
||||||
|
'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600',
|
||||||
|
})
|
||||||
|
|
||||||
|
return { product }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// routes/cart.tsx - Always SSR (user-specific)
|
||||||
|
export const Route = createFileRoute('/cart')({
|
||||||
|
loader: async ({ context }) => {
|
||||||
|
// No caching - user-specific data
|
||||||
|
setHeaders({
|
||||||
|
'Cache-Control': 'private, no-store',
|
||||||
|
})
|
||||||
|
|
||||||
|
const cart = await fetchUserCart(context.user.id)
|
||||||
|
return { cart }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: On-Demand Revalidation
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// API route to trigger revalidation
|
||||||
|
// app/routes/api/revalidate.ts
|
||||||
|
export const APIRoute = createAPIFileRoute('/api/revalidate')({
|
||||||
|
POST: async ({ request }) => {
|
||||||
|
const { secret, path } = await request.json()
|
||||||
|
|
||||||
|
// Verify secret
|
||||||
|
if (secret !== process.env.REVALIDATE_SECRET) {
|
||||||
|
return json({ error: 'Invalid secret' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger revalidation (implementation depends on hosting)
|
||||||
|
await revalidatePath(path)
|
||||||
|
|
||||||
|
return json({ revalidated: true, path })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Usage: POST /api/revalidate { "secret": "...", "path": "/blog/my-post" }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cache-Control Directives
|
||||||
|
|
||||||
|
| Directive | Meaning |
|
||||||
|
|-----------|---------|
|
||||||
|
| `s-maxage=N` | CDN cache duration (seconds) |
|
||||||
|
| `max-age=N` | Browser cache duration |
|
||||||
|
| `stale-while-revalidate=N` | Serve stale while fetching fresh |
|
||||||
|
| `private` | Don't cache on CDN (user-specific) |
|
||||||
|
| `no-store` | Never cache |
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Prerendering happens at build time - no request context
|
||||||
|
- ISR requires CDN/edge support (Vercel, Cloudflare, etc.)
|
||||||
|
- Use prerendering for truly static pages (about, pricing)
|
||||||
|
- Use ISR for content that changes but not per-request
|
||||||
|
- Always SSR for user-specific or real-time data
|
||||||
|
- Test with production builds - dev server is always SSR
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
# ssr-streaming: Implement Streaming SSR for Faster TTFB
|
||||||
|
|
||||||
|
## Priority: MEDIUM
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
Streaming SSR sends HTML chunks to the browser as they're ready, rather than waiting for all data to load. This improves Time to First Byte (TTFB) and perceived performance by showing content progressively.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Blocking SSR - waits for everything
|
||||||
|
export const Route = createFileRoute('/dashboard')({
|
||||||
|
loader: async ({ context: { queryClient } }) => {
|
||||||
|
// All of these must complete before ANY HTML is sent
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.ensureQueryData(userQueries.profile()), // 200ms
|
||||||
|
queryClient.ensureQueryData(dashboardQueries.stats()), // 500ms
|
||||||
|
queryClient.ensureQueryData(activityQueries.recent()), // 300ms
|
||||||
|
queryClient.ensureQueryData(notificationQueries.all()), // 400ms
|
||||||
|
])
|
||||||
|
// TTFB: 500ms (slowest query)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Stream Non-Critical Content
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// routes/dashboard.tsx
|
||||||
|
export const Route = createFileRoute('/dashboard')({
|
||||||
|
loader: async ({ context: { queryClient } }) => {
|
||||||
|
// Only await critical above-the-fold data
|
||||||
|
await queryClient.ensureQueryData(userQueries.profile())
|
||||||
|
|
||||||
|
// Start fetching but don't await
|
||||||
|
queryClient.prefetchQuery(dashboardQueries.stats())
|
||||||
|
queryClient.prefetchQuery(activityQueries.recent())
|
||||||
|
queryClient.prefetchQuery(notificationQueries.all())
|
||||||
|
|
||||||
|
// HTML starts streaming immediately after profile loads
|
||||||
|
// TTFB: 200ms
|
||||||
|
},
|
||||||
|
component: DashboardPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function DashboardPage() {
|
||||||
|
// Critical data - ready immediately (from loader)
|
||||||
|
const { data: user } = useSuspenseQuery(userQueries.profile())
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Header user={user} />
|
||||||
|
|
||||||
|
{/* Non-critical - streams in with Suspense */}
|
||||||
|
<Suspense fallback={<StatsSkeleton />}>
|
||||||
|
<DashboardStats />
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
|
<Suspense fallback={<ActivitySkeleton />}>
|
||||||
|
<RecentActivity />
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
|
<Suspense fallback={<NotificationsSkeleton />}>
|
||||||
|
<NotificationsList />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each section loads independently and streams when ready
|
||||||
|
function DashboardStats() {
|
||||||
|
const { data: stats } = useSuspenseQuery(dashboardQueries.stats())
|
||||||
|
return <StatsDisplay stats={stats} />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Nested Suspense Boundaries
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function DashboardPage() {
|
||||||
|
const { data: user } = useSuspenseQuery(userQueries.profile())
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Header user={user} />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{/* Left column streams together */}
|
||||||
|
<Suspense fallback={<LeftColumnSkeleton />}>
|
||||||
|
<LeftColumn />
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
|
{/* Right column streams independently */}
|
||||||
|
<Suspense fallback={<RightColumnSkeleton />}>
|
||||||
|
<RightColumn />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LeftColumn() {
|
||||||
|
// These load together (same Suspense boundary)
|
||||||
|
const { data: stats } = useSuspenseQuery(dashboardQueries.stats())
|
||||||
|
const { data: chart } = useSuspenseQuery(dashboardQueries.chartData())
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<StatsCard stats={stats} />
|
||||||
|
<ChartDisplay data={chart} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Progressive Enhancement
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export const Route = createFileRoute('/posts/$postId')({
|
||||||
|
loader: async ({ params, context: { queryClient } }) => {
|
||||||
|
// Critical: post content (await)
|
||||||
|
await queryClient.ensureQueryData(postQueries.detail(params.postId))
|
||||||
|
|
||||||
|
// Start but don't block: comments, related posts
|
||||||
|
queryClient.prefetchQuery(commentQueries.forPost(params.postId))
|
||||||
|
queryClient.prefetchQuery(postQueries.related(params.postId))
|
||||||
|
},
|
||||||
|
component: PostPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function PostPage() {
|
||||||
|
const { postId } = Route.useParams()
|
||||||
|
const { data: post } = useSuspenseQuery(postQueries.detail(postId))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article>
|
||||||
|
{/* Streams immediately */}
|
||||||
|
<PostHeader post={post} />
|
||||||
|
<PostContent content={post.content} />
|
||||||
|
|
||||||
|
{/* Streams when ready */}
|
||||||
|
<Suspense fallback={<CommentsSkeleton />}>
|
||||||
|
<CommentsSection postId={postId} />
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
|
<Suspense fallback={<RelatedSkeleton />}>
|
||||||
|
<RelatedPosts postId={postId} />
|
||||||
|
</Suspense>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Error Boundaries with Streaming
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function DashboardPage() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
{/* Each section handles its own errors */}
|
||||||
|
<ErrorBoundary fallback={<StatsError />}>
|
||||||
|
<Suspense fallback={<StatsSkeleton />}>
|
||||||
|
<DashboardStats />
|
||||||
|
</Suspense>
|
||||||
|
</ErrorBoundary>
|
||||||
|
|
||||||
|
<ErrorBoundary fallback={<ActivityError />}>
|
||||||
|
<Suspense fallback={<ActivitySkeleton />}>
|
||||||
|
<RecentActivity />
|
||||||
|
</Suspense>
|
||||||
|
</ErrorBoundary>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Streaming Timeline
|
||||||
|
|
||||||
|
```
|
||||||
|
Traditional SSR:
|
||||||
|
Request → [Wait for all data...] → Send complete HTML → Render
|
||||||
|
|
||||||
|
Streaming SSR:
|
||||||
|
Request → Send shell HTML → Stream chunk 1 → Stream chunk 2 → Stream chunk 3 → Done
|
||||||
|
↓ ↓ ↓ ↓
|
||||||
|
Browser renders Shows content More content Complete
|
||||||
|
skeleton progressively
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Suspense boundaries define streaming chunks
|
||||||
|
- Place boundaries around slow or non-critical content
|
||||||
|
- Critical path data should still be awaited in loader
|
||||||
|
- Each Suspense boundary can error independently
|
||||||
|
- Works with React 18's streaming SSR
|
||||||
|
- Monitor TTFB to verify streaming is working
|
||||||
|
- Consider network conditions - too many chunks can slow total load
|
||||||
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
.env
|
||||||
|
.git
|
||||||
|
*.md
|
||||||
|
dist
|
||||||
|
.tanstack
|
||||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
dist
|
||||||
|
build
|
||||||
42
Dockerfile
Normal file
42
Dockerfile
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
FROM oven/bun:1 AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json bun.lockb ./
|
||||||
|
COPY apps/backend/package.json apps/backend/
|
||||||
|
COPY apps/frontend/package.json apps/frontend/
|
||||||
|
COPY packages/common/package.json packages/common/
|
||||||
|
|
||||||
|
RUN bun install --frozen-lockfile
|
||||||
|
|
||||||
|
FROM deps AS frontend-build
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY apps/frontend/ apps/frontend/
|
||||||
|
COPY packages/common/ packages/common/
|
||||||
|
|
||||||
|
RUN bun run --cwd apps/frontend build
|
||||||
|
|
||||||
|
FROM deps AS backend-build
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY apps/backend/ apps/backend/
|
||||||
|
COPY packages/common/ packages/common/
|
||||||
|
|
||||||
|
RUN bun run --cwd apps/backend prisma:generate
|
||||||
|
|
||||||
|
FROM oven/bun:1 AS production
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=deps /app/package.json ./
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY --from=backend-build /app/apps/backend ./apps/backend
|
||||||
|
COPY --from=backend-build /app/packages/common ./packages/common
|
||||||
|
COPY --from=frontend-build /app/apps/backend/web ./apps/backend/web
|
||||||
|
|
||||||
|
COPY apps/backend/docker-entrypoint.sh /usr/local/bin/
|
||||||
|
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||||
|
CMD ["bun", "run", "apps/backend/src/index.ts"]
|
||||||
7
apps/backend/.gitignore
vendored
Normal file
7
apps/backend/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
# Keep environment variables out of version control
|
||||||
|
.env
|
||||||
|
|
||||||
|
/src/generated/prisma
|
||||||
|
|
||||||
|
/web
|
||||||
22
apps/backend/docker-entrypoint.sh
Normal file
22
apps/backend/docker-entrypoint.sh
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "Running database migrations..."
|
||||||
|
max_retries=30
|
||||||
|
i=0
|
||||||
|
until [ $i -ge $max_retries ]; do
|
||||||
|
if bun run --cwd apps/backend prisma:migrate:deploy 2>&1; then
|
||||||
|
echo "Migrations complete"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
i=$((i + 1))
|
||||||
|
echo "Migration attempt $i failed, retrying in 2s..."
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ $i -ge $max_retries ]; then
|
||||||
|
echo "Failed to run migrations after $max_retries attempts"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
33
apps/backend/package.json
Normal file
33
apps/backend/package.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "@personal-admin/backend",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "bun run --watch src/index.ts",
|
||||||
|
"start": "bun run src/index.ts",
|
||||||
|
"prisma:generate": "prisma generate",
|
||||||
|
"prisma:migrate": "prisma migrate dev",
|
||||||
|
"prisma:studio": "prisma studio",
|
||||||
|
"prisma:migrate:deploy": "prisma migrate deploy"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@personal-admin/common": "workspace:*",
|
||||||
|
"@prisma/adapter-pg": "^7.8.0",
|
||||||
|
"@prisma/client": "^7.8.0",
|
||||||
|
"hono": "^4.7.0",
|
||||||
|
"node-cron": "^4.2.1",
|
||||||
|
"pg": "^8.21.0",
|
||||||
|
"pino": "^10.3.1",
|
||||||
|
"pino-pretty": "^13.1.3"
|
||||||
|
},
|
||||||
|
"prisma": {
|
||||||
|
"seed": "bun run prisma/seed.ts"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node-cron": "^3.0.11",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
|
"dotenv": "^16.4.0",
|
||||||
|
"prisma": "^7.8.0",
|
||||||
|
"typescript": "^5.8.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
14
apps/backend/prisma.config.ts
Normal file
14
apps/backend/prisma.config.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// This file was generated by Prisma, and assumes you have installed the following:
|
||||||
|
// npm install --save-dev prisma dotenv
|
||||||
|
import "dotenv/config";
|
||||||
|
import { defineConfig } from "prisma/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "prisma/schema.prisma",
|
||||||
|
migrations: {
|
||||||
|
path: "prisma/migrations",
|
||||||
|
},
|
||||||
|
datasource: {
|
||||||
|
url: process.env["DATABASE_URL"],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "QuoteType" AS ENUM ('BLUE', 'BNA', 'BELO');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "quotes" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"timeStamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"buy" MONEY NOT NULL,
|
||||||
|
"sell" MONEY NOT NULL,
|
||||||
|
"type" "QuoteType" NOT NULL,
|
||||||
|
"difference" DECIMAL(9,2) NOT NULL DEFAULT 0,
|
||||||
|
"percentage" DECIMAL(9,2) NOT NULL DEFAULT 0,
|
||||||
|
"previousDayDifference" DECIMAL(9,2) NOT NULL DEFAULT 0,
|
||||||
|
"previousDayPercentage" DECIMAL(9,2) NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
CONSTRAINT "quotes_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "quotes_history" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"timeStamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"buy" MONEY NOT NULL,
|
||||||
|
"sell" MONEY NOT NULL,
|
||||||
|
"type" "QuoteType" NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "quotes_history_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
3
apps/backend/prisma/migrations/migration_lock.toml
Normal file
3
apps/backend/prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "postgresql"
|
||||||
44
apps/backend/prisma/schema.prisma
Normal file
44
apps/backend/prisma/schema.prisma
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
// This is your Prisma schema file,
|
||||||
|
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||||
|
|
||||||
|
// Get a free hosted Postgres database in seconds: `npx create-db`
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client"
|
||||||
|
output = "../src/generated/prisma"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
}
|
||||||
|
|
||||||
|
enum QuoteType {
|
||||||
|
BLUE
|
||||||
|
BNA
|
||||||
|
BELO
|
||||||
|
}
|
||||||
|
|
||||||
|
model Quote {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
timeStamp DateTime @default(now())
|
||||||
|
buy Decimal @db.Money
|
||||||
|
sell Decimal @db.Money
|
||||||
|
type QuoteType
|
||||||
|
difference Decimal @default(0) @db.Decimal(9, 2)
|
||||||
|
percentage Decimal @default(0) @db.Decimal(9, 2)
|
||||||
|
previousDayDifference Decimal @default(0) @db.Decimal(9, 2)
|
||||||
|
previousDayPercentage Decimal @default(0) @db.Decimal(9, 2)
|
||||||
|
|
||||||
|
@@map("quotes")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
model QuoteHistory {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
timeStamp DateTime @default(now())
|
||||||
|
buy Decimal @db.Money
|
||||||
|
sell Decimal @db.Money
|
||||||
|
type QuoteType
|
||||||
|
|
||||||
|
@@map("quotes_history")
|
||||||
|
}
|
||||||
18
apps/backend/src/index.ts
Normal file
18
apps/backend/src/index.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import { serveStatic } from "hono/bun";
|
||||||
|
import { startQuoteJob } from "./modules/quotes/quote.job";
|
||||||
|
import quotesRouter from "./modules/quotes/quotes.routes";
|
||||||
|
import { logger } from "./lib/logger";
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
app.route("/api/quotes", quotesRouter);
|
||||||
|
|
||||||
|
app.use("/assets/*", serveStatic({ root: "./web" }));
|
||||||
|
app.get("*", serveStatic({ path: "./web/index.html" }));
|
||||||
|
|
||||||
|
startQuoteJob();
|
||||||
|
|
||||||
|
logger.info("Backend started");
|
||||||
|
|
||||||
|
export default app;
|
||||||
19
apps/backend/src/lib/event-bus.ts
Normal file
19
apps/backend/src/lib/event-bus.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
type Listener = (...args: unknown[]) => void;
|
||||||
|
|
||||||
|
class EventBus {
|
||||||
|
private listeners = new Map<string, Set<Listener>>();
|
||||||
|
|
||||||
|
on(event: string, listener: Listener): () => void {
|
||||||
|
if (!this.listeners.has(event)) {
|
||||||
|
this.listeners.set(event, new Set());
|
||||||
|
}
|
||||||
|
this.listeners.get(event)!.add(listener);
|
||||||
|
return () => this.listeners.get(event)?.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(event: string, ...args: unknown[]): void {
|
||||||
|
this.listeners.get(event)?.forEach((listener) => listener(...args));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const eventBus = new EventBus();
|
||||||
17
apps/backend/src/lib/logger.ts
Normal file
17
apps/backend/src/lib/logger.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import pino from "pino";
|
||||||
|
|
||||||
|
const isDev = process.env.NODE_ENV !== "production";
|
||||||
|
|
||||||
|
export const logger = pino({
|
||||||
|
level: process.env.LOG_LEVEL ?? (isDev ? "debug" : "info"),
|
||||||
|
...(isDev && {
|
||||||
|
transport: {
|
||||||
|
target: "pino-pretty",
|
||||||
|
options: {
|
||||||
|
colorize: true,
|
||||||
|
translateTime: "SYS:standard",
|
||||||
|
ignore: "pid,hostname",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
6
apps/backend/src/lib/prisma.ts
Normal file
6
apps/backend/src/lib/prisma.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import "dotenv/config";
|
||||||
|
import { PrismaPg } from "@prisma/adapter-pg";
|
||||||
|
import { PrismaClient } from "../generated/prisma/client";
|
||||||
|
|
||||||
|
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL! });
|
||||||
|
export const prisma = new PrismaClient({ adapter });
|
||||||
10
apps/backend/src/lib/utils.ts
Normal file
10
apps/backend/src/lib/utils.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export function roundTo(v: number, decimals: number): number {
|
||||||
|
const factor = 10 ** decimals;
|
||||||
|
return Math.round(v * factor) / factor;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startOfToday(): Date {
|
||||||
|
const d = new Date();
|
||||||
|
d.setHours(0, 0, 0, 0);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
84
apps/backend/src/modules/quotes/belo.service.ts
Normal file
84
apps/backend/src/modules/quotes/belo.service.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { logger } from "../../lib/logger";
|
||||||
|
import { prisma } from "../../lib/prisma";
|
||||||
|
import { roundTo, startOfToday } from "../../lib/utils";
|
||||||
|
|
||||||
|
interface BeloApiResponse {
|
||||||
|
ask: number;
|
||||||
|
bid: number;
|
||||||
|
totalAsk: number;
|
||||||
|
totalBid: number;
|
||||||
|
time: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchBeloQuote(): Promise<{ buy: number; sell: number }> {
|
||||||
|
const response = await fetch("https://criptoya.com/api/belo/USDC/ARS/1");
|
||||||
|
const data: BeloApiResponse = await response.json();
|
||||||
|
return { buy: data.bid, sell: data.ask };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processBeloQuote(): Promise<void> {
|
||||||
|
const { buy, sell } = await fetchBeloQuote();
|
||||||
|
|
||||||
|
const existingQuote = await prisma.quote.findFirst({
|
||||||
|
where: { type: "BELO" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingQuote) {
|
||||||
|
await prisma.quote.create({
|
||||||
|
data: {
|
||||||
|
buy,
|
||||||
|
sell,
|
||||||
|
type: "BELO",
|
||||||
|
difference: 0,
|
||||||
|
percentage: 0,
|
||||||
|
previousDayDifference: 0,
|
||||||
|
previousDayPercentage: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.quoteHistory.create({
|
||||||
|
data: { buy, sell, type: "BELO" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingSell = Number(existingQuote.sell);
|
||||||
|
const diff = roundTo(sell - existingSell, 2);
|
||||||
|
const pct = existingSell !== 0 ? roundTo((diff / existingSell) * 100, 2) : 0;
|
||||||
|
|
||||||
|
logger.debug({ buy, sell, diferencia: diff, porcentaje: pct }, `Cotización BELO`);
|
||||||
|
|
||||||
|
const todayStart = startOfToday();
|
||||||
|
|
||||||
|
const prevDayHistory = await prisma.quoteHistory.findFirst({
|
||||||
|
where: {
|
||||||
|
type: "BELO",
|
||||||
|
timeStamp: { lt: todayStart },
|
||||||
|
},
|
||||||
|
orderBy: { timeStamp: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
let prevDayDiff = 0;
|
||||||
|
let prevDayPct = 0;
|
||||||
|
if (prevDayHistory) {
|
||||||
|
const prevSell = Number(prevDayHistory.sell);
|
||||||
|
prevDayDiff = roundTo(sell - prevSell, 2);
|
||||||
|
prevDayPct = prevSell !== 0 ? roundTo((prevDayDiff / prevSell) * 100, 2) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.quote.update({
|
||||||
|
where: { id: existingQuote.id },
|
||||||
|
data: {
|
||||||
|
buy,
|
||||||
|
sell,
|
||||||
|
timeStamp: new Date(),
|
||||||
|
difference: diff,
|
||||||
|
percentage: pct,
|
||||||
|
previousDayDifference: prevDayDiff,
|
||||||
|
previousDayPercentage: prevDayPct,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.quoteHistory.create({
|
||||||
|
data: { buy, sell, type: "BELO" },
|
||||||
|
});
|
||||||
|
}
|
||||||
98
apps/backend/src/modules/quotes/blue.service.ts
Normal file
98
apps/backend/src/modules/quotes/blue.service.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { logger } from "../../lib/logger";
|
||||||
|
import { prisma } from "../../lib/prisma";
|
||||||
|
import { roundTo, startOfToday } from "../../lib/utils";
|
||||||
|
|
||||||
|
interface DolaritoApiResponse {
|
||||||
|
informal: {
|
||||||
|
buy: number;
|
||||||
|
sell: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchBlueQuote(): Promise<{ buy: number; sell: number }> {
|
||||||
|
const response = await fetch("https://api.dolarito.ar/api/frontend/quotations/dolar", {
|
||||||
|
credentials: "omit",
|
||||||
|
headers: {
|
||||||
|
"User-Agent":
|
||||||
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:139.0) Gecko/20100101 Firefox/139.0",
|
||||||
|
Accept: "application/json, text/plain, */*",
|
||||||
|
"Accept-Language": "es-ES,es;q=0.8,en-US;q=0.5,en;q=0.3",
|
||||||
|
"auth-client": "a4b981d93266b37ea6a646f5b3538f8a",
|
||||||
|
"Sec-Fetch-Dest": "empty",
|
||||||
|
"Sec-Fetch-Mode": "cors",
|
||||||
|
"Sec-Fetch-Site": "same-site",
|
||||||
|
},
|
||||||
|
referrer: "https://www.dolarito.ar/",
|
||||||
|
method: "GET",
|
||||||
|
mode: "cors",
|
||||||
|
});
|
||||||
|
const data: DolaritoApiResponse = await response.json();
|
||||||
|
return { buy: data.informal.buy, sell: data.informal.sell };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processBlueQuote(): Promise<void> {
|
||||||
|
const { buy, sell } = await fetchBlueQuote();
|
||||||
|
|
||||||
|
const existingQuote = await prisma.quote.findFirst({
|
||||||
|
where: { type: "BLUE" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingQuote) {
|
||||||
|
await prisma.quote.create({
|
||||||
|
data: {
|
||||||
|
buy,
|
||||||
|
sell,
|
||||||
|
type: "BLUE",
|
||||||
|
difference: 0,
|
||||||
|
percentage: 0,
|
||||||
|
previousDayDifference: 0,
|
||||||
|
previousDayPercentage: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.quoteHistory.create({
|
||||||
|
data: { buy, sell, type: "BLUE" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingSell = Number(existingQuote.sell);
|
||||||
|
const diff = roundTo(sell - existingSell, 2);
|
||||||
|
const pct = existingSell !== 0 ? roundTo((diff / existingSell) * 100, 2) : 0;
|
||||||
|
|
||||||
|
logger.debug({ buy, sell, diferencia: diff, porcentaje: pct }, `Cotización BLUE`);
|
||||||
|
|
||||||
|
const todayStart = startOfToday();
|
||||||
|
|
||||||
|
const prevDayHistory = await prisma.quoteHistory.findFirst({
|
||||||
|
where: {
|
||||||
|
type: "BLUE",
|
||||||
|
timeStamp: { lt: todayStart },
|
||||||
|
},
|
||||||
|
orderBy: { timeStamp: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
let prevDayDiff = 0;
|
||||||
|
let prevDayPct = 0;
|
||||||
|
if (prevDayHistory) {
|
||||||
|
const prevSell = Number(prevDayHistory.sell);
|
||||||
|
prevDayDiff = roundTo(sell - prevSell, 2);
|
||||||
|
prevDayPct = prevSell !== 0 ? roundTo((prevDayDiff / prevSell) * 100, 2) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.quote.update({
|
||||||
|
where: { id: existingQuote.id },
|
||||||
|
data: {
|
||||||
|
buy,
|
||||||
|
sell,
|
||||||
|
timeStamp: new Date(),
|
||||||
|
difference: diff,
|
||||||
|
percentage: pct,
|
||||||
|
previousDayDifference: prevDayDiff,
|
||||||
|
previousDayPercentage: prevDayPct,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.quoteHistory.create({
|
||||||
|
data: { buy, sell, type: "BLUE" },
|
||||||
|
});
|
||||||
|
}
|
||||||
98
apps/backend/src/modules/quotes/bna.service.ts
Normal file
98
apps/backend/src/modules/quotes/bna.service.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { logger } from "../../lib/logger";
|
||||||
|
import { prisma } from "../../lib/prisma";
|
||||||
|
import { roundTo, startOfToday } from "../../lib/utils";
|
||||||
|
|
||||||
|
interface DolaritoApiResponse {
|
||||||
|
oficial: {
|
||||||
|
buy: number;
|
||||||
|
sell: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchBnaQuote(): Promise<{ buy: number; sell: number }> {
|
||||||
|
const response = await fetch("https://api.dolarito.ar/api/frontend/quotations/dolar", {
|
||||||
|
credentials: "omit",
|
||||||
|
headers: {
|
||||||
|
"User-Agent":
|
||||||
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:139.0) Gecko/20100101 Firefox/139.0",
|
||||||
|
Accept: "application/json, text/plain, */*",
|
||||||
|
"Accept-Language": "es-ES,es;q=0.8,en-US;q=0.5,en;q=0.3",
|
||||||
|
"auth-client": "a4b981d93266b37ea6a646f5b3538f8a",
|
||||||
|
"Sec-Fetch-Dest": "empty",
|
||||||
|
"Sec-Fetch-Mode": "cors",
|
||||||
|
"Sec-Fetch-Site": "same-site",
|
||||||
|
},
|
||||||
|
referrer: "https://www.dolarito.ar/",
|
||||||
|
method: "GET",
|
||||||
|
mode: "cors",
|
||||||
|
});
|
||||||
|
const data: DolaritoApiResponse = await response.json();
|
||||||
|
return { buy: data.oficial.buy, sell: data.oficial.sell };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processBnaQuote(): Promise<void> {
|
||||||
|
const { buy, sell } = await fetchBnaQuote();
|
||||||
|
|
||||||
|
const existingQuote = await prisma.quote.findFirst({
|
||||||
|
where: { type: "BNA" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingQuote) {
|
||||||
|
await prisma.quote.create({
|
||||||
|
data: {
|
||||||
|
buy,
|
||||||
|
sell,
|
||||||
|
type: "BNA",
|
||||||
|
difference: 0,
|
||||||
|
percentage: 0,
|
||||||
|
previousDayDifference: 0,
|
||||||
|
previousDayPercentage: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.quoteHistory.create({
|
||||||
|
data: { buy, sell, type: "BNA" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingSell = Number(existingQuote.sell);
|
||||||
|
const diff = roundTo(sell - existingSell, 2);
|
||||||
|
const pct = existingSell !== 0 ? roundTo((diff / existingSell) * 100, 2) : 0;
|
||||||
|
|
||||||
|
logger.debug({ buy, sell, diferencia: diff, porcentaje: pct }, `Cotización BNA`);
|
||||||
|
|
||||||
|
const todayStart = startOfToday();
|
||||||
|
|
||||||
|
const prevDayHistory = await prisma.quoteHistory.findFirst({
|
||||||
|
where: {
|
||||||
|
type: "BNA",
|
||||||
|
timeStamp: { lt: todayStart },
|
||||||
|
},
|
||||||
|
orderBy: { timeStamp: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
let prevDayDiff = 0;
|
||||||
|
let prevDayPct = 0;
|
||||||
|
if (prevDayHistory) {
|
||||||
|
const prevSell = Number(prevDayHistory.sell);
|
||||||
|
prevDayDiff = roundTo(sell - prevSell, 2);
|
||||||
|
prevDayPct = prevSell !== 0 ? roundTo((prevDayDiff / prevSell) * 100, 2) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.quote.update({
|
||||||
|
where: { id: existingQuote.id },
|
||||||
|
data: {
|
||||||
|
buy,
|
||||||
|
sell,
|
||||||
|
timeStamp: new Date(),
|
||||||
|
difference: diff,
|
||||||
|
percentage: pct,
|
||||||
|
previousDayDifference: prevDayDiff,
|
||||||
|
previousDayPercentage: prevDayPct,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.quoteHistory.create({
|
||||||
|
data: { buy, sell, type: "BNA" },
|
||||||
|
});
|
||||||
|
}
|
||||||
25
apps/backend/src/modules/quotes/handlers/fetchQuotes.ts
Normal file
25
apps/backend/src/modules/quotes/handlers/fetchQuotes.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import type { Context } from "hono";
|
||||||
|
import { processBeloQuote } from "../belo.service";
|
||||||
|
import { processBlueQuote } from "../blue.service";
|
||||||
|
import { processBnaQuote } from "../bna.service";
|
||||||
|
import { logger } from "../../../lib/logger";
|
||||||
|
import { eventBus } from "../../../lib/event-bus";
|
||||||
|
|
||||||
|
export async function fetchQuotes(c: Context) {
|
||||||
|
const results: { type: string; status: string; error?: string }[] = [];
|
||||||
|
|
||||||
|
for (const [name, fn] of [["BELO", processBeloQuote], ["BLUE", processBlueQuote], ["BNA", processBnaQuote]] as const) {
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
results.push({ type: name, status: "ok" });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
logger.error(error, `Error fetching ${name} quote via endpoint`);
|
||||||
|
results.push({ type: name, status: "error", error: message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
eventBus.emit("quotes-updated");
|
||||||
|
|
||||||
|
return c.json(results);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { Context } from "hono";
|
||||||
|
import { prisma } from "../../../lib/prisma";
|
||||||
|
|
||||||
|
export async function getCurrentQuotes(c: Context) {
|
||||||
|
const quotes = await prisma.quote.findMany();
|
||||||
|
return c.json(quotes);
|
||||||
|
}
|
||||||
71
apps/backend/src/modules/quotes/handlers/getDailyMinMax.ts
Normal file
71
apps/backend/src/modules/quotes/handlers/getDailyMinMax.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import type { Context } from "hono";
|
||||||
|
import { prisma } from "../../../lib/prisma";
|
||||||
|
import type { QuoteType } from "../../../generated/prisma/client";
|
||||||
|
|
||||||
|
const VALID_TYPES = ["BLUE", "BNA", "BELO"] as const;
|
||||||
|
|
||||||
|
export async function getDailyMinMax(c: Context) {
|
||||||
|
const rawType = c.req.param("type")!.toUpperCase();
|
||||||
|
|
||||||
|
if (!VALID_TYPES.includes(rawType as typeof VALID_TYPES[number])) {
|
||||||
|
return c.json({ error: `Invalid quote type. Must be one of: ${VALID_TYPES.join(", ")}` }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDateParam = c.req.query("startDate");
|
||||||
|
const endDateParam = c.req.query("endDate");
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const startDate = startDateParam
|
||||||
|
? new Date(startDateParam)
|
||||||
|
: new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
const endDate = endDateParam
|
||||||
|
? new Date(new Date(endDateParam).getTime() + 86_400_000)
|
||||||
|
: now;
|
||||||
|
|
||||||
|
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
|
||||||
|
return c.json({ error: "Invalid date format. Use ISO 8601." }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await prisma.$queryRaw<Array<{
|
||||||
|
date: string;
|
||||||
|
minBuy: number;
|
||||||
|
maxBuy: number;
|
||||||
|
minSell: number;
|
||||||
|
maxSell: number;
|
||||||
|
}>>`
|
||||||
|
SELECT
|
||||||
|
DATE("timeStamp")::text AS date,
|
||||||
|
MIN(buy::numeric)::float8 AS "minBuy",
|
||||||
|
MAX(buy::numeric)::float8 AS "maxBuy",
|
||||||
|
MIN(sell::numeric)::float8 AS "minSell",
|
||||||
|
MAX(sell::numeric)::float8 AS "maxSell"
|
||||||
|
FROM "quotes_history"
|
||||||
|
WHERE "type" = ${rawType}::"QuoteType"
|
||||||
|
AND "timeStamp" >= ${startDate}::timestamptz
|
||||||
|
AND "timeStamp" < ${endDate}::timestamptz
|
||||||
|
GROUP BY DATE("timeStamp")
|
||||||
|
ORDER BY DATE("timeStamp") ASC
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
const current = await prisma.quote.findFirst({
|
||||||
|
where: { type: rawType as QuoteType },
|
||||||
|
orderBy: { timeStamp: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (current) {
|
||||||
|
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
||||||
|
return c.json([
|
||||||
|
{
|
||||||
|
date: today,
|
||||||
|
minBuy: Number(current.buy),
|
||||||
|
maxBuy: Number(current.buy),
|
||||||
|
minSell: Number(current.sell),
|
||||||
|
maxSell: Number(current.sell),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json(rows);
|
||||||
|
}
|
||||||
45
apps/backend/src/modules/quotes/handlers/getDailyQuotes.ts
Normal file
45
apps/backend/src/modules/quotes/handlers/getDailyQuotes.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import type { Context } from "hono";
|
||||||
|
import { prisma } from "../../../lib/prisma";
|
||||||
|
|
||||||
|
const VALID_TYPES = ["BLUE", "BNA", "BELO"] as const;
|
||||||
|
|
||||||
|
export async function getDailyQuotes(c: Context) {
|
||||||
|
const rawType = c.req.param("type")!.toUpperCase();
|
||||||
|
|
||||||
|
if (!VALID_TYPES.includes(rawType as typeof VALID_TYPES[number])) {
|
||||||
|
return c.json({ error: `Invalid quote type. Must be one of: ${VALID_TYPES.join(", ")}` }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDateParam = c.req.query("startDate");
|
||||||
|
const endDateParam = c.req.query("endDate");
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const startDate = startDateParam
|
||||||
|
? new Date(startDateParam)
|
||||||
|
: new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
const endDate = endDateParam
|
||||||
|
? new Date(new Date(endDateParam).getTime() + 86_400_000)
|
||||||
|
: now;
|
||||||
|
|
||||||
|
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
|
||||||
|
return c.json({ error: "Invalid date format. Use ISO 8601." }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await prisma.$queryRaw<Array<{
|
||||||
|
date: string;
|
||||||
|
buy: number;
|
||||||
|
sell: number;
|
||||||
|
}>>`
|
||||||
|
SELECT DATE("timeStamp")::text AS date, buy::numeric::float8 AS buy, sell::numeric::float8 AS sell FROM (
|
||||||
|
SELECT DISTINCT ON (DATE("timeStamp")) "timeStamp", buy, sell
|
||||||
|
FROM "quotes_history"
|
||||||
|
WHERE "type" = ${rawType}::"QuoteType"
|
||||||
|
AND "timeStamp" >= ${startDate}::timestamptz
|
||||||
|
AND "timeStamp" < ${endDate}::timestamptz
|
||||||
|
ORDER BY DATE("timeStamp"), "timeStamp" DESC
|
||||||
|
) sub
|
||||||
|
ORDER BY DATE("timeStamp") ASC
|
||||||
|
`;
|
||||||
|
|
||||||
|
return c.json(rows);
|
||||||
|
}
|
||||||
39
apps/backend/src/modules/quotes/handlers/getQuoteHistory.ts
Normal file
39
apps/backend/src/modules/quotes/handlers/getQuoteHistory.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import type { Context } from "hono";
|
||||||
|
import { prisma } from "../../../lib/prisma";
|
||||||
|
import type { QuoteType } from "../../../generated/prisma/client";
|
||||||
|
|
||||||
|
const VALID_TYPES = ["BLUE", "BNA", "BELO"] as const;
|
||||||
|
|
||||||
|
export async function getQuoteHistory(c: Context) {
|
||||||
|
const rawType = c.req.param("type")!.toUpperCase();
|
||||||
|
|
||||||
|
if (!VALID_TYPES.includes(rawType as typeof VALID_TYPES[number])) {
|
||||||
|
return c.json({ error: `Invalid quote type. Must be one of: ${VALID_TYPES.join(", ")}` }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = rawType as QuoteType;
|
||||||
|
const startDateParam = c.req.query("startDate");
|
||||||
|
const endDateParam = c.req.query("endDate");
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const startDate = startDateParam
|
||||||
|
? new Date(startDateParam)
|
||||||
|
: new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
const endDate = endDateParam
|
||||||
|
? new Date(new Date(endDateParam).getTime() + 86_400_000)
|
||||||
|
: now;
|
||||||
|
|
||||||
|
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
|
||||||
|
return c.json({ error: "Invalid date format. Use ISO 8601." }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const history = await prisma.quoteHistory.findMany({
|
||||||
|
where: {
|
||||||
|
type,
|
||||||
|
timeStamp: { gte: startDate, lt: endDate },
|
||||||
|
},
|
||||||
|
orderBy: { timeStamp: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json(history);
|
||||||
|
}
|
||||||
49
apps/backend/src/modules/quotes/handlers/quoteEvents.ts
Normal file
49
apps/backend/src/modules/quotes/handlers/quoteEvents.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import type { Context } from "hono";
|
||||||
|
import { eventBus } from "../../../lib/event-bus";
|
||||||
|
|
||||||
|
export function quoteEvents(c: Context) {
|
||||||
|
const cleanups: (() => void)[] = [];
|
||||||
|
let isCancelled = false;
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
if (isCancelled) return;
|
||||||
|
isCancelled = true;
|
||||||
|
cleanups.forEach((fn) => fn());
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
const unsubscribe = eventBus.on("quotes-updated", () => {
|
||||||
|
const data = "event: quotes-updated\ndata: updated\n\n";
|
||||||
|
try {
|
||||||
|
controller.enqueue(new TextEncoder().encode(data));
|
||||||
|
} catch {
|
||||||
|
/* stream closed */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
cleanups.push(unsubscribe);
|
||||||
|
|
||||||
|
const keepAlive = setInterval(() => {
|
||||||
|
try {
|
||||||
|
controller.enqueue(new TextEncoder().encode(": keepalive\n\n"));
|
||||||
|
} catch {
|
||||||
|
clearInterval(keepAlive);
|
||||||
|
}
|
||||||
|
}, 5_000);
|
||||||
|
cleanups.push(() => clearInterval(keepAlive));
|
||||||
|
|
||||||
|
c.req.raw.signal.addEventListener("abort", cleanup);
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
cleanup();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/event-stream",
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
34
apps/backend/src/modules/quotes/quote.job.ts
Normal file
34
apps/backend/src/modules/quotes/quote.job.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import cron from "node-cron";
|
||||||
|
import { processBeloQuote } from "./belo.service";
|
||||||
|
import { processBlueQuote } from "./blue.service";
|
||||||
|
import { processBnaQuote } from "./bna.service";
|
||||||
|
import { logger } from "../../lib/logger";
|
||||||
|
import { eventBus } from "../../lib/event-bus";
|
||||||
|
|
||||||
|
async function safeProcess(
|
||||||
|
name: string,
|
||||||
|
fn: () => Promise<void>,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
logger.info(`${name} quote processed successfully`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error, `Error processing ${name} quote`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const QUOTES = [
|
||||||
|
{ name: "BELO", fn: processBeloQuote },
|
||||||
|
{ name: "BLUE", fn: processBlueQuote },
|
||||||
|
{ name: "BNA", fn: processBnaQuote },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function startQuoteJob(): void {
|
||||||
|
cron.schedule("*/10 * * * *", async () => {
|
||||||
|
logger.info("Fetching quotes...");
|
||||||
|
await Promise.all(QUOTES.map((q) => safeProcess(q.name, q.fn)));
|
||||||
|
eventBus.emit("quotes-updated");
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info("Cron scheduled: every 10 minutes");
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user