remix 3.0.0-rc.4 → 3.0.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 (191) hide show
  1. package/INDEX.md +6 -21
  2. package/README.md +1 -1
  3. package/dist/component/dev/refresh.d.ts +2 -0
  4. package/dist/component/dev/refresh.d.ts.map +1 -0
  5. package/dist/{ui → component}/dev/refresh.js +1 -1
  6. package/dist/component/jsx-dev-runtime.d.ts +2 -0
  7. package/dist/component/jsx-dev-runtime.d.ts.map +1 -0
  8. package/dist/{ui → component}/jsx-dev-runtime.js +1 -1
  9. package/dist/component/jsx-runtime.d.ts +2 -0
  10. package/dist/component/jsx-runtime.d.ts.map +1 -0
  11. package/dist/{ui → component}/jsx-runtime.js +1 -1
  12. package/dist/component/server.d.ts +2 -0
  13. package/dist/component/server.d.ts.map +1 -0
  14. package/dist/{ui → component}/server.js +1 -1
  15. package/dist/component/test.d.ts +2 -0
  16. package/dist/component/test.d.ts.map +1 -0
  17. package/dist/{ui → component}/test.js +1 -1
  18. package/dist/component-hmr/assets.d.ts +2 -0
  19. package/dist/component-hmr/assets.d.ts.map +1 -0
  20. package/dist/{ui-hmr.js → component-hmr/assets.js} +1 -1
  21. package/dist/component-hmr/node.d.ts +3 -0
  22. package/dist/component-hmr/node.d.ts.map +1 -0
  23. package/dist/{ui-hmr → component-hmr}/node.js +1 -1
  24. package/dist/component-hmr/runtime/browser.d.ts +2 -0
  25. package/dist/component-hmr/runtime/browser.d.ts.map +1 -0
  26. package/dist/{ui-hmr → component-hmr}/runtime/browser.js +1 -1
  27. package/dist/component-hmr/runtime/server.d.ts +2 -0
  28. package/dist/component-hmr/runtime/server.d.ts.map +1 -0
  29. package/dist/{ui-hmr → component-hmr}/runtime/server.js +1 -1
  30. package/dist/component-hmr.d.ts +2 -0
  31. package/dist/component-hmr.d.ts.map +1 -0
  32. package/dist/{ui-hmr/assets.js → component-hmr.js} +1 -1
  33. package/dist/component.d.ts +2 -0
  34. package/dist/component.d.ts.map +1 -0
  35. package/dist/{ui/anchor.js → component.js} +1 -1
  36. package/guides/01-start-here.md +20 -20
  37. package/guides/02-routing-and-controllers.md +1 -1
  38. package/guides/03-request-handling.md +1 -1
  39. package/guides/04-rendering-ui.md +41 -307
  40. package/guides/05-interactivity.md +42 -37
  41. package/guides/06-streaming-ui-with-frames.md +28 -70
  42. package/guides/07-animation.md +41 -35
  43. package/guides/08-data-and-validation.md +423 -27
  44. package/guides/09-forms-and-mutations.md +250 -25
  45. package/guides/13-testing.md +10 -10
  46. package/guides/14-cli-and-tooling.md +2 -2
  47. package/package.json +96 -180
  48. package/src/assets/README.md +12 -12
  49. package/src/cli/README.md +3 -3
  50. package/src/{ui → component}/README.md +31 -75
  51. package/src/{ui → component}/dev/refresh.ts +1 -1
  52. package/src/{ui → component}/jsx-dev-runtime.ts +1 -1
  53. package/src/{ui → component}/jsx-runtime.ts +1 -1
  54. package/src/{ui → component}/server/README.md +8 -8
  55. package/src/{ui → component}/server.ts +1 -1
  56. package/src/{ui → component}/test/README.md +6 -6
  57. package/src/{ui → component}/test.ts +1 -1
  58. package/src/{ui-hmr → component-hmr}/README.md +13 -13
  59. package/src/{ui-hmr → component-hmr}/assets.ts +1 -1
  60. package/src/{ui-hmr → component-hmr}/node.ts +1 -1
  61. package/src/{ui-hmr → component-hmr}/runtime/browser.ts +1 -1
  62. package/src/{ui-hmr → component-hmr}/runtime/server.ts +1 -1
  63. package/{dist/ui/accordion.js → src/component-hmr.ts} +1 -1
  64. package/{dist/ui/tabs.js → src/component.ts} +1 -1
  65. package/src/data-table/README.md +2 -1
  66. package/src/multiple-import-maps-polyfill/README.md +3 -3
  67. package/src/node-hmr/README.md +6 -6
  68. package/src/render-middleware/README.md +5 -5
  69. package/src/spa/README.md +4 -4
  70. package/src/test/README.md +4 -4
  71. package/dist/ui/accordion/primitives.d.ts +0 -2
  72. package/dist/ui/accordion/primitives.d.ts.map +0 -1
  73. package/dist/ui/accordion/primitives.js +0 -2
  74. package/dist/ui/accordion.d.ts +0 -2
  75. package/dist/ui/accordion.d.ts.map +0 -1
  76. package/dist/ui/anchor.d.ts +0 -2
  77. package/dist/ui/anchor.d.ts.map +0 -1
  78. package/dist/ui/animation.d.ts +0 -2
  79. package/dist/ui/animation.d.ts.map +0 -1
  80. package/dist/ui/animation.js +0 -2
  81. package/dist/ui/breadcrumbs.d.ts +0 -2
  82. package/dist/ui/breadcrumbs.d.ts.map +0 -1
  83. package/dist/ui/breadcrumbs.js +0 -2
  84. package/dist/ui/button.d.ts +0 -3
  85. package/dist/ui/button.d.ts.map +0 -1
  86. package/dist/ui/button.js +0 -3
  87. package/dist/ui/checkbox.d.ts +0 -3
  88. package/dist/ui/checkbox.d.ts.map +0 -1
  89. package/dist/ui/checkbox.js +0 -3
  90. package/dist/ui/combobox/primitives.d.ts +0 -2
  91. package/dist/ui/combobox/primitives.d.ts.map +0 -1
  92. package/dist/ui/combobox/primitives.js +0 -2
  93. package/dist/ui/combobox.d.ts +0 -2
  94. package/dist/ui/combobox.d.ts.map +0 -1
  95. package/dist/ui/combobox.js +0 -2
  96. package/dist/ui/dev/refresh.d.ts +0 -2
  97. package/dist/ui/dev/refresh.d.ts.map +0 -1
  98. package/dist/ui/input.d.ts +0 -3
  99. package/dist/ui/input.d.ts.map +0 -1
  100. package/dist/ui/input.js +0 -3
  101. package/dist/ui/jsx-dev-runtime.d.ts +0 -2
  102. package/dist/ui/jsx-dev-runtime.d.ts.map +0 -1
  103. package/dist/ui/jsx-runtime.d.ts +0 -2
  104. package/dist/ui/jsx-runtime.d.ts.map +0 -1
  105. package/dist/ui/listbox.d.ts +0 -2
  106. package/dist/ui/listbox.d.ts.map +0 -1
  107. package/dist/ui/listbox.js +0 -2
  108. package/dist/ui/menu/primitives.d.ts +0 -2
  109. package/dist/ui/menu/primitives.d.ts.map +0 -1
  110. package/dist/ui/menu/primitives.js +0 -2
  111. package/dist/ui/menu.d.ts +0 -2
  112. package/dist/ui/menu.d.ts.map +0 -1
  113. package/dist/ui/menu.js +0 -2
  114. package/dist/ui/popover.d.ts +0 -2
  115. package/dist/ui/popover.d.ts.map +0 -1
  116. package/dist/ui/popover.js +0 -2
  117. package/dist/ui/radio.d.ts +0 -3
  118. package/dist/ui/radio.d.ts.map +0 -1
  119. package/dist/ui/radio.js +0 -3
  120. package/dist/ui/select/primitives.d.ts +0 -2
  121. package/dist/ui/select/primitives.d.ts.map +0 -1
  122. package/dist/ui/select/primitives.js +0 -2
  123. package/dist/ui/select.d.ts +0 -2
  124. package/dist/ui/select.d.ts.map +0 -1
  125. package/dist/ui/select.js +0 -2
  126. package/dist/ui/server.d.ts +0 -2
  127. package/dist/ui/server.d.ts.map +0 -1
  128. package/dist/ui/tabs/primitives.d.ts +0 -2
  129. package/dist/ui/tabs/primitives.d.ts.map +0 -1
  130. package/dist/ui/tabs/primitives.js +0 -2
  131. package/dist/ui/tabs.d.ts +0 -2
  132. package/dist/ui/tabs.d.ts.map +0 -1
  133. package/dist/ui/test.d.ts +0 -2
  134. package/dist/ui/test.d.ts.map +0 -1
  135. package/dist/ui/toggle/primitives.d.ts +0 -2
  136. package/dist/ui/toggle/primitives.d.ts.map +0 -1
  137. package/dist/ui/toggle/primitives.js +0 -2
  138. package/dist/ui/toggle.d.ts +0 -3
  139. package/dist/ui/toggle.d.ts.map +0 -1
  140. package/dist/ui/toggle.js +0 -3
  141. package/dist/ui-hmr/assets.d.ts +0 -2
  142. package/dist/ui-hmr/assets.d.ts.map +0 -1
  143. package/dist/ui-hmr/node.d.ts +0 -3
  144. package/dist/ui-hmr/node.d.ts.map +0 -1
  145. package/dist/ui-hmr/runtime/browser.d.ts +0 -2
  146. package/dist/ui-hmr/runtime/browser.d.ts.map +0 -1
  147. package/dist/ui-hmr/runtime/server.d.ts +0 -2
  148. package/dist/ui-hmr/runtime/server.d.ts.map +0 -1
  149. package/dist/ui-hmr.d.ts +0 -2
  150. package/dist/ui-hmr.d.ts.map +0 -1
  151. package/dist/ui.d.ts +0 -2
  152. package/dist/ui.d.ts.map +0 -1
  153. package/dist/ui.js +0 -2
  154. package/src/ui/accordion/README.md +0 -202
  155. package/src/ui/accordion/primitives.ts +0 -2
  156. package/src/ui/accordion.ts +0 -2
  157. package/src/ui/anchor/README.md +0 -196
  158. package/src/ui/anchor.ts +0 -2
  159. package/src/ui/animation/README.md +0 -318
  160. package/src/ui/animation.ts +0 -2
  161. package/src/ui/breadcrumbs/README.md +0 -55
  162. package/src/ui/breadcrumbs.ts +0 -2
  163. package/src/ui/button/README.md +0 -44
  164. package/src/ui/button.ts +0 -3
  165. package/src/ui/checkbox/README.md +0 -59
  166. package/src/ui/checkbox.ts +0 -3
  167. package/src/ui/combobox/README.md +0 -194
  168. package/src/ui/combobox/primitives.ts +0 -2
  169. package/src/ui/combobox.ts +0 -2
  170. package/src/ui/input/README.md +0 -52
  171. package/src/ui/input.ts +0 -3
  172. package/src/ui/listbox/README.md +0 -83
  173. package/src/ui/listbox.ts +0 -2
  174. package/src/ui/menu/README.md +0 -161
  175. package/src/ui/menu/primitives.ts +0 -2
  176. package/src/ui/menu.ts +0 -2
  177. package/src/ui/popover/README.md +0 -103
  178. package/src/ui/popover.ts +0 -2
  179. package/src/ui/radio/README.md +0 -53
  180. package/src/ui/radio.ts +0 -3
  181. package/src/ui/select/README.md +0 -117
  182. package/src/ui/select/primitives.ts +0 -2
  183. package/src/ui/select.ts +0 -2
  184. package/src/ui/tabs/README.md +0 -141
  185. package/src/ui/tabs/primitives.ts +0 -2
  186. package/src/ui/tabs.ts +0 -2
  187. package/src/ui/toggle/README.md +0 -56
  188. package/src/ui/toggle/primitives.ts +0 -2
  189. package/src/ui/toggle.ts +0 -3
  190. package/src/ui-hmr.ts +0 -2
  191. package/src/ui.ts +0 -2
