@rangojs/router 0.0.0-experimental.96 → 0.0.0-experimental.96fbd4b7

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 CHANGED
@@ -10,6 +10,7 @@ Named-route RSC router with structural composability and type-safe partial rende
10
10
  - **Structural composability** — Attach routes, loaders, middleware, handles, caching, prerendering, and static generation without hiding the route tree
11
11
  - **Composable URL patterns** — Django-style `urls()` DSL with `path`, `layout`, `include`
12
12
  - **Data loaders** — `createLoader()` with automatic streaming and Suspense integration
13
+ - **Server actions** — `"use server"` mutations with `useActionState`, `useOptimistic`, and per-segment + per-loader `revalidate()` rules
13
14
  - **Live data layer** — Pre-render or cache the UI shell while loaders stay live by default at request time
14
15
  - **Layouts & nesting** — Nested layouts with `<Outlet />` and parallel routes
15
16
  - **Segment-level caching** — `cache()` DSL with TTL/SWR and pluggable cache stores
@@ -482,6 +483,70 @@ const urlpatterns = urls(({ path, loader }) => [
482
483
  ]);
483
484
  ```
484
485
 
486
+ ## Server Actions
487
+
488
+ Server actions are React's RSC mutation primitive. Define them with the
489
+ `"use server"` directive — Rango uses standard React 19 hooks
490
+ (`useActionState`, `useFormStatus`, `useOptimistic`) with no framework wrapper.
491
+
492
+ ```tsx
493
+ // app/actions/cart.ts
494
+ "use server";
495
+
496
+ import { getRequestContext } from "@rangojs/router";
497
+
498
+ export async function addToCart(productId: string): Promise<void> {
499
+ const ctx = getRequestContext();
500
+ const userId = ctx.get("user").id;
501
+ await db.cart.insert({ userId, productId });
502
+ }
503
+ ```
504
+
505
+ ```tsx
506
+ // Client form with progressive enhancement + pending state
507
+ "use client";
508
+ import { useActionState } from "react";
509
+ import { saveProfile } from "../actions/profile";
510
+
511
+ export function ProfileForm() {
512
+ const [state, action, pending] = useActionState(saveProfile, null);
513
+ return (
514
+ <form action={action}>
515
+ <input name="name" defaultValue={state?.values?.name} />
516
+ {state?.errors?.name && <p role="alert">{state.errors.name}</p>}
517
+ <button disabled={pending}>{pending ? "Saving…" : "Save"}</button>
518
+ </form>
519
+ );
520
+ }
521
+ ```
522
+
523
+ After an action runs, matched route segments (path/layout/parallel/intercept)
524
+ and loaders can re-render/re-resolve so the UI reflects the new state.
525
+ Attach a `revalidate(({ actionId }) => ...)` rule on any segment or loader
526
+ that owns data the action touched:
527
+
528
+ ```tsx
529
+ urls(({ path, loader, revalidate }) => [
530
+ // Segment-level: re-render the cart page handler after cart actions.
531
+ // Nest loaders that belong to this route inside the same path() so the
532
+ // segment owns its data dependencies.
533
+ path("/cart", CartPage, { name: "cart" }, () => [
534
+ revalidate(
535
+ ({ actionId }) => actionId?.startsWith("src/actions/cart.ts#") ?? false,
536
+ ),
537
+ loader(CartLoader, () => [
538
+ revalidate(
539
+ ({ actionId }) => actionId?.startsWith("src/actions/cart.ts#") ?? false,
540
+ ),
541
+ ]),
542
+ ]),
543
+ ]);
544
+ ```
545
+
546
+ For the full guide — validation with Zod, error handling, file uploads,
547
+ `useOptimistic`, redirects, and progressive enhancement — see the
548
+ `/server-actions` skill.
549
+
485
550
  ## Navigation & Links
486
551
 
487
552
  ### Named Routes with `ctx.reverse()` (Server)
@@ -2040,7 +2040,7 @@ import { resolve } from "node:path";
2040
2040
  // package.json
2041
2041
  var package_default = {
2042
2042
  name: "@rangojs/router",
2043
- version: "0.0.0-experimental.96",
2043
+ version: "0.0.0-experimental.96fbd4b7",
2044
2044
  description: "Django-inspired RSC router with composable URL patterns",
2045
2045
  keywords: [
2046
2046
  "react",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.0.0-experimental.96",
3
+ "version": "0.0.0-experimental.96fbd4b7",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -323,6 +323,11 @@ RSC serialization strips the `collect` function via `toJSON()`. On the client,
323
323
 
324
324
  ## Action Hooks
325
325
 
326
+ For the full server-action guide (defining actions, `useActionState`,
327
+ `useOptimistic`, validation, revalidation, error handling, file uploads),
328
+ see `/server-actions`. `useAction()` below is a Rango-specific hook for
329
+ tracking actions called outside a `<form action={...}>` flow.
330
+
326
331
  ### useAction()
327
332
 
328
333
  Track state of server action invocations:
@@ -187,6 +187,8 @@ export async function getProductUrl(slug: string) {
187
187
  }
188
188
  ```
189
189
 
190
+ See `/server-actions` for the full action surface (`getRequestContext()` is the same context middleware and handlers use).
191
+
190
192
  For static path strings (not named routes), client components can use `href()` — see below.
191
193
 
192
194
  ## Client: href()
@@ -6,7 +6,10 @@ argument-hint: [loader]
6
6
 
7
7
  # Data Loaders with loader()
8
8
 
9
- Loaders fetch data on the server and stream it to the client.
9
+ Loaders fetch data on the server and stream it to the client. For mutations
10
+ (writes triggered by forms or buttons), use server actions instead — see
11
+ `/server-actions`. Loaders re-resolve after an action runs, so the typical
12
+ flow is _action mutates → loader re-reads → UI updates_.
10
13
 
