@remix-run/cli 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +0 -3
- package/dist/lib/bootstrap-project.d.ts.map +1 -1
- package/dist/lib/bootstrap-project.js +32 -9
- package/dist/lib/cli.d.ts +29 -0
- package/dist/lib/cli.d.ts.map +1 -1
- package/dist/lib/cli.js +33 -14
- package/dist/lib/commands/completion.d.ts.map +1 -1
- package/dist/lib/commands/completion.js +5 -1
- package/dist/lib/commands/doctor.js +18 -18
- package/dist/lib/commands/help.d.ts.map +1 -1
- package/dist/lib/commands/help.js +9 -33
- package/dist/lib/commands/routes.js +3 -3
- package/dist/lib/commands/test.d.ts +1 -1
- package/dist/lib/commands/test.d.ts.map +1 -1
- package/dist/lib/commands/test.js +8 -4
- package/dist/lib/completion.d.ts.map +1 -1
- package/dist/lib/completion.js +4 -106
- package/dist/lib/controller-files.d.ts +0 -1
- package/dist/lib/controller-files.d.ts.map +1 -1
- package/dist/lib/controller-files.js +3 -5
- package/dist/lib/controller-ownership.d.ts +9 -9
- package/dist/lib/controller-ownership.d.ts.map +1 -1
- package/dist/lib/controller-ownership.js +56 -91
- package/dist/lib/doctor/controller-findings.d.ts +1 -1
- package/dist/lib/doctor/controller-findings.d.ts.map +1 -1
- package/dist/lib/doctor/controller-findings.js +15 -87
- package/dist/lib/doctor/controller-fix-plans.d.ts.map +1 -1
- package/dist/lib/doctor/controller-fix-plans.js +13 -24
- package/dist/lib/doctor/controller-placeholders.d.ts +2 -3
- package/dist/lib/doctor/controller-placeholders.d.ts.map +1 -1
- package/dist/lib/doctor/controller-placeholders.js +18 -149
- package/dist/lib/doctor/controllers.js +1 -1
- package/dist/lib/doctor/project.js +60 -52
- package/dist/lib/doctor/types.d.ts +2 -2
- package/dist/lib/doctor/types.d.ts.map +1 -1
- package/dist/lib/errors.d.ts +0 -6
- package/dist/lib/errors.d.ts.map +1 -1
- package/dist/lib/errors.js +0 -11
- package/dist/lib/load-route-map-worker.js +17 -9
- package/dist/lib/route-map.d.ts +1 -1
- package/dist/lib/route-map.d.ts.map +1 -1
- package/dist/lib/route-map.js +29 -17
- package/package.json +4 -5
- package/src/lib/bootstrap-project.ts +39 -13
- package/src/lib/cli.ts +46 -15
- package/src/lib/commands/completion.ts +6 -1
- package/src/lib/commands/doctor.ts +18 -21
- package/src/lib/commands/help.ts +9 -43
- package/src/lib/commands/routes.ts +3 -3
- package/src/lib/commands/test.ts +10 -4
- package/src/lib/completion.ts +4 -151
- package/src/lib/controller-files.ts +4 -8
- package/src/lib/controller-ownership.ts +78 -141
- package/src/lib/doctor/controller-findings.ts +20 -97
- package/src/lib/doctor/controller-fix-plans.ts +13 -29
- package/src/lib/doctor/controller-placeholders.ts +17 -189
- package/src/lib/doctor/controllers.ts +1 -1
- package/src/lib/doctor/project.ts +60 -52
- package/src/lib/doctor/types.ts +1 -5
- package/src/lib/errors.ts +0 -12
- package/src/lib/load-route-map-worker.ts +19 -10
- package/src/lib/route-map.ts +61 -16
- package/template/.agents/skills/remix/SKILL.md +588 -0
- package/template/.agents/skills/remix/references/animate-elements.md +195 -0
- package/template/.agents/skills/remix/references/assets-and-browser-modules.md +130 -0
- package/template/.agents/skills/remix/references/auth-and-sessions.md +443 -0
- package/template/.agents/skills/remix/references/component-model.md +282 -0
- package/template/.agents/skills/remix/references/create-mixins.md +158 -0
- package/template/.agents/skills/remix/references/data-and-validation.md +379 -0
- package/template/.agents/skills/remix/references/hydration-frames-navigation.md +297 -0
- package/template/.agents/skills/remix/references/middleware-and-server.md +233 -0
- package/template/.agents/skills/remix/references/mixins-styling-events.md +213 -0
- package/template/.agents/skills/remix/references/routing-and-controllers.md +391 -0
- package/template/.agents/skills/remix/references/testing-patterns.md +172 -0
- package/{bootstrap → template}/AGENTS.md +13 -8
- package/template/README.md +29 -0
- package/template/app/actions/controller.tsx +18 -0
- package/template/app/assets/entry.ts +8 -0
- package/template/app/assets/prompt-button.tsx +163 -0
- package/template/app/assets.ts +20 -0
- package/template/app/middleware/render.tsx +43 -0
- package/template/app/router.ts +20 -0
- package/template/app/routes.ts +6 -0
- package/template/app/ui/document.tsx +33 -0
- package/template/app/ui/scaffold-home-page.tsx +527 -0
- package/template/gitignore +4 -0
- package/{bootstrap → template}/package.json +5 -6
- package/template/public/favicon.svg +11 -0
- package/{bootstrap → template}/server.ts +4 -5
- package/{bootstrap → template}/tsconfig.json +3 -3
- package/bootstrap/README.md +0 -27
- package/bootstrap/app/controllers/auth.tsx +0 -21
- package/bootstrap/app/controllers/home.tsx +0 -26
- package/bootstrap/app/router.ts +0 -10
- package/bootstrap/app/routes.ts +0 -6
- package/bootstrap/app/ui/document.tsx +0 -21
- package/bootstrap/app/ui/layout.tsx +0 -22
- package/bootstrap/app/utils/render.tsx +0 -7
- package/dist/lib/commands/skills.d.ts +0 -6
- package/dist/lib/commands/skills.d.ts.map +0 -1
- package/dist/lib/commands/skills.js +0 -222
- package/dist/lib/skills-cache.d.ts +0 -19
- package/dist/lib/skills-cache.d.ts.map +0 -1
- package/dist/lib/skills-cache.js +0 -89
- package/dist/lib/skills.d.ts +0 -30
- package/dist/lib/skills.d.ts.map +0 -1
- package/dist/lib/skills.js +0 -441
- package/src/lib/commands/skills.ts +0 -306
- package/src/lib/skills-cache.ts +0 -140
- package/src/lib/skills.ts +0 -706
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
# Data Access and Validation
|
|
2
|
+
|
|
3
|
+
## What This Covers
|
|
4
|
+
|
|
5
|
+
How input becomes a value the app trusts, and how that value reaches storage. Read this when the
|
|
6
|
+
task involves:
|
|
7
|
+
|
|
8
|
+
- Defining database tables, columns, relations, and migrations
|
|
9
|
+
- Querying or mutating persisted data with `Database`
|
|
10
|
+
- Parsing and validating user input from forms, query strings, or external payloads
|
|
11
|
+
- Choosing between schema-level checks, table validation hooks, and migration-level constraints
|
|
12
|
+
|
|
13
|
+
For where validation runs in the request lifecycle, see `routing-and-controllers.md`. For session
|
|
14
|
+
or identity-bound writes, see `auth-and-sessions.md`.
|
|
15
|
+
|
|
16
|
+
## Table Definitions (`remix/data-table`)
|
|
17
|
+
|
|
18
|
+
Define tables with typed columns, relations, and optional validation hooks:
|
|
19
|
+
|
|
20
|
+
```typescript
|
|
21
|
+
import { belongsTo, column as c, hasMany, table } from 'remix/data-table'
|
|
22
|
+
import type { TableRow, TableRowWith } from 'remix/data-table'
|
|
23
|
+
|
|
24
|
+
export const books = table({
|
|
25
|
+
name: 'books',
|
|
26
|
+
columns: {
|
|
27
|
+
id: c.integer().primaryKey().autoIncrement(),
|
|
28
|
+
slug: c.text().notNull().unique(),
|
|
29
|
+
title: c.text().notNull(),
|
|
30
|
+
author: c.text().notNull(),
|
|
31
|
+
price: c.decimal(10, 2).notNull(),
|
|
32
|
+
genre: c.text().notNull(),
|
|
33
|
+
in_stock: c.boolean(),
|
|
34
|
+
},
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
export const orders = table({
|
|
38
|
+
name: 'orders',
|
|
39
|
+
columns: {
|
|
40
|
+
id: c.integer().primaryKey().autoIncrement(),
|
|
41
|
+
user_id: c.integer().notNull().references('users', 'id'),
|
|
42
|
+
total: c.decimal(10, 2).notNull(),
|
|
43
|
+
created_at: c.integer().notNull(),
|
|
44
|
+
},
|
|
45
|
+
relations: {
|
|
46
|
+
user: belongsTo('users', 'user_id'),
|
|
47
|
+
items: hasMany('order_items', 'order_id'),
|
|
48
|
+
},
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
export type Book = TableRow<typeof books>
|
|
52
|
+
export type Order = TableRow<typeof orders>
|
|
53
|
+
export type OrderWithItems = TableRowWith<typeof orders, 'items'>
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Column types
|
|
57
|
+
|
|
58
|
+
| Method | SQL type |
|
|
59
|
+
| ----------------------------- | ------------------ |
|
|
60
|
+
| `c.integer()` | INTEGER |
|
|
61
|
+
| `c.text()` | TEXT |
|
|
62
|
+
| `c.boolean()` | BOOLEAN |
|
|
63
|
+
| `c.decimal(precision, scale)` | DECIMAL |
|
|
64
|
+
| `c.enum([...])` | TEXT (string enum) |
|
|
65
|
+
| `c.uuid()` | UUID / TEXT |
|
|
66
|
+
| `c.varchar(length)` | VARCHAR |
|
|
67
|
+
|
|
68
|
+
Column modifiers: `.primaryKey()`, `.autoIncrement()`, `.notNull()`, `.unique()`,
|
|
69
|
+
`.references(table, column, fkName?)`, `.onDelete(action)`, `.default(value)`.
|
|
70
|
+
|
|
71
|
+
Composite primary keys go on the table option, not the column: `primaryKey: ['order_id', 'book_id']`.
|
|
72
|
+
|
|
73
|
+
### Schema vs migrations
|
|
74
|
+
|
|
75
|
+
Column modifiers describe SQL constraints — the source of truth for them is your **migration**
|
|
76
|
+
files, where they generate the actual DDL. Runtime `table(...)` definitions in `app/data/schema.ts`
|
|
77
|
+
can use the same modifiers, or they can stay minimal (`c.integer()`, `c.text()`, ...) since the
|
|
78
|
+
runtime only needs the column shape and validation hooks. Two valid patterns:
|
|
79
|
+
|
|
80
|
+
- **Modifiers in both** — schema and migrations stay in sync visually; useful when you want
|
|
81
|
+
schema-level docs.
|
|
82
|
+
- **Bare columns in schema, full modifiers in migrations** — schema describes what the app reads
|
|
83
|
+
and writes; migrations own the DDL and constraints.
|
|
84
|
+
|
|
85
|
+
Pick one and apply it consistently across the app.
|
|
86
|
+
|
|
87
|
+
### Table lifecycle hooks
|
|
88
|
+
|
|
89
|
+
Tables can define validation and lifecycle hooks:
|
|
90
|
+
|
|
91
|
+
- `validate` runs before `create` and `update` writes and should return either `{ value }` or
|
|
92
|
+
`{ issues }`
|
|
93
|
+
- `beforeWrite` can normalize or veto `create`/`update` values
|
|
94
|
+
- `afterWrite` observes completed `create`/`update` operations
|
|
95
|
+
- `beforeDelete` and `afterDelete` observe or veto deletes
|
|
96
|
+
- `afterRead` can normalize or reject row values after reads
|
|
97
|
+
|
|
98
|
+
```typescript
|
|
99
|
+
export const books = table({
|
|
100
|
+
name: 'books',
|
|
101
|
+
columns: {
|
|
102
|
+
/* ... */
|
|
103
|
+
},
|
|
104
|
+
beforeWrite({ value }) {
|
|
105
|
+
if (typeof value.slug === 'string') {
|
|
106
|
+
return { value: { ...value, slug: value.slug.trim().toLowerCase() } }
|
|
107
|
+
}
|
|
108
|
+
return { value }
|
|
109
|
+
},
|
|
110
|
+
validate({ operation, value }) {
|
|
111
|
+
let issues = []
|
|
112
|
+
if (operation === 'create' && !value.slug) {
|
|
113
|
+
issues.push({ message: 'Slug is required.', path: ['slug'] })
|
|
114
|
+
}
|
|
115
|
+
return issues.length > 0 ? { issues } : { value }
|
|
116
|
+
},
|
|
117
|
+
afterRead({ value }) {
|
|
118
|
+
return { value }
|
|
119
|
+
},
|
|
120
|
+
})
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Database Setup
|
|
124
|
+
|
|
125
|
+
Create a database with an adapter and expose it via middleware:
|
|
126
|
+
|
|
127
|
+
```typescript
|
|
128
|
+
import BetterSqlite3 from 'better-sqlite3'
|
|
129
|
+
import { createDatabase, Database } from 'remix/data-table'
|
|
130
|
+
import { createSqliteDatabaseAdapter } from 'remix/data-table/sqlite'
|
|
131
|
+
|
|
132
|
+
let sqlite = new BetterSqlite3('./db/app.db')
|
|
133
|
+
sqlite.pragma('foreign_keys = ON')
|
|
134
|
+
let adapter = createSqliteDatabaseAdapter(sqlite)
|
|
135
|
+
export let db = createDatabase(adapter)
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
`createSqliteDatabaseAdapter` accepts synchronous SQLite clients with a shared `prepare`/`exec`
|
|
139
|
+
surface, including Node's `node:sqlite`, Bun's `bun:sqlite`, and compatible clients. Use whichever
|
|
140
|
+
client fits the runtime instead of assuming `better-sqlite3` is required.
|
|
141
|
+
|
|
142
|
+
### Database middleware
|
|
143
|
+
|
|
144
|
+
```typescript
|
|
145
|
+
import type { Middleware } from 'remix/router'
|
|
146
|
+
import { Database } from 'remix/data-table'
|
|
147
|
+
|
|
148
|
+
export function loadDatabase(): Middleware {
|
|
149
|
+
return async (context, next) => {
|
|
150
|
+
context.set(Database, db)
|
|
151
|
+
return next()
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### Querying
|
|
157
|
+
|
|
158
|
+
```typescript
|
|
159
|
+
let db = get(Database)
|
|
160
|
+
|
|
161
|
+
// Find by primary key
|
|
162
|
+
let book = await db.find(books, id)
|
|
163
|
+
|
|
164
|
+
// Find one by condition
|
|
165
|
+
let user = await db.findOne(users, { where: { email } })
|
|
166
|
+
|
|
167
|
+
// Find many with ordering
|
|
168
|
+
let allBooks = await db.findMany(books, { orderBy: ['id', 'asc'] })
|
|
169
|
+
|
|
170
|
+
// Count
|
|
171
|
+
let total = await db.count(orders, { where: { user_id: userId } })
|
|
172
|
+
|
|
173
|
+
// Query builder
|
|
174
|
+
let genres = await db.query(books).select('genre').distinct().orderBy('genre', 'asc').all()
|
|
175
|
+
|
|
176
|
+
// Create
|
|
177
|
+
let newBook = await db.create(books, { slug: 'new-book', title: 'New Book' /* ... */ })
|
|
178
|
+
|
|
179
|
+
// Update
|
|
180
|
+
await db.update(books, bookId, { title: 'Updated Title' })
|
|
181
|
+
|
|
182
|
+
// Delete
|
|
183
|
+
await db.delete(books, bookId)
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### Operators
|
|
187
|
+
|
|
188
|
+
```typescript
|
|
189
|
+
import { inList } from 'remix/data-table/operators'
|
|
190
|
+
|
|
191
|
+
let featured = await db.findMany(books, {
|
|
192
|
+
where: inList('slug', ['book-a', 'book-b', 'book-c']),
|
|
193
|
+
})
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## Migrations
|
|
197
|
+
|
|
198
|
+
### Writing migrations
|
|
199
|
+
|
|
200
|
+
```typescript
|
|
201
|
+
import { column as c, createMigration } from 'remix/data-table/migrations'
|
|
202
|
+
import { table } from 'remix/data-table'
|
|
203
|
+
|
|
204
|
+
export default createMigration({
|
|
205
|
+
async up({ schema }) {
|
|
206
|
+
let users = table({
|
|
207
|
+
name: 'users',
|
|
208
|
+
columns: {
|
|
209
|
+
id: c.integer().primaryKey().autoIncrement(),
|
|
210
|
+
email: c.text().notNull().unique(),
|
|
211
|
+
name: c.text().notNull(),
|
|
212
|
+
},
|
|
213
|
+
})
|
|
214
|
+
await schema.createTable(users)
|
|
215
|
+
await schema.createIndex(users, 'email', { name: 'users_email_idx', unique: true })
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
async down({ schema }) {
|
|
219
|
+
await schema.dropTable('users')
|
|
220
|
+
},
|
|
221
|
+
})
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Migrations can also import table definitions from the app schema to avoid duplication:
|
|
225
|
+
|
|
226
|
+
```typescript
|
|
227
|
+
import { createMigration } from 'remix/data-table/migrations'
|
|
228
|
+
import { users, authAccounts } from '../../app/data/schema.ts'
|
|
229
|
+
|
|
230
|
+
export default createMigration({
|
|
231
|
+
async up({ schema }) {
|
|
232
|
+
await schema.createTable(users)
|
|
233
|
+
await schema.createTable(authAccounts)
|
|
234
|
+
},
|
|
235
|
+
})
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
### Running migrations
|
|
239
|
+
|
|
240
|
+
```typescript
|
|
241
|
+
import { createMigrationRunner } from 'remix/data-table/migrations'
|
|
242
|
+
import { loadMigrations } from 'remix/data-table/migrations/node'
|
|
243
|
+
|
|
244
|
+
let migrations = await loadMigrations('./db/migrations')
|
|
245
|
+
let runner = createMigrationRunner(adapter, migrations)
|
|
246
|
+
await runner.up()
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
### Migration file naming
|
|
250
|
+
|
|
251
|
+
Name migration files with a timestamp prefix: `20260228090000_create_users.ts`. Place them in
|
|
252
|
+
`db/migrations/`.
|
|
253
|
+
|
|
254
|
+
## Input Validation (`remix/data-schema`)
|
|
255
|
+
|
|
256
|
+
Use `data-schema` to validate user input (forms, query params, API payloads). This is separate from
|
|
257
|
+
table-level `validate` hooks which run at persistence.
|
|
258
|
+
|
|
259
|
+
### Schema builders
|
|
260
|
+
|
|
261
|
+
```typescript
|
|
262
|
+
import * as s from 'remix/data-schema'
|
|
263
|
+
import { email, minLength, maxLength } from 'remix/data-schema/checks'
|
|
264
|
+
|
|
265
|
+
let userSchema = s.object({
|
|
266
|
+
name: s.string().pipe(minLength(1)),
|
|
267
|
+
email: s.string().pipe(email()),
|
|
268
|
+
age: s.optional(s.number()),
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
let result = s.parse(userSchema, data)
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
### FormData validation
|
|
275
|
+
|
|
276
|
+
Use `remix/data-schema/form-data` to validate `FormData` directly:
|
|
277
|
+
|
|
278
|
+
```typescript
|
|
279
|
+
import * as s from 'remix/data-schema'
|
|
280
|
+
import * as f from 'remix/data-schema/form-data'
|
|
281
|
+
import { email, minLength } from 'remix/data-schema/checks'
|
|
282
|
+
|
|
283
|
+
let signupSchema = f.object({
|
|
284
|
+
name: f.field(s.string().pipe(minLength(1))),
|
|
285
|
+
email: f.field(s.string().pipe(email())),
|
|
286
|
+
password: f.field(s.string().pipe(minLength(8))),
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
// In a controller action:
|
|
290
|
+
let formData = get(FormData)
|
|
291
|
+
let { name, email, password } = s.parse(signupSchema, formData)
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
### Reading FormData: middleware vs `request.formData()`
|
|
295
|
+
|
|
296
|
+
There are two ways to get a `FormData` value inside an action.
|
|
297
|
+
|
|
298
|
+
The recommended way: register `formData()` middleware in the root stack and read with
|
|
299
|
+
`get(FormData)`. The body is parsed once per request, and the typed `FormData` value flows through
|
|
300
|
+
the context system. This also lets `methodOverride()` and CSRF middleware work uniformly.
|
|
301
|
+
|
|
302
|
+
```typescript
|
|
303
|
+
import { formData } from 'remix/middleware/form-data'
|
|
304
|
+
|
|
305
|
+
let router = createRouter({
|
|
306
|
+
middleware: [, /* ... */ formData() /* ... */],
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
// In an action:
|
|
310
|
+
let parsed = s.parseSafe(signupSchema, get(FormData))
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
The fallback: `await request.formData()` directly. This works without middleware and is fine for
|
|
314
|
+
small one-off cases, but it bypasses the context system, runs once per call site, and doesn't
|
|
315
|
+
compose with middleware that depends on parsed form fields.
|
|
316
|
+
|
|
317
|
+
### Safe parsing
|
|
318
|
+
|
|
319
|
+
`s.parse` throws on invalid input. `s.parseSafe` returns a tagged result and is usually what an
|
|
320
|
+
action wants, since validation failure is an expected outcome (re-render the form with errors)
|
|
321
|
+
rather than an exception:
|
|
322
|
+
|
|
323
|
+
```typescript
|
|
324
|
+
let result = s.parseSafe(signupSchema, get(FormData))
|
|
325
|
+
if (!result.success) {
|
|
326
|
+
return render(<SignupPage errors={result.issues} />, { status: 400 })
|
|
327
|
+
}
|
|
328
|
+
let { name, email, password } = result.value
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
Returning a `Response` for validation failures keeps the route contract honest: the same action
|
|
332
|
+
returns 200 on success, 400 with errors on bad input, no out-of-band exception flow.
|
|
333
|
+
|
|
334
|
+
### Transforming validated output
|
|
335
|
+
|
|
336
|
+
Use `.transform(...)` when a schema should validate one shape but return another value or output
|
|
337
|
+
type. Transforms run after validation and compose with `.pipe(...)` and `.refine(...)`:
|
|
338
|
+
|
|
339
|
+
```typescript
|
|
340
|
+
import * as coerce from 'remix/data-schema/coerce'
|
|
341
|
+
|
|
342
|
+
let slugSchema = s
|
|
343
|
+
.string()
|
|
344
|
+
.pipe(minLength(1))
|
|
345
|
+
.transform((value) => value.trim().toLowerCase().replace(/\s+/g, '-'))
|
|
346
|
+
|
|
347
|
+
let pageSchema = f.object({
|
|
348
|
+
page: f.field(s.defaulted(coerce.coerceNumber(), 1).refine(Number.isInteger)),
|
|
349
|
+
q: f.field(s.defaulted(s.string(), '').transform((value) => value.trim())),
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
let { page, q } = s.parse(pageSchema, formData)
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
### Anti-patterns
|
|
356
|
+
|
|
357
|
+
Avoid these shapes when reading and validating input:
|
|
358
|
+
|
|
359
|
+
- **Raw `formData.get('name')` plus an `if (typeof name !== 'string')` guard**, then a thrown
|
|
360
|
+
custom error. This reinvents what `data-schema` already does, loses the typed result, and
|
|
361
|
+
pushes error translation into a `try/catch` instead of a return value.
|
|
362
|
+
- **Letting route-local domain errors leak out of the action.** Translate expected outcomes (bad
|
|
363
|
+
input, missing record, duplicate entry) into the `Response` the route means to return instead of
|
|
364
|
+
throwing a custom `Error` subclass with a `status` field and catching it later.
|
|
365
|
+
- **Trusting `params`, query strings, or external payloads without a schema.** Anything that
|
|
366
|
+
crosses a trust boundary should be parsed before it reaches business logic.
|
|
367
|
+
|
|
368
|
+
### Common patterns
|
|
369
|
+
|
|
370
|
+
```typescript
|
|
371
|
+
// Optional with default
|
|
372
|
+
let limitSchema = f.field(s.defaulted(s.string(), '10'))
|
|
373
|
+
|
|
374
|
+
// Union types
|
|
375
|
+
let methodSchema = s.union([s.literal('credentials'), s.literal('google'), s.literal('github')])
|
|
376
|
+
|
|
377
|
+
// Refinements
|
|
378
|
+
let idSchema = s.number().refine(Number.isInteger, 'Expected an integer')
|
|
379
|
+
```
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
# Hydration, Frames, and Navigation
|
|
2
|
+
|
|
3
|
+
## What This Covers
|
|
4
|
+
|
|
5
|
+
How server-rendered UI becomes interactive in the browser, and how the page updates without a full
|
|
6
|
+
navigation. Read this when the task involves:
|
|
7
|
+
|
|
8
|
+
- Marking a component for client-side hydration with `clientEntry`
|
|
9
|
+
- Booting the client runtime with `run`
|
|
10
|
+
- Streaming server content into a region of the page with `<Frame>` and reloading those regions
|
|
11
|
+
- Triggering Navigation API transitions with `navigate(...)` or `link(...)`
|
|
12
|
+
- Server rendering with `renderToStream` or `renderToString`
|
|
13
|
+
- Managing the document `<head>`
|
|
14
|
+
|
|
15
|
+
For component-local state and updates, see `component-model.md`. For host-element behavior and
|
|
16
|
+
events, see `mixins-styling-events.md`.
|
|
17
|
+
|
|
18
|
+
## Server First, Then Hydrate
|
|
19
|
+
|
|
20
|
+
Make the server route correct before adding `clientEntry(...)`. A POST should already do the right
|
|
21
|
+
thing on its own — return HTML, a redirect, or an error response — and a GET should already render
|
|
22
|
+
the page the user expects. `clientEntry` exists to layer interactivity on top of UI that already
|
|
23
|
+
works without it.
|
|
24
|
+
|
|
25
|
+
When server state changes after a mutation, prefer reloading a `<Frame>` when the UI region already
|
|
26
|
+
maps cleanly to a server-rendered route. Frames re-fetch the same route, so the rendering logic
|
|
27
|
+
stays in one place and the client does not need a parallel "state" API.
|
|
28
|
+
|
|
29
|
+
```tsx
|
|
30
|
+
on('submit', async (event, signal) => {
|
|
31
|
+
event.preventDefault()
|
|
32
|
+
await fetch(routes.cart.add.href(), {
|
|
33
|
+
method: 'POST',
|
|
34
|
+
body: new FormData(event.currentTarget),
|
|
35
|
+
signal,
|
|
36
|
+
})
|
|
37
|
+
if (signal.aborted) return
|
|
38
|
+
await handle.frames.get('cart-summary')?.reload()
|
|
39
|
+
})
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Use polling or a small JSON state endpoint when the data changes outside this page, or when a tiny
|
|
43
|
+
shared widget would be heavier to model as a frame. Pick the lightest sync mechanism that preserves
|
|
44
|
+
clear ownership of rendering logic.
|
|
45
|
+
|
|
46
|
+
## Client Entries
|
|
47
|
+
|
|
48
|
+
Use `clientEntry` to mark a component for client-side hydration. In source-served apps, prefer the
|
|
49
|
+
source module's `import.meta.url` as the entry ID and let server rendering map it to the public
|
|
50
|
+
asset URL:
|
|
51
|
+
|
|
52
|
+
```tsx
|
|
53
|
+
import { clientEntry, on, type Handle } from 'remix/ui'
|
|
54
|
+
|
|
55
|
+
export const Counter = clientEntry(
|
|
56
|
+
import.meta.url,
|
|
57
|
+
function Counter(handle: Handle<{ initialCount: number; label: string }>) {
|
|
58
|
+
let count = handle.props.initialCount
|
|
59
|
+
|
|
60
|
+
return () => (
|
|
61
|
+
<div>
|
|
62
|
+
<span>
|
|
63
|
+
{handle.props.label}: {count}
|
|
64
|
+
</span>
|
|
65
|
+
<button
|
|
66
|
+
mix={on('click', () => {
|
|
67
|
+
count++
|
|
68
|
+
handle.update()
|
|
69
|
+
})}
|
|
70
|
+
>
|
|
71
|
+
+
|
|
72
|
+
</button>
|
|
73
|
+
</div>
|
|
74
|
+
)
|
|
75
|
+
},
|
|
76
|
+
)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
On the server, provide `resolveClientEntry` to `renderToStream(...)` so source file URLs become
|
|
80
|
+
browser-loadable asset URLs. Keep this resolution in the render helper so component modules do not
|
|
81
|
+
hard-code deployment-specific asset paths:
|
|
82
|
+
|
|
83
|
+
```tsx
|
|
84
|
+
let stream = renderToStream(<App />, {
|
|
85
|
+
async resolveClientEntry(entryId, component) {
|
|
86
|
+
let exportName = entryId.split('#')[1] || component.name
|
|
87
|
+
if (!exportName) {
|
|
88
|
+
throw new Error(`Unable to resolve client entry export for ${entryId}`)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
href: await assetServer.getHref(entryId),
|
|
93
|
+
exportName,
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
})
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
If the module export name differs from the component function name, include `#ExportName` in the
|
|
100
|
+
entry ID or return the exact export name from `resolveClientEntry`. A render helper that only
|
|
101
|
+
supports source-owned entries can also fail fast when `entryId` is not a `file://` URL.
|
|
102
|
+
|
|
103
|
+
On the server, `clientEntry` components render like any other component. The server wraps their
|
|
104
|
+
output in comment markers and serializes props into a `<script type="application/json">` tag.
|
|
105
|
+
|
|
106
|
+
Client entry props must be serializable: strings, numbers, booleans, `null`, `undefined`, plain
|
|
107
|
+
objects/arrays of the above, JSX elements, and `<Frame>` elements. Functions and class instances
|
|
108
|
+
cannot be passed.
|
|
109
|
+
|
|
110
|
+
## Booting the Client
|
|
111
|
+
|
|
112
|
+
Use `run` to start the client runtime. It scans the document for client entry markers, loads
|
|
113
|
+
modules, and hydrates each one:
|
|
114
|
+
|
|
115
|
+
```tsx
|
|
116
|
+
import { run } from 'remix/ui'
|
|
117
|
+
|
|
118
|
+
let app = run({
|
|
119
|
+
async loadModule(moduleUrl, exportName) {
|
|
120
|
+
let mod = await import(moduleUrl)
|
|
121
|
+
return mod[exportName]
|
|
122
|
+
},
|
|
123
|
+
async resolveFrame(src, signal, target) {
|
|
124
|
+
let headers = new Headers({ accept: 'text/html' })
|
|
125
|
+
if (target) headers.set('x-remix-target', target)
|
|
126
|
+
let response = await fetch(src, { headers, signal })
|
|
127
|
+
return response.body ?? (await response.text())
|
|
128
|
+
},
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
app.addEventListener('error', (event) => {
|
|
132
|
+
console.error('Component error:', event.error)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
await app.ready()
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### `run` options
|
|
139
|
+
|
|
140
|
+
- **`loadModule(moduleUrl, exportName)`** (required) — return the component function for each
|
|
141
|
+
client entry. Typically uses dynamic `import()`.
|
|
142
|
+
- **`resolveFrame(src, signal, target)`** (optional) — called when a `<Frame>` loads or reloads
|
|
143
|
+
content. `target` is available when frame targeting matters.
|
|
144
|
+
|
|
145
|
+
### `app` methods
|
|
146
|
+
|
|
147
|
+
- **`app.ready()`** — resolves when all initial client entries are hydrated
|
|
148
|
+
- **`app.flush()`** — synchronously flushes all pending updates
|
|
149
|
+
- **`app.dispose()`** — tears down all hydrated components
|
|
150
|
+
|
|
151
|
+
`app` is an `EventTarget` that emits `error` events from any hydrated component.
|
|
152
|
+
|
|
153
|
+
## Frames
|
|
154
|
+
|
|
155
|
+
A `<Frame>` renders server content into the page. Frames stream after the initial HTML, nest inside
|
|
156
|
+
other frames, contain client entries, and can be reloaded without full page navigation.
|
|
157
|
+
|
|
158
|
+
```tsx
|
|
159
|
+
import { Frame } from 'remix/ui'
|
|
160
|
+
|
|
161
|
+
function App() {
|
|
162
|
+
return () => (
|
|
163
|
+
<div>
|
|
164
|
+
<Frame src="/sidebar" fallback={<div>Loading...</div>} />
|
|
165
|
+
<Frame name="main" src="/main-content" />
|
|
166
|
+
</div>
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
### Frame props
|
|
172
|
+
|
|
173
|
+
- **`src`** (required) — URL to fetch the frame content from
|
|
174
|
+
- **`fallback`** (optional) — content to show while loading; determines streaming behavior
|
|
175
|
+
- **`name`** (optional) — registers the frame for lookup via `handle.frames.get(name)`
|
|
176
|
+
- **`on`** (optional) — event handlers for events dispatched from the frame element
|
|
177
|
+
|
|
178
|
+
### Blocking vs non-blocking
|
|
179
|
+
|
|
180
|
+
- **Without `fallback`** (blocking) — the server waits for frame content before sending the initial
|
|
181
|
+
HTML chunk
|
|
182
|
+
- **With `fallback`** (non-blocking) — the fallback renders immediately; real content streams in
|
|
183
|
+
later and replaces it
|
|
184
|
+
|
|
185
|
+
### Reloading frames
|
|
186
|
+
|
|
187
|
+
Client entries inside a frame can trigger a reload:
|
|
188
|
+
|
|
189
|
+
```tsx
|
|
190
|
+
// Reload the containing frame
|
|
191
|
+
handle.frame.reload()
|
|
192
|
+
|
|
193
|
+
// Reload an adjacent named frame
|
|
194
|
+
await handle.frames.get('cart-summary')?.reload()
|
|
195
|
+
|
|
196
|
+
// Reload the entire page/frame tree
|
|
197
|
+
handle.frames.top.reload()
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
When a frame reloads, matching DOM nodes are updated in place. Client entries receive updated props
|
|
201
|
+
while preserving their local component state.
|
|
202
|
+
|
|
203
|
+
### Nested frames
|
|
204
|
+
|
|
205
|
+
Frames can nest. Each frame owns its own DOM region and hydrates client entries independently.
|
|
206
|
+
During SSR, `handle.frame.src` points at the frame being rendered, while
|
|
207
|
+
`handle.frames.top.src` stays fixed at the outer document URL.
|
|
208
|
+
|
|
209
|
+
## Server Rendering
|
|
210
|
+
|
|
211
|
+
### `renderToStream`
|
|
212
|
+
|
|
213
|
+
Renders a component tree to a `ReadableStream<Uint8Array>`. Sends initial HTML immediately and
|
|
214
|
+
streams frame content as it resolves:
|
|
215
|
+
|
|
216
|
+
```tsx
|
|
217
|
+
import { renderToStream } from 'remix/ui/server'
|
|
218
|
+
|
|
219
|
+
let stream = renderToStream(<App />, {
|
|
220
|
+
frameSrc: request.url,
|
|
221
|
+
resolveFrame(src, target, context) {
|
|
222
|
+
let frameUrl = new URL(src, context?.currentFrameSrc ?? request.url)
|
|
223
|
+
return fetchHtml(frameUrl)
|
|
224
|
+
},
|
|
225
|
+
onError(error) {
|
|
226
|
+
console.error(error)
|
|
227
|
+
},
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
return new Response(stream, {
|
|
231
|
+
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
232
|
+
})
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Options:
|
|
236
|
+
|
|
237
|
+
- **`frameSrc`** — seeds SSR frame state; populates `handle.frame.src` and `handle.frames.top.src`
|
|
238
|
+
- **`topFrameSrc`** — overrides the root frame URL for nested frame renders (carry forward from
|
|
239
|
+
`resolveFrame` context)
|
|
240
|
+
- **`resolveFrame(src, target, context)`** — return HTML string, `ReadableStream<Uint8Array>`, or a
|
|
241
|
+
promise of either. `context.currentFrameSrc` is the containing frame URL; `context.topFrameSrc`
|
|
242
|
+
is the outer document URL
|
|
243
|
+
- **`onError(error)`** — called on rendering errors
|
|
244
|
+
|
|
245
|
+
### `renderToString`
|
|
246
|
+
|
|
247
|
+
Renders a component tree to a complete HTML string. Use for static pages or embedding HTML:
|
|
248
|
+
|
|
249
|
+
```tsx
|
|
250
|
+
import { renderToString } from 'remix/ui/server'
|
|
251
|
+
let html = await renderToString(<App />)
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
### CSS in SSR
|
|
255
|
+
|
|
256
|
+
Components using the `css` mixin have styles collected during rendering and emitted as a single
|
|
257
|
+
`<style>` tag in `<head>`. No client-side style injection needed.
|
|
258
|
+
|
|
259
|
+
## Navigation
|
|
260
|
+
|
|
261
|
+
Use real anchors for normal document navigation. For app-driven navigation:
|
|
262
|
+
|
|
263
|
+
- `navigate(href, options?)` — performs a Navigation API transition
|
|
264
|
+
- `link(href, options?)` mixin — makes any element behave like a navigation link
|
|
265
|
+
|
|
266
|
+
```tsx
|
|
267
|
+
import { navigate } from 'remix/ui'
|
|
268
|
+
navigate('/dashboard', { history: 'replace' })
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
Options: `src`, `target`, `history` (`'push' | 'replace'`), `resetScroll`.
|
|
272
|
+
|
|
273
|
+
Attributes understood by the runtime: `rmx-target`, `rmx-src`, `rmx-document`.
|
|
274
|
+
|
|
275
|
+
## Head Management
|
|
276
|
+
|
|
277
|
+
Manage document head with an explicit `<head>` in your document structure:
|
|
278
|
+
|
|
279
|
+
```tsx
|
|
280
|
+
function App() {
|
|
281
|
+
return () => (
|
|
282
|
+
<html>
|
|
283
|
+
<head>
|
|
284
|
+
<title>Dashboard</title>
|
|
285
|
+
<meta name="description" content="Team dashboard" />
|
|
286
|
+
<link rel="stylesheet" href="/styles/app.css" />
|
|
287
|
+
</head>
|
|
288
|
+
<body>
|
|
289
|
+
<main>...</main>
|
|
290
|
+
</body>
|
|
291
|
+
</html>
|
|
292
|
+
)
|
|
293
|
+
}
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
Put `title`, `meta`, `link`, and `style` tags inside an explicit `<head>`. Bare head-like tags
|
|
297
|
+
rendered outside `<head>` stay where they are — they are not moved into the document head for you.
|