@@ -3,50 +3,275 @@ title: Forms and Mutations
3
3
  description: How native forms, action responses, validation failures, redirects, and enhanced mutations fit together.
4
4
  ---
5
5
 
6
- This chapter is unfinished. The sections below outline what it will cover. For working API examples, see these READMEs:
6
+ In [Data and Validation](/data-and-validation/), we moved our albums into SQLite and defined `albumForm` to validate edits.
7
7
 
8
- - [Routes and controllers](../src/fetch-router/README.md)
9
- - [Form data middleware](../src/form-data-middleware/README.md)
10
- - [Form data schemas](../src/data-schema/README.md)
8
+ Let's connect the edit form to that database. We'll parse submissions, return the form with field errors when validation fails, and save valid edits before redirecting to the album. Then we'll add pending feedback.
11
9
 
12
- Build the mutation as an HTML form and controller action first. Once the non-JavaScript request returns the right response, the same form can gain pending state, fetch submission, or a targeted frame reload.
10
+ ## Keep the native form
13
11
 
14
- ## HTML-first form workflows {#html-first-form-workflows}
12
+ Our existing `form("/albums/:albumId/edit")` route pairs two actions at the same URL. The GET `index` action loads the album for editing, and the POST `action` handler parses and saves a submission. Both belong to the controller mapped with `router.map(routes.albums.edit, albumsEditController)`.
15
13
 
