@remix-run/cli 0.3.4 → 0.5.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 (119) hide show
  1. package/README.md +105 -1
  2. package/dist/index.js +1 -1
  3. package/dist/lib/app-root.d.ts +11 -0
  4. package/dist/lib/app-root.d.ts.map +1 -0
  5. package/dist/lib/app-root.js +38 -0
  6. package/dist/lib/bootstrap-project.js +2 -2
  7. package/dist/lib/cli-context.d.ts +6 -0
  8. package/dist/lib/cli-context.d.ts.map +1 -1
  9. package/dist/lib/cli-context.js +12 -1
  10. package/dist/lib/cli.d.ts +3 -2
  11. package/dist/lib/cli.d.ts.map +1 -1
  12. package/dist/lib/cli.js +42 -14
  13. package/dist/lib/commands/completion.js +4 -4
  14. package/dist/lib/commands/db.d.ts +4 -0
  15. package/dist/lib/commands/db.d.ts.map +1 -0
  16. package/dist/lib/commands/db.js +283 -0
  17. package/dist/lib/commands/doctor.d.ts +5 -0
  18. package/dist/lib/commands/doctor.d.ts.map +1 -1
  19. package/dist/lib/commands/doctor.js +26 -251
  20. package/dist/lib/commands/help.d.ts.map +1 -1
  21. package/dist/lib/commands/help.js +17 -9
  22. package/dist/lib/commands/new.js +6 -6
  23. package/dist/lib/commands/routes.js +6 -6
  24. package/dist/lib/commands/test.d.ts +20 -1
  25. package/dist/lib/commands/test.d.ts.map +1 -1
  26. package/dist/lib/commands/test.js +353 -10
  27. package/dist/lib/commands/version.js +3 -3
  28. package/dist/lib/completion.d.ts +1 -1
  29. package/dist/lib/completion.d.ts.map +1 -1
  30. package/dist/lib/completion.js +229 -6
  31. package/dist/lib/controller-files.d.ts +0 -8
  32. package/dist/lib/controller-files.d.ts.map +1 -1
  33. package/dist/lib/controller-files.js +0 -23
  34. package/dist/lib/controller-ownership.d.ts +4 -12
  35. package/dist/lib/controller-ownership.d.ts.map +1 -1
  36. package/dist/lib/controller-ownership.js +31 -127
  37. package/dist/lib/database-command.d.ts +20 -0
  38. package/dist/lib/database-command.d.ts.map +1 -0
  39. package/dist/lib/database-command.js +7 -0
  40. package/dist/lib/doctor/controller-findings.d.ts +2 -2
  41. package/dist/lib/doctor/controller-findings.d.ts.map +1 -1
  42. package/dist/lib/doctor/controller-findings.js +23 -39
  43. package/dist/lib/doctor/controllers.d.ts +1 -2
  44. package/dist/lib/doctor/controllers.d.ts.map +1 -1
  45. package/dist/lib/doctor/controllers.js +4 -7
  46. package/dist/lib/doctor/environment.js +2 -2
  47. package/dist/lib/doctor/fixes.js +1 -1
  48. package/dist/lib/doctor/project.d.ts.map +1 -1
  49. package/dist/lib/doctor/project.js +3 -212
  50. package/dist/lib/doctor/run.d.ts +9 -0
  51. package/dist/lib/doctor/run.d.ts.map +1 -0
  52. package/dist/lib/doctor/run.js +237 -0
  53. package/dist/lib/doctor/types.d.ts +2 -1
  54. package/dist/lib/doctor/types.d.ts.map +1 -1
  55. package/dist/lib/doctor/types.js +4 -1
  56. package/dist/lib/errors.d.ts +30 -0
  57. package/dist/lib/errors.d.ts.map +1 -1
  58. package/dist/lib/errors.js +55 -0
  59. package/dist/lib/help-text.js +1 -1
  60. package/dist/lib/parse-args.d.ts.map +1 -1
  61. package/dist/lib/parse-args.js +17 -4
  62. package/dist/lib/remix-config.d.ts +75 -0
  63. package/dist/lib/remix-config.d.ts.map +1 -0
  64. package/dist/lib/remix-config.js +411 -0
  65. package/dist/lib/reporter.js +1 -1
  66. package/dist/lib/route-map.js +13 -33
  67. package/package.json +13 -7
  68. package/schema/remix.json +242 -0
  69. package/src/lib/app-root.ts +46 -0
  70. package/src/lib/cli-context.ts +17 -0
  71. package/src/lib/cli.ts +41 -6
  72. package/src/lib/commands/db.ts +377 -0
  73. package/src/lib/commands/doctor.ts +27 -329
  74. package/src/lib/commands/help.ts +10 -1
  75. package/src/lib/commands/test.ts +453 -12
  76. package/src/lib/completion.ts +321 -6
  77. package/src/lib/controller-files.ts +0 -38
  78. package/src/lib/controller-ownership.ts +50 -203
  79. package/src/lib/database-command.ts +31 -0
  80. package/src/lib/doctor/controller-findings.ts +30 -47
  81. package/src/lib/doctor/controllers.ts +2 -6
  82. package/src/lib/doctor/project.ts +0 -236
  83. package/src/lib/doctor/run.ts +311 -0
  84. package/src/lib/doctor/types.ts +6 -5
  85. package/src/lib/errors.ts +65 -0
  86. package/src/lib/parse-args.ts +19 -3
  87. package/src/lib/remix-config.ts +639 -0
  88. package/src/lib/route-map.ts +11 -48
  89. package/template/.agents/skills/remix/SKILL.md +38 -27
  90. package/template/.agents/skills/remix/references/assets-and-browser-modules.md +54 -11
  91. package/template/.agents/skills/remix/references/auth-and-sessions.md +5 -5
  92. package/template/.agents/skills/remix/references/data-and-validation.md +15 -15
  93. package/template/.agents/skills/remix/references/hydration-frames-navigation.md +63 -9
  94. package/template/.agents/skills/remix/references/middleware-and-server.md +68 -6
  95. package/template/.agents/skills/remix/references/mixins-styling-events.md +2 -0
  96. package/template/.agents/skills/remix/references/routing-and-controllers.md +8 -8
  97. package/template/.agents/skills/remix/references/testing-patterns.md +19 -18
  98. package/template/AGENTS.md +6 -3
  99. package/template/README.md +6 -3
  100. package/template/app/actions/controller.tsx +1 -1
  101. package/template/app/{ui → actions}/document.tsx +7 -5
  102. package/template/app/{ui/scaffold-home-page.tsx → actions/home-page.tsx} +1 -2
  103. package/template/app/actions/public/entry.ts +48 -0
  104. package/template/app/{assets → actions/public}/prompt-button.tsx +29 -25
  105. package/template/app/assets.ts +15 -2
  106. package/template/app/middleware/render.tsx +8 -2
  107. package/template/hmr.ts +46 -0
  108. package/template/package.json +1 -0
  109. package/template/server.ts +8 -1
  110. package/template/tsconfig.json +1 -1
  111. package/dist/lib/doctor/controller-fix-plans.d.ts +0 -4
  112. package/dist/lib/doctor/controller-fix-plans.d.ts.map +0 -1
  113. package/dist/lib/doctor/controller-fix-plans.js +0 -119
  114. package/dist/lib/doctor/controller-placeholders.d.ts +0 -3
  115. package/dist/lib/doctor/controller-placeholders.d.ts.map +0 -1
  116. package/dist/lib/doctor/controller-placeholders.js +0 -52
  117. package/src/lib/doctor/controller-fix-plans.ts +0 -168
  118. package/src/lib/doctor/controller-placeholders.ts +0 -70
  119. package/template/app/assets/entry.ts +0 -17
