@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.
Files changed (159) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +77 -3
  3. package/bootstrap/.agents/skills/remix/SKILL.md +501 -0
  4. package/bootstrap/.agents/skills/remix/references/animate-elements.md +195 -0
  5. package/bootstrap/.agents/skills/remix/references/assets-and-browser-modules.md +122 -0
  6. package/bootstrap/.agents/skills/remix/references/auth-and-sessions.md +420 -0
  7. package/bootstrap/.agents/skills/remix/references/component-model.md +282 -0
  8. package/bootstrap/.agents/skills/remix/references/create-mixins.md +158 -0
  9. package/bootstrap/.agents/skills/remix/references/data-and-validation.md +363 -0
  10. package/bootstrap/.agents/skills/remix/references/hydration-frames-navigation.md +297 -0
  11. package/bootstrap/.agents/skills/remix/references/middleware-and-server.md +243 -0
  12. package/bootstrap/.agents/skills/remix/references/mixins-styling-events.md +213 -0
  13. package/bootstrap/.agents/skills/remix/references/routing-and-controllers.md +324 -0
  14. package/bootstrap/.agents/skills/remix/references/testing-patterns.md +156 -0
  15. package/bootstrap/AGENTS.md +39 -0
  16. package/bootstrap/README.md +27 -0
  17. package/bootstrap/app/assets/entry.ts +19 -0
  18. package/bootstrap/app/assets.ts +18 -0
  19. package/bootstrap/app/controllers/auth.tsx +21 -0
  20. package/bootstrap/app/controllers/home.tsx +11 -0
  21. package/bootstrap/app/router.ts +16 -0
  22. package/bootstrap/app/routes.ts +7 -0
  23. package/bootstrap/app/ui/document.tsx +26 -0
  24. package/bootstrap/app/ui/layout.tsx +22 -0
  25. package/bootstrap/app/ui/prompt-button.tsx +162 -0
  26. package/bootstrap/app/ui/scaffold-home-page.tsx +526 -0
  27. package/bootstrap/app/utils/render.tsx +26 -0
  28. package/bootstrap/package.json +22 -0
  29. package/bootstrap/server.ts +37 -0
  30. package/bootstrap/tsconfig.json +18 -0
  31. package/dist/index.d.ts +2 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +1 -0
  34. package/dist/lib/bootstrap-project.d.ts +17 -0
  35. package/dist/lib/bootstrap-project.d.ts.map +1 -0
  36. package/dist/lib/bootstrap-project.js +137 -0
  37. package/dist/lib/cli-context.d.ts +11 -0
  38. package/dist/lib/cli-context.d.ts.map +1 -0
  39. package/dist/lib/cli-context.js +19 -0
  40. package/dist/lib/cli.d.ts +6 -0
  41. package/dist/lib/cli.d.ts.map +1 -0
  42. package/dist/lib/cli.js +84 -0
  43. package/dist/lib/commands/completion.d.ts +3 -0
  44. package/dist/lib/commands/completion.d.ts.map +1 -0
  45. package/dist/lib/commands/completion.js +51 -0
  46. package/dist/lib/commands/doctor.d.ts +4 -0
  47. package/dist/lib/commands/doctor.d.ts.map +1 -0
  48. package/dist/lib/commands/doctor.js +293 -0
  49. package/dist/lib/commands/help.d.ts +4 -0
  50. package/dist/lib/commands/help.d.ts.map +1 -0
  51. package/dist/lib/commands/help.js +100 -0
  52. package/dist/lib/commands/new.d.ts +4 -0
  53. package/dist/lib/commands/new.d.ts.map +1 -0
  54. package/dist/lib/commands/new.js +70 -0
  55. package/dist/lib/commands/routes.d.ts +4 -0
  56. package/dist/lib/commands/routes.d.ts.map +1 -0
  57. package/dist/lib/commands/routes.js +161 -0
  58. package/dist/lib/commands/test.d.ts +4 -0
  59. package/dist/lib/commands/test.d.ts.map +1 -0
  60. package/dist/lib/commands/test.js +23 -0
  61. package/dist/lib/commands/version.d.ts +4 -0
  62. package/dist/lib/commands/version.d.ts.map +1 -0
  63. package/dist/lib/commands/version.js +29 -0
  64. package/dist/lib/completion.d.ts +12 -0
  65. package/dist/lib/completion.d.ts.map +1 -0
  66. package/dist/lib/completion.js +310 -0
  67. package/dist/lib/contained-path.d.ts +2 -0
  68. package/dist/lib/contained-path.d.ts.map +1 -0
  69. package/dist/lib/contained-path.js +10 -0
  70. package/dist/lib/controller-files.d.ts +14 -0
  71. package/dist/lib/controller-files.d.ts.map +1 -0
  72. package/dist/lib/controller-files.js +53 -0
  73. package/dist/lib/controller-ownership.d.ts +41 -0
  74. package/dist/lib/controller-ownership.d.ts.map +1 -0
  75. package/dist/lib/controller-ownership.js +224 -0
  76. package/dist/lib/display-path.d.ts +2 -0
  77. package/dist/lib/display-path.d.ts.map +1 -0
  78. package/dist/lib/display-path.js +12 -0
  79. package/dist/lib/doctor/controller-findings.d.ts +4 -0
  80. package/dist/lib/doctor/controller-findings.d.ts.map +1 -0
  81. package/dist/lib/doctor/controller-findings.js +132 -0
  82. package/dist/lib/doctor/controller-fix-plans.d.ts +4 -0
  83. package/dist/lib/doctor/controller-fix-plans.d.ts.map +1 -0
  84. package/dist/lib/doctor/controller-fix-plans.js +130 -0
  85. package/dist/lib/doctor/controller-placeholders.d.ts +4 -0
  86. package/dist/lib/doctor/controller-placeholders.d.ts.map +1 -0
  87. package/dist/lib/doctor/controller-placeholders.js +183 -0
  88. package/dist/lib/doctor/controllers.d.ts +8 -0
  89. package/dist/lib/doctor/controllers.d.ts.map +1 -0
  90. package/dist/lib/doctor/controllers.js +13 -0
  91. package/dist/lib/doctor/environment.d.ts +17 -0
  92. package/dist/lib/doctor/environment.d.ts.map +1 -0
  93. package/dist/lib/doctor/environment.js +226 -0
  94. package/dist/lib/doctor/fixes.d.ts +3 -0
  95. package/dist/lib/doctor/fixes.d.ts.map +1 -0
  96. package/dist/lib/doctor/fixes.js +65 -0
  97. package/dist/lib/doctor/project.d.ts +10 -0
  98. package/dist/lib/doctor/project.d.ts.map +1 -0
  99. package/dist/lib/doctor/project.js +371 -0
  100. package/dist/lib/doctor/types.d.ts +48 -0
  101. package/dist/lib/doctor/types.d.ts.map +1 -0
  102. package/dist/lib/doctor/types.js +15 -0
  103. package/dist/lib/errors.d.ts +167 -0
  104. package/dist/lib/errors.d.ts.map +1 -0
  105. package/dist/lib/errors.js +307 -0
  106. package/dist/lib/help-text.d.ts +13 -0
  107. package/dist/lib/help-text.d.ts.map +1 -0
  108. package/dist/lib/help-text.js +43 -0
  109. package/dist/lib/load-route-map-worker.d.ts +2 -0
  110. package/dist/lib/load-route-map-worker.d.ts.map +1 -0
  111. package/dist/lib/load-route-map-worker.js +92 -0
  112. package/dist/lib/parse-args.d.ts +22 -0
  113. package/dist/lib/parse-args.d.ts.map +1 -0
  114. package/dist/lib/parse-args.js +35 -0
  115. package/dist/lib/remix-version.d.ts +2 -0
  116. package/dist/lib/remix-version.d.ts.map +1 -0
  117. package/dist/lib/remix-version.js +58 -0
  118. package/dist/lib/reporter.d.ts +56 -0
  119. package/dist/lib/reporter.d.ts.map +1 -0
  120. package/dist/lib/reporter.js +357 -0
  121. package/dist/lib/route-map.d.ts +38 -0
  122. package/dist/lib/route-map.d.ts.map +1 -0
  123. package/dist/lib/route-map.js +195 -0
  124. package/dist/lib/terminal.d.ts +17 -0
  125. package/dist/lib/terminal.d.ts.map +1 -0
  126. package/dist/lib/terminal.js +66 -0
  127. package/package.json +47 -5
  128. package/src/index.ts +1 -0
  129. package/src/lib/bootstrap-project.ts +222 -0
  130. package/src/lib/cli-context.ts +38 -0
  131. package/src/lib/cli.ts +116 -0
  132. package/src/lib/commands/completion.ts +74 -0
  133. package/src/lib/commands/doctor.ts +393 -0
  134. package/src/lib/commands/help.ts +123 -0
  135. package/src/lib/commands/new.ts +104 -0
  136. package/src/lib/commands/routes.ts +251 -0
  137. package/src/lib/commands/test.ts +31 -0
  138. package/src/lib/commands/version.ts +39 -0
  139. package/src/lib/completion.ts +418 -0
  140. package/src/lib/contained-path.ts +13 -0
  141. package/src/lib/controller-files.ts +79 -0
  142. package/src/lib/controller-ownership.ts +397 -0
  143. package/src/lib/display-path.ts +16 -0
  144. package/src/lib/doctor/controller-findings.ts +157 -0
  145. package/src/lib/doctor/controller-fix-plans.ts +184 -0
  146. package/src/lib/doctor/controller-placeholders.ts +242 -0
  147. package/src/lib/doctor/controllers.ts +23 -0
  148. package/src/lib/doctor/environment.ts +289 -0
  149. package/src/lib/doctor/fixes.ts +84 -0
  150. package/src/lib/doctor/project.ts +435 -0
  151. package/src/lib/doctor/types.ts +95 -0
  152. package/src/lib/errors.ts +367 -0
  153. package/src/lib/help-text.ts +88 -0
  154. package/src/lib/load-route-map-worker.ts +110 -0
  155. package/src/lib/parse-args.ts +82 -0
  156. package/src/lib/remix-version.ts +80 -0
  157. package/src/lib/reporter.ts +535 -0
  158. package/src/lib/route-map.ts +303 -0
  159. 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.