mirror of
https://github.com/prdlk/dotfiles.git
synced 2026-11-07 20:47:37 +00:00
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:
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.
|
||||
Reference in new issue
Block a user