From e75b6e54eba3d91d0006f19e2b5752ae0320bf1e Mon Sep 17 00:00:00 2001 From: gudnuf Date: Mon, 16 Feb 2026 12:42:52 -0800 Subject: [PATCH] Add TanStack Query and Tailwind design system skills Add two Claude skills with progressive disclosure structure: - tanstack-query: Project-specific TQ v5 conventions (cache class pattern, event-driven updates, version-aware caching, dynamic scope patch, retry strategy). Reference files for cache/query tables and code patterns. - tailwind-design-system: Updated for Tailwind v4 CSS-first config (@theme, @utility, @plugin directives), v4 class renames, dual-theme system (USD/BTC + dark mode). Reference file for component patterns. Co-Authored-By: Claude Opus 4.6 --- .../skills/tailwind-design-system/SKILL.md | 152 ++++++++++ .../references/component-patterns.md | 152 ++++++++++ .claude/skills/tanstack-query/SKILL.md | 137 +++++++++ .../references/cache-reference.md | 88 ++++++ .../references/code-patterns.md | 286 ++++++++++++++++++ 5 files changed, 815 insertions(+) create mode 100644 .claude/skills/tailwind-design-system/SKILL.md create mode 100644 .claude/skills/tailwind-design-system/references/component-patterns.md create mode 100644 .claude/skills/tanstack-query/SKILL.md create mode 100644 .claude/skills/tanstack-query/references/cache-reference.md create mode 100644 .claude/skills/tanstack-query/references/code-patterns.md diff --git a/.claude/skills/tailwind-design-system/SKILL.md b/.claude/skills/tailwind-design-system/SKILL.md new file mode 100644 index 00000000..d4946a17 --- /dev/null +++ b/.claude/skills/tailwind-design-system/SKILL.md @@ -0,0 +1,152 @@ +--- +name: tailwind-design-system +description: Tailwind CSS design system for this mobile-first wallet app. Covers theme system (USD/BTC/dark), CSS variables, CVA components, shadcn/ui patterns, custom animations, and layout conventions. Use when creating or modifying UI components. +--- + +# Tailwind Design System — Project Conventions + +**Stack**: Tailwind CSS 4.1 + `@tailwindcss/vite` + shadcn/ui + CVA + tailwind-merge + tw-animate-css + Radix UI + vaul + +**References** (load when needed): +- [component-patterns.md](references/component-patterns.md) — CVA examples, MoneyDisplay, Dialog/Drawer/Toast animations, view transitions, page layout, available UI components + +## Quick Reference + +| What | How | +|------|-----| +| Class merging | `cn()` from `~/lib/utils` (clsx + tailwind-merge) | +| Component variants | CVA (`class-variance-authority`) | +| Semantic colors | CSS variables: `bg-primary`, `text-foreground`, `border-border` | +| Amount fonts | `font-numeric` (Teko) | +| Primary font | `font-primary` (Kode Mono) | +| Full viewport | `h-dvh` (dynamic viewport height) | +| Mobile container | `mx-auto w-full sm:max-w-sm` | +| Hide scrollbar | `scrollbar-none` (custom `@utility`) | +| Dark mode | Class-based (`.dark` on root) | +| Currency theme | `.usd` or `.btc` class on root | + +## Configuration + +**Tailwind v4 uses CSS-first configuration** — no `tailwind.config.ts`. All config lives in `app/tailwind.css`. + +| File | Purpose | +|------|---------| +| `app/tailwind.css` | All Tailwind config: `@theme`, `@utility`, CSS variables, base styles | +| `vite.config.ts` | `@tailwindcss/vite` plugin (first in plugins array) | +| `app/lib/utils.ts` | `cn()` utility (clsx + tailwind-merge) | +| `app/features/theme/theme-provider.tsx` | Theme context and switching | +| `app/features/theme/colors.ts` | Theme colors in TypeScript (**must stay in sync with CSS**) | +| `app/components/ui/` | shadcn/ui base components | +| `app/components/page.tsx` | Page layout components | +| `app/components/money-display.tsx` | MoneyDisplay / MoneyInputDisplay | +| `components.json` | shadcn/ui config (no `config` path — v4 uses CSS) | + +## v4 CSS-First Config Structure + +The `app/tailwind.css` file structure: + +```css +@import "tailwindcss"; +@plugin "tailwindcss-animate"; + +/* 1. CSS Variables — outside @layer (v4 requirement) */ +:root { --background: hsl(0 0% 100%); /* ... */ } +.usd { --background: hsl(178 100% 15%); /* ... */ } +.btc { --background: hsl(217 68% 35%); /* ... */ } +.dark { --background: hsl(0 0% 3.9%); /* ... */ } + +/* 2. @theme inline — registers CSS vars as Tailwind color tokens */ +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-primary: var(--primary); + /* ... maps all CSS vars to Tailwind tokens */ +} + +/* 3. @theme — custom tokens (fonts, sizes, animations) */ +@theme { + --font-numeric: "Teko", sans-serif; + --font-primary: "Kode Mono", monospace; + --font-size-2xs: 0.625rem; + --animate-shake: shake 0.2s ease-in-out; + --animate-slam: slam 0.4s ease-out both; + --animate-slide-out-up: slide-out-up 300ms ease-out forwards; +} + +/* 4. @keyframes — top-level, referenced by @theme animations */ +@keyframes shake { /* ... */ } + +/* 5. @utility — custom utilities (replaces v3 plugins) */ +@utility scrollbar-none { + -ms-overflow-style: none; + scrollbar-width: none; + &::-webkit-scrollbar { display: none; } +} + +/* 6. @layer base — global styles */ +@layer base { + * { @apply border-border; } + body { @apply bg-background text-foreground; } + button:not(:disabled), [role="button"]:not(:disabled) { + cursor: pointer; /* v4 removed default cursor:pointer */ + } +} +``` + +**Key v4 differences from v3:** +- `@import "tailwindcss"` replaces three `@tailwind` directives +- CSS variables use `hsl()` wrapper (v3 used bare values like `0 0% 100%`) +- `@theme inline` registers CSS vars as Tailwind tokens (replaces `theme.extend.colors` in JS) +- `@theme` defines custom tokens (replaces `theme.extend.*` in JS config) +- `@utility` creates custom utilities (replaces `plugin({ addUtilities })`) +- `@plugin` loads plugins (replaces `require()` in JS config) +- No `postcss.config.js` needed — `@tailwindcss/vite` handles everything + +## v4 Class Name Changes + +Use the v4 names. The v3 names no longer exist: + +| v3 (removed) | v4 (use this) | +|--------------|---------------| +| `shadow-sm` | `shadow-xs` | +| `rounded-sm` | `rounded-xs` | +| `outline-none` | `outline-hidden` | + +Also: default `ring` width changed from `3px` to `1px` (use `ring-3` for old behavior). + +## Theme System + +Two independent axes, both cookie-persisted for SSR: +1. **Currency theme**: `.usd` (teal) or `.btc` (blue) on `` +2. **Color mode**: `light` / `dark` / `system` — applies `.dark` class + +Access via `useTheme()` from `app/features/theme/use-theme.tsx`. + +Always use semantic color classes (`bg-primary`, `text-foreground`, `border-border`). Never hardcode colors like `bg-blue-500`. + +**Keep `app/tailwind.css` and `app/features/theme/colors.ts` in sync** — the TypeScript file duplicates CSS variable values for programmatic access. + +## Layout Conventions + +- **Full viewport**: `h-dvh` (not `h-screen` — handles mobile browser chrome) +- **Safe viewport**: `h-[90svh]` for drawers/modals +- **Mobile container**: `w-full sm:max-w-sm` (full on mobile, 448px max on larger) +- **Centered layout**: `mx-auto sm:items-center` +- **Scrollable content**: `flex-1 overflow-y-auto scrollbar-none min-h-0` +- **Numpad**: `sm:hidden` (keyboard input on larger screens) +- **Mobile-first**: Base styles target mobile; `sm:` adjusts for larger screens + +## Rules + +| Do | Don't | +|----|-------| +| Semantic color classes (`bg-primary`) | Hardcoded colors (`bg-blue-500`) | +| `font-numeric` for all monetary amounts | Arbitrary font values | +| `h-dvh` for full-viewport layouts | `h-screen` (broken on mobile) | +| `cn()` for all className composition | Manual string concatenation | +| CVA for component variants | Inline conditional classes | +| Check `app/components/ui/` before creating new components | Duplicate existing shadcn components | +| Keep CSS vars and `colors.ts` in sync | Change one without the other | +| Add new keyframes/animations in `@theme`/`@keyframes` in CSS | Create animations outside `app/tailwind.css` | +| Use cookies for theme persistence | `localStorage` (breaks SSR) | +| `forwardRef` on components wrapping HTML elements | Skip ref forwarding | diff --git a/.claude/skills/tailwind-design-system/references/component-patterns.md b/.claude/skills/tailwind-design-system/references/component-patterns.md new file mode 100644 index 00000000..07f23134 --- /dev/null +++ b/.claude/skills/tailwind-design-system/references/component-patterns.md @@ -0,0 +1,152 @@ +# Component Patterns + +## CVA Component Pattern + +All UI components use CVA for type-safe variants with `cn()` for className merging: + +```typescript +// app/components/ui/button.tsx +import { cva, type VariantProps } from 'class-variance-authority'; +import { cn } from '~/lib/utils'; + +const buttonVariants = cva( + 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium text-sm ring-offset-background transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', + { + variants: { + variant: { + default: 'bg-primary text-primary-foreground hover:bg-primary/90', + destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90', + outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground', + secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', + ghost: 'hover:bg-accent hover:text-accent-foreground', + link: 'text-primary underline-offset-4 hover:underline', + }, + size: { + default: 'h-10 px-4 py-2', + sm: 'h-9 rounded-md px-3', + lg: 'h-11 rounded-md px-8', + icon: 'h-10 w-10', + }, + }, + defaultVariants: { variant: 'default', size: 'default' }, + }, +); +``` + +**Button features**: `loading` prop with spinner, `asChild` via Radix `Slot`, `forwardRef`, `cn()` for overrides. + +## MoneyDisplay Component + +**Always use `MoneyDisplay` or `MoneyInputDisplay` for monetary amounts.** + +File: `app/components/money-display.tsx` + +```typescript +const valueVariants = cva('font-numeric', { + variants: { + size: { + xs: 'pt-0.5 text-xl', + sm: 'pt-1 text-2xl', + md: 'pt-1.5 text-5xl', + lg: 'pt-2 text-6xl', + }, + }, +}); +``` + +Usage: +```tsx + + +``` + +## Available UI Components + +Check `app/components/ui/` before creating new ones: + +Badge, Button, Card (compound), Carousel, Checkbox, Dialog, Drawer (vaul), Dropdown Menu, Hover Card, Input, Label, Radio Group, Scroll Area, Select, Separator, Skeleton, Tabs, Toast, Toaster + +All follow: CVA variants, `forwardRef`, `cn()`, Radix primitives. + +## Compound Component Pattern (Card) + +```typescript +const Card = forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +// Plus: CardHeader, CardTitle, CardDescription, CardContent, CardFooter +``` + +## Dialog/Drawer/Toast Animations + +**Dialog** uses `tailwindcss-animate` data-attribute animations: +```tsx +// Overlay +'data-[state=open]:animate-in data-[state=closed]:animate-out' +'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0' + +// Content +'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95' +'data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]' +'data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]' +``` + +**Drawer** (vaul): +```tsx +className="bg-gradient-to-b from-transparent via-black/70 to-black/80" // overlay +className="fixed inset-x-0 bottom-0 rounded-t-[10px]" // content +className="h-[90svh] font-primary sm:h-[75vh]" // responsive height +``` + +**Toast** exit uses custom animation: +```tsx +'data-[state=closed]:animate-slide-out-up' +``` + +## View Transitions + +File: `app/lib/transitions/view-transition.tsx` + `transitions.css` + +```tsx + + Go Forward + + +const navigate = useNavigateWithViewTransition(); +navigate('/page', { transition: 'slideRight', applyTo: 'bothViews' }); +``` + +Types: `slideLeft`, `slideRight`, `slideUp`, `slideDown`, `fade` +Duration: 180ms (synced between CSS and TS — keep in sync!) +Apply modes: `newView`, `oldView`, `bothViews` + +## Page Layout Components + +File: `app/components/page.tsx` + +```tsx +// Main page wrapper +
+ +// Content area +
+ +// Footer +
+ +// Header — centered title with absolute positioning +
+``` + +## Icons + +Using **Lucide React**: +- Standard: `size-4` or `size-5` +- Large: `size-6` +- In buttons: `[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0` diff --git a/.claude/skills/tanstack-query/SKILL.md b/.claude/skills/tanstack-query/SKILL.md new file mode 100644 index 00000000..b9b304e7 --- /dev/null +++ b/.claude/skills/tanstack-query/SKILL.md @@ -0,0 +1,137 @@ +--- +name: tanstack-query +description: TanStack Query v5 patterns specific to this project. Covers our cache class pattern, repository integration, suspense queries, event-driven updates, version-aware caching, and mutation patterns. Use for all server state management and cache operations. +--- + +# TanStack Query v5 — Project Conventions + +`@tanstack/react-query` v5.90+ with React 19, React Router v7. + +**References** (load when needed): +- [cache-reference.md](references/cache-reference.md) — All cache classes, queryOptions factories, staleTime config, query key patterns, error classes +- [code-patterns.md](references/code-patterns.md) — Full code examples for each pattern below + +## Architecture Decisions + +### Event-Driven Updates (not polling) + +Most data uses `staleTime: Infinity` and is updated via **Supabase Realtime** subscriptions, not polling or refetching. Cache classes receive Realtime events and update the query cache directly. `invalidate()` is used on reconnection as a safety net for missed events. + +The only polled query is exchange rates (`refetchInterval: 15_000`). + +### Cache Class Pattern + +Domain-specific classes encapsulate all cache operations with a static `Key` property. Provides typed API over raw `setQueryData`. Use `useXxxCache()` hooks for stable instances. + +```typescript +class AccountsCache { + public static Key = 'accounts'; + constructor(private readonly queryClient: QueryClient) {} + upsert(account: Account) { /* version-checked setQueryData */ } + update(account: Account) { /* version-checked setQueryData */ } + getAll() { /* getQueryData */ } + invalidate() { /* invalidateQueries */ } +} + +// Stable instance via useMemo +export function useAccountsCache() { + const queryClient = useQueryClient(); + return useMemo(() => new AccountsCache(queryClient), [queryClient]); +} +``` + +### Version-Aware Updates + +All cache updates check a `version` field to prevent stale data overwriting newer data (Supabase Realtime events can arrive out of order): + +```typescript +// Always compare versions before updating +curr.map((x) => + x.id === item.id && item.version > x.version ? item : x, +); +``` + +### queryOptions() Factories + +Primary abstraction for reusable query configs. Co-locates `queryKey` + `queryFn` with full type inference. Used across `useSuspenseQuery`, `ensureQueryData`, `prefetchQuery`, etc. + +### Query Keys + +Use static `Key` properties from cache classes. Structure generic-to-specific for fuzzy invalidation: +- `[AccountsCache.Key]` — collection +- `[CashuSendQuoteCache.Key, quoteId]` — individual resource +- `[allTransactionsQueryKey, accountId]` — filtered list + +## Key Patterns + +### Required Data → `useSuspenseQuery` + +Data guaranteed non-undefined. Loading/error handled by boundaries. **Gotcha**: Multiple suspense queries in one component run serially — use `useSuspenseQueries` or prefetch in loader for parallel fetching. + +### Select → always `useCallback` + +Memoize `select` callbacks to prevent re-computation every render. + +### useUser Select + +`useUser((user) => user.id)` — components only re-render when selected field changes. + +### Route Loaders + +Use `ensureQueryData` for critical data (blocks rendering), `prefetchQuery` for nice-to-have data (non-blocking). Parallel `Promise.all` in `_protected.tsx` loader. + +### Invalidate vs Direct Update + +- **Direct update** (`setQueryData` via cache class) when you have the new data — avoids network round-trip +- **Invalidation** when server computes the result + +### Mutation Callbacks — Two Levels + +1. `useMutation({ onSuccess })` — always runs, survives unmount → put cache updates here +2. `mutate(vars, { onSuccess })` — only runs if mounted → put navigation/toasts here + +### Retry Strategy + +```typescript +retry: (failureCount, error) => { + if (error instanceof DomainError) return false; // Never retry + if (error instanceof ConcurrencyError) return true; // Always retry + return failureCount < 1; // Network: once +}, +``` + +## Dynamic Scope (Custom Patch) + +**We patch `@tanstack/query-core`** (`patches/@tanstack%2Fquery-core@5.90.20.patch`) to support passing `scope` on individual `mutate()` / `mutateAsync()` calls. Stock TanStack Query only allows `scope` on `useMutation` options (static per-hook). + +**Why**: Payment state machines need global serialization for creation (one quote at a time) but per-entity serialization for state transitions (complete/expire/fail). Without dynamic scope, "complete quote A" blocks "expire quote B." + +```typescript +// Static scope on hook — all create calls serialized globally +useMutation({ + scope: { id: 'initiate-cashu-send-quote' }, + mutationFn: /* ... */, +}); + +// Dynamic scope at call site — per-entity serialization +markAsPending(sendQuote.id, { + scope: { id: `cashu-send-quote-${sendQuote.id}` }, +}); +``` + +**How it works**: `MutationObserver.mutate()` merges `options.scope` into mutation options when building the mutation. A `#mutationScopeOverride` flag preserves the scope across React re-renders (which trigger `setOptions`). + +**Upgrade warning**: When upgrading `@tanstack/query-core`, this patch must be reapplied. + +## Anti-Patterns + +| Don't | Do Instead | +|-------|-----------| +| `useEffect` for data fetching | `useQuery` / `useSuspenseQuery` | +| `onSuccess`/`onError` on `useQuery` | Removed in v5. Use mutation callbacks or `useEffect` on query state | +| Cache update without version check | Always compare `version` field | +| `new AccountsCache(queryClient)` in render | `useAccountsCache()` hook (useMemo) | +| `invalidateQueries` when you have the data | Direct cache update via cache class | +| Unmemoized `select` callback | Wrap in `useCallback` | +| Copy query data to `useState` | Use query data directly, transform with `select` | +| Missing variables in query key | Include ALL `queryFn` dependencies in key | diff --git a/.claude/skills/tanstack-query/references/cache-reference.md b/.claude/skills/tanstack-query/references/cache-reference.md new file mode 100644 index 00000000..bc24d387 --- /dev/null +++ b/.claude/skills/tanstack-query/references/cache-reference.md @@ -0,0 +1,88 @@ +# Cache & Query Reference Tables + +## Cache Classes + +All cache classes follow the same pattern: static `Key` property, constructor takes `QueryClient`, methods are version-aware. Use `useXxxCache()` hooks for stable instances (wraps `useMemo`). + +| Cache Class | Key | Location | +|---|---|---| +| `AccountsCache` | `'accounts'` | `app/features/accounts/account-hooks.ts` | +| `TransactionsCache` | `'transactions'` | `app/features/transactions/transaction-hooks.ts` | +| `ContactsCache` | `'contacts'` | `app/features/contacts/contact-hooks.ts` | +| `CashuSendQuoteCache` | `'cashu-send-quote'` | `app/features/send/cashu-send-quote-hooks.ts` | +| `UnresolvedCashuSendQuotesCache` | `'unresolved-cashu-send-quotes'` | `app/features/send/cashu-send-quote-hooks.ts` | +| `CashuSendSwapCache` | `'cashu-send-swap'` | `app/features/send/cashu-send-swap-hooks.ts` | +| `CashuReceiveQuoteCache` | `'cashu-receive-quote'` | `app/features/receive/cashu-receive-quote-hooks.ts` | +| `PendingCashuReceiveQuotesCache` | `'pending-cashu-receive-quotes'` | `app/features/receive/cashu-receive-quote-hooks.ts` | +| `CashuReceiveSwapCache` | `'cashu-receive-swap'` | `app/features/receive/cashu-receive-swap-hooks.ts` | +| `PendingCashuReceiveSwapsCache` | `'pending-cashu-receive-swaps'` | `app/features/receive/cashu-receive-swap-hooks.ts` | +| `SparkReceiveQuoteCache` | `'spark-receive-quote'` | `app/features/receive/spark-receive-quote-hooks.ts` | +| `PendingSparkReceiveQuotesCache` | `'pending-spark-receive-quotes'` | `app/features/receive/spark-receive-quote-hooks.ts` | +| `UnresolvedSparkSendQuotesCache` | `'unresolved-spark-send-quotes'` | `app/features/send/spark-send-quote-hooks.ts` | + +**Standard methods**: `add()`, `update()` / `updateIfExists()`, `upsert()`, `get()` / `getAll()`, `remove()`, `invalidate()`. + +## queryOptions Factories + +| Factory | staleTime | Location | +|---|---|---| +| `accountsQueryOptions` | `Infinity` | `app/features/accounts/account-hooks.ts` | +| `userQueryOptions` | default | `app/features/user/user-hooks.tsx` | +| `exchangeRatesQueryOptions` | default | `app/hooks/use-exchange-rate.ts` | +| `seedQueryOptions` | `Infinity` | `app/features/shared/cashu.ts` | +| `xpubQueryOptions` | `Infinity` | `app/features/shared/cashu.ts` | +| `privateKeyQueryOptions` | `Infinity` | `app/features/shared/cashu.ts` | +| `mintInfoQueryOptions` | 1 hour | `app/features/shared/cashu.ts` | +| `allMintKeysetsQueryOptions` | 1 hour | `app/features/shared/cashu.ts` | +| `mintKeysQueryOptions` | 1 hour | `app/features/shared/cashu.ts` | +| `isTestMintQueryOptions` | `Infinity` | `app/features/shared/cashu.ts` | +| `sparkMnemonicQueryOptions` | `Infinity` | `app/features/shared/spark.ts` | +| `sparkIdentityPublicKeyQueryOptions` | `Infinity` | `app/features/shared/spark.ts` | +| `sparkWalletQueryOptions` | `Infinity` + `gcTime: Infinity` | `app/features/shared/spark.ts` | + +## staleTime Configuration Rationale + +| Data Type | staleTime | gcTime | Rationale | +|---|---|---|---| +| Crypto keys, seeds, mnemonics | `Infinity` | `Infinity` | Deterministic derivation, never changes | +| Spark wallet instances | `Infinity` | `Infinity` | Expensive to reinitialize | +| Accounts, transactions, contacts | `Infinity` | default | Updated via Supabase Realtime events | +| Mint metadata (info, keysets, keys) | 1 hour | default | Rarely changes but not event-driven | +| Exchange rates | default | default | Uses `refetchInterval: 15_000` | + +## Query Key Patterns + +**Pattern 1 — Static keys on cache classes** (single-collection queries): +```typescript +queryKey: [AccountsCache.Key] // ['accounts'] +queryKey: [ContactsCache.Key] // ['contacts'] +``` + +**Pattern 2 — Hierarchical keys with IDs** (individual resources): +```typescript +queryKey: [CashuSendQuoteCache.Key, quoteId] // ['cashu-send-quote', 'abc'] +queryKey: [TransactionsCache.Key, transactionId] // ['transactions', '123'] +queryKey: ['spark-balance', accountId] // ['spark-balance', 'acc-1'] +``` + +**Pattern 3 — Derived keys** (computed from base keys): +```typescript +const allTransactionsQueryKey = 'all-transactions'; +const unacknowledgedCountQueryKey = `${TransactionsCache.Key}-unacknowledged-count`; +``` + +**Pattern 4 — Parameterized keys** (list views with filters): +```typescript +queryKey: [allTransactionsQueryKey, accountId] // ['all-transactions', 'acc-1'] +``` + +## Error Classes + +Defined in `app/features/shared/error.ts`: + +| Class | Retry behavior | Use | +|---|---|---| +| `DomainError` | Never retry | Business rule violations, user-friendly messages | +| `ConcurrencyError` | Always retry | Optimistic locking conflicts | +| `NotFoundError` | Never retry | Missing resources | +| `UniqueConstraintError` | Never retry | Duplicate entries | diff --git a/.claude/skills/tanstack-query/references/code-patterns.md b/.claude/skills/tanstack-query/references/code-patterns.md new file mode 100644 index 00000000..5a4f9778 --- /dev/null +++ b/.claude/skills/tanstack-query/references/code-patterns.md @@ -0,0 +1,286 @@ +# Code Patterns + +## Cache Class Pattern + +```typescript +// app/features/accounts/account-hooks.ts +export class AccountsCache { + public static Key = 'accounts'; + + constructor(private readonly queryClient: QueryClient) {} + + upsert(account: Account) { + this.queryClient.setQueryData([AccountsCache.Key], (curr: Account[]) => { + const exists = curr.findIndex((x) => x.id === account.id); + if (exists !== -1) { + // Version check: only update if newer + return curr.map((x) => + x.id === account.id && account.version > x.version ? account : x, + ); + } + return [...curr, account]; + }); + } + + update(account: Account) { + this.queryClient.setQueryData([AccountsCache.Key], (curr: Account[]) => + curr.map((x) => + x.id === account.id && account.version > x.version ? account : x, + ), + ); + } + + getAll() { + return this.queryClient.getQueryData([AccountsCache.Key]); + } + + get(id: string) { + return this.getAll()?.find((x) => x.id === id) ?? null; + } + + invalidate() { + return this.queryClient.invalidateQueries({ + queryKey: [AccountsCache.Key], + }); + } +} + +// Hook for stable instance (useMemo prevents re-creation on every render) +export function useAccountsCache() { + const queryClient = useQueryClient(); + return useMemo(() => new AccountsCache(queryClient), [queryClient]); +} +``` + +## queryOptions Factory + +```typescript +export const accountsQueryOptions = ({ + userId, + accountRepository, +}: { userId: string; accountRepository: AccountRepository }) => { + return queryOptions({ + queryKey: [AccountsCache.Key], + queryFn: () => accountRepository.getAll(userId), + staleTime: Number.POSITIVE_INFINITY, + }); +}; + +// Use in component +const { data } = useSuspenseQuery(accountsQueryOptions({ userId, accountRepository })); + +// Use in route loader +await queryClient.ensureQueryData(accountsQueryOptions({ userId, accountRepository })); +``` + +## Suspense Query with Select + +```typescript +export function useAccounts( + select?: UseAccountsSelect, +): UseSuspenseQueryResult[]> { + const user = useUser(); + const accountRepository = useAccountRepository(); + + return useSuspenseQuery({ + ...accountsQueryOptions({ userId: user.id, accountRepository }), + refetchOnWindowFocus: 'always', + refetchOnReconnect: 'always', + // ALWAYS memoize select to prevent re-computation every render + select: useCallback( + (data: Account[]) => data.filter(/* ... */) as ExtendedAccount[], + [select?.currency, select?.type], + ), + }); +} +``` + +## useUser Select Pattern + +Extract specific fields to avoid re-renders when other user fields change: +```typescript +const userId = useUser((user) => user.id); +const defaultCurrency = useUser((x) => x.defaultCurrency); +``` + +## Mutation with Cache Update + +```typescript +export function useAddCashuAccount() { + const userId = useUser((x) => x.id); + const accountCache = useAccountsCache(); + const accountService = useAccountService(); + + const { mutateAsync } = useMutation({ + mutationFn: async (account) => + accountService.addCashuAccount({ userId, account }), + onSuccess: (account) => { + accountCache.upsert(account); // Direct cache update, no refetch + }, + }); + + return mutateAsync; +} +``` + +## Mutation with Static Scope + +```typescript +useMutation({ + mutationKey: ['initiate-cashu-send-quote'], + scope: { id: 'initiate-cashu-send-quote' }, // All calls serialized + mutationFn: /* ... */, + onSuccess: (data) => { + cashuSendQuoteCache.add(data); + onSuccess(data); + }, + retry: (failureCount, error) => { + if (error instanceof ConcurrencyError) return true; + if (error instanceof DomainError) return false; + return failureCount < 1; + }, +}); +``` + +## Dynamic Scope at Call Site + +```typescript +// Per-entity scope: "complete quote A" doesn't block "expire quote B" +markSendQuoteAsPending(sendQuote.id, { + scope: { id: `cashu-send-quote-${sendQuote.id}` }, +}); + +completeSwap(swap.id, { + scope: { id: `send-swap-${swap.id}` }, +}); +``` + +## Infinite Query (Transactions) + +```typescript +const PAGE_SIZE = 20; + +export function useTransactions(accountId?: string) { + return useInfiniteQuery({ + queryKey: [allTransactionsQueryKey, accountId], + initialPageParam: null, + queryFn: async ({ pageParam }: { pageParam: Cursor | null }) => { + const result = await transactionRepository.list({ + userId, cursor: pageParam, pageSize: PAGE_SIZE, accountId, + }); + return { + transactions: result.transactions, + nextCursor: result.transactions.length === PAGE_SIZE + ? result.nextCursor : null, + }; + }, + getNextPageParam: (lastPage) => lastPage.nextCursor, + refetchOnWindowFocus: 'always', + refetchOnReconnect: 'always', + retry: 1, + }); +} +``` + +## Updating Infinite Query Cache + +Use `getQueriesData` to update all filtered views at once: +```typescript +const acknowledgeInHistoryCache = (queryClient, transaction) => { + const queries = queryClient.getQueriesData>({ + queryKey: [allTransactionsQueryKey], + }); + + queries.forEach(([queryKey, data]) => { + if (!data) return; + queryClient.setQueryData(queryKey, { + ...data, + pages: data.pages.map((page) => ({ + ...page, + transactions: page.transactions.map((tx) => + tx.id === transaction.id ? { ...tx, acknowledgmentStatus: 'acknowledged' } : tx, + ), + })), + }); + }); +}; +``` + +## Event-Driven Cache Updates (Supabase Realtime) + +```typescript +// app/features/wallet/use-track-wallet-changes.ts +export const useTrackWalletChanges = () => { + const accountChangeHandlers = useAccountChangeHandlers(); + const transactionChangeHandlers = useTransactionChangeHandlers(); + + useTrackDatabaseChanges({ + handlers: [...accountChangeHandlers, ...transactionChangeHandlers], + onConnected: () => { + accountsCache.invalidate(); // Catch events missed during disconnect + transactionsCache.invalidate(); + }, + }); +}; + +// Change handler pattern +export function useAccountChangeHandlers() { + const accountRepository = useAccountRepository(); + const accountCache = useAccountsCache(); + + return [ + { + event: 'ACCOUNT_CREATED', + handleEvent: async (payload) => { + const account = await accountRepository.toAccount(payload); + accountCache.upsert(account); + }, + }, + { + event: 'ACCOUNT_UPDATED', + handleEvent: async (payload) => { + const account = await accountRepository.toAccount(payload); + accountCache.update(account); // Version-checked + }, + }, + ]; +} +``` + +## Route Loader Prefetching + +```typescript +// app/routes/_protected.tsx +const ensureUserData = async (queryClient, authUser) => { + const [encryptionPrivateKey, encryptionPublicKey, cashuLockingXpub] = + await Promise.all([ + queryClient.ensureQueryData(encryptionPrivateKeyQueryOptions()), + queryClient.ensureQueryData(encryptionPublicKeyQueryOptions()), + queryClient.ensureQueryData(xpubQueryOptions({ queryClient, derivationPath })), + ]); +}; +``` + +## Query Client Setup + +**File**: `app/query-client.ts` + +```typescript +let browserQueryClient: QueryClient | undefined = undefined; + +function makeQueryClient() { + return new QueryClient(); +} + +export function getQueryClient() { + if (isServer) { + return makeQueryClient(); // New instance per SSR request + } + if (!browserQueryClient) { + browserQueryClient = makeQueryClient(); + } + return browserQueryClient; // Singleton in browser +} +``` + +Provider with HydrationBoundary and devtools in `app/root.tsx`.