@remix-run/cli 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +0 -3
  2. package/bootstrap/.agents/skills/remix/SKILL.md +501 -0
  3. package/bootstrap/.agents/skills/remix/references/animate-elements.md +195 -0
  4. package/bootstrap/.agents/skills/remix/references/assets-and-browser-modules.md +122 -0
  5. package/bootstrap/.agents/skills/remix/references/auth-and-sessions.md +420 -0
  6. package/bootstrap/.agents/skills/remix/references/component-model.md +282 -0
  7. package/bootstrap/.agents/skills/remix/references/create-mixins.md +158 -0
  8. package/bootstrap/.agents/skills/remix/references/data-and-validation.md +363 -0
  9. package/bootstrap/.agents/skills/remix/references/hydration-frames-navigation.md +297 -0
  10. package/bootstrap/.agents/skills/remix/references/middleware-and-server.md +243 -0
  11. package/bootstrap/.agents/skills/remix/references/mixins-styling-events.md +213 -0
  12. package/bootstrap/.agents/skills/remix/references/routing-and-controllers.md +324 -0
  13. package/bootstrap/.agents/skills/remix/references/testing-patterns.md +156 -0
  14. package/bootstrap/AGENTS.md +4 -0
  15. package/bootstrap/app/assets/entry.ts +19 -0
  16. package/bootstrap/app/assets.ts +18 -0
  17. package/bootstrap/app/controllers/auth.tsx +2 -2
  18. package/bootstrap/app/controllers/home.tsx +3 -18
  19. package/bootstrap/app/router.ts +6 -0
  20. package/bootstrap/app/routes.ts +2 -1
  21. package/bootstrap/app/ui/document.tsx +6 -1
  22. package/bootstrap/app/ui/prompt-button.tsx +162 -0
  23. package/bootstrap/app/ui/scaffold-home-page.tsx +526 -0
  24. package/bootstrap/app/utils/render.tsx +22 -3
  25. package/bootstrap/server.ts +13 -13
  26. package/bootstrap/tsconfig.json +0 -1
  27. package/dist/lib/cli.d.ts.map +1 -1
  28. package/dist/lib/cli.js +7 -10
  29. package/dist/lib/commands/help.d.ts.map +1 -1
  30. package/dist/lib/commands/help.js +9 -33
  31. package/dist/lib/commands/test.d.ts +1 -1
  32. package/dist/lib/commands/test.d.ts.map +1 -1
  33. package/dist/lib/commands/test.js +8 -4
  34. package/dist/lib/completion.d.ts.map +1 -1
  35. package/dist/lib/completion.js +3 -101
  36. package/dist/lib/errors.d.ts +0 -6
  37. package/dist/lib/errors.d.ts.map +1 -1
  38. package/dist/lib/errors.js +0 -11
  39. package/package.json +3 -4
  40. package/src/lib/cli.ts +7 -11
  41. package/src/lib/commands/help.ts +9 -43
  42. package/src/lib/commands/test.ts +10 -4
  43. package/src/lib/completion.ts +3 -146
  44. package/src/lib/errors.ts +0 -12
  45. package/dist/lib/commands/skills.d.ts +0 -6
  46. package/dist/lib/commands/skills.d.ts.map +0 -1
  47. package/dist/lib/commands/skills.js +0 -222
  48. package/dist/lib/skills-cache.d.ts +0 -19
  49. package/dist/lib/skills-cache.d.ts.map +0 -1
  50. package/dist/lib/skills-cache.js +0 -89
  51. package/dist/lib/skills.d.ts +0 -30
  52. package/dist/lib/skills.d.ts.map +0 -1
  53. package/dist/lib/skills.js +0 -441
  54. package/src/lib/commands/skills.ts +0 -306
  55. package/src/lib/skills-cache.ts +0 -140
  56. package/src/lib/skills.ts +0 -706
