@rangojs/router 0.0.0-experimental.97 → 0.0.0-experimental.98

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.
@@ -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.97",
2043
+ version: "0.0.0-experimental.98",
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.97",
3
+ "version": "0.0.0-experimental.98",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -132,6 +132,15 @@
132
132
  "access": "public",
133
133
  "tag": "experimental"
134
134
  },
135
+ "scripts": {
136
+ "build": "pnpm dlx esbuild src/vite/index.ts --bundle --format=esm --outfile=dist/vite/index.js --platform=node --packages=external && mkdir -p dist/vite/plugins && cp src/vite/plugins/cloudflare-protocol-loader-hook.mjs dist/vite/plugins/cloudflare-protocol-loader-hook.mjs && pnpm dlx esbuild src/bin/rango.ts --bundle --format=esm --outfile=dist/bin/rango.js --platform=node --packages=external --banner:js='#!/usr/bin/env node' && chmod +x dist/bin/rango.js",
137
+ "prepublishOnly": "pnpm build",
138
+ "typecheck": "tsc --noEmit && tsc -p tsconfig.strict-check.json --noEmit",
139
+ "test": "playwright test",
140
+ "test:ui": "playwright test --ui",
141
+ "test:unit": "vitest run",
142
+ "test:unit:watch": "vitest"
143
+ },
135
144
  "dependencies": {
136
145
  "@types/debug": "^4.1.12",
137
146
  "@vitejs/plugin-rsc": "^0.5.23",
@@ -143,12 +152,12 @@
143
152
  "devDependencies": {
144
153
  "@playwright/test": "^1.49.1",
145
154
  "@types/node": "^24.10.1",
146
- "@types/react": "^19.2.7",
147
- "@types/react-dom": "^19.2.3",
155
+ "@types/react": "catalog:",
156
+ "@types/react-dom": "catalog:",
148
157
  "esbuild": "^0.27.0",
149
158
  "jiti": "^2.6.1",
150
- "react": "^19.2.4",
151
- "react-dom": "^19.2.4",
159
+ "react": "catalog:",
160
+ "react-dom": "catalog:",
152
161
  "tinyexec": "^0.3.2",
153
162
  "typescript": "^5.3.0",
154
163
  "vitest": "^4.0.0"
@@ -166,13 +175,5 @@
166
175
  "vite": {
167
176
  "optional": true
168
177
  }
169
- },
170
- "scripts": {
171
- "build": "pnpm dlx esbuild src/vite/index.ts --bundle --format=esm --outfile=dist/vite/index.js --platform=node --packages=external && mkdir -p dist/vite/plugins && cp src/vite/plugins/cloudflare-protocol-loader-hook.mjs dist/vite/plugins/cloudflare-protocol-loader-hook.mjs && pnpm dlx esbuild src/bin/rango.ts --bundle --format=esm --outfile=dist/bin/rango.js --platform=node --packages=external --banner:js='#!/usr/bin/env node' && chmod +x dist/bin/rango.js",
172
- "typecheck": "tsc --noEmit && tsc -p tsconfig.strict-check.json --noEmit",
173
- "test": "playwright test",
174
- "test:ui": "playwright test --ui",
175
- "test:unit": "vitest run",
176
- "test:unit:watch": "vitest"
177
178
  }
178
- }
179
+ }
@@ -0,0 +1,276 @@
1
+ ---
2
+ name: i18n
3
+ description: Locale-aware routing with `include("/:locale?", ...)`, locale resolution chains, and react-intl integration
4
+ argument-hint: "[topic]"
5
+ ---
6
+
7
+ # Internationalization (i18n) and Locale Routing
8
+
9
+ Rango doesn't ship an i18n module. The router gives you the URL primitives
10
+ (optional include prefixes, constraints, typed reverse) and you compose
11
+ them with whatever message library you use — `react-intl`, `lingui`,
12
+ `@formatjs/intl`, or hand-rolled.
13
+
14
+ This skill covers:
15
+
16
+ - Mounting routes under an optional locale prefix (`/`, `/en`, `/gb`)
17
+ - Constraining the prefix to a known locale set
18
+ - Resolving the active locale (URL → cookie → `Accept-Language` → default)
19
+ - Generating localized URLs via `reverse()` round-trip
20
+ - Wiring `react-intl` into an RSC route tree
21
+
22
+ ## URL Shape: Optional Locale Prefix
23
+
24
+ Mount your localized routes under an optional include prefix so the
25
+ default locale lives at the bare URL and other locales get a prefix:
26
+
27
+ ```typescript
28
+ // urls.tsx
29
+ import { urls } from "@rangojs/router";
30
+ import { menuRoutes } from "./menu";
31
+
32
+ export const urlpatterns = urls(({ include }) => [
33
+ include("/:locale?", menuRoutes, { name: "menu" }),
34
+ ]);
35
+ ```
36
+
37
+ URLs that match:
38
+
39
+ | URL | Matched route | `ctx.params.locale` |
40
+ | -------------- | --------------- | ------------------- |
41
+ | `/` | `menu.index` | `undefined` |
42
+ | `/en` | `menu.index` | `"en"` |
43
+ | `/c/breads` | `menu.category` | `undefined` |
44
+ | `/en/c/breads` | `menu.category` | `"en"` |
45
+
46
+ > **Constrain to known locales** when you want unknown locales to fall
47
+ > through to other routes (or 404) instead of being treated as a slug:
48
+ >
49
+ > ```typescript
50
+ > include("/:locale(en|gb|fr)?", menuRoutes, { name: "menu" });
51
+ > ```
52
+ >
53
+ > `/de` now 404s (constraint rejects `de`), and `/c/breads` continues to
54
+ > match `menu.category` with `locale: undefined`. Without the constraint,
55
+ > `/de` would match `menu.index` with `locale: "de"`.
56
+
57
+ ## Reading the Locale in Handlers
58
+
59
+ Absent optionals are `undefined` (not `""`), so `??` coalesces correctly:
60
+
61
+ ```typescript
62
+ import { Handler } from "@rangojs/router";
63
+
64
+ export const MenuIndex: Handler<"menu.index"> = (ctx) => {
65
+ // ctx.params.locale is `string | undefined`
66
+ const locale = resolveLocale(ctx);
67
+ return <Welcome locale={locale} />;
68
+ };
69
+ ```
70
+
71
+ The `resolveLocale` helper below implements a typical fallback chain.
72
+
73
+ ## Locale Resolution
74
+
75
+ URL is the strongest signal but you usually want a fallback chain:
76
+
77
+ 1. **URL prefix** — if the user navigates to `/gb/...`, honor it
78
+ 2. **Cookie** — sticky preference set by a previous language switcher
79
+ 3. **`Accept-Language`** — browser hint
80
+ 4. **Default** — your app default
81
+
82
+ Put it in a small helper that every locale-aware handler calls:
83
+
84
+ ```typescript
85
+ // lib/locale.ts
86
+ import { cookies, headers } from "@rangojs/router";
87
+
88
+ export const SUPPORTED_LOCALES = ["en", "gb", "fr"] as const;
89
+ export type Locale = (typeof SUPPORTED_LOCALES)[number];
90
+ const DEFAULT_LOCALE: Locale = "en";
91
+
92
+ const isSupported = (v: string): v is Locale =>
93
+ (SUPPORTED_LOCALES as readonly string[]).includes(v);
94
+
95
+ export function resolveLocale(ctx: {
96
+ params: Record<string, string | undefined>;
97
+ }): Locale {
98
+ const fromUrl = ctx.params.locale;
99
+ if (fromUrl && isSupported(fromUrl)) return fromUrl;
100
+
101
+ const fromCookie = cookies().get("locale")?.value;
102
+ if (fromCookie && isSupported(fromCookie)) return fromCookie;
103
+
104
+ const accept = headers().get("accept-language") ?? "";
105
+ for (const tag of accept.split(",")) {
106
+ const code = tag.split(";")[0].trim().split("-")[0];
107
+ if (isSupported(code)) return code as Locale;
108
+ }
109
+ return DEFAULT_LOCALE;
110
+ }
111
+ ```
112
+
113
+ If you want to redirect to the canonical URL when the resolved locale
114
+ doesn't match the URL (e.g., user has `gb` cookie but visits `/`), do
115
+ that in a global middleware so it covers actions too:
116
+
117
+ ```typescript
118
+ import { redirect } from "@rangojs/router";
119
+
120
+ router.use("/*", async (ctx, next) => {
121
+ const fromUrl = ctx.params.locale;
122
+ const resolved = resolveLocale(ctx);
123
+ if (resolved !== DEFAULT_LOCALE && !fromUrl) {
124
+ return redirect(`/${resolved}${ctx.url.pathname}`);
125
+ }
126
+ await next();
127
+ });
128
+ ```
129
+
130
+ ## Generating Localized URLs
131
+
132
+ `reverse()` treats `undefined` and `""` for an optional param as "absent"
133
+ and collapses the segment cleanly. The round-trip is symmetric with the
134
+ matcher:
135
+
136
+ ```typescript
137
+ ctx.reverse("menu.index", { locale: "" }); // → "/"
138
+ ctx.reverse("menu.index", { locale: undefined }); // → "/"
139
+ ctx.reverse("menu.index", { locale: "en" }); // → "/en"
140
+ ctx.reverse("menu.category", { locale: "en", slug: "breads" }); // → "/en/c/breads"
141
+ ctx.reverse("menu.category", { slug: "breads" }); // → "/c/breads"
142
+ ```
143
+
144
+ If the active locale is the app default and your URL strategy hides it
145
+ (`"en"` → `/`, others → `/<locale>`), normalize before calling reverse:
146
+
147
+ ```typescript
148
+ const normalized = locale === DEFAULT_LOCALE ? undefined : locale;
149
+ const href = ctx.reverse("menu.category", { locale: normalized, slug });
150
+ ```
151
+
152
+ ## react-intl Integration
153
+
154
+ `react-intl` needs a `<IntlProvider>` wrapping the tree, with `locale`
155
+ and `messages` props. The cleanest split: load messages on the server
156
+ (handler or layout), pass them through to a client provider component.
157
+
158
+ ### Messages loader
159
+
160
+ Load message bundles per locale. Keep them server-side so they stream
161
+ through the RSC payload and don't bloat the client bundle:
162
+
163
+ ```typescript
164
+ // lib/messages.ts
165
+ import type { Locale } from "./locale";
166
+
167
+ const loaders: Record<Locale, () => Promise<Record<string, string>>> = {
168
+ en: () => import("../messages/en.json").then((m) => m.default),
169
+ gb: () => import("../messages/gb.json").then((m) => m.default),
170
+ fr: () => import("../messages/fr.json").then((m) => m.default),
171
+ };
172
+
173
+ export async function loadMessages(locale: Locale) {
174
+ return loaders[locale]();
175
+ }
176
+ ```
177
+
178
+ ### Server layout: hand off to the client provider
179
+
180
+ ```tsx
181
+ // layouts/intl-layout.tsx (server component)
182
+ import type { ReactNode } from "react";
183
+ import { resolveLocale } from "../lib/locale";
184
+ import { loadMessages } from "../lib/messages";
185
+ import { IntlClientProvider } from "../components/intl-client-provider";
186
+
187
+ export async function IntlLayout({
188
+ ctx,
189
+ children,
190
+ }: {
191
+ ctx: any;
192
+ children: ReactNode;
193
+ }) {
194
+ const locale = resolveLocale(ctx);
195
+ const messages = await loadMessages(locale);
196
+ return (
197
+ <IntlClientProvider locale={locale} messages={messages}>
198
+ {children}
199
+ </IntlClientProvider>
200
+ );
201
+ }
202
+ ```
203
+
204
+ ### Client provider
205
+
206
+ ```tsx
207
+ // components/intl-client-provider.tsx
208
+ "use client";
209
+
210
+ import { IntlProvider } from "react-intl";
211
+ import type { ReactNode } from "react";
212
+
213
+ export function IntlClientProvider({
214
+ locale,
215
+ messages,
216
+ children,
217
+ }: {
218
+ locale: string;
219
+ messages: Record<string, string>;
220
+ children: ReactNode;
221
+ }) {
222
+ return (
223
+ <IntlProvider
224
+ locale={locale}
225
+ defaultLocale="en"
226
+ messages={messages}
227
+ onError={(err) => {
228
+ if (err.code === "MISSING_TRANSLATION") return; // common, log only
229
+ console.error(err);
230
+ }}
231
+ >
232
+ {children}
233
+ </IntlProvider>
234
+ );
235
+ }
236
+ ```
237
+
238
+ ### Mounting
239
+
240
+ Wrap your localized routes with the layout:
241
+
242
+ ```typescript
243
+ import { urls } from "@rangojs/router";
244
+ import { IntlLayout } from "./layouts/intl-layout";
245
+ import { menuRoutes } from "./menu";
246
+
247
+ export const urlpatterns = urls(({ layout, include }) => [
248
+ layout(IntlLayout, () => [
249
+ include("/:locale?", menuRoutes, { name: "menu" }),
250
+ ]),
251
+ ]);
252
+ ```
253
+
254
+ `<FormattedMessage>`, `useIntl()`, etc. work in any client component
255
+ under the layout. Server components can use `formatjs`'s `createIntl()`
256
+ directly with the same `messages` map for static text.
257
+
258
+ ## Common Pitfalls
259
+
260
+ | Pitfall | Fix |
261
+ | ------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
262
+ | `ctx.params.locale === ""` returns `false` | Absent optionals are `undefined`, not `""`. Use `=== undefined` or `??`. |
263
+ | `ctx.params.locale ?? "en"` returns `""` | Pre-fix behavior. After the include-prefix fix this works correctly. |
264
+ | Bare `/` 404s when mounted via `include("/:locale?", routes)` | Requires the all-optional pattern fix in `compilePattern` (shipped). |
265
+ | Unknown locale (e.g. `/de`) matches as `locale: "de"` | Add a constraint: `:locale(en\|gb\|fr)?`. Unknown values now 404. |
266
+ | Reverse produces `//c/breads` for absent locale | `reverse()` collapses `undefined`/`""` segments — should not happen. File a bug. |
267
+ | Locale switcher loses search params | Read `ctx.url.search` and pass to `reverse(..., undefined, parsedSearch)`. |
268
+ | Action middleware can't read `ctx.params.locale` | Route middleware doesn't wrap action execution. Use global `router.use()` for actions. |
269
+
270
+ ## Cross-references
271
+
272
+ - `/route` — optional URL param syntax and runtime contract
273
+ - `/typesafety` — `RouteParams<"name">` typing for optionals
274
+ - `/middleware` — global vs route middleware scope (matters for actions)
275
+ - `/server-actions` — actions and the global-vs-route middleware boundary
276
+ - `/links` — `ctx.reverse()` and locale-aware URL generation
@@ -17,6 +17,7 @@ Django-inspired RSC router with composable URL patterns, type-safe href, and ser
17
17
  | `/layout` | Layouts that wrap child routes |