11
14
  ## Creating a Loader
12
15
 
@@ -32,6 +32,8 @@ When the router has a `basename`, pattern-scoped `.use()` patterns are automatic
32
32
 
33
33
  Registered inside `urls()` callback. Wraps **rendering only** -- it does NOT wrap server action execution. Actions run before route middleware, so when route middleware executes during post-action revalidation, it can observe state that the action set (cookies, context variables, headers).
34
34
 
35
+ > **Implication for auth:** route middleware cannot guard server actions. Use `router.use("/admin/*", requireAuth)` (global, scoped) for action protection, or check inside the action body. See `/server-actions` for action-side auth patterns.
36
+
35
37
  ```
36
38
  Request flow (with action):
37
39
  global mw -> action executes -> route mw -> layout -> handler -> loaders
@@ -225,7 +225,7 @@ Loaders are Rango's live data layer. Use them when you need:
225
225
 
226
226
  - **Client-side data refresh** — `useLoader()` in client components for reactive data
227
227
  - **Per-loader caching** — opt in with `loader(MyLoader, () => [cache({ ttl: 60 })])`; loaders stay live by default
228
- - **Revalidation control** — `revalidate()` targets specific loaders after actions
228
+ - **Revalidation control** — `revalidate()` targets specific segments and loaders after actions
229
229
  - **Loading skeletons** — `loading()` shows a Suspense fallback while loaders resolve
230
230
 
231
231
  ```typescript
@@ -463,6 +463,8 @@ Server actions work the same way — `"use server"` directive, `useActionState`,
463
463
 
464
464
  Key difference: in Rango, route middleware does NOT wrap action execution. Actions only see global middleware context. Use `getRequestContext()` in actions to access `ctx.set()`/`ctx.get()`.
465
465
 
466
+ Next.js's `revalidatePath()` / `revalidateTag()` have no direct equivalent — Rango partially re-renders matched route segments (path/layout/parallel/intercept) and re-resolves their loaders, and you scope re-runs by attaching a `revalidate(({ actionId }) => ...)` rule to any segment or loader registration. See `/server-actions` for the full pattern (validation, error handling, file uploads) and `/loader` for revalidation rule semantics.
467
+
466
468
  ## 8. Metadata / Head
467
469
 
468
470
  Rango uses the `Meta` handle + `<MetaTags />` client component:
@@ -483,6 +483,10 @@ Since Rango uses RSC server actions, all React action patterns work:
483
483
  `useActionState`, `useOptimistic`, `useTransition`, `startTransition`,
484
484
  and plain `<form action={serverAction}>`. No framework-specific hook needed.
485
485
 
486
+ For the full guide — defining actions, validation with Zod, error handling,
487
+ revalidation rules, file uploads, and progressive enhancement — see
488
+ `/server-actions`.
489
+
486
490
  ### clientLoader / clientAction (framework mode)
487
491
 
488
492
  RR7 framework mode's `clientLoader` and `clientAction` run in the browser.
@@ -16,6 +16,7 @@ Django-inspired RSC router with composable URL patterns, type-safe href, and ser
16
16
  | `/route` | Define routes with `urls()` and `path()` |
17
17
  | `/layout` | Layouts that wrap child routes |
18
18
  | `/loader` | Data loaders with `createLoader()` |
19
+ | `/server-actions` | Mutations with `"use server"`, useActionState, validation, revalidation |
19
20
  | `/middleware` | Request processing and authentication |
20
21
  | `/intercept` | Modal/slide-over patterns for soft navigation |
21
22
  | `/parallel` | Multi-column layouts and sidebars |
