@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,391 @@
|
|
|
1
|
+
# Routing and Controllers
|
|
2
|
+
|
|
3
|
+
## What This Covers
|
|
4
|
+
|
|
5
|
+
Patterns for declaring URLs, handling requests, and wiring routes to controllers. Read this when
|
|
6
|
+
the task involves:
|
|
7
|
+
|
|
8
|
+
- Defining or changing the URL surface of the app
|
|
9
|
+
- Writing or reorganizing controllers and actions
|
|
10
|
+
- Reading request data (`params`, `url`, `request`, context values)
|
|
11
|
+
- Returning a `Response` for HTML, redirects, JSON, or errors
|
|
12
|
+
- Generating internal URLs with `.href()`
|
|
13
|
+
|
|
14
|
+
The companion reference for shaping `Request` bodies, validating input, and dealing with persisted
|
|
15
|
+
data is `data-and-validation.md`. For request lifecycle and middleware ordering, see
|
|
16
|
+
`middleware-and-server.md`.
|
|
17
|
+
|
|
18
|
+
## Route Builders
|
|
19
|
+
|
|
20
|
+
Import all route builders from `remix/routes`.
|
|
21
|
+
|
|
22
|
+
### `route(prefix, map)` — nested route group
|
|
23
|
+
|
|
24
|
+
Adds a URL prefix to all children. Can also be called as `route(map)` without a prefix for a
|
|
25
|
+
top-level grouping. Inside `route(...)`, a nested map may be either a `route('prefix', { ... })`
|
|
26
|
+
call (when you want a shared URL prefix) or a plain object literal (when each leaf already owns
|
|
27
|
+
its absolute path).
|
|
28
|
+
|
|
29
|
+
```typescript
|
|
30
|
+
import { route, get, post } from 'remix/routes'
|
|
31
|
+
|
|
32
|
+
export const routes = route({
|
|
33
|
+
home: '/',
|
|
34
|
+
|
|
35
|
+
// Plain object — no shared prefix, each leaf has an absolute path.
|
|
36
|
+
books: {
|
|
37
|
+
index: '/books',
|
|
38
|
+
show: '/books/:slug',
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
// route('auth', ...) — every leaf is prefixed with /auth.
|
|
42
|
+
auth: route('auth', {
|
|
43
|
+
login: get('login'),
|
|
44
|
+
logout: post('logout'),
|
|
45
|
+
}),
|
|
46
|
+
})
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Leaf route builders
|
|
50
|
+
|
|
51
|
+
| Builder | HTTP method | Example |
|
|
52
|
+
| -------------- | ----------- | -------------------- |
|
|
53
|
+
| `get(path)` | GET | `get('/search')` |
|
|
54
|
+
| `post(path)` | POST | `post('/logout')` |
|
|
55
|
+
| `put(path)` | PUT | `put('/api/update')` |
|
|
56
|
+
| `del(path)` | DELETE | `del('/api/remove')` |
|
|
57
|
+
| String literal | ANY | `'/about'` |
|
|
58
|
+
|
|
59
|
+
### `form(path, options?)` — form route
|
|
60
|
+
|
|
61
|
+
Creates a GET + POST pair for HTML form workflows. Expands to an `index` (GET) and an `action`
|
|
62
|
+
(POST) by default.
|
|
63
|
+
|
|
64
|
+
```typescript
|
|
65
|
+
contact: form('contact')
|
|
66
|
+
// Produces routes.contact.index (GET /contact) and routes.contact.action (POST /contact)
|
|
67
|
+
|
|
68
|
+
settings: form('settings', { formMethod: 'PUT', names: { action: 'update' } })
|
|
69
|
+
// Produces routes.settings.index (GET) and routes.settings.update (PUT)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### `resources(name, options?)` — REST resources
|
|
73
|
+
|
|
74
|
+
Expands to conventional CRUD routes: `index`, `new`, `create`, `show`, `edit`, `update`, `destroy`.
|
|
75
|
+
|
|
76
|
+
```typescript
|
|
77
|
+
books: resources('books', { param: 'bookId' })
|
|
78
|
+
// GET /books, GET /books/new, POST /books, GET /books/:bookId, ...
|
|
79
|
+
|
|
80
|
+
orders: resources('orders', { only: ['index', 'show'], param: 'orderId' })
|
|
81
|
+
// GET /orders, GET /orders/:orderId
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### URL generation with `.href()`
|
|
85
|
+
|
|
86
|
+
Route objects expose `.href()` for type-safe URL generation:
|
|
87
|
+
|
|
88
|
+
```typescript
|
|
89
|
+
redirect(routes.home.href())
|
|
90
|
+
redirect(routes.account.orders.show.href({ orderId: '42' }))
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Actions
|
|
94
|
+
|
|
95
|
+
An action is the handler for one leaf route. In Remix app code, actions should live in controllers.
|
|
96
|
+
Use `Action` only when a reusable helper needs to type one action before it is added to a
|
|
97
|
+
controller or when you are doing low-level router wiring outside the `app/actions` convention:
|
|
98
|
+
|
|
99
|
+
```typescript
|
|
100
|
+
import { createAction } from 'remix/router'
|
|
101
|
+
|
|
102
|
+
import { routes } from '../routes.ts'
|
|
103
|
+
|
|
104
|
+
export const search = createAction(routes.search, {
|
|
105
|
+
async handler({ url }) {
|
|
106
|
+
let query = url.searchParams.get('q') ?? ''
|
|
107
|
+
let results = await searchIndex(query)
|
|
108
|
+
return render(<SearchPage query={query} results={results} />)
|
|
109
|
+
},
|
|
110
|
+
})
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The handler receives a context object with:
|
|
114
|
+
|
|
115
|
+
- `get(key)` — read a value set by middleware (e.g. `get(Database)`, `get(Session)`, `get(Auth)`)
|
|
116
|
+
- `params` — typed route params
|
|
117
|
+
- `url` — the request URL
|
|
118
|
+
- `request` — the raw `Request`
|
|
119
|
+
|
|
120
|
+
Actions with inline middleware:
|
|
121
|
+
|
|
122
|
+
```typescript
|
|
123
|
+
import { requireAuth } from 'remix/middleware/auth'
|
|
124
|
+
|
|
125
|
+
router.get(routes.account.index, {
|
|
126
|
+
middleware: [requireAuth()],
|
|
127
|
+
handler: accountAction.handler,
|
|
128
|
+
})
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Returning Responses
|
|
132
|
+
|
|
133
|
+
An action returns a `Response`. The shape of that response is part of the route contract, and
|
|
134
|
+
choosing it well saves a lot of glue elsewhere.
|
|
135
|
+
|
|
136
|
+
### Render HTML
|
|
137
|
+
|
|
138
|
+
For pages, render a component tree and return the resulting `Response`:
|
|
139
|
+
|
|
140
|
+
```typescript
|
|
141
|
+
async handler({ get }) {
|
|
142
|
+
let db = get(Database)
|
|
143
|
+
let books = await db.findMany(books, { orderBy: ['id', 'asc'] })
|
|
144
|
+
return render(<IndexPage books={books} />)
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### Redirect after a mutation
|
|
149
|
+
|
|
150
|
+
For state-changing routes (POST, PUT, PATCH, DELETE), the canonical reply is a redirect to the
|
|
151
|
+
resulting page. Pass `303` explicitly when you want a POST-redirect-GET flow:
|
|
152
|
+
|
|
153
|
+
```typescript
|
|
154
|
+
import { redirect } from 'remix/response/redirect'
|
|
155
|
+
|
|
156
|
+
async create({ get }) {
|
|
157
|
+
let formData = get(FormData)
|
|
158
|
+
let parsed = s.parseSafe(bookSchema, formData)
|
|
159
|
+
if (!parsed.success) {
|
|
160
|
+
return render(<NewBookPage errors={parsed.issues} />, { status: 400 })
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
let db = get(Database)
|
|
164
|
+
let book = await db.create(books, parsed.value)
|
|
165
|
+
|
|
166
|
+
return redirect(routes.books.show.href({ slug: book.slug }), 303)
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
This pattern works without JavaScript and stays compatible with `clientEntry(...)` enhancements
|
|
171
|
+
on top.
|
|
172
|
+
|
|
173
|
+
### Return an error response
|
|
174
|
+
|
|
175
|
+
For expected failures — validation, conflict, not found — return a `Response` directly. Reserve
|
|
176
|
+
thrown errors for genuinely unexpected failures.
|
|
177
|
+
|
|
178
|
+
```typescript
|
|
179
|
+
async show({ get, params }) {
|
|
180
|
+
let db = get(Database)
|
|
181
|
+
let book = await db.find(books, params.bookId)
|
|
182
|
+
if (!book) return new Response('Not Found', { status: 404 })
|
|
183
|
+
return render(<ShowPage book={book} />)
|
|
184
|
+
}
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
For form re-rendering with errors, return the page component with the parsed issues:
|
|
188
|
+
|
|
189
|
+
```typescript
|
|
190
|
+
let formData = get(FormData)
|
|
191
|
+
let parsed = s.parseSafe(signupSchema, formData)
|
|
192
|
+
if (!parsed.success) {
|
|
193
|
+
return render(<SignupPage errors={parsed.issues} values={Object.fromEntries(formData)} />, {
|
|
194
|
+
status: 400,
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
### Return JSON
|
|
200
|
+
|
|
201
|
+
For routes consumed by client code rather than rendered as a page (autocomplete endpoints, polling
|
|
202
|
+
APIs, inter-service calls), return a JSON `Response`. Use `SuperHeaders` from `remix/headers` when
|
|
203
|
+
typed header accessors make the response clearer:
|
|
204
|
+
|
|
205
|
+
```typescript
|
|
206
|
+
import Headers from 'remix/headers'
|
|
207
|
+
|
|
208
|
+
let headers = new Headers()
|
|
209
|
+
headers.contentType = { mediaType: 'application/json', charset: 'utf-8' }
|
|
210
|
+
headers.cacheControl = { noStore: true }
|
|
211
|
+
|
|
212
|
+
return new Response(JSON.stringify({ results }), {
|
|
213
|
+
headers,
|
|
214
|
+
})
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
If you find yourself returning JSON for what is really a browser form submission, prefer the
|
|
218
|
+
redirect-after-POST pattern instead. JSON-only mutation endpoints make it harder to support
|
|
219
|
+
non-JS clients, harder to share rendering logic, and easier for the client to drift out of sync
|
|
220
|
+
with the server.
|
|
221
|
+
|
|
222
|
+
## Controllers
|
|
223
|
+
|
|
224
|
+
A controller owns the direct leaf routes in one route map. Each key in `actions` matches a direct
|
|
225
|
+
leaf route key in the route definition passed to `router.map(...)`. Nested route-map keys do not
|
|
226
|
+
belong inside a controller's `actions`; map those route maps with their own controllers.
|
|
227
|
+
|
|
228
|
+
Configure `RouterTypes.context` with your app context in the router module, then use
|
|
229
|
+
`createController()` so `get(Database)`, `get(Session)`, `get(Auth)`, etc. are typed against your
|
|
230
|
+
middleware stack without repeating a type clause on every controller.
|
|
231
|
+
|
|
232
|
+
```typescript
|
|
233
|
+
import { createController } from 'remix/router'
|
|
234
|
+
|
|
235
|
+
import { routes } from '../routes.ts'
|
|
236
|
+
|
|
237
|
+
export default createController(routes.books, {
|
|
238
|
+
actions: {
|
|
239
|
+
async index({ get }) {
|
|
240
|
+
let db = get(Database)
|
|
241
|
+
let items = await db.findMany(books, { orderBy: ['id', 'asc'] })
|
|
242
|
+
return render(<IndexPage items={items} />)
|
|
243
|
+
},
|
|
244
|
+
|
|
245
|
+
async show({ get, params }) {
|
|
246
|
+
let db = get(Database)
|
|
247
|
+
let book = await db.find(books, params.bookId)
|
|
248
|
+
if (!book) return new Response('Not Found', { status: 404 })
|
|
249
|
+
return render(<ShowPage book={book} />)
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
})
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
### Root controller
|
|
256
|
+
|
|
257
|
+
The root route map uses `app/actions/controller.tsx` and owns only top-level leaf routes:
|
|
258
|
+
|
|
259
|
+
```typescript
|
|
260
|
+
// routes.ts
|
|
261
|
+
export const routes = route({
|
|
262
|
+
assets: get('/assets/*path'),
|
|
263
|
+
home: '/',
|
|
264
|
+
account: route('account', {
|
|
265
|
+
index: '/',
|
|
266
|
+
settings: form('settings', { formMethod: 'PUT', names: { action: 'update' } }),
|
|
267
|
+
}),
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
// app/actions/controller.tsx
|
|
271
|
+
export default createController(routes, {
|
|
272
|
+
actions: {
|
|
273
|
+
async assets({ request }) {
|
|
274
|
+
return (await assetServer.fetch(request)) ?? new Response('Not Found', { status: 404 })
|
|
275
|
+
},
|
|
276
|
+
home() {
|
|
277
|
+
return render(<HomePage />)
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
})
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
Because `account` is a nested route map, it is not an action key in the root controller.
|
|
284
|
+
|
|
285
|
+
### Nested route maps
|
|
286
|
+
|
|
287
|
+
Nested route maps use their own controllers under `app/actions/<route-key>/controller.tsx`.
|
|
288
|
+
Directory names under `app/actions/` are route-map keys, not URL path segments.
|
|
289
|
+
|
|
290
|
+
```typescript
|
|
291
|
+
// app/actions/account/controller.tsx
|
|
292
|
+
export default createController(routes.account, {
|
|
293
|
+
middleware: [requireAuth()],
|
|
294
|
+
actions: {
|
|
295
|
+
index() {
|
|
296
|
+
return render(<AccountPage />)
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
// app/actions/account/settings/controller.tsx
|
|
302
|
+
export default createController(routes.account.settings, {
|
|
303
|
+
middleware: [requireAuth()],
|
|
304
|
+
actions: {
|
|
305
|
+
index() {
|
|
306
|
+
return render(<SettingsPage />)
|
|
307
|
+
},
|
|
308
|
+
update() {
|
|
309
|
+
return redirect(routes.account.index.href(), 303)
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
})
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
Then map each route map explicitly:
|
|
316
|
+
|
|
317
|
+
```typescript
|
|
318
|
+
import rootController from './actions/controller.tsx'
|
|
319
|
+
import accountController from './actions/account/controller.tsx'
|
|
320
|
+
import accountSettingsController from './actions/account/settings/controller.tsx'
|
|
321
|
+
|
|
322
|
+
let router = createRouter({ middleware })
|
|
323
|
+
|
|
324
|
+
router.map(routes, rootController)
|
|
325
|
+
router.map(routes.account, accountController)
|
|
326
|
+
router.map(routes.account.settings, accountSettingsController)
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
### Controller middleware
|
|
330
|
+
|
|
331
|
+
The `middleware` array on a controller runs only for the direct actions in that controller, before
|
|
332
|
+
action-level middleware. It does not apply to other controllers.
|
|
333
|
+
|
|
334
|
+
```typescript
|
|
335
|
+
export default createController(routes.admin, {
|
|
336
|
+
middleware: [requireAuth(), requireAdmin()],
|
|
337
|
+
actions: {
|
|
338
|
+
/* all actions require auth + admin */
|
|
339
|
+
},
|
|
340
|
+
})
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
## Registering Routes
|
|
344
|
+
|
|
345
|
+
Use `router.map` for route maps and controllers. Map each nested route map explicitly. Use verb
|
|
346
|
+
methods only for low-level router wiring outside the `app/actions` controller convention.
|
|
347
|
+
|
|
348
|
+
```typescript
|
|
349
|
+
let router = createRouter({ middleware })
|
|
350
|
+
|
|
351
|
+
// Route maps → controllers
|
|
352
|
+
router.map(routes, rootController)
|
|
353
|
+
router.map(routes.contact, contactController)
|
|
354
|
+
router.map(routes.auth, authController)
|
|
355
|
+
router.map(routes.auth.login, authLoginController)
|
|
356
|
+
router.map(routes.admin, adminController)
|
|
357
|
+
router.map(routes.admin.books, adminBooksController)
|
|
358
|
+
|
|
359
|
+
// Leaf route → one-off action
|
|
360
|
+
router.get(routes.search, searchAction)
|
|
361
|
+
router.post(routes.logout, logoutAction)
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
## Typed Context
|
|
365
|
+
|
|
366
|
+
Define an `AppContext` type from your middleware stack, then make it the default context used by
|
|
367
|
+
`createAction()` and `createController()`:
|
|
368
|
+
|
|
369
|
+
```typescript
|
|
370
|
+
import type { MiddlewareContext, ContextWithParams, AnyParams } from 'remix/router'
|
|
371
|
+
|
|
372
|
+
type RootMiddleware = [
|
|
373
|
+
ReturnType<typeof formData>,
|
|
374
|
+
ReturnType<typeof session>,
|
|
375
|
+
ReturnType<typeof loadDatabase>,
|
|
376
|
+
ReturnType<typeof loadAuth>,
|
|
377
|
+
]
|
|
378
|
+
|
|
379
|
+
export type AppContext<params extends AnyParams = {}> = ContextWithParams<
|
|
380
|
+
MiddlewareContext<RootMiddleware>,
|
|
381
|
+
params
|
|
382
|
+
>
|
|
383
|
+
|
|
384
|
+
declare module 'remix/router' {
|
|
385
|
+
interface RouterTypes {
|
|
386
|
+
context: AppContext
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
This gives typed `context.get(Database)`, `context.get(Session)`, `context.get(Auth)`, etc.
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# Testing
|
|
2
|
+
|
|
3
|
+
## What This Covers
|
|
4
|
+
|
|
5
|
+
How to test the two layers most Remix code lives in: HTTP behavior and DOM behavior. Read this when
|
|
6
|
+
the task involves:
|
|
7
|
+
|
|
8
|
+
- Driving the router with `router.fetch(new Request(...))` and asserting on the returned `Response`
|
|
9
|
+
- Building a fresh router per test for session, storage, or database isolation
|
|
10
|
+
- Rendering components into a real DOM with `render(...)` or `createRoot(...)`
|
|
11
|
+
- Configuring `remix test` discovery, excludes, and coverage
|
|
12
|
+
- Using adjacent CLI checks such as `remix routes`, `remix doctor`, and `remix version`
|
|
13
|
+
- Choosing which layer to test for a given behavior
|
|
14
|
+
|
|
15
|
+
For session and auth test setup, see `auth-and-sessions.md`. For component lifecycle, see
|
|
16
|
+
`component-model.md`.
|
|
17
|
+
|
|
18
|
+
## Two Shapes
|
|
19
|
+
|
|
20
|
+
Remix tests run with `remix test`, use `remix/test` for the test framework, and use
|
|
21
|
+
`remix/assert` for assertions. Two main shapes:
|
|
22
|
+
|
|
23
|
+
- **Server / router tests** — drive the router with `router.fetch(new Request(...))` and assert
|
|
24
|
+
on the returned `Response`. No DOM, no browser harness.
|
|
25
|
+
- **Component tests** — render a component into a real DOM `Element` with `render(...)`, or use
|
|
26
|
+
`createRoot(...)` directly when you need lower-level root control.
|
|
27
|
+
|
|
28
|
+
## Server / Router Tests
|
|
29
|
+
|
|
30
|
+
Treat the router as a pure `(Request) => Promise<Response>` function. Build a fresh app router
|
|
31
|
+
per test (or per suite) so middleware state — sessions, in-memory storage, the database — stays
|
|
32
|
+
isolated.
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
import * as assert from 'remix/assert'
|
|
36
|
+
import { describe, it } from 'remix/test'
|
|
37
|
+
|
|
38
|
+
import { createBookstoreRouter } from '../app/router.ts'
|
|
39
|
+
import { routes } from '../app/routes.ts'
|
|
40
|
+
|
|
41
|
+
describe('home', () => {
|
|
42
|
+
it('responds 200 with the home page', async () => {
|
|
43
|
+
let router = createBookstoreRouter()
|
|
44
|
+
let response = await router.fetch(new Request('http://localhost' + routes.home.href()))
|
|
45
|
+
|
|
46
|
+
assert.equal(response.status, 200)
|
|
47
|
+
assert.match(await response.text(), /Welcome to the Bookstore/)
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Use `routes.<name>.href(...)` to build URLs in tests so they stay in sync with the route
|
|
53
|
+
definition. For form-style POSTs, attach a `FormData` body to the `Request`. For tests that need
|
|
54
|
+
a known session, swap in `createMemorySessionStorage()` and a test cookie when constructing the
|
|
55
|
+
router.
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
import { createMemorySessionStorage } from 'remix/session-storage/memory'
|
|
59
|
+
import { createCookie } from 'remix/cookie'
|
|
60
|
+
|
|
61
|
+
let router = createBookstoreRouter({
|
|
62
|
+
sessionCookie: createCookie('session', { secrets: ['test'] }),
|
|
63
|
+
sessionStorage: createMemorySessionStorage(),
|
|
64
|
+
})
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Use `createTestServer` from `remix/node-fetch-server/test` when the behavior depends on a real
|
|
68
|
+
HTTP origin, redirects, streaming, cookies through a network boundary, or browser-style `fetch`:
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
import { createTestServer } from 'remix/node-fetch-server/test'
|
|
72
|
+
|
|
73
|
+
let server = await createTestServer((request) => router.fetch(request))
|
|
74
|
+
try {
|
|
75
|
+
let response = await fetch(new URL(routes.home.href(), server.baseUrl))
|
|
76
|
+
assert.equal(response.status, 200)
|
|
77
|
+
} finally {
|
|
78
|
+
await server.close()
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Test Runner Config
|
|
83
|
+
|
|
84
|
+
Configure discovery and coverage in `remix-test.config.ts` or with CLI flags:
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
export default {
|
|
88
|
+
glob: {
|
|
89
|
+
test: '**/*.test{,.e2e}.{ts,tsx}',
|
|
90
|
+
e2e: '**/*.test.e2e.{ts,tsx}',
|
|
91
|
+
exclude: 'node_modules/**',
|
|
92
|
+
},
|
|
93
|
+
coverage: {
|
|
94
|
+
dir: '.coverage',
|
|
95
|
+
include: ['app/**/*.{ts,tsx}'],
|
|
96
|
+
exclude: ['app/**/*.test.{ts,tsx}'],
|
|
97
|
+
statements: 80,
|
|
98
|
+
lines: 80,
|
|
99
|
+
branches: 70,
|
|
100
|
+
functions: 80,
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Use `remix test --coverage` to enable coverage with defaults. Use `glob.exclude` when discovery
|
|
106
|
+
would otherwise enter generated output, symlinked workspaces, or other paths that should not
|
|
107
|
+
produce tests.
|
|
108
|
+
|
|
109
|
+
## Component Tests
|
|
110
|
+
|
|
111
|
+
Use `render(...)` from `remix/ui/test` for most component tests. It creates a real DOM container,
|
|
112
|
+
flushes the initial render, and returns `act(...)` so interactions can flush pending updates before
|
|
113
|
+
assertions. Use `createRoot(container)` from `remix/ui` directly when a test needs explicit control
|
|
114
|
+
over root rendering, flushing, or disposal.
|
|
115
|
+
|
|
116
|
+
### Basic pattern
|
|
117
|
+
|
|
118
|
+
```tsx
|
|
119
|
+
import * as assert from 'remix/assert'
|
|
120
|
+
import { render } from 'remix/ui/test'
|
|
121
|
+
|
|
122
|
+
let result = render(<Counter />)
|
|
123
|
+
|
|
124
|
+
let button = result.$('button')!
|
|
125
|
+
await result.act(() => button.click())
|
|
126
|
+
|
|
127
|
+
assert.match(result.container.textContent ?? '', /1/)
|
|
128
|
+
result.cleanup()
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Why act / flush
|
|
132
|
+
|
|
133
|
+
- **After initial render** — ensures event listeners are attached and the DOM is ready for
|
|
134
|
+
interaction.
|
|
135
|
+
- **After interactions** — applies updates from `handle.update()` calls triggered by events.
|
|
136
|
+
- **After async work resolves** — applies updates from resolved `queueTask(...)` callbacks.
|
|
137
|
+
|
|
138
|
+
### Async operations
|
|
139
|
+
|
|
140
|
+
For components with async operations in `queueTask`, use `act(...)` after each async step:
|
|
141
|
+
|
|
142
|
+
```tsx
|
|
143
|
+
let result = render(<AsyncLoader />)
|
|
144
|
+
|
|
145
|
+
assert.equal(result.container.textContent, 'Loading...')
|
|
146
|
+
|
|
147
|
+
await waitForFetch()
|
|
148
|
+
await result.act(() => {})
|
|
149
|
+
|
|
150
|
+
assert.equal(result.container.textContent, 'Expected data')
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Component removal
|
|
154
|
+
|
|
155
|
+
Use `result.cleanup()` or `root.dispose()` to remove the component tree and verify cleanup
|
|
156
|
+
behavior:
|
|
157
|
+
|
|
158
|
+
```tsx
|
|
159
|
+
let result = render(<MyComponent />)
|
|
160
|
+
|
|
161
|
+
assert.ok(result.$('.content'))
|
|
162
|
+
|
|
163
|
+
result.cleanup()
|
|
164
|
+
assert.throws(() => result.$('.content'), /cleaned up/)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### Guidelines
|
|
168
|
+
|
|
169
|
+
- Prefer real DOM interactions over mocking framework behavior.
|
|
170
|
+
- Avoid testing implementation-only markers unless they are the only stable synchronization point.
|
|
171
|
+
- One representative flow proving a behavior is better than repeating the same assertion across many
|
|
172
|
+
paths.
|
|
@@ -11,25 +11,30 @@ npm test
|
|
|
11
11
|
npm run typecheck
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
+
## Building Features
|
|
15
|
+
|
|
16
|
+
Refer to ./.agents/skills/remix/SKILL.md
|
|
17
|
+
|
|
14
18
|
## Starter Layout
|
|
15
19
|
|
|
16
|
-
- `app/
|
|
17
|
-
- `app/controllers/auth.tsx` owns the auth page
|
|
20
|
+
- `app/actions/controller.tsx` owns the top-level route actions
|
|
18
21
|
- `app/routes.ts` defines the route contract
|
|
19
22
|
- `app/router.ts` wires routes to route handlers
|
|
20
|
-
- `app/
|
|
21
|
-
- `app/
|
|
23
|
+
- `app/middleware/render.tsx` installs the request-scoped renderer used by actions
|
|
24
|
+
- `app/ui/` holds the shared document shell and home page UI
|
|
25
|
+
- `app/assets.ts` owns the server-side asset pipeline used by the asset route and renderer
|
|
26
|
+
- `public/` contains static files served from the app root
|
|
22
27
|
|
|
23
28
|
## Route Ownership
|
|
24
29
|
|
|
25
30
|
- Start from `app/routes.ts` and map each route to the narrowest owner on disk.
|
|
26
|
-
-
|
|
27
|
-
-
|
|
31
|
+
- Put top-level route actions in `app/actions/controller.tsx`.
|
|
32
|
+
- Add `app/actions/<route-key>/controller.tsx` for nested route maps that need their own actions or middleware.
|
|
28
33
|
- Keep route-owned page modules next to the route that owns them.
|
|
29
|
-
- Move shared UI to `app/ui/`, not `app/
|
|
34
|
+
- Move shared UI to `app/ui/`, not `app/actions/`.
|
|
30
35
|
|
|
31
36
|
## Build-Out Notes
|
|
32
37
|
|
|
33
|
-
- This starter intentionally begins small; add directories like `app/data
|
|
38
|
+
- This starter intentionally begins small; add directories like `app/data/` and `test/` only when you need them.
|
|
34
39
|
- Prefer putting code in the narrowest owner before introducing shared modules.
|
|
35
40
|
- Avoid generic dumping-ground directories like `app/lib/` or `app/components/`.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# %%RMX_APP_DISPLAY_NAME%%
|
|
2
|
+
|
|
3
|
+
A minimal Remix application starter with a home page.
|
|
4
|
+
|
|
5
|
+
## Starter Shape
|
|
6
|
+
|
|
7
|
+
- `app/actions/controller.tsx` owns the top-level route actions.
|
|
8
|
+
- `app/routes.ts` defines the route contract.
|
|
9
|
+
- `app/router.ts` wires routes to handlers.
|
|
10
|
+
- `app/middleware/render.tsx` installs the request-scoped renderer used by actions.
|
|
11
|
+
- `app/ui/` holds the shared document shell and home page UI.
|
|
12
|
+
- `app/assets.ts` owns the server-side asset pipeline used by the asset route and renderer.
|
|
13
|
+
- `public/` contains static files served from the app root.
|
|
14
|
+
|
|
15
|
+
## Growing The App
|
|
16
|
+
|
|
17
|
+
- Put top-level route actions in `app/actions/controller.tsx`.
|
|
18
|
+
- Add `app/actions/<route-key>/controller.tsx` when a nested route map needs its own actions or middleware.
|
|
19
|
+
- Add directories like `app/data/` or `test/` when the app actually needs them.
|
|
20
|
+
- Move shared UI into `app/ui/` once more than one route needs it.
|
|
21
|
+
|
|
22
|
+
## Commands
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
npm i
|
|
26
|
+
npm run start
|
|
27
|
+
npm test
|
|
28
|
+
npm run typecheck
|
|
29
|
+
```
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { createController } from 'remix/router'
|
|
2
|
+
|
|
3
|
+
import { assetServer } from '../assets.ts'
|
|
4
|
+
import { routes } from '../routes.ts'
|
|
5
|
+
import { HomePage } from '../ui/scaffold-home-page.tsx'
|
|
6
|
+
|
|
7
|
+
export default createController(routes, {
|
|
8
|
+
actions: {
|
|
9
|
+
async assets(context) {
|
|
10
|
+
return (
|
|
11
|
+
(await assetServer.fetch(context.request)) ?? new Response('Not Found', { status: 404 })
|
|
12
|
+
)
|
|
13
|
+
},
|
|
14
|
+
home(context) {
|
|
15
|
+
return context.render(<HomePage />)
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
})
|