16
- Use a real `<form method="POST">`, named native controls, and a submit button. The action URL comes from `routes.<name>.href(...)`, and the controller returns an explicit `Response` for every expected outcome.
14
+ We'll keep a native `<form method="post">`, named inputs, and a submit button. With JavaScript disabled, the browser still sends those fields to the action and displays its response. The router's `formData()` middleware already makes the parsed body available as `context.formData`.
17
15
 
18
- ## Form routes and controller ownership {#form-routes}
16
+ ## Load the edit form
19
17
 
20
- `form(path)` creates an `index` GET leaf and an `action` POST leaf by default. Keep both direct leaves in the controller mapped to that form route map, and use custom action names or methods only when the URL contract benefits from them.
18
+ First, replace the edit controller's array lookup with a database query. The `index` action loads the album, returns `404` if it is missing, and renders the edit page:
21
19
 
22
- ## Parse FormData once, then validate it {#parse-and-validate-form-data}
20
+ ```tsx filename=app/actions/albums/edit/controller.tsx lines=[3,9-14]
21
+ import { createController } from "remix/router";
23
22
 
24
- Install `formData()` before middleware that depends on form fields, read `context.get(FormData)`, and parse with a `remix/data-schema/form-data` schema. Treat missing fields, repeated fields, checkboxes, files, and coercion as boundary concerns rather than ad hoc action checks.
23
+ import { albums } from "../../../data/tables.ts";
24
+ import { routes } from "../../../routes.ts";
25
+ import { AlbumEditPage } from "./page.tsx";
25
26
 
26
- ## Return validation failures with the form {#validation-failures}
27
+ export default createController(routes.albums.edit, {
28
+ actions: {
29
+ async index(context) {
30
+ let album = await context.db.find(albums, context.params.albumId);
31
+ if (album === null) return new Response("Album not found", { status: 404 });
27
32
 
28
- Use `parseSafe()` and render the form again with issue paths, submitted values, and a `400` status. Keep entered values and error ownership close to the fields, while avoiding password or file-value replay.
33
+ return context.render(<AlbumEditPage album={album} />);
34
+ },
35
+ async action(context) {
36
+ /* ... */
37
+ },
38
+ },
39
+ });
40
+ ```
29
41
 