18
18
  | `/loader` | Data loaders with `createLoader()` |
19
19
  | `/server-actions` | Mutations with `"use server"`, useActionState, validation, revalidation |
20
+ | `/i18n` | Locale routing with `:locale?`, resolution chains, react-intl integration |
20
21
  | `/middleware` | Request processing and authentication |
21
22
  | `/intercept` | Modal/slide-over patterns for soft navigation |
22
23
  | `/parallel` | Multi-column layouts and sidebars |
@@ -33,6 +33,26 @@ urls(({ path }) => [
33
33
  ]);
34
34
  ```
35
35
 
36
+ ### Optional URL params at runtime
37
+
38
+ Absent optional params are **omitted from `ctx.params`** — `ctx.params.<name>`
39
+ reads as `undefined`, matching the `RouteParams<"name">` type
40
+ (`{ query?: string }`). Use `??` to default and `=== undefined` to check
41
+ absence:
42
+
43
+ ```typescript
44
+ path("/search/:query?", (ctx) => {
45
+ const query = ctx.params.query ?? ""; // works — undefined coalesces
46
+ if (ctx.params.query === undefined) return <EmptySearch />;
47
+ return <Results query={ctx.params.query} />;
48
+ }, { name: "search" });
49
+ ```
50
+
51
+ For the common pattern of an optional locale prefix
52
+ (`include("/:locale?", routes)`) and the wider react-intl integration —
53
+ locale detection, fallback chains, URL generation with absent locale —
54
+ see `/i18n`.
55
+
36
56
  ## Route Handler Patterns
37
57
 
38
58
  ### Component Function
@@ -287,6 +287,12 @@ type SP = RouteSearchParams<"search">;
287
287
  type P = RouteParams<"blogPost">;
288
288
  // { slug: string }
289
289
 
290
+ // Optional URL params (`:slug?`) resolve to `string | undefined`
291
+ // because absent segments are omitted from `ctx.params` at runtime.
292
+ type C = RouteParams<"checkout">;
293
+ // { step?: string }
294
+ // → ctx.params.step is `string | undefined`; use `?? "default"` to coalesce.
295
+
290
296
  // Use in component props
291
297
  interface SearchResultsProps {
292
298
  params: RouteSearchParams<"search">;
@@ -27,16 +27,19 @@ import { shallowEqual } from "./shallow-equal.js";
27
27
  // interface shapes pass the constraint — interfaces lack an implicit
28
28
  // index signature and would otherwise be rejected. The generic is a
29
29
  // shape annotation, not a runtime check; the body always returns the
30
- // underlying params map unchanged.
30
+ // underlying params map unchanged. The default and selector input use
31
+ // `string | undefined` because absent optional params are omitted from
32
+ // the params record at runtime — the type must reflect that so callers
33
+ // don't write `p.locale.length` and crash when the segment is absent.
31
34
  export function useParams<
32
- T extends object = Record<string, string>,
35
+ T extends object = Record<string, string | undefined>,
33
36
  >(): Readonly<T>;
34
37
  export function useParams<T>(
35
- selector: (params: Record<string, string>) => T,
38
+ selector: (params: Record<string, string | undefined>) => T,
36
39
  ): T;
37
40
  export function useParams<T>(
38
- selector?: (params: Record<string, string>) => T,
39
- ): T | Record<string, string> {
41
+ selector?: (params: Record<string, string | undefined>) => T,
42
+ ): T | Record<string, string | undefined> {
40
43
  const ctx = useContext(NavigationStoreContext);
41
44
 
42
45
  const [value, setValue] = useState<T | Record<string, string>>(() => {
@@ -20,7 +20,8 @@ export interface TrieLeaf {
20
20
  sp: string;
21
21
  /** Ancestry shortCodes from root to route [M0L0, M0L0L0, M0L0L0R499] */
22
22
  a: string[];
23
- /** Optional param names (absent params get empty string value) */
23
+ /** Optional param names declared on the route. Absent params are
24
+ * omitted from the matched params record (read as `undefined`). */
24
25
  op?: string[];
25
26
  /** Constraint validation: paramName -> allowed values */
26
27
  cv?: Record<string, string[]>;
package/src/reverse.ts CHANGED
@@ -312,9 +312,10 @@ export function createReverse<TRoutes extends Record<string, string>>(
312
312
  /:([a-zA-Z_][a-zA-Z0-9_]*)(\([^)]*\))?(\?)/g,
313
313
  (_, key, _constraint, optional) => {
314
314
  const value = params[key];
315
- // Empty string is treated as omitted the trie matcher fills
316
- // unmatched optional params with "" (not undefined), so reverse
317
- // must collapse those segments instead of leaving empty slots.
315
+ // The matcher omits absent optional params (so `value` is
316
+ // `undefined` here), but caller-supplied params or `getParams()`
317
+ // shapes may still pass `""` explicitly. Treat both as the
318
+ // absent form so the segment collapses cleanly.
318
319
  if (value === undefined || value === "") {
319
320
  hadOmittedOptional = true;
320
321
  return "";
@@ -176,9 +176,10 @@ export function createReverseFunction(
176
176
  /:([a-zA-Z_][a-zA-Z0-9_]*)(\([^)]*\))?(\?)/g,
177
177
  (_, key) => {
178
178
  const value = effectiveParams[key];
179
- // Empty string is treated as omitted the trie matcher fills
180
- // unmatched optional params with "" (not undefined), so reverse
181
- // must collapse those segments instead of leaving empty slots.
179
+ // The matcher omits absent optional params (so `value` is
180
+ // `undefined` here), but caller-supplied params or `getParams()`
181
+ // shapes may still pass `""` explicitly. Treat both as the
182
+ // absent form so the segment collapses cleanly.
182
183
  if (value === undefined || value === "") {
183
184
  hadOmittedOptional = true;
184
185
  return "";
@@ -53,11 +53,14 @@ export interface CookieOptions {
53
53
  * Context passed to middleware
54
54
  *
55
55
  * @template TEnv - Environment type (bindings, variables) - defaults to any for internal flexibility
56
- * @template TParams - URL params type (typed for route middleware, Record<string, string> for global middleware)
56
+ * @template TParams - URL params type (typed for route middleware,
57
+ * `Record<string, string | undefined>` for global middleware — absent
58
+ * optional segments are omitted from the params record at runtime, so
59
+ * the index signature must include `undefined`)
57
60
  */
58
61
  export interface MiddlewareContext<
59
62
  TEnv = any,
60
- TParams = Record<string, string>,
63
+ TParams = Record<string, string | undefined>,
61
64
  > extends RequestScope<TEnv> {
62
65
  /** URL params extracted from route/middleware pattern */
63
66
  params: TParams;
@@ -149,7 +152,10 @@ export interface MiddlewareContext<
149
152
  * router.use((ctx, next) => {...}) // ctx is typed from router's TEnv
150
153
  * ```
