@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.
- package/README.md +105 -1
- package/dist/index.js +1 -1
- package/dist/lib/app-root.d.ts +11 -0
- package/dist/lib/app-root.d.ts.map +1 -0
- package/dist/lib/app-root.js +38 -0
- package/dist/lib/bootstrap-project.js +2 -2
- package/dist/lib/cli-context.d.ts +6 -0
- package/dist/lib/cli-context.d.ts.map +1 -1
- package/dist/lib/cli-context.js +12 -1
- package/dist/lib/cli.d.ts +3 -2
- package/dist/lib/cli.d.ts.map +1 -1
- package/dist/lib/cli.js +42 -14
- package/dist/lib/commands/completion.js +4 -4
- package/dist/lib/commands/db.d.ts +4 -0
- package/dist/lib/commands/db.d.ts.map +1 -0
- package/dist/lib/commands/db.js +283 -0
- package/dist/lib/commands/doctor.d.ts +5 -0
- package/dist/lib/commands/doctor.d.ts.map +1 -1
- package/dist/lib/commands/doctor.js +26 -251
- package/dist/lib/commands/help.d.ts.map +1 -1
- package/dist/lib/commands/help.js +17 -9
- package/dist/lib/commands/new.js +6 -6
- package/dist/lib/commands/routes.js +6 -6
- package/dist/lib/commands/test.d.ts +20 -1
- package/dist/lib/commands/test.d.ts.map +1 -1
- package/dist/lib/commands/test.js +353 -10
- package/dist/lib/commands/version.js +3 -3
- package/dist/lib/completion.d.ts +1 -1
- package/dist/lib/completion.d.ts.map +1 -1
- package/dist/lib/completion.js +229 -6
- package/dist/lib/controller-files.d.ts +0 -8
- package/dist/lib/controller-files.d.ts.map +1 -1
- package/dist/lib/controller-files.js +0 -23
- package/dist/lib/controller-ownership.d.ts +4 -12
- package/dist/lib/controller-ownership.d.ts.map +1 -1
- package/dist/lib/controller-ownership.js +31 -127
- package/dist/lib/database-command.d.ts +20 -0
- package/dist/lib/database-command.d.ts.map +1 -0
- package/dist/lib/database-command.js +7 -0
- package/dist/lib/doctor/controller-findings.d.ts +2 -2
- package/dist/lib/doctor/controller-findings.d.ts.map +1 -1
- package/dist/lib/doctor/controller-findings.js +23 -39
- package/dist/lib/doctor/controllers.d.ts +1 -2
- package/dist/lib/doctor/controllers.d.ts.map +1 -1
- package/dist/lib/doctor/controllers.js +4 -7
- package/dist/lib/doctor/environment.js +2 -2
- package/dist/lib/doctor/fixes.js +1 -1
- package/dist/lib/doctor/project.d.ts.map +1 -1
- package/dist/lib/doctor/project.js +3 -212
- package/dist/lib/doctor/run.d.ts +9 -0
- package/dist/lib/doctor/run.d.ts.map +1 -0
- package/dist/lib/doctor/run.js +237 -0
- package/dist/lib/doctor/types.d.ts +2 -1
- package/dist/lib/doctor/types.d.ts.map +1 -1
- package/dist/lib/doctor/types.js +4 -1
- package/dist/lib/errors.d.ts +30 -0
- package/dist/lib/errors.d.ts.map +1 -1
- package/dist/lib/errors.js +55 -0
- package/dist/lib/help-text.js +1 -1
- package/dist/lib/parse-args.d.ts.map +1 -1
- package/dist/lib/parse-args.js +17 -4
- package/dist/lib/remix-config.d.ts +75 -0
- package/dist/lib/remix-config.d.ts.map +1 -0
- package/dist/lib/remix-config.js +411 -0
- package/dist/lib/reporter.js +1 -1
- package/dist/lib/route-map.js +13 -33
- package/package.json +13 -7
- package/schema/remix.json +242 -0
- package/src/lib/app-root.ts +46 -0
- package/src/lib/cli-context.ts +17 -0
- package/src/lib/cli.ts +41 -6
- package/src/lib/commands/db.ts +377 -0
- package/src/lib/commands/doctor.ts +27 -329
- package/src/lib/commands/help.ts +10 -1
- package/src/lib/commands/test.ts +453 -12
- package/src/lib/completion.ts +321 -6
- package/src/lib/controller-files.ts +0 -38
- package/src/lib/controller-ownership.ts +50 -203
- package/src/lib/database-command.ts +31 -0
- package/src/lib/doctor/controller-findings.ts +30 -47
- package/src/lib/doctor/controllers.ts +2 -6
- package/src/lib/doctor/project.ts +0 -236
- package/src/lib/doctor/run.ts +311 -0
- package/src/lib/doctor/types.ts +6 -5
- package/src/lib/errors.ts +65 -0
- package/src/lib/parse-args.ts +19 -3
- package/src/lib/remix-config.ts +639 -0
- package/src/lib/route-map.ts +11 -48
- package/template/.agents/skills/remix/SKILL.md +38 -27
- package/template/.agents/skills/remix/references/assets-and-browser-modules.md +54 -11
- package/template/.agents/skills/remix/references/auth-and-sessions.md +5 -5
- package/template/.agents/skills/remix/references/data-and-validation.md +15 -15
- package/template/.agents/skills/remix/references/hydration-frames-navigation.md +63 -9
- package/template/.agents/skills/remix/references/middleware-and-server.md +68 -6
- package/template/.agents/skills/remix/references/mixins-styling-events.md +2 -0
- package/template/.agents/skills/remix/references/routing-and-controllers.md +8 -8
- package/template/.agents/skills/remix/references/testing-patterns.md +19 -18
- package/template/AGENTS.md +6 -3
- package/template/README.md +6 -3
- package/template/app/actions/controller.tsx +1 -1
- package/template/app/{ui → actions}/document.tsx +7 -5
- package/template/app/{ui/scaffold-home-page.tsx → actions/home-page.tsx} +1 -2
- package/template/app/actions/public/entry.ts +48 -0
- package/template/app/{assets → actions/public}/prompt-button.tsx +29 -25
- package/template/app/assets.ts +15 -2
- package/template/app/middleware/render.tsx +8 -2
- package/template/hmr.ts +46 -0
- package/template/package.json +1 -0
- package/template/server.ts +8 -1
- package/template/tsconfig.json +1 -1
- package/dist/lib/doctor/controller-fix-plans.d.ts +0 -4
- package/dist/lib/doctor/controller-fix-plans.d.ts.map +0 -1
- package/dist/lib/doctor/controller-fix-plans.js +0 -119
- package/dist/lib/doctor/controller-placeholders.d.ts +0 -3
- package/dist/lib/doctor/controller-placeholders.d.ts.map +0 -1
- package/dist/lib/doctor/controller-placeholders.js +0 -52
- package/src/lib/doctor/controller-fix-plans.ts +0 -168
- package/src/lib/doctor/controller-placeholders.ts +0 -70
- 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
|
|
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
|
-
|
|
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,
|
|
105
|
-
let headers = new Headers({ accept: 'text/html' })
|
|
106
|
-
if (target) headers.set('x-remix-target', target)
|
|
107
|
-
let response = await fetch(src, {
|
|
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,
|
|
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 {
|
|
122
|
+
import { databaseContext } from '~/middleware/database.ts'
|
|
120
123
|
|
|
121
124
|
export function loadDatabase(): Middleware {
|
|
122
125
|
return async (context, next) => {
|
|
123
|
-
context.set(
|
|
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 {
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
74
|
-
|
|
75
|
-
```
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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 `
|
|
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
|
|
package/template/AGENTS.md
CHANGED
|
@@ -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/
|
|
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
|
|
package/template/README.md
CHANGED
|
@@ -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/
|
|
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 '
|
|
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 {
|
|
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
|
-
|
|
90
|
-
|
|
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',
|
package/template/app/assets.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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)
|