@@ -7,11 +7,12 @@ How server-rendered UI becomes interactive in the browser, and how the page upda
7
7
  - Marking a component for client-side hydration with `clientEntry`
8
8
  - Booting the client runtime with `run`
9
9
  - Streaming server content into a region of the page with `<Frame>` and reloading those regions
10
+ - Handling browser HMR updates for hydrated entries
10
11
  - Triggering Navigation API transitions with `navigate(...)` or `link(...)`
11
12
  - Server rendering with `renderToStream` or `renderToString`
12
13
  - Managing the document `<head>`
13
14
 
14
- For component-local state and updates, see `component-model.md`. For host-element behavior and events, see `mixins-styling-events.md`.
15
+ For component-local state and updates, see `component-model.md`. For host-element behavior and events, see `mixins-styling-events.md`. For browser asset HMR setup, see `assets-and-browser-modules.md`.
15
16
 
16
17
  ## Server First, Then Hydrate
17
18
 
@@ -75,9 +76,15 @@ let stream = renderToStream(<App />, {
75
76
  throw new Error(`Unable to resolve client entry export for ${entryId}`)
76
77
  }
77
78
 
79
+ let [href, preloads] = await Promise.all([
80
+ assetServer.getHref(entryId),
81
+ assetServer.getPreloads(entryId),
82
+ ])
83
+
78
84
  return {
79
- href: await assetServer.getHref(entryId),
85
+ href,
80
86
  exportName,
87
+ preloads,
81
88
  }
82
89
  },
83
90
  })
@@ -89,26 +96,46 @@ On the server, `clientEntry` components render like any other component. The ser
89
96
 
90
97
  Client entry props must be serializable: strings, numbers, booleans, `null`, `undefined`, plain objects/arrays of the above, JSX elements, and `<Frame>` elements. Functions and class instances cannot be passed.
91
98
 
99
+ The resolved `preloads` array contains browser module hrefs. During server rendering these are emitted as `<link rel="modulepreload">` tags, including preloads discovered in blocking frames. When a later frame response introduces a client entry, its preloads start before the entry module is loaded.
100
+
92
101
  ## Booting the Client
93
102
 