30
- ## Redirect after a successful mutation {#post-redirect-get}
42
+ Open `/albums/thriller/edit`. The form now starts with the album's stored values.
31
43
 
32
- Return a `303` redirect to the resulting page after create, update, or delete work. This prevents accidental resubmission on refresh and gives the next GET a stable URL; session flash data can carry one-request success messages.
44
+ ## Return validation failures with the form
33
45
 
34
- ## Multiple submit intents and mutation responses {#mutation-intents-and-responses}
46
+ The page needs to distinguish the stored album from an unsuccessful edit. Add optional `values` and `issues` props to `AlbumEditPage`:
35
47
 
36
- Use named submit buttons when one form has a small set of related intents. Choose the response that matches the caller: rendered HTML for an in-place failure, a redirect for normal browser success, or JSON for an endpoint genuinely consumed as data.
48
+ ```tsx filename=app/actions/albums/edit/page.tsx lines=[1,10-11,22,28,31-33,41-42,44-45,47]
49
+ import type { Issue } from "remix/data-schema";
50
+ import type { Handle } from "remix/component";
37
51
 
38
- ## PUT, PATCH, and DELETE from HTML forms {#method-override-for-put-patch-and-delete}
52
+ import type { Album } from "../../../data/tables.ts";
53
+ import { routes } from "../../../routes.ts";
54
+ import { Document } from "../../document.tsx";
39
55
 
40
- HTML forms submit GET or POST. Add a hidden `_method` field and run `methodOverride()` after `formData()` when the route contract uses PUT, PATCH, or DELETE, as the bookstore demo's RESTful form wrapper does.
56
+ interface AlbumEditPageProps {
57
+ album: Album;
58
+ values?: { title: string; artist: string; year: string };
59
+ issues?: readonly Issue[];
60
+ }
41
61
 
42
- ## Enhance submissions without replacing the action {#enhanced-form-submissions}
62
+ const fields = [
63
+ { name: "title", label: "Title" },
64
+ { name: "artist", label: "Artist" },
65
+ { name: "year", label: "Year" },
66
+ ] as const;
43
67
 
44
- A hydrated component may intercept submit, send `new FormData(form)` with the event's abort signal, and then navigate or reload a frame. The server action should remain correct when JavaScript fails to load or the enhancement is removed.
68
+ export function AlbumEditPage(handle: Handle<AlbumEditPageProps>) {
69
+ return () => {
70
+ let { album, values, issues = [] } = handle.props;
45
71
 
46
- ## Pending, optimistic, and conflicting mutations {#optimistic-ui}
72
+ return (
73
+ <Document title={`Edit ${album.title} — Albums`}>
74
+ <main>
75
+ <h1>Edit {album.title}</h1>
76
+ {issues.length > 0 && <p role="alert">Please correct the fields below.</p>}
77
+ <form method="post" action={routes.albums.edit.action.href({ albumId: album.id })}>
78
+ {fields.map(({ name, label }) => {
79
+ let issue = issues.find((issue) => issue.path?.[0] === name);
80
+ let inputId = `album-${name}`;
81
+ let errorId = `${inputId}-error`;
47
82
 
48
- Track the attempted operation in setup scope to disable duplicate controls and render pending or optimistic state. Reconcile with the returned response, roll back failures, and use app-owned revisions or preconditions when concurrent writes can conflict.
83
+ return (
84
+ <div key={name}>
85
+ <label htmlFor={inputId}>{label}</label>
86
+ <input
87
+ id={inputId}
88
+ name={name}
89
+ defaultValue={values?.[name] ?? String(album[name])}
90
+ inputMode={name === "year" ? "numeric" : undefined}
91
+ required
92
+ aria-invalid={issue ? true : undefined}
93
+ aria-describedby={issue ? errorId : undefined}
94
+ />
95
+ {issue && <p id={errorId}>{issue.message}</p>}
96
+ </div>
97
+ );
98
+ })}
99
+ <button type="submit">Save album</button>
100
+ </form>
101
+ </main>
102
+ </Document>
103
+ );
104
+ };
105
+ }
106
+ ```
49
107
 