151
154
  */
152
- export type MiddlewareFn<TEnv = any, TParams = Record<string, string>> = (
155
+ export type MiddlewareFn<
156
+ TEnv = any,
157
+ TParams = Record<string, string | undefined>,
158
+ > = (
153
159
  ctx: MiddlewareContext<TEnv, TParams>,
154
160
  next: () => Promise<Response>,
155
161
  ) => Response | void | Promise<Response | void>;
@@ -196,5 +202,8 @@ export interface MiddlewareCollectableEntry {
196
202
  */
197
203
  export interface CollectedMiddleware {
198
204
  handler: MiddlewareFn<any, any>;
205
+ // Internal shape only. The user-facing `MiddlewareContext.params` is
206
+ // typed `Record<string, string | undefined>` to reflect that absent
207
+ // optional segments are omitted from the params record at runtime.
199
208
  params: Record<string, string>;
200
209
  }
@@ -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;
@@ -42,10 +42,11 @@ export function substituteRouteParams(
42
42
  let hadOmittedOptional = false;
43
43
 
44
44
  // First pass: substitute provided params.
45
- // Empty string on an optional placeholder is treated as omitted (the trie
46
- // matcher fills unmatched optionals with "" letting the second pass
47
- // strip them keeps slash cleanup consistent). Empty string on required
48
- // `:key` or wildcard `*key` still substitutes, matching prior behaviour.
45
+ // Empty string on an optional placeholder is treated as omitted
46
+ // caller-supplied params or `getParams()` shapes may pass `""` for an
47
+ // absent optional, so letting the second pass strip them keeps slash
48
+ // cleanup consistent. Empty string on required `:key` or wildcard
49
+ // `*key` still substitutes, matching prior behaviour.
49
50
  for (const [key, value] of Object.entries(params)) {
50
51
  const escaped = escapeRegExp(key);
51
52
  if (value === "") {