94
103
  Use `run` to start the client runtime. It scans the document for client entry markers, loads modules, and hydrates each one:
95
104
 
96
105
  ```tsx
106
+ import type { ResolveFrameOptions } from 'remix/ui'
97
107
  import { run } from 'remix/ui'
98
108
 
99
- let app = run({
109
+ const app = run({
100
110
  async loadModule(moduleUrl, exportName) {
101
111
  let mod = await import(moduleUrl)
102
112
  return mod[exportName]
103
113
  },
104
- async resolveFrame(src, signal, target) {
105
- let headers = new Headers({ accept: 'text/html' })
106
- if (target) headers.set('x-remix-target', target)
107
- let response = await fetch(src, { headers, signal })
114
+ async resolveFrame(src, options) {
115
+ let headers = new Headers({ accept: 'text/html', 'x-remix-frame': 'true' })
116
+ if (options?.target) headers.set('x-remix-target', options.target)
117
+ let response = await fetch(src, {
118
+ body: getRequestBody(options),
119
+ headers,
120
+ method: options?.method,
121
+ signal: options?.signal,
122
+ })
108
123
  return response.body ?? (await response.text())
109
124
  },
110
125
  })
111
126
 
127
+ function getRequestBody(options?: ResolveFrameOptions): BodyInit | undefined {
128
+ let formData = options?.formData
129
+ if (!formData) return
130
+ if (options.encType !== 'application/x-www-form-urlencoded') return formData
131
+
132
+ let body = new URLSearchParams()
133
+ for (let [name, value] of formData) {
134
+ body.append(name, typeof value === 'string' ? value : value.name)
135
+ }
136
+ return body
137
+ }
138
+
112
139
  app.addEventListener('error', (event) => {
113
140
  console.error('Component error:', event.error)
114
141
  })
@@ -119,7 +146,7 @@ await app.ready()
119
146
  ### `run` options
120
147
 
121
148
  - **`loadModule(moduleUrl, exportName)`** (required) — return the component function for each client entry. Typically uses dynamic `import()`.
122
- - **`resolveFrame(src, signal, target)`** (optional) — called when a `<Frame>` loads or reloads content. `target` is available when frame targeting matters.
149
+ - **`resolveFrame(src, options)`** (optional) — called when a `<Frame>` loads or reloads content and for intercepted link and form navigations. `options` may contain `signal` and `target`; non-GET forms also provide `formData`, `method`, and `encType`.
123
150
 
124
151
  ### `app` methods
125
152
 
@@ -129,6 +156,19 @@ await app.ready()
129
156
 
130
157
  `app` is an `EventTarget` that emits `error` events from any hydrated component.
131
158
 
159
+ ## Browser HMR Updates
160
+
161
+ When `remix/node-hmr` reports a server update, reload the top frame to apply the latest server-rendered document while preserving browser state:
162
+
163
+ ```tsx
164
+ if (import.meta.hot) {
165
+ import.meta.hot.on('server:update', async () => {
166
+ await app.ready()
167
+ await app.frames.top.reload()
168
+ })
169
+ }
170
+ ```
171
+
132
172
  ## Frames
133
173
 
134
174
  A `<Frame>` renders server content into the page. Frames stream after the initial HTML, nest inside other frames, contain client entries, and can be reloaded without full page navigation.
@@ -175,6 +215,20 @@ handle.frames.top.reload()
175
215
 
176
216
  When a frame reloads, matching DOM nodes are updated in place. Client entries receive updated props while preserving their local component state.
177
217
 
218
+ ### Form navigation
219
+
220
+ When `run({ resolveFrame })` is active, eligible same-origin forms progressively enhance into frame navigations. Native validation and the form's `submit` event still run first.
221
+
222
+ - Forms target `handle.frames.top` by default.
223
+ - `rmx-target` selects a named frame.
224
+ - `rmx-src` selects a different frame request URL while preserving the form action as the navigation destination.
225
+ - `rmx-history="push|replace"` overrides how the navigation updates history.
226
+ - `rmx-reset-scroll="false"` preserves scroll position.
227
+ - `rmx-document` opts back into a document submission.
228
+ - Cross-origin forms, `method="dialog"`, and `target="_blank"` remain browser-owned.
229
+
230
+ GET controls are already encoded in `src`, so GET forms reach the resolver like links. Non-GET forms provide their native `FormData`, effective method, and encoding. The resolver owns body encoding and method-override conventions. Non-GET submissions to the current URL replace its history entry; GET submissions and submissions to a different URL push one. The `rmx-history` attribute overrides those defaults.
231
+
178
232
  ### Nested frames
179
233
 
180
234
  Frames can nest. Each frame owns its own DOM region and hydrates client entries independently. During SSR, `handle.frame.src` points at the frame being rendered, while `handle.frames.top.src` stays fixed at the outer document URL.
@@ -238,7 +292,7 @@ navigate('/dashboard', { history: 'replace' })
238
292
 
239
293
  Options: `src`, `target`, `history` (`'push' | 'replace'`), `resetScroll`.
240
294
 
241
- Attributes understood by the runtime: `rmx-target`, `rmx-src`, `rmx-document`.
295
+ Attributes understood by the runtime: `rmx-target`, `rmx-src`, `rmx-history`, `rmx-reset-scroll`, `rmx-document`.
242
296
 
243
297
  ## Head Management
244
298
 
@@ -8,8 +8,9 @@ How to compose the request lifecycle and bridge the router to a runtime. Read th
8
8
  - Writing custom middleware that sets typed context values
9
9
  - Adding fast-exit handling (static files, CORS preflights) versus request-enriching layers (sessions, auth, data loading)
10
10
  - Choosing when to keep the generated Node server versus switching server adapters
11
+ - Running the app server under development HMR
11
12
 
12
- For data and persistence specifics, see `data-and-validation.md`. For session and auth specifics, see `auth-and-sessions.md`.
13
+ For data and persistence specifics, see `data-and-validation.md`. For session and auth specifics, see `auth-and-sessions.md`. For browser asset HMR, see `assets-and-browser-modules.md`.
13
14
 
14
15
  ## Middleware Stack
15
16
 
@@ -64,8 +65,9 @@ let router = createRouter({ middleware })
64
65
 
65
66
  ### Static files vs browser modules
66
67
 
67
- - Use `staticFiles()` for files that should be served directly from disk, such as images, fonts, or already-built assets in `public/`
68
+ - Use `staticFiles()` for files that should be served directly from disk, such as images, fonts, or already-built assets in the root `public/` directory
68
69
  - Use `remix/assets` when browser modules should be compiled and served from source files with import rewriting, preloads, or fingerprinted URLs
70
+ - `public/` directories inside `app/` hold browser-reachable source for the asset server
69
71
 
70
72
  ### Ordering notes
71
73
 
@@ -80,6 +82,7 @@ let router = createRouter({ middleware })
80
82
  - **Session-backed HTML app** -> `compression()`, `staticFiles()`, optional `cop()`, `formData()`, `methodOverride()`, `session()`, optional `csrf()`, `asyncContext()`, `auth({ schemes })`
81
83
  - **Cross-origin API** -> `compression()`, `cors()`, optional `asyncContext()`, optional `auth({ schemes })`
82
84
  - **Upload flow** -> `compression()`, `staticFiles()`, `formData({ uploadHandler })`, then sessions, auth, and data-loading middleware as needed
85
+ - **Optional development HMR** -> keep `server.ts` as the child app server, add `hmr.ts` for `remix/node-hmr`, and proxy public requests through `createHmrReadyFetch()`
83
86
 
84
87
  ### Middleware with options
85
88
 
@@ -116,11 +119,11 @@ Use `context.set(key, value)` to add typed values accessible downstream via `con
116
119
 