50
- ## When to return JSON {#resource-routes-and-json-endpoints}
108
+ The inputs use the stored album initially and preserve submitted `values` after validation fails. Each field displays its first validation issue, linked to the input with `aria-describedby`.
51
109
 
52
- Use JSON for autocomplete, polling, isolated widgets, APIs, and inter-service calls. Do not create a parallel JSON mutation layer for a form that already has a useful HTML response and redirect path.
110
+ ## Parse and save the submitted form
111
+
112
+ Pass `context.formData` to `s.parseSafe(albumForm, context.formData)` in the edit action. When `parsed.success` is false, validation errors are available in `parsed.issues`. On success, `parsed.value` contains the validated fields, with trimmed text and a numeric year.
113
+
114
+ Update the controller to load the album from SQLite, render invalid submissions with their errors, and save validated edits:
115
+
116
+ ```tsx filename=app/actions/albums/edit/controller.tsx lines=[5,19-20,30-33,36-43]
117
+ import * as s from "remix/data-schema";
118
+ import { redirect } from "remix/response/redirect";
119
+ import { createController } from "remix/router";
120
+
121
+ import { albums } from "../../../data/tables.ts";
122
+ import { routes } from "../../../routes.ts";
123
+ import { AlbumEditPage } from "./page.tsx";
124
+ import { albumForm } from "./schema.ts";
125
+
126
+ export default createController(routes.albums.edit, {
127
+ actions: {
128
+ async index(context) {
129
+ let album = await context.db.find(albums, context.params.albumId);
130
+ if (album === null) return new Response("Album not found", { status: 404 });
131
+
132
+ return context.render(<AlbumEditPage album={album} />);
133
+ },
134
+ async action(context) {
135
+ let parsed = s.parseSafe(albumForm, context.formData);
136
+ if (!parsed.success) {
137
+ let album = await context.db.find(albums, context.params.albumId);
138
+ if (album === null) return new Response("Album not found", { status: 404 });
139
+
140
+ let values = {
141
+ title: readText(context.formData, "title"),
142
+ artist: readText(context.formData, "artist"),
143
+ year: readText(context.formData, "year"),
144
+ };
145
+
146
+ return context.render(
147
+ <AlbumEditPage album={album} values={values} issues={parsed.issues} />,
148
+ { status: 400 },
149
+ );
150
+ }
151
+
152
+ try {
153
+ await context.db.update(albums, context.params.albumId, parsed.value);
154
+ } catch (error) {
155
+ console.error("Failed to save album:", error);
156
+ return new Response("Unable to save album", { status: 500 });
157
+ }
158
+
159
+ return redirect(routes.albums.show.href({ albumId: context.params.albumId }), 303);
160
+ },
161
+ },
162
+ });
163
+
164
+ function readText(formData: FormData, name: string): string {
165
+ let value = formData.get(name);
166
+ return typeof value === "string" ? value : "";
167
+ }
168
+ ```
169
+
170
+ `readText()` collects text for display without converting a missing entry or a file into a string. These values never become the database update. That still comes from `parsed.value`, after trimming, coercion, and validation have succeeded.
171
+
172
+ The failure branch renders directly with status `400`. Redirecting back to the GET action would load the stored album again and lose the user's changes and errors. For forms with sensitive fields, choose which values to return. Leave password fields empty, and ask the user to select files again.
173
+
174
+ ## Redirect after saving
175
+
176
+ After parsing succeeds, `db.update(...)` saves `parsed.value` to the album selected by the route's `albumId`. If the update fails, the action logs the error on the server and returns `500` without exposing database details to the browser. For other read and write helpers and their error behavior, see the [`data-table` overview](../src/data-table/README.md).
177
+
178
+ After a successful save, the action returns `303 See Other` with the album page's URL. The browser follows that redirect with a GET:
179
+
180
+ ```txt
181
+ POST /albums/thriller/edit
182
+ → update the album
183
+ → 303 Location: /albums/thriller
184
+ GET /albums/thriller
185
+ → render the saved album
186
+ ```
187
+
188
+ Refreshing the resulting page repeats the GET instead of submitting the edit again.
189
+
190
+ If the destination should display “Album saved,” session flash data can carry that message across the redirect. We'll add sessions in [Auth, Sessions, and Security](/auth-sessions-security/).
191
+
192
+ ## Add pending feedback
193
+
194
+ With the app's browser runtime running, Remix handles the form submission as a navigation. It renders validation responses into the page and follows redirects after a successful save.
195
+
196
+ In [Start Here](/start-here/), we hydrated the whole form and set pending state when its submit event fired. Here, the button can follow the containing frame's reload events, which tell us when the navigation starts and when it finishes.
197
+
198
+ We can show progress by hydrating just the save button. Put it in the edit action's `public/` directory:
199
+
200
+ ```tsx filename=app/actions/albums/edit/public/save-button.tsx
201
+ import { clientEntry } from "remix/component";
202
+ import type { Handle } from "remix/component";
203
+
204
+ export const SaveButton = clientEntry(import.meta.url, function SaveButton(handle: Handle) {
205
+ let pending = false;
206
+
207
+ handle.frame.addEventListener(
208
+ "reloadStart",
209
+ () => {
210
+ pending = true;
211
+ handle.update();
212
+ },
213
+ { signal: handle.signal },
214
+ );
215
+
216
+ handle.frame.addEventListener(
217
+ "reloadComplete",
218
+ () => {
219
+ pending = false;
220
+ handle.update();
221
+ },
222
+ { signal: handle.signal },
223
+ );
224
+
225
+ return () => (
226
+ <button type="submit" disabled={pending}>
227
+ {pending ? "Saving…" : "Save album"}
228
+ </button>
229
+ );
230
+ });
231
+ ```
232
+
233
+ Back in `page.tsx`, import `SaveButton` and replace the plain submit button:
234
+
235
+ ```tsx filename=app/actions/albums/edit/page.tsx
236
+ import { SaveButton } from "./public/save-button.tsx";
237
+
238
+ // Inside AlbumEditPage's render function:
239
+ <form method="post" action={routes.albums.edit.action.href({ albumId: album.id })}>
240
+ {/* Keep the existing fields and validation messages. */}
241
+ <SaveButton />
242
+ </form>;
243
+ ```
244
+
245
+ The button listens to its containing frame: `reloadStart` disables it and shows “Saving…”, and `reloadComplete` restores it after the navigation settles, including a validation response or request failure. Passing `handle.signal` removes the listeners when the component is removed.
246
+
247
+ ## Use other HTTP methods when the route calls for them
248
+
249
+ Our edit route works with POST. If your app instead exposes a PATCH update route, HTML forms need a method override because their submission methods are GET and POST.
250
+
251
+ Change the edit route's action method with `form("/albums/:albumId/edit", { formMethod: "PATCH" })`, and add this hidden field inside its existing POST form:
252
+
253
+ ```html
254
+ <input type="hidden" name="_method" value="PATCH" />
255
+ ```
256
+
257
+ Install method override in the router's middleware stack, immediately after form parsing:
258
+
259
+ ```ts lines=[2,10]
260
+ // In app/router.ts:
261
+ import { methodOverride } from "remix/middleware/method-override";
262
+
263
+ // Keep the other middleware and controller mappings:
264
+ export const router = createRouter({
265
+ middleware: [
266
+ staticFiles("./public", { index: false }),
267
+ loadDatabase(),
268
+ formData(),
269
+ methodOverride(),
270
+ render({ assets }),
271
+ ],
272
+ });
273
+ ```
274
+
275
+ The browser sends POST. After parsing `_method`, the middleware sets `context.method` to PATCH before route matching. The same controller's `action` now matches that PATCH route. Installing method override on the controller would be too late to select the route. PUT and DELETE work the same way when you define matching routes.
276
+
277
+ In [Auth, Sessions, and Security](/auth-sessions-security/), we'll decide who is allowed to edit an album and connect those decisions to the request's session.
@@ -13,12 +13,12 @@ This chapter starts at that boundary, then moves outward to browser component te
13
13
 
