shell: fish functions -> ~/.local/bin executables + bash/zsh ports; manage ~/.agents

- 16 cross-shell utilities converted to sh/bash chezmoi executables:
  ai c2p cp-appid extract_agents fab gdc gh-sonr-org-migrate ghg-clone
  ghn gho git-summary launch_omp_livediff_overlay lc-elir mdcp mdp pj
  (ghg-clone not named ghg: unrelated cargo binary owns that name)
- cwd/history helpers stay per-shell: yy c cd clip-last + ghg/ghn/gho
  cd-wrappers in bash/functions/ and zsh/functions/, loaded by rc files
- fish keeps only shell-state functions; converted ones now hit PATH
- cp-appid ignore gating moved to .local/bin path
- dot_agents/skills: 82 skills materialized from dotstate storage so
  dot_claude skill symlinks resolve on fresh bootstrap; dotstate
  profile/symlinks.json released .agents/skills .bashrc .zshrc
This commit is contained in:
Prad Nukala committed 2026-08-10 15:15:06 -04:00
1 parent 6776366ce6
commit 6406208b35
637 files changed
+100297 -497

No files matched your search

@@ -0,0 +1,186 @@
# Animating Elements
## What This Covers
How to animate insertion, removal, and layout changes of elements. Read this when the task involves:
- Adding entrance, exit, or shared-layout transitions to UI
- Choosing between spring physics (`spring(...)`) and time-based easing (`tween`)
- Coordinating CSS transitions with the same easing as JS animations
- Imperative animation loops via `requestAnimationFrame`
Import animation APIs from `remix/ui/animation`. For the smaller set of animation helpers that show up alongside other mixins, see `mixins-styling-events.md`.
## Animation Mixins
### `animateEntrance(config)`
Animates an element when inserted. Config specifies the **starting** style the element animates **from**:
```tsx
<div
mix={animateEntrance({
opacity: 0,
transform: 'translateY(8px)',
...spring('smooth'),
})}
/>
```
### `animateExit(config)`
Animates an element when removed. Config specifies the **ending** style the element animates **to**. The element stays in the DOM until the animation completes:
```tsx
{
isVisible && (
<div
key="panel"
mix={[
animateEntrance({ opacity: 0, transform: 'scale(0.98)', ...spring('smooth') }),
animateExit({ opacity: 0, duration: 120, easing: 'ease-in' }),
]}
/>
)
}
```
### `animateLayout(config?)`
Animates layout changes (position/size) using FLIP-style transforms:
```tsx
{
items.map((item) => (
<li key={item.id} mix={animateLayout({ ...spring({ duration: 500, bounce: 0.2 }) })} />
))
}
```
Options: `duration` (default 200ms), `easing` (default spring snappy), `size` (default true — include scale projection for size changes).
### Combining mixins
```tsx
<div
key="card"
mix={[
animateEntrance({ opacity: 0, transform: 'scale(0.95)', ...spring('snappy') }),
animateExit({ opacity: 0, transform: 'scale(0.98)', duration: 120, easing: 'ease-in' }),
animateLayout({ duration: 220, easing: 'ease-out' }),
]}
/>
```
### Shared-layout swap
```tsx
<div mix={css({ display: 'grid', '& > *': { gridArea: '1 / 1' } })}>
{stateA ? (
<div key="a" mix={[animateEntrance({ opacity: 0 }), animateExit({ opacity: 0 })]} />
) : (
<div key="b" mix={[animateEntrance({ opacity: 0 }), animateExit({ opacity: 0 })]} />
)}
</div>
```
## Spring API
Physics-based spring animation. Returns a `SpringIterator` with `duration`, `easing`, and `toString()` for CSS.
### Presets
| Preset | Bounce | Duration | Character |
| -------- | ------ | -------- | --------------------------- |
| `smooth` | -0.3 | 400ms | Overdamped, no overshoot |
| `snappy` | 0 | 200ms | Critically damped, quick |
| `bouncy` | 0.3 | 400ms | Underdamped, visible bounce |
```tsx
spring('bouncy')
spring('snappy')
spring('smooth')
spring('bouncy', { duration: 300 }) // override duration
```
### Custom spring
```tsx
spring({ duration: 500, bounce: 0.3 })
spring({ duration: 500, bounce: 0.3, velocity: 2 }) // continue momentum from gesture
```
### Spread into animation mixins
Spreading a spring gives both `duration` and `easing`:
```tsx
animateEntrance({ opacity: 0, ...spring('bouncy') })
```
### CSS transitions
The iterator stringifies to `"550ms linear(...)"`:
```tsx
css({ transition: `width ${spring('bouncy')}` })
```
Or use the `spring.transition()` helper for multiple properties:
```tsx
css({ transition: spring.transition('width', 'bouncy') })
css({ transition: spring.transition(['left', 'top'], 'snappy') })
```
### Web Animations API
```tsx
element.animate(keyframes, { ...spring('bouncy') })
```
### JS iteration
The iterator yields position values from 0 to 1, one per frame:
```tsx
for (let t of spring('bouncy')) {
let x = from + (to - from) * t
updateSomething(x)
await nextFrame()
}
```
## Tween API
Generator-based tween for animating values over time with cubic bezier easing. Prefer animation mixins or CSS transitions with `spring` for most UI work. Use `tween` for imperative `requestAnimationFrame` loops, canvas/WebGL, or non-CSS properties.
```tsx
import { tween, easings } from 'remix/ui/animation'
let animation = tween({
from: 0,
to: 100,
duration: 300,
curve: easings.easeOut,
})
animation.next() // initialize
function tick(timestamp: number) {
if (handle.signal.aborted) return
let { value, done } = animation.next(timestamp)
element.style.transform = `translateX(${value}px)`
if (!done) requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
```
Built-in easings: `easings.linear`, `easings.ease`, `easings.easeIn`, `easings.easeOut`, `easings.easeInOut`.
## Practical Guidance
- Always key conditional or switching elements you expect to animate.
- Use `animateLayout` only on the element whose position or size changes.
- Prefer one clear transition intent per mixin: entrance starts from a style, exit ends at a style.
- Default to `...spring()` for duration and easing in most cases.
- Keep DOM work in `handle.queueTask(...)` or `ref(...)`, not in render.
@@ -0,0 +1,111 @@
# Assets and Browser Modules
## What This Covers
How to serve browser scripts and styles from source. Read this when the task involves:
- Configuring `createAssetServer` (`basePath`, `fileMap`, `allow`, `deny`, fingerprinting, compiler options)
- Choosing between `staticFiles()` for already-built files and `createAssetServer()` for source assets that need import rewriting, preloads, or fingerprinted URLs
- Generating script URLs or `<link rel="modulepreload">` tags for a client entry
- Keeping server-only files out of the browser via `deny` rules
For routing the URL namespace itself, see `routing-and-controllers.md`. For client entry hydration, see `hydration-frames-navigation.md`.
## When To Reach For It
Use `remix/assets` when the app serves browser JavaScript, TypeScript, or CSS from source files. This is the right tool for client entrypoints, browser-only helpers, styles under `app/assets/`, and monorepo code that should be compiled and served under a public URL namespace.
Use `staticFiles()` for files that already exist on disk exactly as they should be served. Use `createAssetServer()` for source scripts or styles that need rewriting, dependency scanning, preloads, sourcemaps, or fingerprinted URLs.
## Default Pattern
```typescript
import { createAssetServer } from 'remix/assets'
import { createController } from 'remix/router'
import { get, route } from 'remix/routes'
export const routes = route({
assets: get('/assets/*path'),
})
let assetServer = createAssetServer({
basePath: '/assets',
rootDir: process.cwd(),
fileMap: {
'app/*path': 'app/*path',
'node_modules/*path': 'node_modules/*path',
},
allow: ['app/assets/**', 'node_modules/**'],
deny: ['app/**/*.server.*'],
target: { es: '2020', chrome: '109', safari: '16.4' },
sourceMaps: process.env.NODE_ENV === 'development' ? 'external' : undefined,
minify: process.env.NODE_ENV === 'production',
scripts: {
define: {
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV ?? 'development'),
},
},
})
export default createController(routes, {
actions: {
async assets({ request }) {
return (await assetServer.fetch(request)) ?? new Response('Not Found', { status: 404 })
},
},
})
```
## Rules
- Treat `allow` and `deny` as the security boundary for browser-reachable source files.
- Add a `deny` list for server-only modules such as `*.server.*`, private config, or other files that should never be exposed.
- Set `rootDir` explicitly in monorepos so relative paths resolve from the intended project root.
- `basePath` is the public URL namespace handled by the asset server.
- `fileMap` keys are URL patterns relative to `basePath`, and values are root-relative file path patterns. They use `route-pattern` syntax on both sides.
- Keep the same wildcard params on both sides of a `fileMap` entry so import rewriting can map source files back to public URLs.
- CSS files are compiled and served alongside scripts. Local CSS `@import` rules are rewritten and fingerprinted with the same asset server routing rules.
## Rendering HTML
Use `getHref()` when you need the public URL for one module, and `getPreloads()` when you want `<link rel="modulepreload">` tags or `Link` headers for one or more entrypoints and their dependencies.
```typescript
let entryHref = await assetServer.getHref('app/assets/entry.ts')
let preloads = await assetServer.getPreloads(['app/assets/entry.ts'])
```
Use this when rendering documents or layouts that boot browser behavior with a known client entry.
When resolving hydrated client entries during server rendering, pass the source entry ID from `clientEntry(import.meta.url, ...)` to `getHref()` inside `resolveClientEntry`. Keep export-name resolution in that render helper, and avoid hard-coding public asset URLs in source-owned component modules.
## Development vs Deployment
In development:
- Keep `watch` enabled so source changes are picked up without restarting the server
- Prefer stable URLs with normal revalidation
- Enable source maps when debugging browser code
In deployment:
- Set `watch: false`
- Use `fingerprint: { buildId }` for long-lived immutable caching
- Make sure `buildId` changes for each deploy
Fingerprinting assumes files on disk are stable and requires `watch: false`.
## Useful Compiler Options
- `minify` for production minification of scripts and styles
- `sourceMaps` for `'external'` or `'inline'` source maps for scripts and styles
- `sourceMapSourcePaths` for `'url'` or `'absolute'` source map paths
- `target` as an object for shared browser targets and script-only ECMAScript output, such as `{ es: '2020', chrome: '109', safari: '16.4' }`
- `scripts.define` to replace globals such as `process.env.NODE_ENV`
- `scripts.external` to leave specific script imports untouched
Do not nest shared compiler options under `scripts`. Use top-level `minify`, `sourceMaps`, `sourceMapSourcePaths`, and `target` so they apply to styles as well as scripts.
## Lifecycle
If the asset server is long-lived and watching the file system, call `await assetServer.close()` when shutting down dev servers or disposing tests.
@@ -0,0 +1,422 @@
# Authentication and Sessions
## What This Covers
How to remember things about a browser between requests and how to identify a user. Read this when the task involves:
- Storing per-browser state across requests (login, cart, "I have submitted this form")
- Adding a credentials login flow or an OAuth provider
- Protecting routes with `requireAuth()` or stacking authorization checks
- Reading or writing `Session`, `Auth`, or other identity-related context values
- Logging in, logging out, or rotating session IDs
For raw cookies that are not session-backed (theme, locale, dismissed-banner), see `createCookie` in this file plus the broader `Package Map` in `SKILL.md`.
## Sessions vs Plain Cookies
Reach for `remix/session` when state is sensitive, must be tamper-resistant, or represents the identity of a request: who is logged in, which form a browser already submitted, what items are in a cart. Sessions sign or encrypt their backing cookie with a server-held secret and give you a typed `Session` object you can `get`, `set`, `flash`, `unset`, and `regenerateId`.
Reach for `remix/cookie` directly when the browser is allowed to carry the value and the server does not need session semantics. This often means preferences (theme, locale, dismissed banner), but a signed cookie can also be fine for small low-risk values where you truly only need one cookie-shaped fact and do not need `Session` helpers.
If a malicious user editing the value would be a bug, or if the value needs server-managed lifecycle, reach for a session.
### Quick chooser
| Need | Best fit | Why |
| ------------------------------------------------------------------- | --------------- | -------------------------------------------------- |
| Theme, locale, dismissed banner | `remix/cookie` | Browser-controlled preference |
| Small signed hint with minimal lifecycle | `remix/cookie` | One value, no `Session` helpers needed |
| "This browser already submitted", cart, flash messages, login state | `remix/session` | Tamper-sensitive, server-managed per-browser state |
| "One real person only", ownership, durable identity | account/auth | Cookies or sessions alone do not prove personhood |
## Session Setup
### Create a session cookie
```typescript
import { createCookie } from 'remix/cookie'
let sessionSecret = process.env.SESSION_SECRET
if (!sessionSecret && process.env.NODE_ENV !== 'test') {
throw new Error('SESSION_SECRET is required')
}
export let sessionCookie = createCookie('session', {
secrets: [sessionSecret ?? 'test-only-secret'],
httpOnly: true,
sameSite: 'Lax',
secure: process.env.NODE_ENV === 'production',
maxAge: 2592000, // 30 days
path: '/',
})
```
The cookie should always be `httpOnly`, default to `sameSite: 'Lax'`, and be `secure` in production. Demo defaults like `'s3cr3t'` are fine in tests but should never reach production — fail fast when the secret is missing.
### Create session storage
```typescript
// Filesystem storage
import { createFsSessionStorage } from 'remix/session-storage/fs'
export let sessionStorage = createFsSessionStorage('./tmp/sessions')
// Memory storage (for tests)
import { createMemorySessionStorage } from 'remix/session-storage/memory'
export let sessionStorage = createMemorySessionStorage()
```
### Add session middleware
```typescript
import { session } from 'remix/middleware/session'
let router = createRouter({
middleware: [
session(sessionCookie, sessionStorage),
// ... other middleware
],
})
```
### Using sessions in handlers
```typescript
import { Session } from 'remix/session'
async function handler({ get }) {
let session = get(Session)
// Read
let userId = session.get('userId')
// Write
session.set('userId', 42)
// Flash (read once, then cleared)
session.flash('message', 'Settings saved!')
let message = session.get('message') // returns and clears
// Remove a key
session.unset('userId')
// Regenerate session ID (after login/logout)
session.regenerateId(true)
}
```
### Sessions for non-auth state
Sessions are not just for login. They are the right place to store any tamper-sensitive per-browser fact: which form a browser already submitted, how many free actions are left in a trial, which feature flags a tester opted into, what items are in a cart.
```typescript
async function submit({ get }) {
let session = get(Session)
if (session.get('hasSubmitted')) {
return render(<AlreadySubmittedPage />, { status: 409 })
}
let parsed = s.parseSafe(submitSchema, get(FormData))
if (!parsed.success) {
return render(<SubmitPage errors={parsed.issues} />, { status: 400 })
}
await saveSubmission(parsed.value)
session.set('hasSubmitted', true)
session.flash('message', 'Thanks for submitting!')
return redirect(routes.thanks.href())
}
```
Notice that there is no manual `Set-Cookie` plumbing in the action — the session middleware handles that, and the handler returns an ordinary `Response`. Per-browser state enforced this way is still bypassable by clearing cookies; if the guarantee needs to survive that, you also need an account (see auth providers below).
## Auth Middleware
### Basic setup
```typescript
import { auth, createSessionAuthScheme } from 'remix/middleware/auth'
import { Session } from 'remix/session'
import { Database } from 'remix/data-table'
export function loadAuth() {
return auth({
schemes: [
createSessionAuthScheme({
read(session) {
let data = session.get('auth')
return data ?? null
},
async verify(value, context) {
let db = context.get(Database)
return (await db.find(users, value.userId)) ?? null
},
invalidate(session) {
session.unset('auth')
},
}),
],
})
}
```
### Reading auth state
```typescript
import { Auth } from 'remix/middleware/auth'
function handler({ get }) {
let auth = get(Auth)
if (auth.ok) {
// User is authenticated
let user = auth.identity
}
}
```
## Credentials Auth
### Define a credentials provider
```typescript
import { createCredentialsAuthProvider, verifyCredentials, completeAuth } from 'remix/auth'
import * as s from 'remix/data-schema'
import * as f from 'remix/data-schema/form-data'
let loginSchema = f.object({
email: f.field(s.defaulted(s.string(), '')),
password: f.field(s.defaulted(s.string(), '')),
})
export let passwordProvider = createCredentialsAuthProvider({
parse(context) {
let formData = context.get(FormData)
return s.parse(loginSchema, formData)
},
async verify({ email, password }, context) {
let db = context.get(Database)
let user = await db.findOne(users, { where: { email } })
if (!user || !(await verifyPassword(password, user.password_hash))) {
return null
}
return user
},
})
```
### Login action
```typescript
import { verifyCredentials, completeAuth } from 'remix/auth'
import { redirect } from 'remix/response/redirect'
async action(context) {
let user = await verifyCredentials(passwordProvider, context)
if (user == null) {
let session = context.get(Session)
session.flash('error', 'Invalid email or password.')
return redirect(routes.auth.login.href())
}
let session = completeAuth(context)
session.set('auth', { userId: user.id })
return redirect(routes.home.href())
},
```
### Logout action
```typescript
import { Session } from 'remix/session'
import { redirect } from 'remix/response/redirect'
function logout(context) {
let session = context.get(Session)
session.unset('auth')
session.regenerateId(true)
return redirect(routes.home.href())
}
```
## OAuth / External Auth
### Create providers
```typescript
import {
createAtmosphereAuthProvider,
createGoogleAuthProvider,
createGitHubAuthProvider,
startExternalAuth,
finishExternalAuth,
completeAuth,
refreshExternalAuth,
} from 'remix/auth'
let googleProvider = createGoogleAuthProvider({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
redirectUri: new URL(routes.auth.google.callback.href(), origin),
})
let githubProvider = createGitHubAuthProvider({
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
redirectUri: new URL(routes.auth.github.callback.href(), origin),
})
let atmosphereSessionSecret = process.env.ATMOSPHERE_SESSION_SECRET
if (!atmosphereSessionSecret && process.env.NODE_ENV !== 'test') {
throw new Error('ATMOSPHERE_SESSION_SECRET is required')
}
let atmosphereProvider = createAtmosphereAuthProvider({
clientId: 'https://app.example.com/oauth/client-metadata.json',
redirectUri: new URL(routes.auth.atmosphere.callback.href(), origin),
sessionSecret: atmosphereSessionSecret ?? 'test-only-secret',
})
```
For Atmosphere-compatible atproto OAuth, create the provider once, call `atmosphereProvider.prepare(handleOrDid)` before `startExternalAuth(...)`, then pass the same module-scope provider to `finishExternalAuth(...)` and `refreshExternalAuth(...)`.
### OAuth controller
```typescript
import { createController } from 'remix/router'
export default createController(routes.auth.google, {
actions: {
// GET /auth/google — redirect to Google
async index(context) {
return await startExternalAuth(googleProvider, context, {
returnTo: context.url.searchParams.get('returnTo'),
})
},
// GET /auth/google/callback — handle redirect back
async callback(context) {
let { result, returnTo } = await finishExternalAuth(googleProvider, context)
let db = context.get(Database)
let { user, authAccount } = await resolveExternalAuth(db, result)
let session = completeAuth(context)
session.set('auth', {
userId: user.id,
loginMethod: result.provider,
authAccountId: authAccount.id,
})
return redirect(returnTo ?? routes.account.index.href())
},
},
})
```
### Refresh stored provider tokens
Use `refreshExternalAuth(provider, tokens)` when an app has stored OAuth/OIDC tokens and needs a fresh access token from a refresh token. Built-in OIDC providers, X, and Atmosphere support refresh-token exchange. If the provider does not rotate the refresh token, the refreshed bundle preserves the current one.
```typescript
async function refreshGoogleTokens({ get }) {
let db = get(Database)
let account = await db.findOne(authAccounts, { where: { provider: 'google' } })
if (!account) return null
let refreshed = await refreshExternalAuth(googleProvider, account.tokens)
await db.update(authAccounts, account.id, { tokens: refreshed.tokens })
return refreshed.tokens
}
```
## Protecting Routes
### Controller middleware protection
Apply `requireAuth()` as controller middleware to every action in one controller:
```typescript
import { createController } from 'remix/router'
import { requireAuth } from 'remix/middleware/auth'
export default createController(routes.account, {
middleware: [requireAuth()],
actions: {
index() {
/* guaranteed authenticated */
},
},
})
```
Nested route maps need their own explicit protection:
```typescript
// app/router.ts
router.map(routes.account, accountController)
router.map(routes.account.settings, accountSettingsController)
// app/actions/account/settings/controller.tsx
export default createController(routes.account.settings, {
middleware: [requireAuth()],
actions: {
index() {
/* guaranteed authenticated */
},
update() {
/* guaranteed authenticated */
},
},
})
```
### Stacking middleware
Combine auth checks with role checks:
```typescript
export default createController(routes.admin, {
middleware: [requireAuth(), requireAdmin()],
actions: {
index() {
/* requires auth + admin */
},
},
})
```
### Action middleware protection
Apply middleware to a single route:
```typescript
import { Auth, requireAuth } from 'remix/middleware/auth'
router.get(routes.account.index, {
middleware: [requireAuth()],
handler(context) {
let auth = context.get(Auth)
return render(<AccountPage identity={auth.identity} />)
},
})
```
### Redirect on auth failure
```typescript
import { requireAuth } from 'remix/middleware/auth'
import { redirect } from 'remix/response/redirect'
export function requireAuthRedirect() {
return requireAuth({
onFailure(context) {
let returnTo = encodeURIComponent(context.url.pathname)
return redirect(routes.auth.login.href() + `?returnTo=${returnTo}`, 303)
},
})
}
```
@@ -0,0 +1,270 @@
# Component Model
## What This Covers
How a Remix Component is shaped and how its state, lifecycle, and updates behave. Read this when the task involves:
- Writing a component (`handle` plus render function)
- Managing component-local state, derived values, or post-render DOM work
- Using `handle.props`, `handle.update()`, `handle.queueTask()`, `handle.signal`, `handle.id`, or `handle.context`
- Listening to global events with cleanup tied to the component lifecycle
For host-element behavior (event handlers, styles, refs, animations), see `mixins-styling-events.md`. For browser hydration, frames, and navigation, see `hydration-frames-navigation.md`.
## Phases
A component has two phases:
1. **Setup phase** — runs once when the component is created
2. **Render phase** — returned zero-argument function runs on initial render and every update
The component shape is `function Component(handle: Handle<Props>) { return () => ... }`. Props are available as `handle.props` in setup scope and are updated before every render.
```tsx
import { on, type Handle } from 'remix/ui'
function Counter(handle: Handle<{ initialCount?: number; label: string }>) {
let count = handle.props.initialCount ?? 0
return () => (
<button
mix={on('click', () => {
count++
handle.update()
})}
>
{handle.props.label}: {count}
</button>
)
}
```
## Props
Components receive all JSX props through `handle.props`. The object identity is stable for the component lifetime, and its values are updated before each render. Put initialization inputs on normal JSX props and read them from `handle.props`:
```tsx
function Timer(handle: Handle<{ initialSeconds: number; paused?: boolean }>) {
let seconds = handle.props.initialSeconds
return () => <div>Time remaining: {seconds}s</div>
}
// Usage: <Timer initialSeconds={60} paused={false} />
```
Because `handle.props` is stable, destructuring `let { props } = handle` is safe when helpers need to read current values later. Destructuring individual prop values is only a snapshot; prefer `handle.props.name` inside callbacks and render output when values can change.
## State Rules
- Keep state in setup scope as plain JavaScript variables.
- Store only what affects rendering. Derive computed values in render.
- Do not mirror input state unless you truly need controlled behavior.
- Do work in event handlers, not in render. Use the handler scope for transient state.
```tsx
// Derive computed values in render
function TodoList(handle: Handle) {
let todos: Array<{ text: string; completed: boolean }> = []
return () => {
let completedCount = todos.filter((t) => t.completed).length
return <div>Completed: {completedCount}</div>
}
}
```
## Handle API
### `handle.update()`
Schedules a rerender. Returns a promise that resolves with an `AbortSignal` after the update completes. Await it when you need the updated DOM before follow-up work:
```tsx
on('click', async () => {
isPlaying = true
let signal = await handle.update()
// DOM is now updated, safe to focus or measure
stopButton.focus()
})
```
### `handle.queueTask(task)`
Schedules a task to run after the next update. The task receives an `AbortSignal` that aborts when the component re-renders or is removed. Use for post-render DOM work, reactive data loading, or hydration-sensitive setup:
```tsx
let data = null
let requestedUrl: string | null = null
// Post-render DOM work in an event handler
on('click', () => {
showDetails = true
handle.update()
handle.queueTask(() => {
detailsSection.scrollIntoView({ behavior: 'smooth' })
})
})
// Reactive data loading keyed by props.url
return () => {
if (requestedUrl !== handle.props.url) {
let nextUrl = handle.props.url
requestedUrl = nextUrl
data = null
handle.queueTask(async (signal) => {
let response = await fetch(nextUrl, { signal })
let json = await response.json()
if (signal.aborted || requestedUrl !== nextUrl) return
data = json
handle.update()
})
}
return <div>{data ?? 'Loading...'}</div>
}
```
Avoid creating intermediate state just to trigger `queueTask`. Do the work directly in the handler or the queued task.
### `handle.signal`
An `AbortSignal` aborted when the component disconnects. Use for cleanup:
```tsx
function Clock(handle: Handle) {
let interval = setInterval(handle.update, 1000)
handle.signal.addEventListener('abort', () => clearInterval(interval))
return () => <span>{new Date().toString()}</span>
}
```
### `handle.id`
Stable identifier per component instance. Useful for `htmlFor`, `aria-owns`, etc.:
```tsx
function LabeledInput(handle: Handle) {
return () => (
<div>
<label htmlFor={handle.id}>Name</label>
<input id={handle.id} type="text" />
</div>
)
}
```
### `handle.frame` and `handle.frames`
Frame-aware behavior for client entries rendered inside frames:
- `handle.frame.reload()` — reload the containing frame
- `handle.frame.src` — the URL of the containing frame
- `handle.frames.top` — the root frame (the whole page)
- `handle.frames.top.reload()` — reload the entire page/frame tree
- `handle.frames.get(name)` — look up a named frame; returns `FrameHandle | undefined`
```tsx
function RefreshButton(handle: Handle) {
return () => <button mix={on('click', () => handle.frame.reload())}>Refresh</button>
}
```
### `handle.context`
Context for ancestor/descendant communication. See the context section below.
## Context
Use `handle.context.set()` to provide values and `handle.context.get(Provider)` to consume them. `set()` does **not** trigger updates — call `handle.update()` if the tree needs to rerender.
```tsx
function ThemeProvider(handle: Handle<{ children?: RemixNode }, { theme: 'light' | 'dark' }>) {
let theme: 'light' | 'dark' = 'light'
handle.context.set({ theme })
return () => (
<div>
<button
mix={on('click', () => {
theme = theme === 'light' ? 'dark' : 'light'
handle.context.set({ theme })
handle.update()
})}
>
Toggle
</button>
{handle.props.children}
</div>
)
}
function ThemedContent(handle: Handle) {
let { theme } = handle.context.get(ThemeProvider)
return () => <div>Current theme: {theme}</div>
}
```
For granular updates without re-rendering the full subtree, use `TypedEventTarget`:
```tsx
import { TypedEventTarget, addEventListeners } from 'remix/ui'
class Theme extends TypedEventTarget<{ change: Event }> {
#value: 'light' | 'dark' = 'light'
get value() {
return this.#value
}
setValue(value: 'light' | 'dark') {
this.#value = value
this.dispatchEvent(new Event('change'))
}
}
function ThemeProvider(handle: Handle<{ children?: RemixNode }, Theme>) {
let theme = new Theme()
handle.context.set(theme)
return () => (
<div>
<button mix={on('click', () => theme.setValue(theme.value === 'light' ? 'dark' : 'light'))}>
Toggle
</button>
{handle.props.children}
</div>
)
}
function ThemedContent(handle: Handle) {
let theme = handle.context.get(ThemeProvider)
addEventListeners(theme, handle.signal, {
change() {
handle.update()
},
})
return () => <div>Theme: {theme.value}</div>
}
```
## Global Events
Use `addEventListeners(target, handle.signal, listeners)` to listen to global targets with automatic cleanup when the component disconnects:
```tsx
import { addEventListeners, type Handle } from 'remix/ui'
function ResizeTracker(handle: Handle) {
let width = window.innerWidth
addEventListeners(window, handle.signal, {
resize() {
width = window.innerWidth
handle.update()
},
})
return () => <div>{width}</div>
}
```
@@ -0,0 +1,153 @@
# Creating Mixins
## What This Covers
How to author your own reusable host-element behavior with `createMixin`. Read this when the task involves:
- Combining multiple low-level events or DOM hooks into one semantic mixin
- Dispatching custom DOM events from a host node
- Encapsulating imperative DOM setup that several components share
- Typing custom events on `HTMLElementEventMap` for use with `on(...)`
For the built-in mixins most code should use, see `mixins-styling-events.md`.
Use `createMixin` from `remix/ui` to author reusable host-element behavior.
Most app code should use built-in core mixins (`on`, `css`, `ref`, `link`, `attrs`) and animation mixins from `remix/ui/animation`. Create custom mixins when combining multiple low-level events into one semantic event, or when the pattern is reused across components.
## Core Semantics
1. A mixin handle is tied to one mounted host node lifecycle.
2. `insert` is the host-node availability point for imperative setup.
3. `remove` is teardown for that same lifecycle.
4. `queueTask` runs post-commit and receives `(node, signal)` for mixins.
5. Mixin render functions should stay pure; side effects belong in `insert`, `remove`, or queued work.
```tsx
import { createMixin } from 'remix/ui'
let myMixin = createMixin<HTMLElement>((handle) => {
handle.addEventListener('insert', (event) => {
// event.node is the mounted host node
})
handle.addEventListener('remove', () => {
// Clean up listeners, timers, observers
})
return (props) => {
handle.queueTask((node) => {
// Post-commit work that needs the concrete host node
})
return <handle.element {...props} />
}
})
```
## Patterns
### Pure prop transform
```tsx
let withTitle = createMixin((handle) => (title: string, props: { title?: string }) => (
<handle.element {...props} title={title} />
))
```
### Lifecycle-managed imperative setup
```tsx
let withFocus = createMixin<HTMLElement>((handle) => {
handle.addEventListener('insert', (event) => {
event.node.focus()
})
return (props) => <handle.element {...props} />
})
```
## Custom Event Mixins
Create event mixins when you combine multiple low-level events into one semantic custom event that is reused across components.
1. Namespace custom event names (`myapp:*`) to avoid collisions.
2. Extend `Event` with the data consumers need.
3. Declare the event on `HTMLElementEventMap` for type safety with `on(...)`.
4. Dispatch from the host node inside the mixin.
```tsx
import { createMixin, on } from 'remix/ui'
export let dragReleaseType = 'myapp:drag-release' as const
declare global {
interface HTMLElementEventMap {
[dragReleaseType]: DragReleaseEvent
}
}
export class DragReleaseEvent extends Event {
velocityX: number
velocityY: number
constructor(init: { velocityX: number; velocityY: number }) {
super(dragReleaseType, { bubbles: true, cancelable: true })
this.velocityX = init.velocityX
this.velocityY = init.velocityY
}
}
export let dragRelease = createMixin<HTMLElement>((handle) => {
let node: HTMLElement | undefined
let tracking = false
let velocityX = 0
let velocityY = 0
let lastX = 0
let lastY = 0
let lastT = 0
handle.addEventListener('insert', (event) => {
node = event.node
})
return () => (
<handle.element
mix={[
on('pointerdown', (event) => {
if (!event.isPrimary) return
tracking = true
lastX = event.clientX
lastY = event.clientY
lastT = event.timeStamp
node?.setPointerCapture(event.pointerId)
}),
on('pointermove', (event) => {
if (!tracking) return
let dt = Math.max(1, event.timeStamp - lastT)
velocityX = (event.clientX - lastX) / dt
velocityY = (event.clientY - lastY) / dt
lastX = event.clientX
lastY = event.clientY
lastT = event.timeStamp
}),
on('pointerup', () => {
if (!tracking) return
tracking = false
node?.dispatchEvent(new DragReleaseEvent({ velocityX, velocityY }))
}),
]}
/>
)
})
```
Consume it:
```tsx
<div
mix={[
dragRelease(),
on(dragReleaseType, (event) => {
console.log('velocity:', event.velocityX, event.velocityY)
}),
]}
/>
```
@@ -0,0 +1,356 @@
# Data Access and Validation
## What This Covers
How input becomes a value the app trusts, and how that value reaches storage. Read this when the task involves:
- Defining database tables, columns, relations, and migrations
- Querying or mutating persisted data with `Database`
- Parsing and validating user input from forms, query strings, or external payloads
- Choosing between schema-level checks, table validation hooks, and migration-level constraints
For where validation runs in the request lifecycle, see `routing-and-controllers.md`. For session or identity-bound writes, see `auth-and-sessions.md`.
## Table Definitions (`remix/data-table`)
Define tables with typed columns, relations, and optional validation hooks:
```typescript
import { belongsTo, column as c, hasMany, table } from 'remix/data-table'
import type { TableRow, TableRowWith } from 'remix/data-table'
export const books = table({
name: 'books',
columns: {
id: c.integer().primaryKey().autoIncrement(),
slug: c.text().notNull().unique(),
title: c.text().notNull(),
author: c.text().notNull(),
price: c.decimal(10, 2).notNull(),
genre: c.text().notNull(),
in_stock: c.boolean(),
},
})
export const orders = table({
name: 'orders',
columns: {
id: c.integer().primaryKey().autoIncrement(),
user_id: c.integer().notNull().references('users', 'id'),
total: c.decimal(10, 2).notNull(),
created_at: c.integer().notNull(),
},
relations: {
user: belongsTo('users', 'user_id'),
items: hasMany('order_items', 'order_id'),
},
})
export type Book = TableRow<typeof books>
export type Order = TableRow<typeof orders>
export type OrderWithItems = TableRowWith<typeof orders, 'items'>
```
### Column types
| Method | SQL type |
| ----------------------------- | ------------------ |
| `c.integer()` | INTEGER |
| `c.text()` | TEXT |
| `c.boolean()` | BOOLEAN |
| `c.decimal(precision, scale)` | DECIMAL |
| `c.enum([...])` | TEXT (string enum) |
| `c.uuid()` | UUID / TEXT |
| `c.varchar(length)` | VARCHAR |
Column modifiers: `.primaryKey()`, `.autoIncrement()`, `.notNull()`, `.unique()`, `.references(table, column, fkName?)`, `.onDelete(action)`, `.default(value)`.
Composite primary keys go on the table option, not the column: `primaryKey: ['order_id', 'book_id']`.
### Schema vs migrations
Column modifiers on runtime `table(...)` definitions in `app/data/schema.ts` describe app-facing column metadata. They do not create or update database tables by themselves. The source of truth for actual DDL and constraints is your hand-written SQL migration files. Two valid patterns:
- **Mirror constraints in schema and SQL** — table definitions stay useful as schema-level docs, and migrations still own the actual DDL.
- **Bare columns in schema, constraints in SQL** — schema describes what the app reads and writes; migrations own the DDL and constraints.
Pick one and apply it consistently across the app.
### Table lifecycle hooks
Tables can define validation and lifecycle hooks:
- `validate` runs before `create` and `update` writes and should return either `{ value }` or `{ issues }`
- `beforeWrite` can normalize or veto `create`/`update` values
- `afterWrite` observes completed `create`/`update` operations
- `beforeDelete` and `afterDelete` observe or veto deletes
- `afterRead` can normalize or reject row values after reads
```typescript
export const books = table({
name: 'books',
columns: {
/* ... */
},
beforeWrite({ value }) {
if (typeof value.slug === 'string') {
return { value: { ...value, slug: value.slug.trim().toLowerCase() } }
}
return { value }
},
validate({ operation, value }) {
let issues = []
if (operation === 'create' && !value.slug) {
issues.push({ message: 'Slug is required.', path: ['slug'] })
}
return issues.length > 0 ? { issues } : { value }
},
afterRead({ value }) {
return { value }
},
})
```
## Database Setup
Create a database with an adapter and expose it via middleware:
```typescript
import BetterSqlite3 from 'better-sqlite3'
import { createDatabase, Database } from 'remix/data-table'
import { createSqliteDatabaseAdapter } from 'remix/data-table/sqlite'
let sqlite = new BetterSqlite3('./db/app.db')
sqlite.pragma('foreign_keys = ON')
let adapter = createSqliteDatabaseAdapter(sqlite)
export let db = createDatabase(adapter)
```
`createSqliteDatabaseAdapter` accepts synchronous SQLite clients with a shared `prepare`/`exec` surface, including Node's `node:sqlite`, Bun's `bun:sqlite`, and compatible clients. Use whichever client fits the runtime instead of assuming `better-sqlite3` is required.
### Database middleware
```typescript
import type { Middleware } from 'remix/router'
import { Database } from 'remix/data-table'
export function loadDatabase(): Middleware {
return async (context, next) => {
context.set(Database, db)
return next()
}
}
```
### Querying
```typescript
let db = get(Database)
// Find by primary key
let book = await db.find(books, id)
// Find one by condition
let user = await db.findOne(users, { where: { email } })
// Find many with ordering
let allBooks = await db.findMany(books, { orderBy: ['id', 'asc'] })
// Count
let total = await db.count(orders, { where: { user_id: userId } })
// Query builder
let genres = await db.query(books).select('genre').distinct().orderBy('genre', 'asc').all()
// Create
let newBook = await db.create(books, { slug: 'new-book', title: 'New Book' /* ... */ })
// Update
await db.update(books, bookId, { title: 'Updated Title' })
// Delete
await db.delete(books, bookId)
```
### Operators
```typescript
import { inList } from 'remix/data-table/operators'
let featured = await db.findMany(books, {
where: inList('slug', ['book-a', 'book-b', 'book-c']),
})
```
## Migrations
Migrations are plain SQL files. Each migration is a directory named `YYYYMMDDHHmmss_<slug>/` containing a hand-written `up.sql` (required) and an optional `down.sql` (omit for irreversible migrations).
```txt
db/
migrations/
20260228090000_create_users/
up.sql
down.sql
20260301083000_add_books_search_index/
up.sql
```
### Writing migrations
Write standard SQL in `up.sql` and `down.sql`:
```sql
-- up.sql
create table users (
id integer primary key autoincrement,
email text not null unique,
name text not null
);
create index users_email_idx on users (email);
```
```sql
-- down.sql
drop table if exists users;
```
Do **not** import app code (e.g. `app/data/schema.ts`) into migration files. Migrations must be stable, immutable artifacts — importing live schema definitions creates drift between what the migration meant when it was written and what it does when replayed later. SQL files guarantee stability because they cannot import anything.
### Transaction modes
Migrations run inside a transaction by default (when the adapter supports transactional DDL). Override per migration with a directive comment in `up.sql`:
```sql
-- data-table/transaction: none
create index concurrently users_email_idx on users (email);
```
Modes: `auto` (default — wrap when supported), `required` (wrap; throw if unsupported), `none` (never wrap).
### Running migrations
```typescript
import { createMigrationRunner } from 'remix/data-table/migrations'
import { loadMigrations } from 'remix/data-table/migrations/node'
let migrations = await loadMigrations('./db/migrations')
let runner = createMigrationRunner(adapter, migrations)
await runner.up()
```
The runner checksums each `up.sql` and detects drift if a previously applied migration changes. Use `runner.status()` to inspect applied/pending/drifted state, and `runner.down()` to revert.
## Input Validation (`remix/data-schema`)
Use `data-schema` to validate user input (forms, query params, API payloads). This is separate from table-level `validate` hooks which run at persistence.
### Schema builders
```typescript
import * as s from 'remix/data-schema'
import { email, minLength, maxLength } from 'remix/data-schema/checks'
let userSchema = s.object({
name: s.string().pipe(minLength(1)),
email: s.string().pipe(email()),
age: s.optional(s.number()),
})
let result = s.parse(userSchema, data)
```
### FormData validation
Use `remix/data-schema/form-data` to validate `FormData` directly:
```typescript
import * as s from 'remix/data-schema'
import * as f from 'remix/data-schema/form-data'
import { email, minLength } from 'remix/data-schema/checks'
let signupSchema = f.object({
name: f.field(s.string().pipe(minLength(1))),
email: f.field(s.string().pipe(email())),
password: f.field(s.string().pipe(minLength(8))),
})
// In a controller action:
let formData = get(FormData)
let { name, email, password } = s.parse(signupSchema, formData)
```
### Reading FormData: middleware vs `request.formData()`
There are two ways to get a `FormData` value inside an action.
The recommended way: register `formData()` middleware in the root stack and read with `get(FormData)`. The body is parsed once per request, and the typed `FormData` value flows through the context system. This also lets `methodOverride()` and CSRF middleware work uniformly.
```typescript
import { formData } from 'remix/middleware/form-data'
let router = createRouter({
middleware: [, /* ... */ formData() /* ... */],
})
// In an action:
let parsed = s.parseSafe(signupSchema, get(FormData))
```
The fallback: `await request.formData()` directly. This works without middleware and is fine for small one-off cases, but it bypasses the context system, runs once per call site, and doesn't compose with middleware that depends on parsed form fields.
### Safe parsing
`s.parse` throws on invalid input. `s.parseSafe` returns a tagged result and is usually what an action wants, since validation failure is an expected outcome (re-render the form with errors) rather than an exception:
```typescript
let result = s.parseSafe(signupSchema, get(FormData))
if (!result.success) {
return render(<SignupPage errors={result.issues} />, { status: 400 })
}
let { name, email, password } = result.value
```
Returning a `Response` for validation failures keeps the route contract honest: the same action returns 200 on success, 400 with errors on bad input, no out-of-band exception flow.
### Transforming validated output
Use `.transform(...)` when a schema should validate one shape but return another value or output type. Transforms run after validation and compose with `.pipe(...)` and `.refine(...)`:
```typescript
import * as coerce from 'remix/data-schema/coerce'
let slugSchema = s
.string()
.pipe(minLength(1))
.transform((value) => value.trim().toLowerCase().replace(/\s+/g, '-'))
let pageSchema = f.object({
page: f.field(s.defaulted(coerce.coerceNumber(), 1).refine(Number.isInteger)),
q: f.field(s.defaulted(s.string(), '').transform((value) => value.trim())),
})
let { page, q } = s.parse(pageSchema, formData)
```
### Anti-patterns
Avoid these shapes when reading and validating input:
- **Raw `formData.get('name')` plus an `if (typeof name !== 'string')` guard**, then a thrown custom error. This reinvents what `data-schema` already does, loses the typed result, and pushes error translation into a `try/catch` instead of a return value.
- **Letting route-local domain errors leak out of the action.** Translate expected outcomes (bad input, missing record, duplicate entry) into the `Response` the route means to return instead of throwing a custom `Error` subclass with a `status` field and catching it later.
- **Trusting `params`, query strings, or external payloads without a schema.** Anything that crosses a trust boundary should be parsed before it reaches business logic.
### Common patterns
```typescript
// Optional with default
let limitSchema = f.field(s.defaulted(s.string(), '10'))
// Union types
let methodSchema = s.union([s.literal('credentials'), s.literal('google'), s.literal('github')])
// Refinements
let idSchema = s.number().refine(Number.isInteger, 'Expected an integer')
```
@@ -0,0 +1,264 @@
# Hydration, Frames, and Navigation
## What This Covers
How server-rendered UI becomes interactive in the browser, and how the page updates without a full navigation. Read this when the task involves:
- Marking a component for client-side hydration with `clientEntry`
- Booting the client runtime with `run`
- Streaming server content into a region of the page with `<Frame>` and reloading those regions
- Triggering Navigation API transitions with `navigate(...)` or `link(...)`
- Server rendering with `renderToStream` or `renderToString`
- Managing the document `<head>`
For component-local state and updates, see `component-model.md`. For host-element behavior and events, see `mixins-styling-events.md`.
## Server First, Then Hydrate
Make the server route correct before adding `clientEntry(...)`. A POST should already do the right thing on its own — return HTML, a redirect, or an error response — and a GET should already render the page the user expects. `clientEntry` exists to layer interactivity on top of UI that already works without it.
When server state changes after a mutation, prefer reloading a `<Frame>` when the UI region already maps cleanly to a server-rendered route. Frames re-fetch the same route, so the rendering logic stays in one place and the client does not need a parallel "state" API.
```tsx
on('submit', async (event, signal) => {
event.preventDefault()
await fetch(routes.cart.add.href(), {
method: 'POST',
body: new FormData(event.currentTarget),
signal,
})
if (signal.aborted) return
await handle.frames.get('cart-summary')?.reload()
})
```
Use polling or a small JSON state endpoint when the data changes outside this page, or when a tiny shared widget would be heavier to model as a frame. Pick the lightest sync mechanism that preserves clear ownership of rendering logic.
## Client Entries
Use `clientEntry` to mark a component for client-side hydration. In source-served apps, prefer the source module's `import.meta.url` as the entry ID and let server rendering map it to the public asset URL:
```tsx
import { clientEntry, on, type Handle } from 'remix/ui'
export const Counter = clientEntry(
import.meta.url,
function Counter(handle: Handle<{ initialCount: number; label: string }>) {
let count = handle.props.initialCount
return () => (
<div>
<span>
{handle.props.label}: {count}
</span>
<button
mix={on('click', () => {
count++
handle.update()
})}
>
+
</button>
</div>
)
},
)
```
On the server, provide `resolveClientEntry` to `renderToStream(...)` so source file URLs become browser-loadable asset URLs. Keep this resolution in the render helper so component modules do not hard-code deployment-specific asset paths:
```tsx
let stream = renderToStream(<App />, {
async resolveClientEntry(entryId, component) {
let exportName = entryId.split('#')[1] || component.name
if (!exportName) {
throw new Error(`Unable to resolve client entry export for ${entryId}`)
}
return {
href: await assetServer.getHref(entryId),
exportName,
}
},
})
```
If the module export name differs from the component function name, include `#ExportName` in the entry ID or return the exact export name from `resolveClientEntry`. A render helper that only supports source-owned entries can also fail fast when `entryId` is not a `file://` URL.
On the server, `clientEntry` components render like any other component. The server wraps their output in comment markers and serializes props into a `<script type="application/json">` tag.
Client entry props must be serializable: strings, numbers, booleans, `null`, `undefined`, plain objects/arrays of the above, JSX elements, and `<Frame>` elements. Functions and class instances cannot be passed.
## Booting the Client
Use `run` to start the client runtime. It scans the document for client entry markers, loads modules, and hydrates each one:
```tsx
import { run } from 'remix/ui'
let app = run({
async loadModule(moduleUrl, exportName) {
let mod = await import(moduleUrl)
return mod[exportName]
},
async resolveFrame(src, signal, target) {
let headers = new Headers({ accept: 'text/html' })
if (target) headers.set('x-remix-target', target)
let response = await fetch(src, { headers, signal })
return response.body ?? (await response.text())
},
})
app.addEventListener('error', (event) => {
console.error('Component error:', event.error)
})
await app.ready()
```
### `run` options
- **`loadModule(moduleUrl, exportName)`** (required) — return the component function for each client entry. Typically uses dynamic `import()`.
- **`resolveFrame(src, signal, target)`** (optional) — called when a `<Frame>` loads or reloads content. `target` is available when frame targeting matters.
### `app` methods
- **`app.ready()`** — resolves when all initial client entries are hydrated
- **`app.flush()`** — synchronously flushes all pending updates
- **`app.dispose()`** — tears down all hydrated components
`app` is an `EventTarget` that emits `error` events from any hydrated component.
## Frames
A `<Frame>` renders server content into the page. Frames stream after the initial HTML, nest inside other frames, contain client entries, and can be reloaded without full page navigation.
```tsx
import { Frame } from 'remix/ui'
function App() {
return () => (
<div>
<Frame src="/sidebar" fallback={<div>Loading...</div>} />
<Frame name="main" src="/main-content" />
</div>
)
}
```
### Frame props
- **`src`** (required) — URL to fetch the frame content from
- **`fallback`** (optional) — content to show while loading; determines streaming behavior
- **`name`** (optional) — registers the frame for lookup via `handle.frames.get(name)`
- **`on`** (optional) — event handlers for events dispatched from the frame element
### Blocking vs non-blocking
- **Without `fallback`** (blocking) — the server waits for frame content before sending the initial HTML chunk
- **With `fallback`** (non-blocking) — the fallback renders immediately; real content streams in later and replaces it
### Reloading frames
Client entries inside a frame can trigger a reload:
```tsx
// Reload the containing frame
handle.frame.reload()
// Reload an adjacent named frame
await handle.frames.get('cart-summary')?.reload()
// Reload the entire page/frame tree
handle.frames.top.reload()
```
When a frame reloads, matching DOM nodes are updated in place. Client entries receive updated props while preserving their local component state.
### Nested frames
Frames can nest. Each frame owns its own DOM region and hydrates client entries independently. During SSR, `handle.frame.src` points at the frame being rendered, while `handle.frames.top.src` stays fixed at the outer document URL.
## Server Rendering
### `renderToStream`
Renders a component tree to a `ReadableStream<Uint8Array>`. Sends initial HTML immediately and streams frame content as it resolves:
```tsx
import { renderToStream } from 'remix/ui/server'
let stream = renderToStream(<App />, {
frameSrc: request.url,
resolveFrame(src, target, context) {
let frameUrl = new URL(src, context?.currentFrameSrc ?? request.url)
return fetchHtml(frameUrl)
},
onError(error) {
console.error(error)
},
})
return new Response(stream, {
headers: { 'Content-Type': 'text/html; charset=utf-8' },
})
```
Options:
- **`frameSrc`** — seeds SSR frame state; populates `handle.frame.src` and `handle.frames.top.src`
- **`topFrameSrc`** — overrides the root frame URL for nested frame renders (carry forward from `resolveFrame` context)
- **`resolveFrame(src, target, context)`** — return HTML string, `ReadableStream<Uint8Array>`, or a promise of either. `context.currentFrameSrc` is the containing frame URL; `context.topFrameSrc` is the outer document URL
- **`onError(error)`** — called on rendering errors
### `renderToString`
Renders a component tree to a complete HTML string. Use for static pages or embedding HTML:
```tsx
import { renderToString } from 'remix/ui/server'
let html = await renderToString(<App />)
```
### CSS in SSR
Components using the `css` mixin have styles collected during rendering and emitted as a single `<style>` tag in `<head>`. No client-side style injection needed.
## Navigation
Use real anchors for normal document navigation. For app-driven navigation:
- `navigate(href, options?)` — performs a Navigation API transition
- `link(href, options?)` mixin — makes any element behave like a navigation link
```tsx
import { navigate } from 'remix/ui'
navigate('/dashboard', { history: 'replace' })
```
Options: `src`, `target`, `history` (`'push' | 'replace'`), `resetScroll`.
Attributes understood by the runtime: `rmx-target`, `rmx-src`, `rmx-document`.
## Head Management
Manage document head with an explicit `<head>` in your document structure:
```tsx
function App() {
return () => (
<html>
<head>
<title>Dashboard</title>
<meta name="description" content="Team dashboard" />
<link rel="stylesheet" href="/styles/app.css" />
</head>
<body>
<main>...</main>
</body>
</html>
)
}
```
Put `title`, `meta`, `link`, and `style` tags inside an explicit `<head>`. Bare head-like tags rendered outside `<head>` stay where they are — they are not moved into the document head for you.
@@ -0,0 +1,216 @@
# Middleware and Server Setup
## What This Covers
How to compose the request lifecycle and bridge the router to a runtime. Read this when the task involves:
- Choosing or ordering built-in middleware in the stack
- Writing custom middleware that sets typed context values
- Adding fast-exit handling (static files, CORS preflights) versus request-enriching layers (sessions, auth, data loading)
- Choosing when to keep the generated Node server versus switching server adapters
For data and persistence specifics, see `data-and-validation.md`. For session and auth specifics, see `auth-and-sessions.md`.
## Middleware Stack
Middleware runs in order for every request. Place fast-exit middleware (static files) early and request-enriching middleware (session, auth) later.
Recommended ordering:
```typescript
import { createRouter } from 'remix/router'
import { compression } from 'remix/middleware/compression'
import { formData } from 'remix/middleware/form-data'
import { logger } from 'remix/middleware/logger'
import { methodOverride } from 'remix/middleware/method-override'
import { session } from 'remix/middleware/session'
import { staticFiles } from 'remix/middleware/static'
import { asyncContext } from 'remix/middleware/async-context'
let middleware = []
if (process.env.NODE_ENV === 'development') {
middleware.push(logger())
}
middleware.push(compression())
middleware.push(staticFiles('./public'))
middleware.push(formData())
middleware.push(methodOverride())
middleware.push(session(cookie, storage))
middleware.push(asyncContext())
middleware.push(loadDatabase())
middleware.push(loadAuth())
let router = createRouter({ middleware })
```
### Built-in middleware catalog
| Middleware | Import | Use when | Notes |
| -------------------------- | ---------------------------------- | ----------------------------------------------------------------------------- | -------------------------------------------------------------- |
| `staticFiles(dir, opts?)` | `remix/middleware/static` | Serve files from `public/` or another directory exactly as they exist on disk | Fast exit; usually near the top |
| `compression()` | `remix/middleware/compression` | Compress text-like responses | Usually app-wide |
| `logger()` | `remix/middleware/logger` | Log requests and responses | Often development-only; `colors` can force color output on/off |
| `cors(opts?)` | `remix/middleware/cors` | Endpoints must serve cross-origin browsers or preflight `OPTIONS` requests | Usually early so preflights can short-circuit |
| `cop(opts?)` | `remix/middleware/cop` | Reject unsafe cross-origin browser requests without synchronizer tokens | Put before session or CSRF when used |
| `formData(opts?)` | `remix/middleware/form-data` | Parse `FormData` bodies, especially forms and uploads | Needed for `_csrf` form field extraction |
| `methodOverride()` | `remix/middleware/method-override` | HTML forms need `PUT`, `PATCH`, or `DELETE` semantics | Run after form parsing |
| `session(cookie, storage)` | `remix/middleware/session` | Cookie-backed sessions | Must run before session-backed auth or CSRF |
| `csrf(opts?)` | `remix/middleware/csrf` | Session-backed form workflows need synchronizer-token CSRF protection | Requires `session()` before it |
| `asyncContext()` | `remix/middleware/async-context` | Helpers outside handlers need request context via `getContext()` | Add before helpers rely on it |
| `auth({ schemes })` | `remix/middleware/auth` | Resolve auth state into `context.get(Auth)` | Run after `session()` for session-backed auth |
| `requireAuth()` | `remix/middleware/auth` | A controller or action must reject anonymous access | Usually controller middleware or action middleware |
### Static files vs browser modules
- Use `staticFiles()` for files that should be served directly from disk, such as images, fonts, or already-built assets in `public/`
- Use `remix/assets` when browser modules should be compiled and served from source files with import rewriting, preloads, or fingerprinted URLs
### Ordering notes
- Put fast exits early: `staticFiles()`, `cors()` preflight handling, and `cop()` when used
- Parse request bodies before middleware that depends on them, such as `methodOverride()` and form field token extraction in `csrf()`
- Run `session()` before `csrf()` and before session-backed `auth()`
- Add `asyncContext()` before helpers or shared code call `getContext()`
- Keep route protection like `requireAuth()` as controller middleware or action middleware unless the entire app is private
### Common stacks
- **Session-backed HTML app** -> `compression()`, `staticFiles()`, optional `cop()`, `formData()`, `methodOverride()`, `session()`, optional `csrf()`, `asyncContext()`, `auth({ schemes })`
- **Cross-origin API** -> `compression()`, `cors()`, optional `asyncContext()`, optional `auth({ schemes })`
- **Upload flow** -> `compression()`, `staticFiles()`, `formData({ uploadHandler })`, then sessions, auth, and data-loading middleware as needed
### Middleware with options
```typescript
// Static files with cache headers
staticFiles('./public', {
cacheControl: 'no-store, must-revalidate',
etag: false,
lastModified: false,
})
// Form data with upload handler
import type { FileUpload } from 'remix/form-data-parser'
import { createFsFileStorage } from 'remix/file-storage/fs'
let fileStorage = createFsFileStorage('./tmp/uploads')
formData({
uploadHandler(fileUpload: FileUpload) {
return fileStorage.set(fileUpload.name, fileUpload)
},
})
```
Errors thrown or rejected by `uploadHandler` propagate directly. Catch domain-specific upload errors at the route boundary when they should become user-facing `Response` objects.
## Writing Custom Middleware
Middleware is a function that receives `(context, next)`. Return a `Response` to short-circuit, call and return `next()` when you need the downstream response, or return nothing when you only set context and want the router to continue automatically.
### Setting context values
Use `context.set(key, value)` to add typed values accessible downstream via `context.get(key)`.
```typescript
import type { Middleware } from 'remix/router'
import { Database } from 'remix/data-table'
export function loadDatabase(): Middleware {
return async (context, next) => {
context.set(Database, db)
return next()
}
}
```
### Guarding routes
```typescript
import { Auth } from 'remix/middleware/auth'
export function requireAdmin(): Middleware {
return (context, next) => {
let auth = context.get(Auth)
if (auth.identity?.role !== 'admin') {
return new Response('Forbidden', { status: 403 })
}
return next()
}
}
```
### Async context for helpers
`asyncContext()` stores the request context in `AsyncLocalStorage` so helpers can reach it without the context being threaded through every call. Wrap `getContext()` in app-specific helpers:
```typescript
// app/utils/context.ts
import { getContext } from 'remix/middleware/async-context'
import { Auth } from 'remix/middleware/auth'
import { Database } from 'remix/data-table'
import { Session } from 'remix/session'
export function getCurrentDb() {
return getContext().get(Database)
}
export function getCurrentSession() {
return getContext().get(Session)
}
export function getCurrentUser() {
let auth = getContext().get(Auth)
if (!auth.ok) {
throw new Error('Expected an authenticated user. Run requireAuth() before this code.')
}
return auth.identity
}
export function getCurrentUserSafely() {
let auth = getContext().get(Auth)
return auth.ok ? auth.identity : null
}
```
## Middleware Types
Middleware has three API-owned forms:
1. **Router middleware** — runs for every request:
```typescript
let router = createRouter({ middleware: [logger(), session(cookie, storage)] })
```
2. **Controller middleware** — runs for the direct actions in one controller:
```typescript
export default createController(routes.account, {
middleware: [requireAuth()],
actions: { ... },
})
```
Controller middleware does not flow into other controllers. Add the middleware to each controller that needs it.
3. **Action middleware** — runs for a single action:
```typescript
router.get(routes.account.index, {
middleware: [requireAuth()],
handler(context) {
return render(<AccountPage identity={context.auth.identity} />)
},
})
```
Prefer inline arrays for `middleware` options. Use `RouterContext<typeof router>` to derive an app context from a router that uses inline middleware. Use `createMiddleware()` only when a chain is stored in a variable and its exact tuple type needs to be preserved, such as when deriving `MiddlewareContext<typeof rootMiddleware>` without a router value, exporting a reusable chain, or returning a chain from a factory.
## Node Server Setup
New apps already include a `server.ts` that adapts the app router with `remix/node-fetch-server`. Keep that generated server unless the task specifically needs to change runtime behavior such as host/protocol handling, TLS, HTTP/2, WebSockets, deployment lifecycle, or test-only server setup.
Use `remix/node-fetch-server` when you want to keep owning a standard Node `http`, `https`, or `http2` server directly.
@@ -0,0 +1,195 @@
# Mixins, Styling, and Events
## What This Covers
How to attach behavior, styles, and DOM-aware setup to host elements with `mix`. Read this when the task involves:
- DOM event handling with `on(...)`
- Static styling with `css(...)` and dynamic styling with `style`
- Imperative DOM access via `ref(...)`
- Navigation behavior on non-anchor elements with `link(...)`
- Native click, pointer, and keyboard behavior with `on(...)`, plus attributes with `attrs(...)`
- Element-level animation mixins from `remix/ui/animation`
For richer animation work (springs, tweens, layout transitions), see `animate-elements.md`. For authoring custom mixins, see `create-mixins.md`. For component lifecycle and updates, see `component-model.md`.
Compose behavior on host elements with `mix`. Pass a single mixin directly (`mix={on(...)}`), or an array when composing multiple mixins (`mix={[css(...), on(...)]}`). Core mixins are imported from `remix/ui`; animation mixins are imported from `remix/ui/animation`.
## `on(type, handler, capture?)`
Attaches a typed DOM event handler. The handler receives the event and an `AbortSignal` that aborts when the handler is re-entered or the component is removed — this prevents race conditions:
```tsx
<input
mix={on('input', async (event, signal) => {
let query = event.currentTarget.value
loading = true
handle.update()
let response = await fetch(`/search?q=${query}`, { signal })
let data = await response.json()
if (signal.aborted) return
results = data.results
loading = false
handle.update()
})}
/>
```
Multiple events on the same element:
```tsx
<form
mix={on('submit', (event) => {
event.preventDefault()
let formData = new FormData(event.currentTarget)
})}
>
```
## `css(styles)`
Applies generated class names for CSS object styles. Produces static CSS rules inserted into the document. Supports pseudo-selectors, pseudo-elements, attribute selectors, descendant selectors, and media queries using `&` to reference the current element:
```tsx
<button
mix={css({
color: 'white',
backgroundColor: 'blue',
padding: '12px 24px',
borderRadius: '4px',
border: 'none',
cursor: 'pointer',
'&:hover': { backgroundColor: 'darkblue' },
'&:active': { transform: 'scale(0.98)' },
'&:disabled': { opacity: 0.5, cursor: 'not-allowed' },
'& .title': { fontSize: '20px', fontWeight: 'bold' },
'@media (max-width: 768px)': { width: '100%' },
})}
/>
```
### `css(...)` vs `style` prop
Use `css(...)` for static styles, selectors, and media queries. Use `style` for dynamic values that change often. Prefer CSS nested selectors for parent-state-affects-children over managing hover/focus state in JavaScript:
```tsx
<div
mix={css({
backgroundColor: 'blue', // static
'&:hover': { '& .title': { color: 'blue' } }, // parent hover → child
})}
style={{ width: `${progress}%` }} // dynamic
/>
```
## `ref(callback)`
Calls a callback when an element is inserted. The callback receives the DOM node and an `AbortSignal` that aborts when the element is removed:
```tsx
<input mix={ref((node) => node.focus())} />
<div mix={ref((node, signal) => {
let observer = new ResizeObserver((entries) => {
dimensions.width = Math.round(entries[0].contentRect.width)
handle.update()
})
observer.observe(node)
signal.addEventListener('abort', () => observer.disconnect())
})} />
```
The `ref` callback runs once when the element is first rendered, not on every update.
## `link(href, options?)`
Adds client-side navigation behavior to any element. Makes non-anchor elements behave like Remix navigation links:
```tsx
<article mix={link('/courses/intro')}>
<h3>Introduction</h3>
</article>
```
Options match `NavigationOptions`: `src`, `target`, `history` (`'push' | 'replace'`), `resetScroll`.
## Native press and keyboard interactions
Use native DOM events directly with `on(...)`. For buttons and links, `click` already includes keyboard activation when the element has the right semantics:
```tsx
<button mix={on('click', () => doAction())}>Action</button>
```
For gesture-specific behavior, compose the pointer or keyboard events the interaction actually needs:
```tsx
<button
mix={[
on('pointerdown', (event) => {
event.currentTarget.setPointerCapture(event.pointerId)
}),
on('pointerup', () => doAction()),
]}
>
Action
</button>
<div
tabIndex={0}
mix={on('keydown', (event) => {
if (event.key === 'Escape') close()
if (event.key === 'Enter' || event.key === ' ') doAction()
})}
/>
```
## `attrs()`
Sets HTML attributes through the mixin system.
## Animation Mixins
### `animateEntrance(config)`
Animates an element when it is inserted into the DOM. Config specifies the **starting** style:
```tsx
<div mix={animateEntrance({ opacity: 0, transform: 'translateY(8px)', duration: 180 })} />
```
### `animateExit(config)`
Animates an element when it is removed. Config specifies the **ending** style. The element is kept in the DOM until the animation completes:
```tsx
{
isVisible && (
<div
key="panel"
mix={[
animateEntrance({ opacity: 0, transform: 'scale(0.98)', ...spring('smooth') }),
animateExit({ opacity: 0, duration: 120, easing: 'ease-in' }),
]}
/>
)
}
```
### `animateLayout(config?)`
Animates layout changes (position/size) using FLIP-style transforms:
```tsx
{
items.map((item) => (
<li key={item.id} mix={animateLayout({ duration: 220, easing: 'ease-out' })} />
))
}
```
Options: `duration` (default 200ms), `easing` (default spring snappy), `size` (boolean, default true — include scale projection for size changes).
Always key elements you expect to animate. Use `...spring(preset)` to spread `duration` and `easing` into any animation config.
@@ -0,0 +1,362 @@
# Routing and Controllers
## What This Covers
Patterns for declaring URLs, handling requests, and wiring routes to controllers. Read this when the task involves:
- Defining or changing the URL surface of the app
- Writing or reorganizing controllers and actions
- Reading request data (`params`, `url`, `request`, context values)
- Returning a `Response` for HTML, redirects, JSON, or errors
- Generating internal URLs with `.href()`
The companion reference for shaping `Request` bodies, validating input, and dealing with persisted data is `data-and-validation.md`. For request lifecycle and middleware ordering, see `middleware-and-server.md`.
## Route Builders
Import all route builders from `remix/routes`.
### `route(prefix, map)` — nested route group
Adds a URL prefix to all children. Can also be called as `route(map)` without a prefix for a top-level grouping. Inside `route(...)`, a nested map may be either a `route('prefix', { ... })` call (when you want a shared URL prefix) or a plain object literal (when each leaf already owns its absolute path).
```typescript
import { route, get, post } from 'remix/routes'
export const routes = route({
home: '/',
// Plain object — no shared prefix, each leaf has an absolute path.
books: {
index: '/books',
show: '/books/:slug',
},
// route('auth', ...) — every leaf is prefixed with /auth.
auth: route('auth', {
login: get('login'),
logout: post('logout'),
}),
})
```
### Leaf route builders
| Builder | HTTP method | Example |
| -------------- | ----------- | -------------------- |
| `get(path)` | GET | `get('/search')` |
| `post(path)` | POST | `post('/logout')` |
| `put(path)` | PUT | `put('/api/update')` |
| `del(path)` | DELETE | `del('/api/remove')` |
| String literal | ANY | `'/about'` |
### `form(path, options?)` — form route
Creates a GET + POST pair for HTML form workflows. Expands to an `index` (GET) and an `action` (POST) by default.
```typescript
contact: form('contact')
// Produces routes.contact.index (GET /contact) and routes.contact.action (POST /contact)
settings: form('settings', { formMethod: 'PUT', names: { action: 'update' } })
// Produces routes.settings.index (GET) and routes.settings.update (PUT)
```
### `resources(name, options?)` — REST resources
Expands to conventional CRUD routes: `index`, `new`, `create`, `show`, `edit`, `update`, `destroy`.
```typescript
books: resources('books', { param: 'bookId' })
// GET /books, GET /books/new, POST /books, GET /books/:bookId, ...
orders: resources('orders', { only: ['index', 'show'], param: 'orderId' })
// GET /orders, GET /orders/:orderId
```
### URL generation with `.href()`
Route objects expose `.href()` for type-safe URL generation:
```typescript
redirect(routes.home.href())
redirect(routes.account.orders.show.href({ orderId: '42' }))
```
## Actions
An action is the handler for one leaf route. In Remix app code, actions should live in controllers. Use `Action` only when a reusable helper needs to type one action before it is added to a controller or when you are doing low-level router wiring outside the `app/actions` convention:
```typescript
import { createAction } from 'remix/router'
import { routes } from '../routes.ts'
export const search = createAction(routes.search, {
async handler({ url }) {
let query = url.searchParams.get('q') ?? ''
let results = await searchIndex(query)
return render(<SearchPage query={query} results={results} />)
},
})
```
The handler receives a context object with:
- `get(key)` — read a value set by middleware (e.g. `get(Database)`, `get(Session)`, `get(Auth)`)
- `params` — typed route params
- `url` — the request URL
- `request` — the raw `Request`
Actions with action middleware:
```typescript
import { createAction } from 'remix/router'
import { requireAuth } from 'remix/middleware/auth'
export const account = createAction(routes.account.index, {
middleware: [requireAuth()],
handler(context) {
return render(<AccountPage />)
},
})
```
## Returning Responses
An action returns a `Response`. The shape of that response is part of the route contract, and choosing it well saves a lot of glue elsewhere.
### Render HTML
For pages, render a component tree and return the resulting `Response`:
```typescript
async handler({ get }) {
let db = get(Database)
let books = await db.findMany(books, { orderBy: ['id', 'asc'] })
return render(<IndexPage books={books} />)
}
```
### Redirect after a mutation
For state-changing routes (POST, PUT, PATCH, DELETE), the canonical reply is a redirect to the resulting page. Pass `303` explicitly when you want a POST-redirect-GET flow:
```typescript
import { redirect } from 'remix/response/redirect'
async create({ get }) {
let formData = get(FormData)
let parsed = s.parseSafe(bookSchema, formData)
if (!parsed.success) {
return render(<NewBookPage errors={parsed.issues} />, { status: 400 })
}
let db = get(Database)
let book = await db.create(books, parsed.value)
return redirect(routes.books.show.href({ slug: book.slug }), 303)
}
```
This pattern works without JavaScript and stays compatible with `clientEntry(...)` enhancements on top.
### Return an error response
For expected failures — validation, conflict, not found — return a `Response` directly. Reserve thrown errors for genuinely unexpected failures.
```typescript
async show({ get, params }) {
let db = get(Database)
let book = await db.find(books, params.bookId)
if (!book) return new Response('Not Found', { status: 404 })
return render(<ShowPage book={book} />)
}
```
For form re-rendering with errors, return the page component with the parsed issues:
```typescript
let formData = get(FormData)
let parsed = s.parseSafe(signupSchema, formData)
if (!parsed.success) {
return render(<SignupPage errors={parsed.issues} values={Object.fromEntries(formData)} />, {
status: 400,
})
}
```
### Return JSON
For routes consumed by client code rather than rendered as a page (autocomplete endpoints, polling APIs, inter-service calls), return a JSON `Response`. Use `SuperHeaders` from `remix/headers` when typed header accessors make the response clearer:
```typescript
import Headers from 'remix/headers'
let headers = new Headers()
headers.contentType = { mediaType: 'application/json', charset: 'utf-8' }
headers.cacheControl = { noStore: true }
return new Response(JSON.stringify({ results }), {
headers,
})
```
If you find yourself returning JSON for what is really a browser form submission, prefer the redirect-after-POST pattern instead. JSON-only mutation endpoints make it harder to support non-JS clients, harder to share rendering logic, and easier for the client to drift out of sync with the server.
## Controllers
A controller owns the direct leaf routes in one route map. Each key in `actions` matches a direct leaf route key in the route definition passed to `router.map(...)`. Nested route-map keys do not belong inside a controller's `actions`; map those route maps with their own controllers.
Configure `RouterTypes.context` with your app context in the router module, then use `createController()` so `get(Database)`, `get(Session)`, `get(Auth)`, etc. are typed against your middleware stack without repeating a type clause on every controller.
```typescript
import { createController } from 'remix/router'
import { routes } from '../routes.ts'
export default createController(routes.books, {
actions: {
async index({ get }) {
let db = get(Database)
let items = await db.findMany(books, { orderBy: ['id', 'asc'] })
return render(<IndexPage items={items} />)
},
async show({ get, params }) {
let db = get(Database)
let book = await db.find(books, params.bookId)
if (!book) return new Response('Not Found', { status: 404 })
return render(<ShowPage book={book} />)
},
},
})
```
### Root controller
The root route map uses `app/actions/controller.tsx` and owns only top-level leaf routes:
```typescript
// routes.ts
export const routes = route({
assets: get('/assets/*path'),
home: '/',
account: route('account', {
index: '/',
settings: form('settings', { formMethod: 'PUT', names: { action: 'update' } }),
}),
})
// app/actions/controller.tsx
export default createController(routes, {
actions: {
async assets({ request }) {
return (await assetServer.fetch(request)) ?? new Response('Not Found', { status: 404 })
},
home() {
return render(<HomePage />)
},
},
})
```
Because `account` is a nested route map, it is not an action key in the root controller.
### Nested route maps
Nested route maps use their own controllers under `app/actions/<route-key>/controller.tsx`. Directory names under `app/actions/` are route-map keys, not URL path segments.
```typescript
// app/actions/account/controller.tsx
export default createController(routes.account, {
middleware: [requireAuth()],
actions: {
index() {
return render(<AccountPage />)
},
},
})
// app/actions/account/settings/controller.tsx
export default createController(routes.account.settings, {
middleware: [requireAuth()],
actions: {
index() {
return render(<SettingsPage />)
},
update() {
return redirect(routes.account.index.href(), 303)
},
},
})
```
Then map each route map explicitly:
```typescript
import rootController from './actions/controller.tsx'
import accountController from './actions/account/controller.tsx'
import accountSettingsController from './actions/account/settings/controller.tsx'
let router = createRouter({ middleware })
router.map(routes, rootController)
router.map(routes.account, accountController)
router.map(routes.account.settings, accountSettingsController)
```
### Controller middleware
The `middleware` array on a controller runs only for the direct actions in that controller, before action middleware. It does not apply to other controllers.
```typescript
export default createController(routes.admin, {
middleware: [requireAuth(), requireAdmin()],
actions: {
/* all actions require auth + admin */
},
})
```
## Registering Routes
Use `router.map` for route maps and controllers. Map each nested route map explicitly. Use verb methods only for low-level router wiring outside the `app/actions` controller convention.
```typescript
let router = createRouter({ middleware })
// Route maps → controllers
router.map(routes, rootController)
router.map(routes.contact, contactController)
router.map(routes.auth, authController)
router.map(routes.auth.login, authLoginController)
router.map(routes.admin, adminController)
router.map(routes.admin.books, adminBooksController)
// Leaf route → one-off action
router.get(routes.search, searchAction)
router.post(routes.logout, logoutAction)
```
## Typed Context
Define an `AppContext` type from your router, then make it the default context used by `createAction()` and `createController()`:
```typescript
import { createRouter, type RouterContext } from 'remix/router'
export const router = createRouter({
middleware: [formData(), session(cookie, storage), loadDatabase(), loadAuth()],
})
export type AppContext = RouterContext<typeof router>
declare module 'remix/router' {
interface RouterTypes {
context: AppContext
}
}
```
This gives typed `context.get(Database)`, `context.get(Session)`, `context.get(Auth)`, etc.
@@ -0,0 +1,153 @@
# Testing
## What This Covers
How to test the two layers most Remix code lives in: HTTP behavior and DOM behavior. Read this when the task involves:
- Driving the router with `router.fetch(new Request(...))` and asserting on the returned `Response`
- Building a fresh router per test for session, storage, or database isolation
- Rendering components into a real DOM with `render(...)` or `createRoot(...)`
- Configuring `remix test` discovery, excludes, and coverage
- Using adjacent CLI checks such as `remix routes`, `remix doctor`, and `remix version`
- Choosing which layer to test for a given behavior
For session and auth test setup, see `auth-and-sessions.md`. For component lifecycle, see `component-model.md`.
## Two Shapes
Remix tests run with `remix test`, use `remix/test` for the test framework, and use `remix/assert` for assertions. Two main shapes:
- **Server / router tests** — drive the router with `router.fetch(new Request(...))` and assert on the returned `Response`. No DOM, no browser harness.
- **Component tests** — render a component into a real DOM `Element` with `render(...)`, or use `createRoot(...)` directly when you need lower-level root control.
## Server / Router Tests
Treat the router as a pure `(Request) => Promise<Response>` function. Build a fresh app router per test (or per suite) so middleware state — sessions, in-memory storage, the database — stays isolated.
```ts
import * as assert from 'remix/assert'
import { describe, it } from 'remix/test'
import { createBookstoreRouter } from '../app/router.ts'
import { routes } from '../app/routes.ts'
describe('home', () => {
it('responds 200 with the home page', async () => {
let router = createBookstoreRouter()
let response = await router.fetch(new Request('http://localhost' + routes.home.href()))
assert.equal(response.status, 200)
assert.match(await response.text(), /Welcome to the Bookstore/)
})
})
```
Use `routes.<name>.href(...)` to build URLs in tests so they stay in sync with the route definition. For form-style POSTs, attach a `FormData` body to the `Request`. For tests that need a known session, swap in `createMemorySessionStorage()` and a test cookie when constructing the router.
```ts
import { createMemorySessionStorage } from 'remix/session-storage/memory'
import { createCookie } from 'remix/cookie'
let router = createBookstoreRouter({
sessionCookie: createCookie('session', { secrets: ['test'] }),
sessionStorage: createMemorySessionStorage(),
})
```
Use `createTestServer` from `remix/node-fetch-server/test` when the behavior depends on a real HTTP origin, redirects, streaming, cookies through a network boundary, or browser-style `fetch`:
```ts
import { createTestServer } from 'remix/node-fetch-server/test'
let server = await createTestServer((request) => router.fetch(request))
try {
let response = await fetch(new URL(routes.home.href(), server.baseUrl))
assert.equal(response.status, 200)
} finally {
await server.close()
}
```
## Test Runner Config
Configure discovery and coverage in `remix-test.config.ts` or with CLI flags:
```ts
export default {
glob: {
test: '**/*.test{,.e2e}.{ts,tsx}',
e2e: '**/*.test.e2e.{ts,tsx}',
exclude: 'node_modules/**',
},
coverage: {
dir: '.coverage',
include: ['app/**/*.{ts,tsx}'],
exclude: ['app/**/*.test.{ts,tsx}'],
statements: 80,
lines: 80,
branches: 70,
functions: 80,
},
}
```
Use `remix test --coverage` to enable coverage with defaults. Use `glob.exclude` when discovery would otherwise enter generated output, symlinked workspaces, or other paths that should not produce tests.
## Component Tests
Use `render(...)` from `remix/ui/test` for most component tests. It creates a real DOM container, flushes the initial render, and returns `act(...)` so interactions can flush pending updates before assertions. Use `createRoot(container)` from `remix/ui` directly when a test needs explicit control over root rendering, flushing, or disposal.
### Basic pattern
```tsx
import * as assert from 'remix/assert'
import { render } from 'remix/ui/test'
let result = render(<Counter />)
let button = result.$('button')!
await result.act(() => button.click())
assert.match(result.container.textContent ?? '', /1/)
result.cleanup()
```
### Why act / flush
- **After initial render** — ensures event listeners are attached and the DOM is ready for interaction.
- **After interactions** — applies updates from `handle.update()` calls triggered by events.
- **After async work resolves** — applies updates from resolved `queueTask(...)` callbacks.
### Async operations
For components with async operations in `queueTask`, use `act(...)` after each async step:
```tsx
let result = render(<AsyncLoader />)
assert.equal(result.container.textContent, 'Loading...')
await waitForFetch()
await result.act(() => {})
assert.equal(result.container.textContent, 'Expected data')
```
### Component removal
Use `result.cleanup()` or `root.dispose()` to remove the component tree and verify cleanup behavior:
```tsx
let result = render(<MyComponent />)
assert.ok(result.$('.content'))
result.cleanup()
assert.throws(() => result.$('.content'), /cleaned up/)
```
### Guidelines
- Prefer real DOM interactions over mocking framework behavior.
- Avoid testing implementation-only markers unless they are the only stable synchronization point.
- One representative flow proving a behavior is better than repeating the same assertion across many paths.