@@ -0,0 +1,739 @@
1
+ ---
2
+ name: server-actions
3
+ description: Define and call server actions (`"use server"`) — forms, useActionState, useOptimistic, validation, error handling, redirects, revalidation
4
+ argument-hint: "[action]"
5
+ ---
6
+
7
+ # Server Actions with `"use server"`
8
+
9
+ Server actions are async functions that run on the server and are callable from
10
+ the client. They are React's RSC mutation primitive — Rango uses them as-is
11
+ with no framework wrapper. All standard React hooks (`useActionState`,
12
+ `useFormStatus`, `useOptimistic`, `useTransition`) work directly.
13
+
14
+ ## When to Use Actions vs Loaders
15
+
16
+ | Need | Use |
17
+ | ---------------------------------- | -------------------------------------------- |
18
+ | Mutate state and revalidate UI | Server action |
19
+ | Read live data on every navigation | `createLoader()` + `useLoader()` |
20
+ | Read on demand from the client | Fetchable loader + `useFetchLoader()` |
21
+ | Submit a form and show the result | Action + `useActionState` |
22
+ | File upload | Action with `FormData` (or fetchable loader) |
23
+
24
+ Use loaders and route handlers for reads. Use actions for writes. After an
25
+ action runs, the matched route tree can partially re-render so handlers and
26
+ loaders that opt into revalidation see the new state — see "Revalidation"
27
+ below.
28
+
29
+ ## Revalidation Model
30
+
31
+ Actions mutate state; route handlers and loaders read the latest state. After
32
+ an action finishes, Rango performs a server-side revalidation render for the
33
+ matched route so the UI receives fresh segment output and loader data.
34
+
35
+ The main control point is `revalidate(({ actionId }) => ...)` on the segment
36
+ that owns the data. This applies to `path()` handlers, `layout()` handlers,
37
+ `parallel()` slots, `intercept()` routes, and loader registrations:
38
+
39
+ ```typescript
40
+ // urls.tsx — path/layout/parallel/intercept/loader/revalidate are passed in by urls()
41
+ import { urls } from "@rangojs/router";
42
+
43
+ export const urlpatterns = urls(({ path, loader, revalidate }) => [
44
+ // The loader belongs to the route that consumes its data — nest it inside
45
+ // the owning path() so the segment owns its data dependency.
46
+ path("/cart", CartPage, { name: "cart" }, () => [
47
+ revalidate(
48
+ ({ actionId }) => actionId?.startsWith("src/actions/cart.ts#") ?? false,
49
+ ),
50
+ loader(CartLoader, () => [
51
+ revalidate(
52
+ ({ actionId }) => actionId?.startsWith("src/actions/cart.ts#") ?? false,
53
+ ),
54
+ ]),
55
+ ]),
56
+ ]);
57
+ ```
58
+
59
+ For module-level `"use server"` files, the `actionId` passed to every
60
+ server-side `revalidate()` predicate is path-bearing in the server/RSC
61
+ environment in both dev and production: `src/actions/cart.ts#addToCart`. This
62
+ is intentional so path, layout, parallel, intercept, and loader revalidation
63
+ predicates can filter by action file, directory, or export name.
64
+
65
+ Actions and the follow-up revalidation render share one request context.
66
+ Values written in the action with `ctx.set(MyVar, value)` or `ctx.set("key",
67
+ value)` are visible to downstream route middleware, handlers, loaders, and
68
+ `revalidate()` callbacks through `context.get(MyVar)` / `context.get("key")`:
69
+
70
+ ```typescript
71
+ // app/context.ts
72
+ import { createVar } from "@rangojs/router";
73
+
74
+ export const ChangedTenant = createVar<string>();
75
+ ```
76
+
77
+ ```typescript
78
+ // app/actions/tenant.ts
79
+ "use server";
80
+
81
+ import { getRequestContext } from "@rangojs/router";
82
+ import { ChangedTenant } from "../context";
83
+
84
+ export async function switchTenant(tenantId: string) {
85
+ const ctx = getRequestContext();
86
+ ctx.set(ChangedTenant, tenantId);
87
+ await db.tenants.touch(tenantId);
88
+ }
89
+ ```
90
+
91
+ ```typescript
92
+ // urls.tsx
93
+ import { urls } from "@rangojs/router";
94
+ import { ChangedTenant } from "./context";
95
+
96
+ export const urlpatterns = urls(({ path, revalidate }) => [
97
+ path("/dashboard/:tenantId", DashboardPage, { name: "dashboard" }, () => [
98
+ revalidate(
99
+ ({ actionId, context }) =>
100
+ actionId?.startsWith("src/actions/tenant.ts#") &&
101
+ context.get(ChangedTenant) === context.params.tenantId,
102
+ ),
103
+ ]),
104
+ ]);
105
+ ```
106
+
107
+ Client and SSR-facing action references keep hashed IDs in production for
108
+ security and hydration compatibility. Do path matching inside server-side
109
+ `revalidate()` predicates, not from client action metadata. Inline actions
110
+ declared inside RSC components also keep hashed IDs; if you need path-based
111
+ revalidation, export the action from a module-level `"use server"` file.
112
+
113
+ ## Defining an Action
114
+
115
+ Two equivalent patterns. Prefer the file-level form for anything reusable.
116
+
117
+ ### File-level (`"use server"` at the top of a module)
118
+
119
+ Every exported async function becomes a callable server action.
120
+
121
+ ```typescript
122
+ // app/actions/cart.ts
123
+ "use server";
124
+
125
+ import { cookies } from "@rangojs/router";
126
+
127
+ export async function addToCart(productId: string): Promise<void> {
128
+ const userId = cookies().get("user-id")?.value;
129
+ await db.cart.insert({ userId, productId });
130
+ }
131
+
132
+ export async function removeFromCart(productId: string): Promise<void> {
133
+ const userId = cookies().get("user-id")?.value;
134
+ await db.cart.delete({ userId, productId });
135
+ }
136
+ ```
137
+
138
+ ### Inline (`"use server"` inside a server component)
139
+
140
+ Define a one-off action where it is used. Captured variables are serialized,
141
+ so keep them small and serializable.
142
+
143
+ ```tsx
144
+ // Server component (handler)
145
+ import type { Handler } from "@rangojs/router";
146
+
147
+ const SettingsPage: Handler<"settings"> = (ctx) => {
148
+ const userId = ctx.get("user").id;
149
+
150
+ async function updateName(formData: FormData) {
151
+ "use server";
152
+ await db.users.update(userId, { name: formData.get("name") as string });
153
+ }
154
+
155
+ return (
156
+ <form action={updateName}>
157
+ <input name="name" />
158
+ <button type="submit">Save</button>
159
+ </form>
160
+ );
161
+ };
162
+ ```
163
+
164
+ ## Calling Actions from the Client
165
+
166
+ Three patterns, in order of how much state you need to preserve:
167
+
168
+ ### 1. Plain `<form action={...}>` — fire-and-forget
169
+
170
+ Submits as `FormData`. Works without JavaScript (progressive enhancement).
171
+ The page revalidates after the action returns.
172
+
173
+ ```tsx
174
+ "use client";
175
+ import { addToCart } from "../actions/cart";
176
+
177
+ export function AddToCartForm({ productId }: { productId: string }) {
178
+ return (
179
+ <form action={addToCart.bind(null, productId)}>
180
+ <button type="submit">Add to cart</button>
181
+ </form>
182
+ );
183
+ }
184
+ ```
185
+
186
+ ### 2. `useActionState` — preserve return value + pending state
187
+
188
+ Standard React 19 hook. The action receives `(prevState, formData)` and its
189
+ return value becomes the new `state`. The form input values are preserved by
190
+ the browser on validation errors as long as you re-render the same form
191
+ element with `defaultValue` (not `value`).
192
+
193
+ Define the state shape next to the action so the client and server share
194
+ one type:
195
+
196
+ ```typescript
197
+ // app/actions/profile.ts
198
+ "use server";
199
+
200
+ export type ProfileFormState = {
201
+ errors?: Record<string, string>;
202
+ values?: { name: string };
203
+ } | null;
204
+
205
+ export async function saveProfile(
206
+ _prev: ProfileFormState,
207
+ formData: FormData,
208
+ ): Promise<ProfileFormState> {
209
+ const name = (formData.get("name") as string)?.trim() ?? "";
210
+ if (!name) {
211
+ return { errors: { name: "Name is required" }, values: { name } };
212
+ }
213
+ await db.users.update({ name });
214
+ return null; // success
215
+ }
216
+ ```
217
+
218
+ ```tsx
219
+ "use client";
220
+ import { useActionState } from "react";
221
+ import { saveProfile, type ProfileFormState } from "../actions/profile";
222
+
223
+ export function ProfileForm({ initial }: { initial: { name: string } }) {
224
+ const [state, formAction, isPending] = useActionState<
225
+ ProfileFormState,
226
+ FormData
227
+ >(saveProfile, null);
228
+
229
+ return (
230
+ <form action={formAction}>
231
+ <input
232
+ name="name"
233
+ defaultValue={state?.values?.name ?? initial.name}
234
+ aria-invalid={!!state?.errors?.name}
235
+ />
236
+ {state?.errors?.name && <p role="alert">{state.errors.name}</p>}
237
+ <button type="submit" disabled={isPending}>
238
+ {isPending ? "Saving…" : "Save"}
239
+ </button>
240
+ </form>
241
+ );
242
+ }
243
+ ```
244
+
245
+ **Why `defaultValue`, not `value`** — on validation error the form re-renders.
246
+ With `value` the inputs reset; with `defaultValue` (and a stable form key) the
247
+ browser keeps user input. Re-echo the submitted values in `state.values` so
248
+ they survive a full no-JS re-render too.
249
+
250
+ ### 3. `useOptimistic` — instant UI before the action settles
251
+
252
+ For reactive UI like quantity controls, like buttons, or todo toggles.
253
+ Combine with `startTransition` so the optimistic update is part of the same
254
+ transition as the action call.
255
+
256
+ ```tsx
257
+ "use client";
258
+ import { useOptimistic, startTransition } from "react";
259
+ import { updateQuantity } from "../actions/cart";
260
+
261
+ export function QuantityControl({
262
+ productId,
263
+ initialQuantity,
264
+ }: {
265
+ productId: string;
266
+ initialQuantity: number;
267
+ }) {
268
+ const [optimistic, setOptimistic] = useOptimistic(
269
+ initialQuantity,
270
+ (_current, next: number) => next,
271
+ );
272
+
273
+ function change(delta: number) {
274
+ const next = Math.max(0, optimistic + delta);
275
+ startTransition(async () => {
276
+ setOptimistic(next);
277
+ await updateQuantity(productId, delta);
278
+ });
279
+ }
280
+
281
+ return (
282
+ <div>
283
+ <button onClick={() => change(-1)}>-</button>
284
+ <span>{optimistic}</span>
285
+ <button onClick={() => change(1)}>+</button>
286
+ </div>
287
+ );
288
+ }
289
+ ```
290
+
291
+ `useOptimistic` resets to the real value once the surrounding transition
292
+ settles and React re-renders with the post-action loader data.
293
+
294
+ ### `useFormStatus` — pending state in nested children
295
+
296
+ When the submit button is in a separate component from the form, use
297
+ `useFormStatus()` instead of threading `isPending` down via props.
298
+
299
+ ```tsx
300
+ "use client";
301
+ import { useFormStatus } from "react-dom";
302
+
303
+ export function SubmitButton({ children }: { children: React.ReactNode }) {
304
+ const { pending } = useFormStatus();
305
+ return (
306
+ <button type="submit" disabled={pending}>
307
+ {pending ? "Working…" : children}
308
+ </button>
309
+ );
310
+ }
311
+ ```
312
+
313
+ `useFormStatus` only reports the form it is rendered inside — it does not
314
+ observe other forms.
315
+
316
+ ## Validation with Zod
317
+
318
+ Validate on the server, return structured errors via `useActionState`. Keep
319
+ the schema next to the action so client and server agree on the shape.
320
+
321
+ ```typescript
322
+ // app/actions/signup.ts
323
+ "use server";
324
+
325
+ import { z } from "zod";
326
+ import { redirect } from "@rangojs/router";
327
+
328
+ const SignupSchema = z.object({
329
+ email: z.string().email("Enter a valid email"),
330
+ password: z.string().min(8, "At least 8 characters"),
331
+ name: z.string().min(1, "Required"),
332
+ });
333
+
334
+ export type SignupState = {
335
+ errors?: Partial<Record<keyof z.infer<typeof SignupSchema>, string>>;
336
+ values?: Partial<z.infer<typeof SignupSchema>>;
337
+ } | null;
338
+
339
+ export async function signup(
340
+ _prev: SignupState,
341
+ formData: FormData,
342
+ ): Promise<SignupState> {
343
+ const raw = {
344
+ email: formData.get("email") as string,
345
+ password: formData.get("password") as string,
346
+ name: formData.get("name") as string,
347
+ };
348
+
349
+ const parsed = SignupSchema.safeParse(raw);
350
+ if (!parsed.success) {
351
+ const errors: Record<string, string> = {};
352
+ for (const issue of parsed.error.issues) {
353
+ const key = String(issue.path[0]);
354
+ errors[key] ??= issue.message; // first error per field
355
+ }
356
+ // Echo back values (omit password) so the form preserves user input.
357
+ const { password: _drop, ...safeValues } = raw;
358
+ return { errors, values: safeValues };
359
+ }
360
+
361
+ await db.users.create(parsed.data);
362
+ throw redirect("/welcome");
363
+ }
364
+ ```
365
+
366
+ The form reads `state.errors` field-by-field and re-uses `state.values` as
367
+ `defaultValue`s (see `useActionState` example above). Never echo back
368
+ secrets like passwords.
369
+
370
+ For schemas shared with a `useFetchLoader()` JSON body, parse the same way:
371
+
372
+ ```typescript
373
+ const parsed = SignupSchema.safeParse(ctx.body);
374
+ ```
375
+
376
+ ## Revalidation After an Action
377
+
378
+ When an action mutates data, the matched route tree may need to partially
379
+ re-render so the UI updates. Rango runs the action, then evaluates
380
+ `revalidate()` on matched segments and loaders. Each path, layout, parallel,
381
+ intercept, or loader rule decides whether that piece re-renders/re-resolves.
382
+
383
+ The `actionId` arrives as part of the revalidation context — match it to
384
+ scope re-runs to specific actions.
385
+
386
+ ```typescript
387
+ // urls.tsx — inside the urls() callback. Nest each loader inside the path(),
388
+ // layout(), or parallel() that owns its data so the route tree mirrors the
389
+ // data dependencies.
390
+ urls(({ path, loader, revalidate }) => [
391
+ path("/", HomePage, { name: "home" }, () => [
392
+ // Loader data re-runs by default after any action. Opt out with revalidate(() => false).
393
+ loader(StaticHomepageLoader, () => [revalidate(() => false)]),
394
+ ]),
395
+
396
+ // Re-render the cart page handler AND re-resolve its loader after cart actions
397
+ path("/cart", CartPage, { name: "cart" }, () => [
398
+ revalidate(
399
+ ({ actionId }) => actionId?.startsWith("src/actions/cart.ts#") ?? false,
400
+ ),
401
+ loader(CartLoader, () => [
402
+ revalidate(
403
+ ({ actionId }) => actionId?.startsWith("src/actions/cart.ts#") ?? false,
404
+ ),
405
+ ]),
406
+ ]),
407
+
408
+ // Re-run after any action under src/actions/account/
409
+ path("/account", AccountPage, { name: "account" }, () => [
410
+ loader(AccountLoader, () => [
411
+ revalidate(
412
+ ({ actionId }) => actionId?.startsWith("src/actions/account/") ?? false,
413
+ ),
414
+ ]),
415
+ ]),
416
+ ]);
417
+ ```
418
+
419
+ `actionId` is stable per action. For actions exported from a module-level
420
+ `"use server"` file, the ID is prefixed with the source file path
421
+ (`src/actions/cart.ts#addToCart`), so substring matching by file path is the
422
+ recommended scope. **Inline `"use server"` actions** (declared inside an RSC
423
+ component) intentionally keep their hashed IDs — file paths are withheld
424
+ from the client for security. If you need file-path-based revalidation
425
+ predicates, define the action in a module-level `"use server"` file rather
426
+ than inline. See `/loader` for the full revalidation contract (deferred
427
+ returns, soft suggestions).
428
+
429
+ ### Cross-segment dependencies
430
+
431
+ If a loader reads `ctx.get()` data set by an outer layout/handler, that
432
+ outer segment must also re-run after the action — otherwise the loader sees
433
+ stale context. Share the same `revalidate` predicate on both producer and
434
+ consumer:
435
+
436
+ ```typescript
437
+ const revalidateCart = ({ actionId }) =>
438
+ actionId?.startsWith("src/actions/cart.ts#") ?? false;
439
+
440
+ urls(({ path, layout, loader, revalidate }) => [
441
+ layout(CartLayout, () => [
442
+ revalidate(revalidateCart), // producer reruns
443
+ path("/cart", CartPage, { name: "cart" }, () => [
444
+ loader(CartItemsLoader, () => [revalidate(revalidateCart)]), // consumer reruns
445
+ ]),
446
+ ]),
447
+ ]);
448
+ ```
449
+
450
+ See `/middleware` for the full cross-segment revalidation contract.
451
+
452
+ ## Redirects
453
+
454
+ `redirect()` works inside actions. Both `return redirect(...)` and
455
+ `throw redirect(...)` are supported and behave the same way for the
456
+ client. Throwing is clearer when the redirect is conditional.
457
+
458
+ ```typescript
459
+ "use server";
460
+
461
+ import { redirect, cookies } from "@rangojs/router";
462
+ import { FlashMessage } from "../location-states";
463
+
464
+ export async function login(_prev: unknown, formData: FormData) {
465
+ const email = formData.get("email") as string;
466
+ const session = await authenticate(email);
467
+ if (!session) return { error: "Invalid credentials" };
468
+
469
+ cookies().set("session", session.token, { httpOnly: true, path: "/" });
470
+ throw redirect("/dashboard", {
471
+ state: FlashMessage({ text: "Welcome back!" }),
472
+ });
473
+ }
474
+ ```
475
+
476
+ Redirects from actions render the **target** route tree's matched segments
477
+ (paths, layouts, parallels, intercepts) and re-resolve its loaders, not the
478
+ source page's — the target is what the user sees next. See `/hooks
479
+ useLocationState` for reading flash state on the target page.
480
+
481
+ ## Error Handling
482
+
483
+ ### Validation errors — return them as state
484
+
485
+ Recoverable errors (form validation, business rules) should be returned via
486
+ `useActionState`. The form re-renders with the error and the user can fix
487
+ it. Throwing for a validation error escalates to an error boundary, which
488
+ is usually too aggressive.
489
+
490
+ ### Unexpected errors — let them throw
491
+
492
+ Throw for genuinely exceptional conditions (network failure, DB outage,
493
+ auth violation). The nearest `errorBoundary()` in the route tree catches
494
+ them.
495
+
496
+ ```typescript
497
+ import { errorBoundary } from "@rangojs/router";
498
+
499
+ layout(CheckoutLayout, () => [
500
+ errorBoundary(({ error, reset }) => (
501
+ <div>
502
+ <p>Checkout failed: {error.message}</p>
503
+ <button onClick={reset}>Try again</button>
504
+ </div>
505
+ )),
506
+ path("/checkout", CheckoutPage, { name: "checkout" }),
507
+ ]);
508
+ ```
509
+
510
+ ### Not found from an action
511
+
512
+ ```typescript
513
+ import { notFound } from "@rangojs/router";
514
+
515
+ export async function deletePost(id: string): Promise<void> {
516
+ "use server";
517
+ const post = await db.posts.find(id);
518
+ if (!post) notFound("Post not found"); // hits notFoundBoundary
519
+ await db.posts.delete(id);
520
+ }
521
+ ```
522
+
523
+ ### Authorization in actions
524
+
525
+ Route middleware does **not** wrap action execution — only global
526
+ middleware (`router.use()`) does. Auth checks must therefore live in
527
+ `router.use()` or inside the action itself. Don't rely on a route-level
528
+ `middleware()` to gate action access.
529
+
530
+ ```typescript
531
+ // router.tsx — global guard wraps action + render
532
+ const router = createRouter()
533
+ .use(authInit)
534
+ .use("/admin/*", requireAdmin) // protects actions on /admin too
535
+ .routes(urlpatterns);
536
+ ```
537
+
538
+ ```typescript
539
+ // Or check inside the action body
540
+ "use server";
541
+ import { getRequestContext, redirect, notFound } from "@rangojs/router";
542
+
543
+ export class ForbiddenError extends Error {
544
+ constructor() {
545
+ super("You do not have permission to perform this action.");
546
+ this.name = "ForbiddenError";
547
+ }
548
+ }
549
+
550
+ export async function deleteOrder(orderId: string) {
551
+ const ctx = getRequestContext();
552
+ const user = ctx.get("user");
553
+ if (!user) throw redirect("/login"); // unauthenticated → bounce to login
554
+
555
+ const order = await db.orders.get(orderId);
556
+ if (!order) notFound("Order not found"); // → notFoundBoundary
557
+ if (order.userId !== user.id) throw new ForbiddenError(); // → errorBoundary
558
+
559
+ await db.orders.delete(orderId);
560
+ }
561
+ ```
562
+
563
+ > **Don't `throw new Response("Unauthorized", { status: 401 })`** — non-redirect
564
+ > Responses thrown from actions are treated as errors and routed to the nearest
565
+ > `errorBoundary()`, not returned as real HTTP responses (the dev build warns
566
+ > when you do this). Use `redirect()` to send unauthenticated users to a login
567
+ > page, `notFound()` for missing resources, and a domain error class for
568
+ > forbidden access so the boundary can render an appropriate UI. For
569
+ > recoverable cases, return `{ error: "..." }` via `useActionState` instead of
570
+ > throwing.
571
+
572
+ ## Action Context
573
+
574
+ Actions can read the request context with `getRequestContext()`. This gives
575
+ the same context-variable, header, and reverse APIs that handlers and
576
+ middleware use.
577
+
578
+ ```typescript
579
+ "use server";
580
+ import { getRequestContext, cookies, headers } from "@rangojs/router";
581
+
582
+ export async function trackEvent(name: string) {
583
+ const ctx = getRequestContext();
584
+ const user = ctx.get("user"); // set by global middleware
585
+ const ua = headers().get("user-agent");
586
+ const url = ctx.reverse("dashboard"); // type-safe URL by route name
587
+ await analytics.record({ name, userId: user?.id, ua, url });
588
+ }
589
+ ```
590
+
591
+ State written via `ctx.set(...)` or `cookies().set(...)` during an action
592
+ is visible to downstream route middleware, segment handlers (path/layout/
593
+ parallel/intercept), loaders, and `revalidate()` callbacks during the
594
+ post-action revalidation render — actions and revalidation share the same
595
+ request scope.
596
+
597
+ ### Constraints
598
+
599
+ | Constraint | Why |
600
+ | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
601
+ | Actions cannot return or throw a non-redirect `Response` | Return values go through RSC Flight serialization. A thrown non-redirect `Response` is treated as a regular error and hits the nearest `errorBoundary()` (dev warns). Use `redirect()`, `notFound()`, or domain errors. |
602
+ | Route DSL `middleware()` does not wrap actions | Actions execute before route middleware. Only global `router.use()` middleware (and its scoped variants) wrap action execution. |
603
+ | `useFetchLoader()` is for reads, not writes | Actions are the mutation primitive; loaders are for data fetching. |
604
+
605
+ Cookies/headers set in **global** `router.use()` middleware DO propagate to
606
+ action responses (the same merge path as a normal render). The constraint
607
+ specific to **per-fetchable-loader** middleware (`createLoader(fn, {
608
+ middleware })` on a POST request) is that it cannot set cookies — set them
609
+ in the loader body instead. See `/middleware` for the full middleware
610
+ contract.
611
+
612
+ ## File Uploads
613
+
614
+ Forms with `enctype="multipart/form-data"` (or any file input) submit to
615
+ actions as `FormData`. Stream the file directly — don't buffer if the
616
+ runtime supports streaming.
617
+
618
+ Write the action with the `(prevState, formData) => newState` signature so
619
+ it can be passed straight to `<form action={uploadAvatar}>` (PE-compatible)
620
+ **and** to `useActionState` without a client-side wrapper:
621
+
622
+ ```typescript
623
+ // app/actions/avatar.ts
624
+ "use server";
625
+
626
+ import { getRequestContext } from "@rangojs/router";
627
+
628
+ export type AvatarUploadState = { url?: string; error?: string } | null;
629
+
630
+ export async function uploadAvatar(
631
+ _prev: AvatarUploadState,
632
+ formData: FormData,
633
+ ): Promise<AvatarUploadState> {
634
+ const file = formData.get("avatar") as File | null;
635
+ if (!file || file.size === 0) return { error: "No file selected" };
636
+ if (file.size > 5_000_000) return { error: "File too large (max 5MB)" };
637
+
638
+ const ctx = getRequestContext();
639
+ const key = `avatars/${crypto.randomUUID()}-${file.name}`;
640
+ await ctx.env.BUCKET.put(key, file.stream());
641
+ return { url: `/r2/${key}` };
642
+ }
643
+ ```
644
+
645
+ ```tsx
646
+ "use client";
647
+ import { useActionState } from "react";
648
+ import { uploadAvatar, type AvatarUploadState } from "../actions/avatar";
649
+
650
+ export function AvatarUpload() {
651
+ const [state, action, pending] = useActionState<AvatarUploadState, FormData>(
652
+ uploadAvatar,
653
+ null,
654
+ );
655
+ return (
656
+ <form action={action}>
657
+ <input type="file" name="avatar" accept="image/*" />
658
+ <button disabled={pending}>{pending ? "Uploading…" : "Upload"}</button>
659
+ {state?.error && <p role="alert">{state.error}</p>}
660
+ {state?.url && <img src={state.url} alt="" />}
661
+ </form>
662
+ );
663
+ }
664
+ ```
665
+
666
+ Wrapping the action in a client-side inline function (`useActionState(async
667
+ (_prev, fd) => uploadAvatar(fd), null)`) breaks PE: that closure isn't a
668
+ server reference, so the form has no real `action` URL when JS hasn't
669
+ loaded. Keep the action's signature `(_prev, formData)` and pass it
670
+ directly.
671
+
672
+ For client-side upload progress or cancellation, use a fetchable loader
673
+ with `useFetchLoader()` instead — see `/hooks useFetchLoader`.
674
+
675
+ ## Tracking Action State Without `useActionState`
676
+
677
+ Use `useAction()` from `@rangojs/router/client` to track an action's
678
+ state from outside a form (e.g. an action triggered by `onClick`).
679
+
680
+ ```tsx
681
+ "use client";
682
+ import { useAction } from "@rangojs/router/client";
683
+ import { addToCart } from "../actions/cart";
684
+
685
+ function AddButton({ productId }: { productId: string }) {
686
+ const { state, error } = useAction(addToCart);
687
+ return (
688
+ <>
689
+ <button
690
+ onClick={() => addToCart(productId)}
691
+ disabled={state === "loading"}
692
+ >
693
+ {state === "loading" ? "Adding…" : "Add"}
694
+ </button>
695
+ {error && <p role="alert">{error.message}</p>}
696
+ </>
697
+ );
698
+ }
699
+ ```
700
+
701
+ `useActionState` and `useAction` are complementary — use `useActionState`
702
+ for `<form action={...}>` flows, `useAction` for imperative button clicks
703
+ or to observe an action triggered elsewhere on the page.
704
+
705
+ ## Progressive Enhancement
706
+
707
+ `<form action={serverAction}>` works without JavaScript: the form posts as a
708
+ normal HTTP request, the action runs, and the page re-renders server-side.
709
+ For PE to work, write actions that accept `FormData` directly (not curried
710
+ or wrapped):
711
+
712
+ ```tsx
713
+ // Works with no-JS submission
714
+ <form action={submitName}>
715
+ <input name="name" />
716
+ <button>Submit</button>
717
+ </form>
718
+ ```
719
+
720
+ ```typescript
721
+ "use server";
722
+ export async function submitName(formData: FormData) {
723
+ const name = formData.get("name") as string;
724
+ await db.entries.add({ name });
725
+ }
726
+ ```
727
+
728
+ `useActionState` and `useOptimistic` only enhance the experience once JS is
729
+ loaded — without JS, the underlying action still runs and the page still
730
+ re-renders. Don't rely on client-only state for required form behavior.
731
+
732
+ ## Cross-references
733
+
734
+ - `/hooks` — `useAction`, `useFetchLoader`, `useLocationState` (flash state)
735
+ - `/loader` — read patterns, fetchable loaders, revalidation rule semantics
736
+ - `/middleware` — action vs render scope, revalidation contracts
737
+ - `/links` — `ctx.reverse()` and `getRequestContext().reverse()` from actions
738
+ - `/migrate-react-router` — `action()` → `"use server"` mapping
739
+ - `/migrate-nextjs` — Next.js server action parity
@@ -188,6 +188,20 @@ export function compilePattern(pattern: string): CompiledPattern {
188
188
  regexPattern = "/";
189
189
  }
190
190
 
191
+ // Patterns of only optional segments (e.g. `/:locale?`, `/:a?/:b?`) need
192
+ // an explicit `/` alternative so a bare `/` matches the absent form. The
193
+ // optional template `(?:/X)?` matches `/X` or empty string, but pathnames
194
+ // are never empty. Arises from `include("/:locale?", routes)` + inner
195
+ // `path("/")`. Skip when an explicit trailing slash already anchors the
196
+ // match.
197
+ const hasOnlyOptionalSegments =
198
+ !hasTrailingSlash &&
199
+ segments.length > 0 &&
200
+ segments.every((segment) => segment.type === "param" && segment.optional);
201
+ if (hasOnlyOptionalSegments) {
202
+ regexPattern = `(?:/|${regexPattern})`;
203
+ }
204
+
191
205
  // Add trailing slash to regex if pattern has one
192
206
  if (hasTrailingSlash) {
193
207
  regexPattern += "/";
@@ -205,7 +219,9 @@ export function compilePattern(pattern: string): CompiledPattern {
205
219
  /**
206
220
  * Validate decoded params against a compiled pattern's constraints.
207
221
  * Returns false if any constrained param has a non-empty value not in the
208
- * allowed list (empty-string = absent optional, which is allowed).
222
+ * allowed list. Absent optionals (key missing or `undefined`) are allowed;
223
+ * `""` is also tolerated as "absent" so user-provided params or fixtures
224
+ * that pass empty strings explicitly behave the same way.
209
225
  */
210
226
  function satisfiesConstraints(
211
227
  params: Record<string, string>,
@@ -232,6 +248,27 @@ function escapeRegex(str: string): string {
232
248
  return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
233
249
  }
234
250
 
251
+ /**
252
+ * Build the named-params record from a regex match. Optional segments that
253
+ * didn't capture leave the corresponding group `undefined`; we skip those
254
+ * keys so `ctx.params.<name>` reads as `undefined` rather than `""`. This
255
+ * keeps the runtime aligned with the `ExtractParams` type and matches the
256
+ * trie matcher's contract (see `trie-matching.ts:validateAndBuild`).
257
+ */
258
+ function buildParamsFromMatch(
259
+ match: RegExpExecArray,
260
+ paramNames: string[],
261
+ ): Record<string, string> {
262
+ const params: Record<string, string> = {};
263
+ paramNames.forEach((name, index) => {
264
+ const captured = match[index + 1];
265
+ if (captured !== undefined) {
266
+ params[name] = safeDecodeURIComponent(captured);
267
+ }
268
+ });
269
+ return params;
270
+ }
271
+
235
272
  /**
236
273
  * Extract the static prefix from a route pattern.
237
274
  * Returns everything before the first param/wildcard.
@@ -283,8 +320,10 @@ export function extractStaticPrefix(pattern: string): string {
283
320
  /**
284
321
  * Match a pathname against registered routes
285
322
  *
286
- * Note: Optional params that are absent in the path will have empty string value.
287
- * Use the pattern definition to determine if a param is optional.
323
+ * Note: Optional params that are absent in the path are omitted from the
324
+ * returned `params` (read as `undefined`), matching the trie matcher and
325
+ * the `ExtractParams<"/:locale?/...">` type. Use the pattern definition or
326
+ * `optionalParams` to determine which keys are optional.
288
327
  *
289
328
  * Trailing slash handling (priority order):
290
329
  * 1. Per-route `trailingSlash` config from route()
@@ -451,10 +490,7 @@ export function findMatch<TEnv>(
451
490
  // Try exact match first
452
491
  const match = regex.exec(pathname);
453
492
  if (match) {
454
- const params: Record<string, string> = {};
455
- paramNames.forEach((name, index) => {
456
- params[name] = safeDecodeURIComponent(match[index + 1] ?? "");
457
- });
493
+ const params = buildParamsFromMatch(match, paramNames);
458
494
 
459
495
  // Validate constraints against decoded values; a failure falls
460
496
  // through to the next route so other patterns can still match.
@@ -512,10 +548,7 @@ export function findMatch<TEnv>(
512
548
  // Try alternate pathname (opposite trailing slash)
513
549
  const altMatch = regex.exec(alternatePathname);
514
550
  if (altMatch) {
515
- const params: Record<string, string> = {};
516
- paramNames.forEach((name, index) => {
517
- params[name] = safeDecodeURIComponent(altMatch[index + 1] ?? "");
518
- });
551
+ const params = buildParamsFromMatch(altMatch, paramNames);
519
552
 
520
553
  if (!satisfiesConstraints(params, constraints)) {
521
554
  continue;
@@ -15,7 +15,9 @@ export interface TrieMatchResult {
15
15
  sp: string;
16
16
  /** Matched route params */
17
17
  params: Record<string, string>;
18
- /** Optional param names (absent params have empty string value) */
18
+ /** Optional param names declared on the route. Absent params are omitted
19
+ * from `params` (read as `undefined`), matching the
20
+ * `ExtractParams<"/:locale?/...">` type. */
19
21
  optionalParams?: string[];
20
22
  /** Ancestry shortCodes for layout pruning */
21
23
  ancestry: string[];
@@ -203,14 +205,11 @@ function validateAndBuild(
203
205
  }
204
206
  }
205
207
 
206
- // Fill in empty strings for optional params that weren't matched
207
- if (leaf.op) {
208
- for (const name of leaf.op) {
209
- if (!(name in params)) {
210
- params[name] = "";
211
- }
212
- }
213
- }
208
+ // Optional params that weren't matched are left absent from `params` so
209
+ // `ctx.params.locale` reads as `undefined`, matching the
210
+ // `ExtractParams<"/:locale?/...">` type (`{ locale?: string }`). Both
211
+ // internal consumers — the constraint check above and `reverse()`
212
+ // already treat missing/undefined as the absent form.
214
213
 
215
214
  // Trailing slash handling
216
215
  const tsMode = leaf.ts as "never" | "always" | "ignore" | undefined;