117
120
  ```typescript
118
121
  import type { Middleware } from 'remix/router'
119
- import { Database } from 'remix/data-table'
122
+ import { databaseContext } from '~/middleware/database.ts'
120
123
 
121
124
  export function loadDatabase(): Middleware {
122
125
  return async (context, next) => {
123
- context.set(Database, db)
126
+ context.set(databaseContext, db)
124
127
  return next()
125
128
  }
126
129
  }
@@ -150,11 +153,11 @@ export function requireAdmin(): Middleware {
150
153
  // app/utils/context.ts
151
154
  import { getContext } from 'remix/middleware/async-context'
152
155
  import { Auth } from 'remix/middleware/auth'
153
- import { Database } from 'remix/data-table'
156
+ import { databaseContext } from '~/middleware/database.ts'
154
157
  import { Session } from 'remix/session'
155
158
 
156
159
  export function getCurrentDb() {
157
- return getContext().get(Database)
160
+ return getContext().get(databaseContext)
158
161
  }
159
162
 
160
163
  export function getCurrentSession() {
@@ -214,3 +217,62 @@ Prefer inline arrays for `middleware` options. Use `RouterContext<typeof router>
214
217
  New apps already include a `server.ts` that adapts the app router with `remix/node-fetch-server`. Keep that generated server unless the task specifically needs to change runtime behavior such as host/protocol handling, TLS, HTTP/2, WebSockets, deployment lifecycle, or test-only server setup.
215
218
 
216
219
  Use `remix/node-fetch-server` when you want to keep owning a standard Node `http`, `https`, or `http2` server directly.
