@remix-run/cli 0.0.0 → 0.2.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/LICENSE +21 -0
- package/README.md +77 -3
- package/bootstrap/.agents/skills/remix/SKILL.md +501 -0
- package/bootstrap/.agents/skills/remix/references/animate-elements.md +195 -0
- package/bootstrap/.agents/skills/remix/references/assets-and-browser-modules.md +122 -0
- package/bootstrap/.agents/skills/remix/references/auth-and-sessions.md +420 -0
- package/bootstrap/.agents/skills/remix/references/component-model.md +282 -0
- package/bootstrap/.agents/skills/remix/references/create-mixins.md +158 -0
- package/bootstrap/.agents/skills/remix/references/data-and-validation.md +363 -0
- package/bootstrap/.agents/skills/remix/references/hydration-frames-navigation.md +297 -0
- package/bootstrap/.agents/skills/remix/references/middleware-and-server.md +243 -0
- package/bootstrap/.agents/skills/remix/references/mixins-styling-events.md +213 -0
- package/bootstrap/.agents/skills/remix/references/routing-and-controllers.md +324 -0
- package/bootstrap/.agents/skills/remix/references/testing-patterns.md +156 -0
- package/bootstrap/AGENTS.md +39 -0
- package/bootstrap/README.md +27 -0
- package/bootstrap/app/assets/entry.ts +19 -0
- package/bootstrap/app/assets.ts +18 -0
- package/bootstrap/app/controllers/auth.tsx +21 -0
- package/bootstrap/app/controllers/home.tsx +11 -0
- package/bootstrap/app/router.ts +16 -0
- package/bootstrap/app/routes.ts +7 -0
- package/bootstrap/app/ui/document.tsx +26 -0
- package/bootstrap/app/ui/layout.tsx +22 -0
- package/bootstrap/app/ui/prompt-button.tsx +162 -0
- package/bootstrap/app/ui/scaffold-home-page.tsx +526 -0
- package/bootstrap/app/utils/render.tsx +26 -0
- package/bootstrap/package.json +22 -0
- package/bootstrap/server.ts +37 -0
- package/bootstrap/tsconfig.json +18 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1 -0
- package/dist/lib/bootstrap-project.d.ts +17 -0
- package/dist/lib/bootstrap-project.d.ts.map +1 -0
- package/dist/lib/bootstrap-project.js +137 -0
- package/dist/lib/cli-context.d.ts +11 -0
- package/dist/lib/cli-context.d.ts.map +1 -0
- package/dist/lib/cli-context.js +19 -0
- package/dist/lib/cli.d.ts +6 -0
- package/dist/lib/cli.d.ts.map +1 -0
- package/dist/lib/cli.js +84 -0
- package/dist/lib/commands/completion.d.ts +3 -0
- package/dist/lib/commands/completion.d.ts.map +1 -0
- package/dist/lib/commands/completion.js +51 -0
- package/dist/lib/commands/doctor.d.ts +4 -0
- package/dist/lib/commands/doctor.d.ts.map +1 -0
- package/dist/lib/commands/doctor.js +293 -0
- package/dist/lib/commands/help.d.ts +4 -0
- package/dist/lib/commands/help.d.ts.map +1 -0
- package/dist/lib/commands/help.js +100 -0
- package/dist/lib/commands/new.d.ts +4 -0
- package/dist/lib/commands/new.d.ts.map +1 -0
- package/dist/lib/commands/new.js +70 -0
- package/dist/lib/commands/routes.d.ts +4 -0
- package/dist/lib/commands/routes.d.ts.map +1 -0
- package/dist/lib/commands/routes.js +161 -0
- package/dist/lib/commands/test.d.ts +4 -0
- package/dist/lib/commands/test.d.ts.map +1 -0
- package/dist/lib/commands/test.js +23 -0
- package/dist/lib/commands/version.d.ts +4 -0
- package/dist/lib/commands/version.d.ts.map +1 -0
- package/dist/lib/commands/version.js +29 -0
- package/dist/lib/completion.d.ts +12 -0
- package/dist/lib/completion.d.ts.map +1 -0
- package/dist/lib/completion.js +310 -0
- package/dist/lib/contained-path.d.ts +2 -0
- package/dist/lib/contained-path.d.ts.map +1 -0
- package/dist/lib/contained-path.js +10 -0
- package/dist/lib/controller-files.d.ts +14 -0
- package/dist/lib/controller-files.d.ts.map +1 -0
- package/dist/lib/controller-files.js +53 -0
- package/dist/lib/controller-ownership.d.ts +41 -0
- package/dist/lib/controller-ownership.d.ts.map +1 -0
- package/dist/lib/controller-ownership.js +224 -0
- package/dist/lib/display-path.d.ts +2 -0
- package/dist/lib/display-path.d.ts.map +1 -0
- package/dist/lib/display-path.js +12 -0
- package/dist/lib/doctor/controller-findings.d.ts +4 -0
- package/dist/lib/doctor/controller-findings.d.ts.map +1 -0
- package/dist/lib/doctor/controller-findings.js +132 -0
- package/dist/lib/doctor/controller-fix-plans.d.ts +4 -0
- package/dist/lib/doctor/controller-fix-plans.d.ts.map +1 -0
- package/dist/lib/doctor/controller-fix-plans.js +130 -0
- package/dist/lib/doctor/controller-placeholders.d.ts +4 -0
- package/dist/lib/doctor/controller-placeholders.d.ts.map +1 -0
- package/dist/lib/doctor/controller-placeholders.js +183 -0
- package/dist/lib/doctor/controllers.d.ts +8 -0
- package/dist/lib/doctor/controllers.d.ts.map +1 -0
- package/dist/lib/doctor/controllers.js +13 -0
- package/dist/lib/doctor/environment.d.ts +17 -0
- package/dist/lib/doctor/environment.d.ts.map +1 -0
- package/dist/lib/doctor/environment.js +226 -0
- package/dist/lib/doctor/fixes.d.ts +3 -0
- package/dist/lib/doctor/fixes.d.ts.map +1 -0
- package/dist/lib/doctor/fixes.js +65 -0
- package/dist/lib/doctor/project.d.ts +10 -0
- package/dist/lib/doctor/project.d.ts.map +1 -0
- package/dist/lib/doctor/project.js +371 -0
- package/dist/lib/doctor/types.d.ts +48 -0
- package/dist/lib/doctor/types.d.ts.map +1 -0
- package/dist/lib/doctor/types.js +15 -0
- package/dist/lib/errors.d.ts +167 -0
- package/dist/lib/errors.d.ts.map +1 -0
- package/dist/lib/errors.js +307 -0
- package/dist/lib/help-text.d.ts +13 -0
- package/dist/lib/help-text.d.ts.map +1 -0
- package/dist/lib/help-text.js +43 -0
- package/dist/lib/load-route-map-worker.d.ts +2 -0
- package/dist/lib/load-route-map-worker.d.ts.map +1 -0
- package/dist/lib/load-route-map-worker.js +92 -0
- package/dist/lib/parse-args.d.ts +22 -0
- package/dist/lib/parse-args.d.ts.map +1 -0
- package/dist/lib/parse-args.js +35 -0
- package/dist/lib/remix-version.d.ts +2 -0
- package/dist/lib/remix-version.d.ts.map +1 -0
- package/dist/lib/remix-version.js +58 -0
- package/dist/lib/reporter.d.ts +56 -0
- package/dist/lib/reporter.d.ts.map +1 -0
- package/dist/lib/reporter.js +357 -0
- package/dist/lib/route-map.d.ts +38 -0
- package/dist/lib/route-map.d.ts.map +1 -0
- package/dist/lib/route-map.js +195 -0
- package/dist/lib/terminal.d.ts +17 -0
- package/dist/lib/terminal.d.ts.map +1 -0
- package/dist/lib/terminal.js +66 -0
- package/package.json +47 -5
- package/src/index.ts +1 -0
- package/src/lib/bootstrap-project.ts +222 -0
- package/src/lib/cli-context.ts +38 -0
- package/src/lib/cli.ts +116 -0
- package/src/lib/commands/completion.ts +74 -0
- package/src/lib/commands/doctor.ts +393 -0
- package/src/lib/commands/help.ts +123 -0
- package/src/lib/commands/new.ts +104 -0
- package/src/lib/commands/routes.ts +251 -0
- package/src/lib/commands/test.ts +31 -0
- package/src/lib/commands/version.ts +39 -0
- package/src/lib/completion.ts +418 -0
- package/src/lib/contained-path.ts +13 -0
- package/src/lib/controller-files.ts +79 -0
- package/src/lib/controller-ownership.ts +397 -0
- package/src/lib/display-path.ts +16 -0
- package/src/lib/doctor/controller-findings.ts +157 -0
- package/src/lib/doctor/controller-fix-plans.ts +184 -0
- package/src/lib/doctor/controller-placeholders.ts +242 -0
- package/src/lib/doctor/controllers.ts +23 -0
- package/src/lib/doctor/environment.ts +289 -0
- package/src/lib/doctor/fixes.ts +84 -0
- package/src/lib/doctor/project.ts +435 -0
- package/src/lib/doctor/types.ts +95 -0
- package/src/lib/errors.ts +367 -0
- package/src/lib/help-text.ts +88 -0
- package/src/lib/load-route-map-worker.ts +110 -0
- package/src/lib/parse-args.ts +82 -0
- package/src/lib/remix-version.ts +80 -0
- package/src/lib/reporter.ts +535 -0
- package/src/lib/route-map.ts +303 -0
- package/src/lib/terminal.ts +86 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# Mixins, Styling, and Events
|
|
2
|
+
|
|
3
|
+
## What This Covers
|
|
4
|
+
|
|
5
|
+
How to attach behavior, styles, and DOM-aware setup to host elements with `mix`. Read this when the
|
|
6
|
+
task involves:
|
|
7
|
+
|
|
8
|
+
- DOM event handling with `on(...)`
|
|
9
|
+
- Static styling with `css(...)` and dynamic styling with `style`
|
|
10
|
+
- Imperative DOM access via `ref(...)`
|
|
11
|
+
- Navigation behavior on non-anchor elements with `link(...)`
|
|
12
|
+
- Native click, pointer, and keyboard behavior with `on(...)`, plus attributes with `attrs(...)`
|
|
13
|
+
- Element-level animation mixins from `remix/ui/animation`
|
|
14
|
+
|
|
15
|
+
For richer animation work (springs, tweens, layout transitions), see `animate-elements.md`. For
|
|
16
|
+
authoring custom mixins, see `create-mixins.md`. For component lifecycle and updates, see
|
|
17
|
+
`component-model.md`.
|
|
18
|
+
|
|
19
|
+
Compose behavior on host elements with `mix`. Pass a single mixin directly (`mix={on(...)}`), or
|
|
20
|
+
an array when composing multiple mixins (`mix={[css(...), on(...)]}`). Core mixins are imported
|
|
21
|
+
from `remix/ui`; animation mixins are imported from `remix/ui/animation`.
|
|
22
|
+
|
|
23
|
+
## `on(type, handler, capture?)`
|
|
24
|
+
|
|
25
|
+
Attaches a typed DOM event handler. The handler receives the event and an `AbortSignal` that aborts
|
|
26
|
+
when the handler is re-entered or the component is removed — this prevents race conditions:
|
|
27
|
+
|
|
28
|
+
```tsx
|
|
29
|
+
<input
|
|
30
|
+
mix={on('input', async (event, signal) => {
|
|
31
|
+
let query = event.currentTarget.value
|
|
32
|
+
loading = true
|
|
33
|
+
handle.update()
|
|
34
|
+
|
|
35
|
+
let response = await fetch(`/search?q=${query}`, { signal })
|
|
36
|
+
let data = await response.json()
|
|
37
|
+
if (signal.aborted) return
|
|
38
|
+
|
|
39
|
+
results = data.results
|
|
40
|
+
loading = false
|
|
41
|
+
handle.update()
|
|
42
|
+
})}
|
|
43
|
+
/>
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Multiple events on the same element:
|
|
47
|
+
|
|
48
|
+
```tsx
|
|
49
|
+
<form
|
|
50
|
+
mix={on('submit', (event) => {
|
|
51
|
+
event.preventDefault()
|
|
52
|
+
let formData = new FormData(event.currentTarget)
|
|
53
|
+
})}
|
|
54
|
+
>
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## `css(styles)`
|
|
58
|
+
|
|
59
|
+
Applies generated class names for CSS object styles. Produces static CSS rules inserted into the
|
|
60
|
+
document. Supports pseudo-selectors, pseudo-elements, attribute selectors, descendant selectors, and
|
|
61
|
+
media queries using `&` to reference the current element:
|
|
62
|
+
|
|
63
|
+
```tsx
|
|
64
|
+
<button
|
|
65
|
+
mix={css({
|
|
66
|
+
color: 'white',
|
|
67
|
+
backgroundColor: 'blue',
|
|
68
|
+
padding: '12px 24px',
|
|
69
|
+
borderRadius: '4px',
|
|
70
|
+
border: 'none',
|
|
71
|
+
cursor: 'pointer',
|
|
72
|
+
'&:hover': { backgroundColor: 'darkblue' },
|
|
73
|
+
'&:active': { transform: 'scale(0.98)' },
|
|
74
|
+
'&:disabled': { opacity: 0.5, cursor: 'not-allowed' },
|
|
75
|
+
'& .title': { fontSize: '20px', fontWeight: 'bold' },
|
|
76
|
+
'@media (max-width: 768px)': { width: '100%' },
|
|
77
|
+
})}
|
|
78
|
+
/>
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### `css(...)` vs `style` prop
|
|
82
|
+
|
|
83
|
+
Use `css(...)` for static styles, selectors, and media queries. Use `style` for dynamic values that
|
|
84
|
+
change often. Prefer CSS nested selectors for parent-state-affects-children over managing hover/focus
|
|
85
|
+
state in JavaScript:
|
|
86
|
+
|
|
87
|
+
```tsx
|
|
88
|
+
<div
|
|
89
|
+
mix={css({
|
|
90
|
+
backgroundColor: 'blue', // static
|
|
91
|
+
'&:hover': { '& .title': { color: 'blue' } }, // parent hover → child
|
|
92
|
+
})}
|
|
93
|
+
style={{ width: `${progress}%` }} // dynamic
|
|
94
|
+
/>
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## `ref(callback)`
|
|
98
|
+
|
|
99
|
+
Calls a callback when an element is inserted. The callback receives the DOM node and an
|
|
100
|
+
`AbortSignal` that aborts when the element is removed:
|
|
101
|
+
|
|
102
|
+
```tsx
|
|
103
|
+
<input mix={ref((node) => node.focus())} />
|
|
104
|
+
|
|
105
|
+
<div mix={ref((node, signal) => {
|
|
106
|
+
let observer = new ResizeObserver((entries) => {
|
|
107
|
+
dimensions.width = Math.round(entries[0].contentRect.width)
|
|
108
|
+
handle.update()
|
|
109
|
+
})
|
|
110
|
+
observer.observe(node)
|
|
111
|
+
signal.addEventListener('abort', () => observer.disconnect())
|
|
112
|
+
})} />
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
The `ref` callback runs once when the element is first rendered, not on every update.
|
|
116
|
+
|
|
117
|
+
## `link(href, options?)`
|
|
118
|
+
|
|
119
|
+
Adds client-side navigation behavior to any element. Makes non-anchor elements behave like Remix
|
|
120
|
+
navigation links:
|
|
121
|
+
|
|
122
|
+
```tsx
|
|
123
|
+
<article mix={link('/courses/intro')}>
|
|
124
|
+
<h3>Introduction</h3>
|
|
125
|
+
</article>
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Options match `NavigationOptions`: `src`, `target`, `history` (`'push' | 'replace'`),
|
|
129
|
+
`resetScroll`.
|
|
130
|
+
|
|
131
|
+
## Native press and keyboard interactions
|
|
132
|
+
|
|
133
|
+
Use native DOM events directly with `on(...)`. For buttons and links, `click` already includes
|
|
134
|
+
keyboard activation when the element has the right semantics:
|
|
135
|
+
|
|
136
|
+
```tsx
|
|
137
|
+
<button mix={on('click', () => doAction())}>Action</button>
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
For gesture-specific behavior, compose the pointer or keyboard events the interaction actually
|
|
141
|
+
needs:
|
|
142
|
+
|
|
143
|
+
```tsx
|
|
144
|
+
<button
|
|
145
|
+
mix={[
|
|
146
|
+
on('pointerdown', (event) => {
|
|
147
|
+
event.currentTarget.setPointerCapture(event.pointerId)
|
|
148
|
+
}),
|
|
149
|
+
on('pointerup', () => doAction()),
|
|
150
|
+
]}
|
|
151
|
+
>
|
|
152
|
+
Action
|
|
153
|
+
</button>
|
|
154
|
+
|
|
155
|
+
<div
|
|
156
|
+
tabIndex={0}
|
|
157
|
+
mix={on('keydown', (event) => {
|
|
158
|
+
if (event.key === 'Escape') close()
|
|
159
|
+
if (event.key === 'Enter' || event.key === ' ') doAction()
|
|
160
|
+
})}
|
|
161
|
+
/>
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## `attrs()`
|
|
165
|
+
|
|
166
|
+
Sets HTML attributes through the mixin system.
|
|
167
|
+
|
|
168
|
+
## Animation Mixins
|
|
169
|
+
|
|
170
|
+
### `animateEntrance(config)`
|
|
171
|
+
|
|
172
|
+
Animates an element when it is inserted into the DOM. Config specifies the **starting** style:
|
|
173
|
+
|
|
174
|
+
```tsx
|
|
175
|
+
<div mix={animateEntrance({ opacity: 0, transform: 'translateY(8px)', duration: 180 })} />
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### `animateExit(config)`
|
|
179
|
+
|
|
180
|
+
Animates an element when it is removed. Config specifies the **ending** style. The element is kept
|
|
181
|
+
in the DOM until the animation completes:
|
|
182
|
+
|
|
183
|
+
```tsx
|
|
184
|
+
{
|
|
185
|
+
isVisible && (
|
|
186
|
+
<div
|
|
187
|
+
key="panel"
|
|
188
|
+
mix={[
|
|
189
|
+
animateEntrance({ opacity: 0, transform: 'scale(0.98)', ...spring('smooth') }),
|
|
190
|
+
animateExit({ opacity: 0, duration: 120, easing: 'ease-in' }),
|
|
191
|
+
]}
|
|
192
|
+
/>
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
### `animateLayout(config?)`
|
|
198
|
+
|
|
199
|
+
Animates layout changes (position/size) using FLIP-style transforms:
|
|
200
|
+
|
|
201
|
+
```tsx
|
|
202
|
+
{
|
|
203
|
+
items.map((item) => (
|
|
204
|
+
<li key={item.id} mix={animateLayout({ duration: 220, easing: 'ease-out' })} />
|
|
205
|
+
))
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Options: `duration` (default 200ms), `easing` (default spring snappy), `size` (boolean, default
|
|
210
|
+
true — include scale projection for size changes).
|
|
211
|
+
|
|
212
|
+
Always key elements you expect to animate. Use `...spring(preset)` to spread `duration` and
|
|
213
|
+
`easing` into any animation config.
|
|
@@ -0,0 +1,324 @@
|
|
|
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/fetch-router/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/fetch-router/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 a handler for a single leaf route. Type it with `BuildAction`:
|
|
96
|
+
|
|
97
|
+
```typescript
|
|
98
|
+
import type { BuildAction } from 'remix/fetch-router'
|
|
99
|
+
|
|
100
|
+
export const search: BuildAction<'GET', typeof routes.search> = {
|
|
101
|
+
async handler({ url }) {
|
|
102
|
+
let query = url.searchParams.get('q') ?? ''
|
|
103
|
+
let results = await searchIndex(query)
|
|
104
|
+
return render(<SearchPage query={query} results={results} />)
|
|
105
|
+
},
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The handler receives a context object with:
|
|
110
|
+
|
|
111
|
+
- `get(key)` — read a value set by middleware (e.g. `get(Database)`, `get(Session)`, `get(Auth)`)
|
|
112
|
+
- `params` — typed route params
|
|
113
|
+
- `url` — the request URL
|
|
114
|
+
- `request` — the raw `Request`
|
|
115
|
+
|
|
116
|
+
Actions with inline middleware:
|
|
117
|
+
|
|
118
|
+
```typescript
|
|
119
|
+
import { requireAuth } from 'remix/auth-middleware'
|
|
120
|
+
|
|
121
|
+
router.get(routes.account, {
|
|
122
|
+
middleware: [requireAuth()],
|
|
123
|
+
handler: accountAction.handler,
|
|
124
|
+
})
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Returning Responses
|
|
128
|
+
|
|
129
|
+
An action returns a `Response`. The shape of that response is part of the route contract, and
|
|
130
|
+
choosing it well saves a lot of glue elsewhere.
|
|
131
|
+
|
|
132
|
+
### Render HTML
|
|
133
|
+
|
|
134
|
+
For pages, render a component tree and return the resulting `Response`:
|
|
135
|
+
|
|
136
|
+
```typescript
|
|
137
|
+
async handler({ get }) {
|
|
138
|
+
let db = get(Database)
|
|
139
|
+
let books = await db.findMany(books, { orderBy: ['id', 'asc'] })
|
|
140
|
+
return render(<IndexPage books={books} />)
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### Redirect after a mutation
|
|
145
|
+
|
|
146
|
+
For state-changing routes (POST, PUT, PATCH, DELETE), the canonical reply is a redirect to the
|
|
147
|
+
resulting page. Pass `303` explicitly when you want a POST-redirect-GET flow:
|
|
148
|
+
|
|
149
|
+
```typescript
|
|
150
|
+
import { redirect } from 'remix/response/redirect'
|
|
151
|
+
|
|
152
|
+
async create({ get }) {
|
|
153
|
+
let formData = get(FormData)
|
|
154
|
+
let parsed = s.parseSafe(bookSchema, formData)
|
|
155
|
+
if (!parsed.success) {
|
|
156
|
+
return render(<NewBookPage errors={parsed.issues} />, { status: 400 })
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let db = get(Database)
|
|
160
|
+
let book = await db.create(books, parsed.value)
|
|
161
|
+
|
|
162
|
+
return redirect(routes.books.show.href({ slug: book.slug }), 303)
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
This pattern works without JavaScript and stays compatible with `clientEntry(...)` enhancements
|
|
167
|
+
on top.
|
|
168
|
+
|
|
169
|
+
### Return an error response
|
|
170
|
+
|
|
171
|
+
For expected failures — validation, conflict, not found — return a `Response` directly. Reserve
|
|
172
|
+
thrown errors for genuinely unexpected failures.
|
|
173
|
+
|
|
174
|
+
```typescript
|
|
175
|
+
async show({ get, params }) {
|
|
176
|
+
let db = get(Database)
|
|
177
|
+
let book = await db.find(books, params.bookId)
|
|
178
|
+
if (!book) return new Response('Not Found', { status: 404 })
|
|
179
|
+
return render(<ShowPage book={book} />)
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
For form re-rendering with errors, return the page component with the parsed issues:
|
|
184
|
+
|
|
185
|
+
```typescript
|
|
186
|
+
let formData = get(FormData)
|
|
187
|
+
let parsed = s.parseSafe(signupSchema, formData)
|
|
188
|
+
if (!parsed.success) {
|
|
189
|
+
return render(<SignupPage errors={parsed.issues} values={Object.fromEntries(formData)} />, {
|
|
190
|
+
status: 400,
|
|
191
|
+
})
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
### Return JSON
|
|
196
|
+
|
|
197
|
+
For routes consumed by client code rather than rendered as a page (autocomplete endpoints, polling
|
|
198
|
+
APIs, inter-service calls), return a JSON `Response`. Use `remix/headers` for cache headers
|
|
199
|
+
instead of hand-formatting strings:
|
|
200
|
+
|
|
201
|
+
```typescript
|
|
202
|
+
import { CacheControl } from 'remix/headers'
|
|
203
|
+
|
|
204
|
+
return new Response(JSON.stringify({ results }), {
|
|
205
|
+
headers: {
|
|
206
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
207
|
+
'Cache-Control': new CacheControl({ noStore: true }).toString(),
|
|
208
|
+
},
|
|
209
|
+
})
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
If you find yourself returning JSON for what is really a browser form submission, prefer the
|
|
213
|
+
redirect-after-POST pattern instead. JSON-only mutation endpoints make it harder to support
|
|
214
|
+
non-JS clients, harder to share rendering logic, and easier for the client to drift out of sync
|
|
215
|
+
with the server.
|
|
216
|
+
|
|
217
|
+
## Controllers
|
|
218
|
+
|
|
219
|
+
A controller mirrors a route map. Each key in `actions` matches a key in the route definition.
|
|
220
|
+
Pass `AppContext` as the second generic to `Controller` so `get(Database)`, `get(Session)`,
|
|
221
|
+
`get(Auth)`, etc. are typed against your middleware stack.
|
|
222
|
+
|
|
223
|
+
```typescript
|
|
224
|
+
import type { Controller } from 'remix/fetch-router'
|
|
225
|
+
import type { AppContext } from '../router.ts'
|
|
226
|
+
|
|
227
|
+
export default {
|
|
228
|
+
actions: {
|
|
229
|
+
async index({ get }) {
|
|
230
|
+
let db = get(Database)
|
|
231
|
+
let items = await db.findMany(books, { orderBy: ['id', 'asc'] })
|
|
232
|
+
return render(<IndexPage items={items} />)
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
async show({ get, params }) {
|
|
236
|
+
let db = get(Database)
|
|
237
|
+
let book = await db.find(books, params.bookId)
|
|
238
|
+
if (!book) return new Response('Not Found', { status: 404 })
|
|
239
|
+
return render(<ShowPage book={book} />)
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
} satisfies Controller<typeof routes.books, AppContext>
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### Nested controllers
|
|
246
|
+
|
|
247
|
+
When a route map contains nested maps, the controller nests too:
|
|
248
|
+
|
|
249
|
+
```typescript
|
|
250
|
+
// routes.ts
|
|
251
|
+
export const routes = route({
|
|
252
|
+
account: route('account', {
|
|
253
|
+
index: '/',
|
|
254
|
+
settings: form('settings', { formMethod: 'PUT', names: { action: 'update' } }),
|
|
255
|
+
orders: resources('orders', { only: ['index', 'show'], param: 'orderId' }),
|
|
256
|
+
}),
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
// controllers/account/controller.tsx
|
|
260
|
+
import settingsController from './settings/controller.tsx'
|
|
261
|
+
import ordersController from './orders/controller.tsx'
|
|
262
|
+
|
|
263
|
+
export default {
|
|
264
|
+
middleware: [requireAuth()],
|
|
265
|
+
actions: {
|
|
266
|
+
index() { return render(<AccountPage />) },
|
|
267
|
+
settings: settingsController,
|
|
268
|
+
orders: ordersController,
|
|
269
|
+
},
|
|
270
|
+
} satisfies Controller<typeof routes.account, AppContext>
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
### Controller middleware
|
|
274
|
+
|
|
275
|
+
The `middleware` array on a controller runs for every action in that subtree, before action-level
|
|
276
|
+
middleware:
|
|
277
|
+
|
|
278
|
+
```typescript
|
|
279
|
+
export default {
|
|
280
|
+
middleware: [requireAuth(), requireAdmin()],
|
|
281
|
+
actions: {
|
|
282
|
+
/* all actions require auth + admin */
|
|
283
|
+
},
|
|
284
|
+
} satisfies Controller<typeof routes.admin, AppContext>
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
## Registering Routes
|
|
288
|
+
|
|
289
|
+
Use `router.map` for route maps (controllers) and verb methods for leaf routes:
|
|
290
|
+
|
|
291
|
+
```typescript
|
|
292
|
+
let router = createRouter({ middleware })
|
|
293
|
+
|
|
294
|
+
// Route map → controller
|
|
295
|
+
router.map(routes.auth, authController)
|
|
296
|
+
router.map(routes.admin, adminController)
|
|
297
|
+
|
|
298
|
+
// Leaf route → action
|
|
299
|
+
router.map(routes.home, home)
|
|
300
|
+
router.get(routes.search, searchAction)
|
|
301
|
+
router.post(routes.logout, logoutAction)
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
## Typed Context
|
|
305
|
+
|
|
306
|
+
Define an `AppContext` type from your middleware stack for use in actions and controllers:
|
|
307
|
+
|
|
308
|
+
```typescript
|
|
309
|
+
import type { MiddlewareContext, WithParams, AnyParams } from 'remix/fetch-router'
|
|
310
|
+
|
|
311
|
+
type RootMiddleware = [
|
|
312
|
+
ReturnType<typeof formData>,
|
|
313
|
+
ReturnType<typeof session>,
|
|
314
|
+
ReturnType<typeof loadDatabase>,
|
|
315
|
+
ReturnType<typeof loadAuth>,
|
|
316
|
+
]
|
|
317
|
+
|
|
318
|
+
export type AppContext<params extends AnyParams = AnyParams> = WithParams<
|
|
319
|
+
MiddlewareContext<RootMiddleware>,
|
|
320
|
+
params
|
|
321
|
+
>
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
This gives typed `context.get(Database)`, `context.get(Session)`, `context.get(Auth)`, etc.
|
|
@@ -0,0 +1,156 @@
|
|
|
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
|
+
- Choosing which layer to test for a given behavior
|
|
13
|
+
|
|
14
|
+
For session and auth test setup, see `auth-and-sessions.md`. For component lifecycle, see
|
|
15
|
+
`component-model.md`.
|
|
16
|
+
|
|
17
|
+
## Two Shapes
|
|
18
|
+
|
|
19
|
+
Remix tests run with `remix test`, use `remix/test` for the test framework, and use
|
|
20
|
+
`remix/assert` for assertions. Two main shapes:
|
|
21
|
+
|
|
22
|
+
- **Server / router tests** — drive the router with `router.fetch(new Request(...))` and assert
|
|
23
|
+
on the returned `Response`. No DOM, no browser harness.
|
|
24
|
+
- **Component tests** — render a component into a real DOM `Element` with `render(...)`, or use
|
|
25
|
+
`createRoot(...)` directly when you need lower-level root control.
|
|
26
|
+
|
|
27
|
+
## Server / Router Tests
|
|
28
|
+
|
|
29
|
+
Treat the router as a pure `(Request) => Promise<Response>` function. Build a fresh app router
|
|
30
|
+
per test (or per suite) so middleware state — sessions, in-memory storage, the database — stays
|
|
31
|
+
isolated.
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
import * as assert from 'remix/assert'
|
|
35
|
+
import { describe, it } from 'remix/test'
|
|
36
|
+
|
|
37
|
+
import { createBookstoreRouter } from '../app/router.ts'
|
|
38
|
+
import { routes } from '../app/routes.ts'
|
|
39
|
+
|
|
40
|
+
describe('home', () => {
|
|
41
|
+
it('responds 200 with the home page', async () => {
|
|
42
|
+
let router = createBookstoreRouter()
|
|
43
|
+
let response = await router.fetch(new Request('http://localhost' + routes.home.href()))
|
|
44
|
+
|
|
45
|
+
assert.equal(response.status, 200)
|
|
46
|
+
assert.match(await response.text(), /Welcome to the Bookstore/)
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Use `routes.<name>.href(...)` to build URLs in tests so they stay in sync with the route
|
|
52
|
+
definition. For form-style POSTs, attach a `FormData` body to the `Request`. For tests that need
|
|
53
|
+
a known session, swap in `createMemorySessionStorage()` and a test cookie when constructing the
|
|
54
|
+
router.
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
import { createMemorySessionStorage } from 'remix/session/memory-storage'
|
|
58
|
+
import { createCookie } from 'remix/cookie'
|
|
59
|
+
|
|
60
|
+
let router = createBookstoreRouter({
|
|
61
|
+
sessionCookie: createCookie('session', { secrets: ['test'] }),
|
|
62
|
+
sessionStorage: createMemorySessionStorage(),
|
|
63
|
+
})
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Test Runner Config
|
|
67
|
+
|
|
68
|
+
Configure discovery and coverage in `remix-test.config.ts` or with CLI flags:
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
export default {
|
|
72
|
+
glob: {
|
|
73
|
+
test: '**/*.test{,.e2e}.{ts,tsx}',
|
|
74
|
+
e2e: '**/*.test.e2e.{ts,tsx}',
|
|
75
|
+
exclude: 'node_modules/**',
|
|
76
|
+
},
|
|
77
|
+
coverage: {
|
|
78
|
+
dir: '.coverage',
|
|
79
|
+
include: ['app/**/*.{ts,tsx}'],
|
|
80
|
+
exclude: ['app/**/*.test.{ts,tsx}'],
|
|
81
|
+
statements: 80,
|
|
82
|
+
lines: 80,
|
|
83
|
+
branches: 70,
|
|
84
|
+
functions: 80,
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Use `remix test --coverage` to enable coverage with defaults. Use `glob.exclude` when discovery
|
|
90
|
+
would otherwise enter generated output, symlinked workspaces, or other paths that should not
|
|
91
|
+
produce tests.
|
|
92
|
+
|
|
93
|
+
## Component Tests
|
|
94
|
+
|
|
95
|
+
Use `render(...)` from `remix/ui/test` for most component tests. It creates a real DOM container,
|
|
96
|
+
flushes the initial render, and returns `act(...)` so interactions can flush pending updates before
|
|
97
|
+
assertions. Use `createRoot(container)` from `remix/ui` directly when a test needs explicit control
|
|
98
|
+
over root rendering, flushing, or disposal.
|
|
99
|
+
|
|
100
|
+
### Basic pattern
|
|
101
|
+
|
|
102
|
+
```tsx
|
|
103
|
+
import * as assert from 'remix/assert'
|
|
104
|
+
import { render } from 'remix/ui/test'
|
|
105
|
+
|
|
106
|
+
let result = render(<Counter />)
|
|
107
|
+
|
|
108
|
+
let button = result.$('button')!
|
|
109
|
+
await result.act(() => button.click())
|
|
110
|
+
|
|
111
|
+
assert.match(result.container.textContent ?? '', /1/)
|
|
112
|
+
result.cleanup()
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Why act / flush
|
|
116
|
+
|
|
117
|
+
- **After initial render** — ensures event listeners are attached and the DOM is ready for
|
|
118
|
+
interaction.
|
|
119
|
+
- **After interactions** — applies updates from `handle.update()` calls triggered by events.
|
|
120
|
+
- **After async work resolves** — applies updates from resolved `queueTask(...)` callbacks.
|
|
121
|
+
|
|
122
|
+
### Async operations
|
|
123
|
+
|
|
124
|
+
For components with async operations in `queueTask`, use `act(...)` after each async step:
|
|
125
|
+
|
|
126
|
+
```tsx
|
|
127
|
+
let result = render(<AsyncLoader />)
|
|
128
|
+
|
|
129
|
+
assert.equal(result.container.textContent, 'Loading...')
|
|
130
|
+
|
|
131
|
+
await waitForFetch()
|
|
132
|
+
await result.act(() => {})
|
|
133
|
+
|
|
134
|
+
assert.equal(result.container.textContent, 'Expected data')
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Component removal
|
|
138
|
+
|
|
139
|
+
Use `result.cleanup()` or `root.dispose()` to remove the component tree and verify cleanup
|
|
140
|
+
behavior:
|
|
141
|
+
|
|
142
|
+
```tsx
|
|
143
|
+
let result = render(<MyComponent />)
|
|
144
|
+
|
|
145
|
+
assert.ok(result.$('.content'))
|
|
146
|
+
|
|
147
|
+
result.cleanup()
|
|
148
|
+
assert.throws(() => result.$('.content'), /cleaned up/)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### Guidelines
|
|
152
|
+
|
|
153
|
+
- Prefer real DOM interactions over mocking framework behavior.
|
|
154
|
+
- Avoid testing implementation-only markers unless they are the only stable synchronization point.
|
|
155
|
+
- One representative flow proving a behavior is better than repeating the same assertion across many
|
|
156
|
+
paths.
|