14
14
  Choose the smallest boundary that includes the behavior you want to prove:
15
15
 
16
- | Test boundary | Use it for | How to test it | Runner type |
17
- | ---------------------- | -------------------------------------------------------------------- | ---------------------------------------------------------------- | ----------- |
18
- | Unit test | A data helper, schema, utility, or other isolated module | Import it, call it, and assert on the result | `server` |
19
- | Router test | An action, response, middleware, session, or database-backed request | Send a request through `router.fetch(...)` | `server` |
20
- | Browser component test | A component event, DOM update, or browser API | Render the component with `remix/ui/test`'s `render()` helper | `browser` |
21
- | End-to-end test | Navigation or a complete browser/server flow | Run the router behind a test server and drive it with Playwright | `e2e` |
16
+ | Test boundary | Use it for | How to test it | Runner type |
17
+ | ---------------------- | -------------------------------------------------------------------- | -------------------------------------------------------------------- | ----------- |
18
+ | Unit test | A data helper, schema, utility, or other isolated module | Import it, call it, and assert on the result | `server` |
19
+ | Router test | An action, response, middleware, session, or database-backed request | Send a request through `router.fetch(...)` | `server` |
20
+ | Browser component test | A component event, DOM update, or browser API | Render the component with `remix/component/test`'s `render()` helper | `browser` |
21
+ | End-to-end test | Navigation or a complete browser/server flow | Run the router behind a test server and drive it with Playwright | `e2e` |
22
22
 