220
+
221
+ ## Development HMR
222
+
223
+ Treat HMR as an optional mode for rapid UI edits. Keep HMR supervision out of normal app code:
224
+ put the real app server in `server.ts`, keep the `dev` script running it directly (usually with
225
+ Node's watch mode), then add a development-only `hmr.ts` behind an `hmr` script when the project
226
+ benefits from HMR.
227
+
228
+ ```typescript
229
+ // hmr.ts
230
+ import * as http from 'node:http'
231
+
232
+ import { createFetchProxy } from 'remix/fetch-proxy'
233
+ import { createHmrReadyFetch, run } from 'remix/node-hmr'
234
+ import { createRequestListener } from 'remix/node-fetch-server'
235
+
236
+ const hmrProxyPort = 44100
237
+ const hmrEventPort = 44101
238
+ const appPort = 44102
239
+
240
+ const hmrRunner = run('./server.ts', {
241
+ env: {
242
+ ...process.env,
243
+ PORT: String(appPort),
244
+ HMR_PROXY_PORT: String(hmrProxyPort),
245
+ },
246
+ nodeArgs: ['--import', 'remix/node-tsx', '--import', 'remix/ui-hmr/node'],
247
+ browserHmrChannel: { port: hmrEventPort },
248
+ })
249
+
250
+ let proxyFetch = createFetchProxy(`http://127.0.0.1:${appPort}`, {
251
+ xForwardedHeaders: true,
252
+ })
253
+
254
+ let server = http.createServer(createRequestListener(createHmrReadyFetch(hmrRunner, proxyFetch)))
255
+
256
+ server.listen(hmrProxyPort, '127.0.0.1')
257
+ ```
258
+
259
+ Keep `browserHmrChannel.port` stable so browser HMR clients can reconnect to the same event channel if the dev server is manually restarted.
260
+
261
+ Use a stable public proxy when browser requests may happen while the child server is restarting. `createHmrReadyFetch()` waits for the active child generation before forwarding requests and retries safe unavailable responses when the child changes during a request.
262
+
263
+ In the child `server.ts`, report readiness after the server is listening:
264
+
265
+ ```typescript
266
+ server.listen(port, () => {
267
+ if (process.env.REMIX_NODE_HMR) {
268
+ import('remix/node-hmr/runtime').then((nodeHmr) => nodeHmr.emitServerReady())
269
+ }
270
+ })
271
+ ```
272
+
273
+ Rules:
274
+
275
+ - Guard `remix/node-hmr/runtime` imports with `process.env.REMIX_NODE_HMR`.
276
+ - Use `--import remix/ui-hmr/node` only when server-rendered Remix UI component modules should hot update.
277
+ - Keep the default development and production startup paths independent from `hmr.ts`.
278
+ - Close the public server and `hmrRunner` during `SIGINT` and `SIGTERM` shutdown.
@@ -115,6 +115,8 @@ Adds client-side navigation behavior to any element. Makes non-anchor elements b
115
115
 
116
116
  Options match `NavigationOptions`: `src`, `target`, `history` (`'push' | 'replace'`), `resetScroll`.
117
117
 
118
+ On a native anchor, the `history` option renders the corresponding `rmx-history="push|replace"` attribute so the enhanced navigation uses the same history behavior.
119
+
118
120
  ## Native press and keyboard interactions
119
121
 
120
122
  Use native DOM events directly with `on(...)`. For buttons and links, `click` already includes keyboard activation when the element has the right semantics:
@@ -103,7 +103,7 @@ export const search = createAction(routes.search, {
103
103
 
104
104
  The handler receives a context object with:
105
105
 
106
- - `get(key)` — read a value set by middleware (e.g. `get(Database)`, `get(Session)`, `get(Auth)`)
106
+ - `get(key)` — read a value set by middleware (e.g. `get(databaseContext)`, `get(Session)`, `get(Auth)`)
107
107
  - `params` — typed route params
108
108
  - `url` — the request URL
109
109
  - `request` — the raw `Request`
@@ -132,7 +132,7 @@ For pages, render a component tree and return the resulting `Response`:
132
132
 
133
133
  ```typescript
134
134
  async handler({ get }) {
135
- let db = get(Database)
135
+ let db = get(databaseContext)
136
136
  let books = await db.findMany(books, { orderBy: ['id', 'asc'] })
137
137
  return render(<IndexPage books={books} />)
138
138
  }
@@ -152,7 +152,7 @@ async create({ get }) {
152
152
  return render(<NewBookPage errors={parsed.issues} />, { status: 400 })
153
153
  }
154
154
 
155
- let db = get(Database)
155
+ let db = get(databaseContext)
156
156
  let book = await db.create(books, parsed.value)
157
157
 
158
158
  return redirect(routes.books.show.href({ slug: book.slug }), 303)
@@ -167,7 +167,7 @@ For expected failures — validation, conflict, not found — return a `Response
167
167
 
168
168
  ```typescript