@@ -0,0 +1,420 @@
1
+ # Authentication and Sessions
2
+
3
+ ## What This Covers
4
+
5
+ How to remember things about a browser between requests and how to identify a user. Read this when
6
+ the task involves:
7
+
8
+ - Storing per-browser state across requests (login, cart, "I have submitted this form")
9
+ - Adding a credentials login flow or an OAuth provider
10
+ - Protecting routes with `requireAuth()` or stacking authorization checks
11
+ - Reading or writing `Session`, `Auth`, or other identity-related context values
12
+ - Logging in, logging out, or rotating session IDs
13
+
14
+ For raw cookies that are not session-backed (theme, locale, dismissed-banner), see
15
+ `createCookie` in this file plus the broader `Package Map` in `SKILL.md`.
16
+
17
+ ## Sessions vs Plain Cookies
18
+
19
+ Reach for `remix/session` when state is sensitive, must be tamper-resistant, or represents the
20
+ identity of a request: who is logged in, which form a browser already submitted, what items are in
21
+ a cart. Sessions sign or encrypt their backing cookie with a server-held secret and give you a
22
+ typed `Session` object you can `get`, `set`, `flash`, `unset`, and `regenerateId`.
23
+
24
+ Reach for `remix/cookie` directly when the browser is allowed to carry the value and the server
25
+ does not need session semantics. This often means preferences (theme, locale, dismissed banner),
26
+ but a signed cookie can also be fine for small low-risk values where you truly only need one
27
+ cookie-shaped fact and do not need `Session` helpers.
28
+
29
+ If a malicious user editing the value would be a bug, or if the value needs server-managed
30
+ lifecycle, reach for a session.
31
+
32
+ ### Quick chooser
33
+
34
+ | Need | Best fit | Why |
35
+ | ------------------------------------------------------------------- | --------------- | -------------------------------------------------- |
36
+ | Theme, locale, dismissed banner | `remix/cookie` | Browser-controlled preference |
37
+ | Small signed hint with minimal lifecycle | `remix/cookie` | One value, no `Session` helpers needed |
38
+ | "This browser already submitted", cart, flash messages, login state | `remix/session` | Tamper-sensitive, server-managed per-browser state |
39
+ | "One real person only", ownership, durable identity | account/auth | Cookies or sessions alone do not prove personhood |
40
+
41
+ ## Session Setup
42
+
43
+ ### Create a session cookie
44
+
45
+ ```typescript
46
+ import { createCookie } from 'remix/cookie'
47
+
48
+ let sessionSecret = process.env.SESSION_SECRET
49
+ if (!sessionSecret && process.env.NODE_ENV !== 'test') {
50
+ throw new Error('SESSION_SECRET is required')
51
+ }
52
+
53
+ export let sessionCookie = createCookie('session', {
54
+ secrets: [sessionSecret ?? 'test-only-secret'],
55
+ httpOnly: true,
56
+ sameSite: 'Lax',
57
+ secure: process.env.NODE_ENV === 'production',
58
+ maxAge: 2592000, // 30 days
59
+ path: '/',
60
+ })
61
+ ```
62
+
63
+ The cookie should always be `httpOnly`, default to `sameSite: 'Lax'`, and be `secure` in
64
+ production. Demo defaults like `'s3cr3t'` are fine in tests but should never reach production —
65
+ fail fast when the secret is missing.
66
+
67
+ ### Create session storage
68
+
69
+ ```typescript
70
+ // Filesystem storage
71
+ import { createFsSessionStorage } from 'remix/session/fs-storage'
72
+ export let sessionStorage = createFsSessionStorage('./tmp/sessions')
73
+
74
+ // Memory storage (for tests)
75
+ import { createMemorySessionStorage } from 'remix/session/memory-storage'
76
+ export let sessionStorage = createMemorySessionStorage()
77
+ ```
78
+
79
+ ### Add session middleware
80
+
81
+ ```typescript
82
+ import { session } from 'remix/session-middleware'
83
+
84
+ let router = createRouter({
85
+ middleware: [
86
+ session(sessionCookie, sessionStorage),
87
+ // ... other middleware
88
+ ],
89
+ })
90
+ ```
91
+
92
+ ### Using sessions in handlers
93
+
94
+ ```typescript
95
+ import { Session } from 'remix/session'
96
+
97
+ async function handler({ get }) {
98
+ let session = get(Session)
99
+
100
+ // Read
101
+ let userId = session.get('userId')
102
+
103
+ // Write
104
+ session.set('userId', 42)
105
+
106
+ // Flash (read once, then cleared)
107
+ session.flash('message', 'Settings saved!')
108
+ let message = session.get('message') // returns and clears
109
+
110
+ // Remove a key
111
+ session.unset('userId')
112
+
113
+ // Regenerate session ID (after login/logout)
114
+ session.regenerateId(true)
115
+ }
116
+ ```
117
+
118
+ ### Sessions for non-auth state
119
+
120
+ Sessions are not just for login. They are the right place to store any tamper-sensitive
121
+ per-browser fact: which form a browser already submitted, how many free actions are left in a
122
+ trial, which feature flags a tester opted into, what items are in a cart.
123
+
124
+ ```typescript
125
+ async function submit({ get }) {
126
+ let session = get(Session)
127
+ if (session.get('hasSubmitted')) {
128
+ return render(<AlreadySubmittedPage />, { status: 409 })
129
+ }
130
+
131
+ let parsed = s.parseSafe(submitSchema, get(FormData))
132
+ if (!parsed.success) {
133
+ return render(<SubmitPage errors={parsed.issues} />, { status: 400 })
134
+ }
135
+
136
+ await saveSubmission(parsed.value)
137
+ session.set('hasSubmitted', true)
138
+ session.flash('message', 'Thanks for submitting!')
139
+
140
+ return redirect(routes.thanks.href())
141
+ }
142
+ ```
143
+
144
+ Notice that there is no manual `Set-Cookie` plumbing in the action — the session middleware handles
145
+ that, and the handler returns an ordinary `Response`. Per-browser state enforced this way is still
146
+ bypassable by clearing cookies; if the guarantee needs to survive that, you also need an account
147
+ (see auth providers below).
148
+
149
+ ## Auth Middleware
150
+
151
+ ### Basic setup
152
+
153
+ ```typescript
154
+ import { auth, createSessionAuthScheme } from 'remix/auth-middleware'
155
+ import { Session } from 'remix/session'
156
+ import { Database } from 'remix/data-table'
157
+
158
+ export function loadAuth() {
159
+ return auth({
160
+ schemes: [
161
+ createSessionAuthScheme({
162
+ read(session) {
163
+ let data = session.get('auth')
164
+ return data ?? null
165
+ },
166
+ async verify(value, context) {
167
+ let db = context.get(Database)
168
+ return (await db.find(users, value.userId)) ?? null
169
+ },
170
+ invalidate(session) {
171
+ session.unset('auth')
172
+ },
173
+ }),
174
+ ],
175
+ })
176
+ }
177
+ ```
178
+
179
+ ### Reading auth state
180
+
181
+ ```typescript
182
+ import { Auth } from 'remix/auth-middleware'
183
+
184
+ function handler({ get }) {
185
+ let auth = get(Auth)
186
+
187
+ if (auth.ok) {
188
+ // User is authenticated
189
+ let user = auth.identity
190
+ }
191
+ }
192
+ ```
193
+
194
+ ## Credentials Auth
195
+
196
+ ### Define a credentials provider
197
+
198
+ ```typescript
199
+ import { createCredentialsAuthProvider, verifyCredentials, completeAuth } from 'remix/auth'
200
+ import * as s from 'remix/data-schema'
201
+ import * as f from 'remix/data-schema/form-data'
202
+
203
+ let loginSchema = f.object({
204
+ email: f.field(s.defaulted(s.string(), '')),
205
+ password: f.field(s.defaulted(s.string(), '')),
206
+ })
207
+
208
+ export let passwordProvider = createCredentialsAuthProvider({
209
+ parse(context) {
210
+ let formData = context.get(FormData)
211
+ return s.parse(loginSchema, formData)
212
+ },
213
+ async verify({ email, password }, context) {
214
+ let db = context.get(Database)
215
+ let user = await db.findOne(users, { where: { email } })
216
+ if (!user || !(await verifyPassword(password, user.password_hash))) {
217
+ return null
218
+ }
219
+ return user
220
+ },
221
+ })
222
+ ```
223
+
224
+ ### Login action
225
+
226
+ ```typescript
227
+ import { verifyCredentials, completeAuth } from 'remix/auth'
228
+ import { redirect } from 'remix/response/redirect'
229
+
230
+ async action(context) {
231
+ let user = await verifyCredentials(passwordProvider, context)
232
+
233
+ if (user == null) {
234
+ let session = context.get(Session)
235
+ session.flash('error', 'Invalid email or password.')
236
+ return redirect(routes.auth.login.href())
237
+ }
238
+
239
+ let session = completeAuth(context)
240
+ session.set('auth', { userId: user.id })
241
+
242
+ return redirect(routes.home.href())
243
+ },
244
+ ```
245
+
246
+ ### Logout action
247
+
248
+ ```typescript
249
+ import { Session } from 'remix/session'
250
+ import { redirect } from 'remix/response/redirect'
251
+
252
+ function logout(context) {
253
+ let session = context.get(Session)
254
+ session.unset('auth')
255
+ session.regenerateId(true)
256
+ return redirect(routes.home.href())
257
+ }
258
+ ```
259
+
260
+ ## OAuth / External Auth
261
+
262
+ ### Create providers
263
+
264
+ ```typescript
265
+ import {
266
+ createAtmosphereAuthProvider,
267
+ createGoogleAuthProvider,
268
+ createGitHubAuthProvider,
269
+ startExternalAuth,
270
+ finishExternalAuth,
271
+ completeAuth,
272
+ refreshExternalAuth,
273
+ } from 'remix/auth'
274
+
275
+ let googleProvider = createGoogleAuthProvider({
276
+ clientId: process.env.GOOGLE_CLIENT_ID,
277
+ clientSecret: process.env.GOOGLE_CLIENT_SECRET,
278
+ redirectUri: new URL(routes.auth.google.callback.href(), origin),
279
+ })
280
+
281
+ let githubProvider = createGitHubAuthProvider({
282
+ clientId: process.env.GITHUB_CLIENT_ID,
283
+ clientSecret: process.env.GITHUB_CLIENT_SECRET,
284
+ redirectUri: new URL(routes.auth.github.callback.href(), origin),
285
+ })
286
+
287
+ let atmosphereSessionSecret = process.env.ATMOSPHERE_SESSION_SECRET
288
+ if (!atmosphereSessionSecret && process.env.NODE_ENV !== 'test') {
289
+ throw new Error('ATMOSPHERE_SESSION_SECRET is required')
290
+ }
291
+
292
+ let atmosphereProvider = createAtmosphereAuthProvider({
293
+ clientId: 'https://app.example.com/oauth/client-metadata.json',
294
+ redirectUri: new URL(routes.auth.atmosphere.callback.href(), origin),
295
+ sessionSecret: atmosphereSessionSecret ?? 'test-only-secret',
296
+ })
297
+ ```
298
+
299
+ For Atmosphere-compatible atproto OAuth, create the provider once, call
300
+ `atmosphereProvider.prepare(handleOrDid)` before `startExternalAuth(...)`, then pass the same
301
+ module-scope provider to `finishExternalAuth(...)` and `refreshExternalAuth(...)`.
302
+
303
+ ### OAuth controller
304
+
305
+ ```typescript
306
+ export default {
307
+ actions: {
308
+ // GET /auth/google — redirect to Google
309
+ async index(context) {
310
+ return await startExternalAuth(googleProvider, context, {
311
+ returnTo: context.url.searchParams.get('returnTo'),
312
+ })
313
+ },
314
+
315
+ // GET /auth/google/callback — handle redirect back
316
+ async callback(context) {
317
+ let { result, returnTo } = await finishExternalAuth(googleProvider, context)
318
+
319
+ let db = context.get(Database)
320
+ let { user, authAccount } = await resolveExternalAuth(db, result)
321
+
322
+ let session = completeAuth(context)
323
+ session.set('auth', {
324
+ userId: user.id,
325
+ loginMethod: result.provider,
326
+ authAccountId: authAccount.id,
327
+ })
328
+
329
+ return redirect(returnTo ?? routes.account.href())
330
+ },
331
+ },
332
+ } satisfies Controller<typeof routes.auth.google>
333
+ ```
334
+
335
+ ### Refresh stored provider tokens
336
+
337
+ Use `refreshExternalAuth(provider, tokens)` when an app has stored OAuth/OIDC tokens and needs a
338
+ fresh access token from a refresh token. Built-in OIDC providers, X, and Atmosphere support
339
+ refresh-token exchange. If the provider does not rotate the refresh token, the refreshed bundle
340
+ preserves the current one.
341
+
342
+ ```typescript
343
+ async function refreshGoogleTokens({ get }) {
344
+ let db = get(Database)
345
+ let account = await db.findOne(authAccounts, { where: { provider: 'google' } })
346
+ if (!account) return null
347
+
348
+ let refreshed = await refreshExternalAuth(googleProvider, account.tokens)
349
+ await db.update(authAccounts, account.id, { tokens: refreshed.tokens })
350
+
351
+ return refreshed.tokens
352
+ }
353
+ ```
354
+
355
+ ## Protecting Routes
356
+
357
+ ### Controller-level protection
358
+
359
+ Apply `requireAuth()` to an entire controller subtree:
360
+
361
+ ```typescript
362
+ import { requireAuth } from 'remix/auth-middleware'
363
+
364
+ export default {
365
+ middleware: [requireAuth()],
366
+ actions: {
367
+ index() {
368
+ /* guaranteed authenticated */
369
+ },
370
+ settings: settingsController,
371
+ },
372
+ } satisfies Controller<typeof routes.account>
373
+ ```
374
+
375
+ ### Stacking middleware
376
+
377
+ Combine auth checks with role checks:
378
+
379
+ ```typescript
380
+ export default {
381
+ middleware: [requireAuth(), requireAdmin()],
382
+ actions: {
383
+ index() {
384
+ /* requires auth + admin */
385
+ },
386
+ },
387
+ } satisfies Controller<typeof routes.admin>
388
+ ```
389
+
390
+ ### Action-level protection
391
+
392
+ Apply middleware to a single route:
393
+
394
+ ```typescript
395
+ import { Auth, requireAuth } from 'remix/auth-middleware'
396
+
397
+ router.get(routes.account, {
398
+ middleware: [requireAuth()],
399
+ handler(context) {
400
+ let auth = context.get(Auth)
401
+ return render(<AccountPage identity={auth.identity} />)
402
+ },
403
+ })
404
+ ```
405
+
406
+ ### Redirect on auth failure
407
+
408
+ ```typescript
409
+ import { requireAuth } from 'remix/auth-middleware'
410
+ import { redirect } from 'remix/response/redirect'
411
+
412
+ export function requireAuthRedirect() {
413
+ return requireAuth({
414
+ onFailure(context) {
415
+ let returnTo = encodeURIComponent(context.url.pathname)
416
+ return redirect(routes.auth.login.href() + `?returnTo=${returnTo}`, 303)
417
+ },
418
+ })
419
+ }
420
+ ```
@@ -0,0 +1,282 @@
1
+ # Component Model
2
+
3
+ ## What This Covers
4
+
5
+ How a Remix Component is shaped and how its state, lifecycle, and updates behave. Read this when
6
+ the task involves:
7
+
8
+ - Writing a component (`handle` plus render function)
9
+ - Managing component-local state, derived values, or post-render DOM work
10
+ - Using `handle.props`, `handle.update()`, `handle.queueTask()`, `handle.signal`, `handle.id`, or
11
+ `handle.context`
12
+ - Listening to global events with cleanup tied to the component lifecycle
13
+
14
+ For host-element behavior (event handlers, styles, refs, animations), see
15
+ `mixins-styling-events.md`. For browser hydration, frames, and navigation, see
16
+ `hydration-frames-navigation.md`.
17
+
18
+ ## Phases
19
+
20
+ A component has two phases:
21
+
22
+ 1. **Setup phase** — runs once when the component is created
23
+ 2. **Render phase** — returned function runs on initial render and every update
24
+
25
+ ```tsx
26
+ import { on, type Handle } from 'remix/ui'
27
+
28
+ function Counter(handle: Handle<{ initialCount?: number; label: string }>) {
29
+ let count = handle.props.initialCount ?? 0
30
+
31
+ return () => (
32
+ <button
33
+ mix={on('click', () => {
34
+ count++
35
+ handle.update()
36
+ })}
37
+ >
38
+ {handle.props.label}: {count}
39
+ </button>
40
+ )
41
+ }
42
+ ```
43
+
44
+ ## Props
45
+
46
+ Components receive all JSX props through `handle.props`. The object identity is stable for the
47
+ component lifetime, and its values are updated before each render. Put initialization inputs on
48
+ normal JSX props and read them from `handle.props`:
49
+
50
+ ```tsx
51
+ function Timer(handle: Handle<{ initialSeconds: number; paused?: boolean }>) {
52
+ let seconds = handle.props.initialSeconds
53
+
54
+ return () => <div>Time remaining: {seconds}s</div>
55
+ }
56
+
57
+ // Usage: <Timer initialSeconds={60} paused={false} />
58
+ ```
59
+
60
+ Because `handle.props` is stable, destructuring `let { props } = handle` is safe when helpers need
61
+ to read current values later. Destructuring individual prop values is only a snapshot; prefer
62
+ `handle.props.name` inside callbacks and render output when values can change.
63
+
64
+ ## State Rules
65
+
66
+ - Keep state in setup scope as plain JavaScript variables.
67
+ - Store only what affects rendering. Derive computed values in render.
68
+ - Do not mirror input state unless you truly need controlled behavior.
69
+ - Do work in event handlers, not in render. Use the handler scope for transient state.
70
+
71
+ ```tsx
72
+ // Derive computed values in render
73
+ function TodoList(handle: Handle) {
74
+ let todos: Array<{ text: string; completed: boolean }> = []
75
+
76
+ return () => {
77
+ let completedCount = todos.filter((t) => t.completed).length
78
+ return <div>Completed: {completedCount}</div>
79
+ }
80
+ }
81
+ ```
82
+
83
+ ## Handle API
84
+
85
+ ### `handle.update()`
86
+
87
+ Schedules a rerender. Returns a promise that resolves with an `AbortSignal` after the update
88
+ completes. Await it when you need the updated DOM before follow-up work:
89
+
90
+ ```tsx
91
+ on('click', async () => {
92
+ isPlaying = true
93
+ let signal = await handle.update()
94
+ // DOM is now updated, safe to focus or measure
95
+ stopButton.focus()
96
+ })
97
+ ```
98
+
99
+ ### `handle.queueTask(task)`
100
+
101
+ Schedules a task to run after the next update. The task receives an `AbortSignal` that aborts when
102
+ the component re-renders or is removed. Use for post-render DOM work, reactive data loading, or
103
+ hydration-sensitive setup:
104
+
105
+ ```tsx
106
+ let data = null
107
+ let requestedUrl: string | null = null
108
+
109
+ // Post-render DOM work in an event handler
110
+ on('click', () => {
111
+ showDetails = true
112
+ handle.update()
113
+ handle.queueTask(() => {
114
+ detailsSection.scrollIntoView({ behavior: 'smooth' })
115
+ })
116
+ })
117
+
118
+ // Reactive data loading keyed by props.url
119
+ return () => {
120
+ if (requestedUrl !== handle.props.url) {
121
+ let nextUrl = handle.props.url
122
+ requestedUrl = nextUrl
123
+ data = null
124
+
125
+ handle.queueTask(async (signal) => {
126
+ let response = await fetch(nextUrl, { signal })
127
+ let json = await response.json()
128
+ if (signal.aborted || requestedUrl !== nextUrl) return
129
+ data = json
130
+ handle.update()
131
+ })
132
+ }
133
+
134
+ return <div>{data ?? 'Loading...'}</div>
135
+ }
136
+ ```
137
+
138
+ Avoid creating intermediate state just to trigger `queueTask`. Do the work directly in the handler
139
+ or the queued task.
140
+
141
+ ### `handle.signal`
142
+
143
+ An `AbortSignal` aborted when the component disconnects. Use for cleanup:
144
+
145
+ ```tsx
146
+ function Clock(handle: Handle) {
147
+ let interval = setInterval(handle.update, 1000)
148
+ handle.signal.addEventListener('abort', () => clearInterval(interval))
149
+
150
+ return () => <span>{new Date().toString()}</span>
151
+ }
152
+ ```
153
+
154
+ ### `handle.id`
155
+
156
+ Stable identifier per component instance. Useful for `htmlFor`, `aria-owns`, etc.:
157
+
158
+ ```tsx
159
+ function LabeledInput(handle: Handle) {
160
+ return () => (
161
+ <div>
162
+ <label htmlFor={handle.id}>Name</label>
163
+ <input id={handle.id} type="text" />
164
+ </div>
165
+ )
166
+ }
167
+ ```
168
+
169
+ ### `handle.frame` and `handle.frames`
170
+
171
+ Frame-aware behavior for client entries rendered inside frames:
172
+
173
+ - `handle.frame.reload()` — reload the containing frame
174
+ - `handle.frame.src` — the URL of the containing frame
175
+ - `handle.frames.top` — the root frame (the whole page)
176
+ - `handle.frames.top.reload()` — reload the entire page/frame tree
177
+ - `handle.frames.get(name)` — look up a named frame; returns `FrameHandle | undefined`
178
+
179
+ ```tsx
180
+ function RefreshButton(handle: Handle) {
181
+ return () => <button mix={on('click', () => handle.frame.reload())}>Refresh</button>
182
+ }
183
+ ```
184
+
185
+ ### `handle.context`
186
+
187
+ Context for ancestor/descendant communication. See the context section below.
188
+
189
+ ## Context
190
+
191
+ Use `handle.context.set()` to provide values and `handle.context.get(Provider)` to consume them.
192
+ `set()` does **not** trigger updates — call `handle.update()` if the tree needs to rerender.
193
+
194
+ ```tsx
195
+ function ThemeProvider(handle: Handle<{ children?: RemixNode }, { theme: 'light' | 'dark' }>) {
196
+ let theme: 'light' | 'dark' = 'light'
197
+ handle.context.set({ theme })
198
+
199
+ return () => (
200
+ <div>
201
+ <button
202
+ mix={on('click', () => {
203
+ theme = theme === 'light' ? 'dark' : 'light'
204
+ handle.context.set({ theme })
205
+ handle.update()
206
+ })}
207
+ >
208
+ Toggle
209
+ </button>
210
+ {handle.props.children}
211
+ </div>
212
+ )
213
+ }
214
+
215
+ function ThemedContent(handle: Handle) {
216
+ let { theme } = handle.context.get(ThemeProvider)
217
+ return () => <div>Current theme: {theme}</div>
218
+ }
219
+ ```
220
+
221
+ For granular updates without re-rendering the full subtree, use `TypedEventTarget`:
222
+
223
+ ```tsx
224
+ import { TypedEventTarget, addEventListeners } from 'remix/ui'
225
+
226
+ class Theme extends TypedEventTarget<{ change: Event }> {
227
+ #value: 'light' | 'dark' = 'light'
228
+ get value() {
229
+ return this.#value
230
+ }
231
+ setValue(value: 'light' | 'dark') {
232
+ this.#value = value
233
+ this.dispatchEvent(new Event('change'))
234
+ }
235
+ }
236
+
237
+ function ThemeProvider(handle: Handle<{ children?: RemixNode }, Theme>) {
238
+ let theme = new Theme()
239
+ handle.context.set(theme)
240
+
241
+ return () => (
242
+ <div>
243
+ <button mix={on('click', () => theme.setValue(theme.value === 'light' ? 'dark' : 'light'))}>
244
+ Toggle
245
+ </button>
246
+ {handle.props.children}
247
+ </div>
248
+ )
249
+ }
250
+
251
+ function ThemedContent(handle: Handle) {
252
+ let theme = handle.context.get(ThemeProvider)
253
+ addEventListeners(theme, handle.signal, {
254
+ change() {
255
+ handle.update()
256
+ },
257
+ })
258
+ return () => <div>Theme: {theme.value}</div>
259
+ }
260
+ ```
261
+
262
+ ## Global Events
263
+
264
+ Use `addEventListeners(target, handle.signal, listeners)` to listen to global targets with
265
+ automatic cleanup when the component disconnects:
266
+
267
+ ```tsx
268
+ import { addEventListeners, type Handle } from 'remix/ui'
269
+
270
+ function ResizeTracker(handle: Handle) {
271
+ let width = window.innerWidth
272
+
273
+ addEventListeners(window, handle.signal, {
274
+ resize() {
275
+ width = window.innerWidth
276
+ handle.update()
277
+ },
278
+ })
279
+
280
+ return () => <div>{width}</div>
281
+ }
282
+ ```