23
23
  A controller that returns the wrong status belongs in a router test. A submit button that does not enter its pending state belongs in a browser component test. Use an end-to-end test when browser and server behavior must work together, such as submitting a form and following its redirect to the updated page.
24
24
 
@@ -219,8 +219,8 @@ npx playwright install
219
219
  Consider a small client component that tracks whether an album is a favorite:
220
220
 
221
221
  ```tsx filename=app/actions/albums/public/favorite-button.tsx
222
- import { clientEntry, on } from "remix/ui";
223
- import type { Handle } from "remix/ui";
222
+ import { clientEntry, on } from "remix/component";
223
+ import type { Handle } from "remix/component";
224
224
 
225
225
  export const FavoriteButton = clientEntry(
226
226
  import.meta.url,
@@ -247,12 +247,12 @@ export const FavoriteButton = clientEntry(
247
247
  );
248
248
  ```
249
249
 
250
- `render(...)` from [`remix/ui/test`](../src/ui/test/README.md) mounts the component, flushes its initial render, and returns helpers for querying and interacting with the DOM:
250
+ `render(...)` from [`remix/component/test`](../src/component/test/README.md) mounts the component, flushes its initial render, and returns helpers for querying and interacting with the DOM:
251
251
 
252
252
  ```tsx filename=app/actions/albums/favorite-button.test.browser.tsx
253
253
  import * as assert from "remix/assert";
254
254
  import { describe, it } from "remix/test";
255
- import { render } from "remix/ui/test";
255
+ import { render } from "remix/component/test";
256
256
 
257
257
  import { FavoriteButton } from "./public/favorite-button.tsx";
258
258
 
@@ -13,7 +13,7 @@ The installed `remix` package provides the CLI and the runtime subpaths used by
13
13
 
14
14
  ## Create an app with remix new {#remix-new}
15
15
 
16
- Use `npx remix@next new <target-dir>` before the package is installed locally, or `remix new <target-dir>` afterward. Cover `--app-name`, `--force`, the generated `app/actions` layout, and the scripts in the starter `package.json`.
16
+ Use `npx remix new <target-dir>` before the package is installed locally, or `remix new <target-dir>` afterward. Cover `--app-name`, `--force`, the generated `app/actions` layout, and the scripts in the starter `package.json`.
17
17
 
18
18
  ## Inspect route ownership with remix routes {#remix-routes}
19
19
 
@@ -37,7 +37,7 @@ Cover `remix help`, command-level `--help`, `remix version` and `--version`, glo
37
37
 
38
38
  ## TypeScript and JSX setup {#typescript-and-jsx-setup}
39
39
 
40
- Explain the generated `NodeNext` module settings, `.ts` extensions in relative imports, type-only imports, `jsx: react-jsx`, and `jsxImportSource: remix/ui`. Keep `tsc --noEmit` as a separate typecheck because runtime transformation is not type checking.
40
+ Explain the generated `NodeNext` module settings, `.ts` extensions in relative imports, type-only imports, `jsx: react-jsx`, and `jsxImportSource: remix/component`. Keep `tsc --noEmit` as a separate typecheck because runtime transformation is not type checking.
41
41
 
42
42
  ## Run source files with remix/node-tsx {#using-remix-node-tsx}
43
43