169
169
  async show({ get, params }) {
170
- let db = get(Database)
170
+ let db = get(databaseContext)
171
171
  let book = await db.find(books, params.bookId)
172
172
  if (!book) return new Response('Not Found', { status: 404 })
173
173
  return render(<ShowPage book={book} />)
@@ -208,7 +208,7 @@ If you find yourself returning JSON for what is really a browser form submission
208
208
 
209
209
  A controller owns the direct leaf routes in one route map. Each key in `actions` matches a direct leaf route key in the route definition passed to `router.map(...)`. Nested route-map keys do not belong inside a controller's `actions`; map those route maps with their own controllers.
210
210
 
211
- Configure `RouterTypes.context` with your app context in the router module, then use `createController()` so `get(Database)`, `get(Session)`, `get(Auth)`, etc. are typed against your middleware stack without repeating a type clause on every controller.
211
+ Configure `RouterTypes.context` with your app context in the router module, then use `createController()` so `get(databaseContext)`, `get(Session)`, `get(Auth)`, etc. are typed against your middleware stack without repeating a type clause on every controller.
212
212
 
213
213
  ```typescript
214
214
  import { createController } from 'remix/router'
@@ -218,13 +218,13 @@ import { routes } from '../routes.ts'
218
218
  export default createController(routes.books, {
219
219
  actions: {
220
220
  async index({ get }) {
221
- let db = get(Database)
221
+ let db = get(databaseContext)
222
222
  let items = await db.findMany(books, { orderBy: ['id', 'asc'] })
223
223
  return render(<IndexPage items={items} />)
224
224
  },
225
225
 
226
226
  async show({ get, params }) {
227
- let db = get(Database)
227
+ let db = get(databaseContext)
228
228
  let book = await db.find(books, params.bookId)
229
229
  if (!book) return new Response('Not Found', { status: 404 })
230
230
  return render(<ShowPage book={book} />)
@@ -359,4 +359,4 @@ declare module 'remix/router' {
359
359
  }
360
360
  ```
361
361
 
362
- This gives typed `context.get(Database)`, `context.get(Session)`, `context.get(Auth)`, etc.
362
+ This gives typed `context.get(databaseContext)`, `context.get(Session)`, `context.get(Auth)`, etc.
@@ -70,28 +70,29 @@ try {
70
70
 
71
71
  ## Test Runner Config
72
72
 
73
- Configure discovery and coverage in `remix-test.config.ts` or with CLI flags:
74
-
75
- ```ts
76
- export default {
77
- glob: {
78
- test: '**/*.test{,.e2e}.{ts,tsx}',
79
- e2e: '**/*.test.e2e.{ts,tsx}',
80
- exclude: 'node_modules/**',
81
- },
82
- coverage: {
83
- dir: '.coverage',
84
- include: ['app/**/*.{ts,tsx}'],
85
- exclude: ['app/**/*.test.{ts,tsx}'],
86
- statements: 80,
87
- lines: 80,
88
- branches: 70,
89
- functions: 80,
73
+ Configure discovery and coverage in the `test` section of `remix.json` or with CLI flags:
74
+
75
+ ```jsonc
76
+ {
77
+ "$schema": "https://remix.run/schemas/remix.json",
78
+ "test": {
79
+ "files": ["**/*.test{,.e2e}.{ts,tsx}"],
80
+ "e2eFiles": ["**/*.test.e2e.{ts,tsx}"],
81
+ "exclude": ["node_modules/**"],
82
+ "coverage": {
83
+ "dir": ".coverage",
84
+ "include": ["app/**/*.{ts,tsx}"],
85
+ "exclude": ["app/**/*.test.{ts,tsx}"],
86
+ "statements": 80,
87
+ "lines": 80,
88
+ "branches": 70,
89
+ "functions": 80,
90
+ },
90
91
  },
91
92
  }
92
93
  ```
93
94
 
94
- Use `remix test --coverage` to enable coverage with defaults. Use `glob.exclude` when discovery would otherwise enter generated output, symlinked workspaces, or other paths that should not produce tests.
95
+ Use `remix test --coverage` to enable coverage with defaults. Use `exclude` when discovery would otherwise enter generated output, symlinked workspaces, or other paths that should not produce tests.
95
96
 
96
97
  ## Component Tests
97
98
 
@@ -6,6 +6,8 @@ This app was scaffolded with `remix new`. Use these conventions when continuing
6
6
 
7
7
  ```sh
8
8
  npm i
9
+ npm run dev
10
+ npm run hmr
9
11
  npm run start
10
12
  npm test
11
13
  npm run typecheck
@@ -18,12 +20,13 @@ Refer to ./.agents/skills/remix/SKILL.md
18
20
  ## Starter Layout
19
21
 
20
22
  - `app/actions/controller.tsx` owns the top-level route actions
21
- - `app/routes.ts` defines the route contract
23
+ - `app/actions/home-page.tsx` and `app/actions/document.tsx` render the route-owned starter UI
24
+ - `app/actions/public/` contains the browser runtime entry and interactive prompt button
25
+ - `app/routes.ts` defines the shared route contract used by server and browser modules for type-safe hrefs
22
26
  - `app/router.ts` wires routes to route handlers
23
27
  - `app/middleware/render.tsx` installs the request-scoped renderer used by actions
24
- - `app/ui/` holds the shared document shell and home page UI
25
28
  - `app/assets.ts` owns the server-side asset pipeline used by the asset route and renderer
26
- - `public/` contains static files served from the app root
29
+ - Root `public/` contains static files served unchanged from the app root
27
30
 
28
31
  ## Route Ownership
29
32
 
@@ -5,12 +5,13 @@ A minimal Remix application starter with a home page.
5
5
  ## Starter Shape
6
6
 
7
7
  - `app/actions/controller.tsx` owns the top-level route actions.
8
- - `app/routes.ts` defines the route contract.
8
+ - `app/actions/home-page.tsx` and `app/actions/document.tsx` render the route-owned starter UI.
9
+ - `app/actions/public/` contains the browser runtime entry and interactive prompt button.
10
+ - `app/routes.ts` defines the shared route contract used by server and browser modules for type-safe hrefs.
9
11
  - `app/router.ts` wires routes to handlers.
10
12
  - `app/middleware/render.tsx` installs the request-scoped renderer used by actions.
11
- - `app/ui/` holds the shared document shell and home page UI.
12
13
  - `app/assets.ts` owns the server-side asset pipeline used by the asset route and renderer.
13
- - `public/` contains static files served from the app root.
14
+ - Root `public/` contains static files served unchanged from the app root.
14
15
 
15
16
  ## Growing The App
16
17
 
@@ -23,6 +24,8 @@ A minimal Remix application starter with a home page.
23
24
 
24
25
  ```sh
25
26
  npm i
27
+ npm run dev
28
+ npm run hmr
26
29
  npm run start
27
30
  npm test
28
31
  npm run typecheck
@@ -2,7 +2,7 @@ import { createController } from 'remix/router'
2
2
 
3
3
  import { assetServer } from '../assets.ts'
4
4
  import { routes } from '../routes.ts'
5
- import { HomePage } from '../ui/scaffold-home-page.tsx'
5
+ import { HomePage } from './home-page.tsx'
6
6
 
7
7
  export default createController(routes, {
8
8
  actions: {
@@ -1,7 +1,7 @@
1
1
  import type { Handle, RemixNode } from 'remix/ui'
2
2
  import { css } from 'remix/ui'
3
3
 
4
- import { routes } from '../routes.ts'
4
+ import { entryHref, entryPreloads } from '../assets.ts'
5
5
 
6
6
  export interface DocumentProps {
7
7
  children?: RemixNode
@@ -20,14 +20,16 @@ export function Document(handle: Handle<DocumentProps>) {
20
20
  <head>
21
21
  <meta charSet="utf-8" />
22
22
  <meta name="viewport" content="width=device-width, initial-scale=1" />
23
+ <meta name="color-scheme" content="light dark" />
23
24
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
24
25
  <title>{title}</title>
25
26
  {head}
27
+ {entryPreloads.map((href) => (
28
+ <link key={href} rel="modulepreload" href={href} />
29
+ ))}
30
+ <script type="module" src={entryHref}></script>
26
31
  </head>
27
- <body mix={css({ margin: 0 })}>
28
- {children}
29
- <script type="module" src={routes.assets.href({ path: 'app/assets/entry.ts' })}></script>
30
- </body>
32
+ <body mix={css({ margin: 0 })}>{children}</body>
31
33
  </html>
32
34
  )
33
35
  }
@@ -2,8 +2,8 @@
2
2
  import type { Handle, RemixNode } from 'remix/ui'
3
3
  import { css } from 'remix/ui'
4
4
 
5
- import { PromptButton } from '../assets/prompt-button.tsx'
6
5
  import { Document } from './document.tsx'
6
+ import { PromptButton } from './public/prompt-button.tsx'
7
7
 
8
8
  const FONT_STACK =
9
9
  "'JetBrains Mono', ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace"
@@ -66,7 +66,6 @@ export function HomePage() {
66
66
  function HomeHead() {
67
67
  return () => (
68
68
  <>
69
- <meta name="color-scheme" content="light dark" />
70
69
  <link rel="preconnect" href="https://fonts.googleapis.com" />
71
70
  <link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
72
71
  <link
@@ -0,0 +1,48 @@
1
+ import { run } from 'remix/ui'
2
+
3
+ const app = run({
4
+ async loadModule(moduleUrl, exportName) {
5
+ let mod = await import(moduleUrl)
6
+ return mod[exportName]
7
+ },
8
+ async resolveFrame(src, options) {
9
+ let response = await fetch(src, {
10
+ headers: { Accept: 'text/html' },
11
+ method: options?.method,
12
+ body: getRequestBody(options?.formData, options?.method, options?.encType),
13
+ signal: options?.signal,
14
+ })
15
+ if (!response.ok) {
16
+ return `<pre>Frame error: ${response.status} ${response.statusText}</pre>`
17
+ }
18
+
19
+ if (response.body) return response.body
20
+ return await response.text()
21
+ },
22
+ })
23
+
24
+ if (import.meta.hot) {
25
+ import.meta.hot.on('server:update', async () => {
26
+ try {
27
+ await app.ready()
28
+ await app.frames.top.reload()
29
+ } catch (error) {
30
+ console.error('Error reloading top frame on server update', error)
31
+ }
32
+ })
33
+ }
34
+
35
+ function getRequestBody(
36
+ formData?: FormData,
37
+ method?: string,
38
+ encType?: string,
39
+ ): BodyInit | undefined {
40
+ if (!formData || method?.toLowerCase() === 'get') return
41
+ if (encType !== 'application/x-www-form-urlencoded') return formData
42
+
43
+ let body = new URLSearchParams()
44
+ for (let [name, value] of formData) {
45
+ body.append(name, typeof value === 'string' ? value : value.name)
46
+ }
47
+ return body
48
+ }
@@ -28,11 +28,6 @@ export const PromptButton = clientEntry(
28
28
  return (
29
29
  <button
30
30
  type="button"
31
- className={state}
32
- style={{
33
- background: active ? 'var(--surface-4)' : undefined,
34
- color: active ? 'var(--brand-blue)' : undefined,
35
- }}
36
31
  mix={[
37
32
  buttonStyle,
38
33
  on('click', async (_event, signal) => {
@@ -67,32 +62,23 @@ export const PromptButton = clientEntry(
67
62
  await handle.update()
68
63
  }),
69
64
  ]}
65
+ style={
66
+ active
67
+ ? {
68
+ background: 'var(--surface-4)',
69
+ color: 'var(--brand-blue)',
70
+ }
71
+ : undefined
72
+ }
70
73
  >
71
74
  <span aria-hidden="true" mix={iconSlotStyle}>
72
75
  <CopyIcon />
73
76
  </span>
74
- <span
75
- mix={css({
76
- alignItems: 'center',
77
- display: 'flex',
78
- fontSize: '14px',
79
- flex: '1 1 0',
80
- lineHeight: 1.5,
81
- minWidth: 0,
82
- position: 'relative',
83
- transition: 'opacity 180ms ease',
84
- })}
85
- style={{ opacity: state === 'resetting' ? 0 : undefined }}
86
- >
77
+ <span mix={labelSlotStyle} style={{ opacity: state === 'resetting' ? 0 : 1 }}>
87
78
  <span
88
79
  aria-hidden={state === 'idle' ? true : undefined}
89
- style={{
90
- alignItems: 'center',
91
- display: 'flex',
92
- inset: 0,
93
- position: 'absolute',
94
- visibility: state === 'idle' ? 'hidden' : 'visible',
95
- }}
80
+ mix={statusLabelStyle}
81
+ style={{ visibility: state === 'idle' ? 'hidden' : 'visible' }}
96
82
  >
97
83
  {label}
98
84
  </span>
@@ -148,6 +134,24 @@ const buttonStyle = css({
148
134
  },
149
135
  })
150
136
 
137
+ const labelSlotStyle = css({
138
+ alignItems: 'center',
139
+ display: 'flex',
140
+ fontSize: '14px',
141
+ flex: '1 1 0',
142
+ lineHeight: 1.5,
143
+ minWidth: 0,
144
+ position: 'relative',
145
+ transition: 'opacity 180ms ease',
146
+ })
147
+
148
+ const statusLabelStyle = css({
149
+ alignItems: 'center',
150
+ display: 'flex',
151
+ inset: 0,
152
+ position: 'absolute',
153
+ })
154
+
151
155
  const iconSlotStyle = css({
152
156
  flex: '0 0 24px',
153
157
  width: '24px',
@@ -1,8 +1,10 @@
1
1
  import { createAssetServer } from 'remix/assets'
2
+ import { uiHmr } from 'remix/ui-hmr/assets'
2
3
 
3
4
  const rootDir = process.cwd()
4
5
  const nodeEnv = process.env.NODE_ENV ?? 'development'
5
6
  const isDevelopment = nodeEnv === 'development'
7
+ const isHmr = Boolean(isDevelopment && process.env.REMIX_NODE_HMR)
6
8
 
7
9
  export const assetServer = createAssetServer({
8
10
  basePath: '/assets',
@@ -11,8 +13,19 @@ export const assetServer = createAssetServer({
11
13
  'app/*path': 'app/*path',
12
14
  'node_modules/*path': 'node_modules/*path',
13
15
  },
14
- allow: ['app/assets/**', 'node_modules/**'],
16
+ allowFiles: ['app/routes.ts', 'app/**/public/**'],
17
+ allowPackages: ['remix'],
18
+ denyFiles: ['app/**/*.test.*'],
15
19
  sourceMaps: isDevelopment ? 'external' : undefined,
16
20
  minify: !isDevelopment,
17
- watch: false,
21
+ watch: isDevelopment,
22
+ hmr: isHmr
23
+ ? async () => (await import('remix/node-hmr/runtime')).createBrowserHmrChannel()
24
+ : undefined,
25
+ scripts: { loaders: isHmr ? [uiHmr()] : undefined },
18
26
  })
27
+
28
+ const entry = 'app/actions/public/entry.ts'
29
+
30
+ export const entryHref = await assetServer.getHref(entry)
31
+ export const entryPreloads = await assetServer.getPreloads(entry)