@rangojs/router 0.0.0-experimental.dfdb0387 → 0.0.0-experimental.ede38110

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 (36) hide show
  1. package/dist/vite/index.js +16 -8
  2. package/package.json +3 -3
  3. package/skills/handler-use/SKILL.md +362 -0
  4. package/skills/intercept/SKILL.md +20 -0
  5. package/skills/layout/SKILL.md +22 -0
  6. package/skills/middleware/SKILL.md +32 -3
  7. package/skills/migrate-nextjs/SKILL.md +560 -0
  8. package/skills/migrate-react-router/SKILL.md +764 -0
  9. package/skills/parallel/SKILL.md +59 -0
  10. package/skills/rango/SKILL.md +24 -22
  11. package/skills/route/SKILL.md +24 -0
  12. package/src/browser/navigation-bridge.ts +21 -2
  13. package/src/browser/partial-update.ts +9 -2
  14. package/src/browser/prefetch/fetch.ts +20 -4
  15. package/src/browser/react/use-navigation.ts +11 -10
  16. package/src/browser/segment-reconciler.ts +10 -14
  17. package/src/build/route-trie.ts +50 -24
  18. package/src/client.tsx +82 -174
  19. package/src/index.ts +37 -9
  20. package/src/reverse.ts +4 -1
  21. package/src/route-definition/dsl-helpers.ts +159 -20
  22. package/src/route-definition/helpers-types.ts +57 -13
  23. package/src/route-types.ts +7 -0
  24. package/src/router/handler-context.ts +4 -1
  25. package/src/router/lazy-includes.ts +5 -5
  26. package/src/router/manifest.ts +22 -13
  27. package/src/router/match-middleware/cache-lookup.ts +2 -1
  28. package/src/segment-content-promise.ts +67 -0
  29. package/src/segment-loader-promise.ts +122 -0
  30. package/src/segment-system.tsx +11 -61
  31. package/src/server/context.ts +26 -3
  32. package/src/types/route-entry.ts +11 -0
  33. package/src/types/segments.ts +0 -1
  34. package/src/urls/include-helper.ts +24 -14
  35. package/src/urls/path-helper-types.ts +30 -4
  36. package/src/vite/utils/prerender-utils.ts +20 -6
@@ -206,6 +206,65 @@ parallel(
206
206
  )
207
207
  ```
208
208
 
209
+ ## Composable Slots via `handler.use`
210
+
211
+ Slot handlers can carry their own loader, loading, error/notFound boundaries, revalidation, and transition defaults via `.use`. The mount site then declares **just the slot names** — no per-call data wiring.
212
+
213
+ ```typescript
214
+ const CartSummary: Handler = async (ctx) => {
215
+ const cart = await ctx.use(CartLoader);
216
+ return <CartSummaryView cart={cart} />;
217
+ };
218
+ CartSummary.use = () => [
219
+ loader(CartLoader),
220
+ loading(<CartSkeleton />),
221
+ revalidate(revalidateCartData),
222
+ ];
223
+
224
+ // Same slot, no copy-pasted plumbing across layouts.
225
+ layout(<DashboardLayout />, () => [
226
+ parallel({ "@cart": CartSummary }),
227
+ path("/dashboard", DashboardIndex, { name: "dashboard.index" }),
228
+ ]);
229
+
230
+ layout(<AccountLayout />, () => [
231
+ parallel({ "@cart": CartSummary }),
232
+ path("/account", AccountIndex, { name: "account.index" }),
233
+ ]);
234
+ ```
235
+
236
+ A slot's `loading()` (whether from `handler.use` or explicit) makes that slot an independent streaming unit, exactly as in the **Streaming Behavior** section above.
237
+
238
+ The `parallel` mount site has the narrowest allow-list for `handler.use` items — slots cannot bring their own middleware or layout, only `revalidate`, `loader`, `loading`, `errorBoundary`, `notFoundBoundary`, and `transition`. See [skills/handler-use](../handler-use/SKILL.md) for the full table and merge rules.
239
+
240
+ ### Two scopes for explicit `use`: shared (broadcast) and slot-local
241
+
242
+ `parallel({...slots}, () => [...use])` runs the shared `use()` callback **once per slot** ([dsl-helpers.ts](../../src/route-definition/dsl-helpers.ts)) — items in that callback land on every slot's entry. That's the right behavior for the items the parallel allow-list permits and that accumulate (`loader`, `revalidate`, `errorBoundary`, `notFoundBoundary`, `transition`). (Slots cannot bring `middleware` or `layout` — see the allowed-types note above.)
243
+
244
+ For single-assignment items like `loading()`, broadcasting overwrites every slot's `handler.use` default. Pass a **slot descriptor** `{ handler, use }` instead — items in the descriptor's `use` apply only to that slot:
245
+
246
+ ```typescript
247
+ // @cart gets a custom skeleton; @notifs keeps its handler.use default.
248
+ parallel({
249
+ "@cart": {
250
+ handler: Cart,
251
+ use: () => [loading(<CustomCartSkeleton />)],
252
+ },
253
+ "@notifs": Notifs,
254
+ });
255
+
256
+ // Opt one slot out of streaming while siblings still stream the broadcast.
257
+ parallel(
258
+ {
259
+ "@cart": { handler: Cart, use: () => [loading(false)] },
260
+ "@notifs": Notifs,
261
+ },
262
+ () => [loading(<BroadcastSkeleton />)],
263
+ );
264
+ ```
265
+
266
+ Per-slot merge order is **handler.use → shared use → slot-local use**. Slot-local is the narrowest scope, so it wins for last-write-wins items. See [skills/handler-use § `loading()` is a single-assignment item — scope it correctly](../handler-use/SKILL.md#loading-is-a-single-assignment-item--scope-it-correctly) for the full reasoning.
267
+
209
268
  ## Slot Override Semantics
210
269
 
211
270
  When multiple `parallel()` calls define the same slot name, **the last
@@ -10,28 +10,30 @@ Django-inspired RSC router with composable URL patterns, type-safe href, and ser
10
10
 
11
11
  ## Skills
12
12
 
13
- | Skill | Description |
14
- | ------------------ | -------------------------------------------------------------------------- |
15
- | `/router-setup` | Create and configure the RSC router |
16
- | `/route` | Define routes with `urls()` and `path()` |
17
- | `/layout` | Layouts that wrap child routes |
18
- | `/loader` | Data loaders with `createLoader()` |
19
- | `/middleware` | Request processing and authentication |
20
- | `/intercept` | Modal/slide-over patterns for soft navigation |
21
- | `/parallel` | Multi-column layouts and sidebars |
22
- | `/caching` | Segment caching with memory or KV stores |
23
- | `/use-cache` | Function-level caching with `"use cache"` directive |
24
- | `/cache-guide` | When to use `cache()` vs `"use cache"` — differences and decision guide |
25
- | `/document-cache` | Edge caching with Cache-Control headers |
26
- | `/theme` | Light/dark mode with FOUC prevention |
27
- | `/links` | URL generation: ctx.reverse, href, useHref, useMount, scopedReverse |
28
- | `/hooks` | Client-side React hooks |
29
- | `/typesafety` | Type-safe routes, params, href, and environment |
30
- | `/host-router` | Multi-app host routing with domain/subdomain patterns |
31
- | `/tailwind` | Set up Tailwind CSS v4 with `?url` imports |
32
- | `/response-routes` | JSON/text/HTML/XML/stream endpoints with `path.json()`, `path.text()` |
33
- | `/mime-routes` | Content negotiation — same URL, different response types via Accept header |
34
- | `/fonts` | Load web fonts with preload hints |
13
+ | Skill | Description |
14
+ | ----------------------- | -------------------------------------------------------------------------- |
15
+ | `/router-setup` | Create and configure the RSC router |
16
+ | `/route` | Define routes with `urls()` and `path()` |
17
+ | `/layout` | Layouts that wrap child routes |
18
+ | `/loader` | Data loaders with `createLoader()` |
19
+ | `/middleware` | Request processing and authentication |
20
+ | `/intercept` | Modal/slide-over patterns for soft navigation |
21
+ | `/parallel` | Multi-column layouts and sidebars |
22
+ | `/caching` | Segment caching with memory or KV stores |
23
+ | `/use-cache` | Function-level caching with `"use cache"` directive |
24
+ | `/cache-guide` | When to use `cache()` vs `"use cache"` — differences and decision guide |
25
+ | `/document-cache` | Edge caching with Cache-Control headers |
26
+ | `/theme` | Light/dark mode with FOUC prevention |
27
+ | `/links` | URL generation: ctx.reverse, href, useHref, useMount, scopedReverse |
28
+ | `/hooks` | Client-side React hooks |
29
+ | `/typesafety` | Type-safe routes, params, href, and environment |
30
+ | `/host-router` | Multi-app host routing with domain/subdomain patterns |
31
+ | `/tailwind` | Set up Tailwind CSS v4 with `?url` imports |
32
+ | `/response-routes` | JSON/text/HTML/XML/stream endpoints with `path.json()`, `path.text()` |
33
+ | `/mime-routes` | Content negotiation — same URL, different response types via Accept header |
34
+ | `/fonts` | Load web fonts with preload hints |
35
+ | `/migrate-nextjs` | Migrate a Next.js App Router project to Rango |
36
+ | `/migrate-react-router` | Migrate a React Router / Remix project to Rango |
35
37
 
36
38
  ## Quick Start
37
39
 
@@ -383,6 +383,30 @@ urls(({ path, layout }) => [
383
383
  ])
384
384
  ```
385
385
 
386
+ ## Handler-attached `.use`
387
+
388
+ Page handlers can carry their own loader, middleware, error boundaries, parallels, and other defaults via a `.use` callback — so the page is self-contained and reusable across mount sites without re-wiring the same items.
389
+
390
+ ```typescript
391
+ const ProductPage: Handler<"/product/:slug"> = async (ctx) => {
392
+ const product = await ctx.use(ProductLoader);
393
+ return <ProductView product={product} />;
394
+ };
395
+ ProductPage.use = () => [
396
+ loader(ProductLoader),
397
+ loading(<ProductSkeleton />),
398
+ middleware(async (ctx, next) => {
399
+ await next();
400
+ ctx.header("Cache-Control", "private, max-age=60");
401
+ }),
402
+ ];
403
+
404
+ // Mount site has no per-page wiring — defaults travel with the handler.
405
+ path("/product/:slug", ProductPage, { name: "product" });
406
+ ```
407
+
408
+ Explicit `use()` at the mount site merges with `handler.use` (handler defaults first, explicit second). See [skills/handler-use](../handler-use/SKILL.md) for the merge order, allowed item types per mount site, and override semantics.
409
+
386
410
  ## Complete Example
387
411
 
388
412
  ```typescript
@@ -261,18 +261,24 @@ export function createNavigationBridge(
261
261
  // 2. routes that CAN be intercepted - we don't know if this navigation will intercept
262
262
  // 3. when leaving intercept - we need fresh non-intercept segments from server
263
263
  // 4. redirect-with-state - force re-render so hooks read fresh state
264
+ // 5. stale cache - server action invalidated it, need fresh data with loading state
264
265
  const hasUsableCache =
265
266
  cachedSegments &&
266
267
  cachedSegments.length > 0 &&
267
268
  !isInterceptOnlyCache(cachedSegments) &&
268
269
  !hasInterceptCache &&
269
270
  !isLeavingIntercept &&
271
+ !cached?.stale &&
270
272
  !options?._skipCache;
271
273
 
274
+ // Forward navigations always await fetchPartialUpdate before rendering,
275
+ // so useNavigation should always report "loading". skipLoadingState is
276
+ // only used for popstate background revalidation (line ~526) where
277
+ // cached content renders instantly without a network wait.
272
278
  const tx = createNavigationTransaction(store, eventController, url, {
273
279
  ...options,
274
280
  state: resolvedState,
275
- skipLoadingState: hasUsableCache,
281
+ skipLoadingState: false,
276
282
  });
277
283
 
278
284
  // REVALIDATE: Fetch fresh data from server
@@ -412,6 +418,15 @@ export function createNavigationBridge(
412
418
  eventController.abortAllActions();
413
419
  }
414
420
 
421
+ // Popstate that exits an intercept to a non-intercept destination. The
422
+ // fallback fetch path below needs `leave-intercept` mode so it filters
423
+ // the cached @modal segment from the request and forces a re-render —
424
+ // otherwise a cache-miss popstate whose server response has an empty
425
+ // diff hits the "no changes" branch in partial-update and the modal
426
+ // stays on screen.
427
+ const isLeavingIntercept =
428
+ !isIntercept && currentInterceptSource !== null;
429
+
415
430
  // Compute history key from URL (with intercept suffix if applicable)
416
431
  const historyKey = generateHistoryKey(url, { intercept: isIntercept });
417
432
 
@@ -562,7 +577,11 @@ export function createNavigationBridge(
562
577
  intercept: isIntercept,
563
578
  interceptSourceUrl,
564
579
  }),
565
- isIntercept ? { type: "navigate", interceptSourceUrl } : undefined,
580
+ isIntercept
581
+ ? { type: "navigate", interceptSourceUrl }
582
+ : isLeavingIntercept
583
+ ? { type: "leave-intercept" }
584
+ : undefined,
566
585
  );
567
586
  // Restore scroll position after fetch completes
568
587
  handleNavigationEnd({ restore: true, isStreaming });
@@ -167,9 +167,16 @@ export function createPartialUpdater(
167
167
  segments = segmentIds ?? segmentState.currentSegmentIds;
168
168
  }
169
169
 
170
- // For intercept revalidation, use the intercept source URL as previousUrl
170
+ // For intercept revalidation, use the intercept source URL as previousUrl.
171
+ // For leave-intercept, tx.currentUrl captures window.location.href at tx
172
+ // creation, which on popstate is already the destination URL and would
173
+ // tell the server "from == to". segmentState.currentUrl still points at
174
+ // the URL the cached segments render (the intercept URL), which is the
175
+ // correct "from" for the server's diff computation.
171
176
  const previousUrl =
172
- interceptSourceUrl || tx.currentUrl || segmentState.currentUrl;
177
+ mode.type === "leave-intercept"
178
+ ? segmentState.currentUrl || tx.currentUrl
179
+ : interceptSourceUrl || tx.currentUrl || segmentState.currentUrl;
173
180
 
174
181
  debugLog(`\n[Browser] >>> NAVIGATION`);
175
182
  debugLog(`[Browser] From: ${previousUrl}`);
@@ -25,6 +25,23 @@ import { enqueuePrefetch } from "./queue.js";
25
25
  import { shouldPrefetch } from "./policy.js";
26
26
  import { debugLog } from "../logging.js";
27
27
 
28
+ /**
29
+ * Check if a URL resolves to the current page (same pathname + search).
30
+ * Used to prevent same-page prefetching with prefetchKey, which would
31
+ * produce a trivial diff that corrupts the wildcard cache.
32
+ */
33
+ function isSamePage(url: string): boolean {
34
+ try {
35
+ const target = new URL(url, window.location.origin);
36
+ return (
37
+ target.pathname + target.search ===
38
+ window.location.pathname + window.location.search
39
+ );
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
44
+
28
45
  /**
29
46
  * Build an RSC partial URL for prefetching.
30
47
  * Includes _rsc_segments so the server can diff against currently mounted
@@ -122,7 +139,7 @@ export function prefetchDirect(
122
139
  if (!targetUrl) return;
123
140
  // Skip same-page prefetch with prefetchKey — a same-page diff is trivial
124
141
  // and would corrupt the wildcard cache entry for cross-page navigation.
125
- if (prefetchKey != null && targetUrl.pathname === window.location.pathname) {
142
+ if (prefetchKey != null && isSamePage(url)) {
126
143
  return;
127
144
  }
128
145
  const key = buildPrefetchKey(window.location.href, targetUrl, prefetchKey);
@@ -160,7 +177,7 @@ export function prefetchQueued(
160
177
  if (!targetUrl) return "";
161
178
  // Skip same-page prefetch with prefetchKey — a same-page diff is trivial
162
179
  // and would corrupt the wildcard cache entry for cross-page navigation.
163
- if (prefetchKey != null && targetUrl.pathname === window.location.pathname) {
180
+ if (prefetchKey != null && isSamePage(url)) {
164
181
  return "";
165
182
  }
166
183
  const key = buildPrefetchKey(window.location.href, targetUrl, prefetchKey);
@@ -173,7 +190,6 @@ export function prefetchQueued(
173
190
  return key;
174
191
  }
175
192
  const fetchUrlStr = targetUrl.toString();
176
- const targetPathname = targetUrl.pathname;
177
193
  enqueuePrefetch(key, (signal) => {
178
194
  // Re-check at execution time: a hover-triggered prefetchDirect may
179
195
  // have started or completed this key while the item sat in the queue.
@@ -181,7 +197,7 @@ export function prefetchQueued(
181
197
  // By execution time, the user may have navigated to the target page.
182
198
  // A same-page prefetch produces a trivial diff that would overwrite
183
199
  // the useful cross-page entry in the wildcard cache.
184
- if (prefetchKey != null && targetPathname === window.location.pathname) {
200
+ if (prefetchKey != null && isSamePage(url)) {
185
201
  return Promise.resolve();
186
202
  }
187
203
  return executePrefetchFetch(key, fetchUrlStr, signal).then(() => {});
@@ -78,16 +78,17 @@ export function useNavigation<T>(
78
78
  if (!shallowEqual(nextSelected, prevState.current)) {
79
79
  prevState.current = nextSelected;
80
80
 
81
- // Check if any actions are in progress for optimistic updates
82
- const hasInflightActions =
83
- ctx.eventController.getInflightActions().size > 0;
84
-
85
- if (hasInflightActions || publicState.state !== "idle") {
86
- // Use optimistic update for immediate feedback during transitions
87
- startTransition(() => {
88
- setOptimisticValue(nextSelected);
89
- });
90
- }
81
+ // Always mirror into the optimistic store inside a transition.
82
+ // useOptimistic returns the optimistic value while any transition that
83
+ // includes a setOptimisticValue is pending. Calling it only when
84
+ // entering "loading" left the optimistic store stuck on the previous
85
+ // value when going back to "idle" — if a parent transition (e.g., a
86
+ // <Link> click) was still pending, useOptimistic would keep returning
87
+ // the stale loading value even after baseValue flipped to idle.
88
+ // Updating in both directions keeps the optimistic store in sync.
89
+ startTransition(() => {
90
+ setOptimisticValue(nextSelected);
91
+ });
91
92
 
92
93
  // Always update base state so UI reflects current state
93
94
  setBaseValue(nextSelected);
@@ -184,20 +184,16 @@ export function reconcileSegments(input: ReconcileInput): ReconcileResult {
184
184
  `[reconcile] ${segId}: CACHE only (not from server, type=${fromCache.type}, component=${fromCache.component != null ? "yes" : "null"})`,
185
185
  );
186
186
 
187
- // For non-action actors: cached segments the server decided not to re-render.
188
- // - Preserve loading=false (suppressed boundary) to maintain tree structure
189
- // - Preserve parallel segment loading so renderSegments can reconstruct
190
- // parallel-owned loader markers from the cached slot metadata
191
- // - Clear other truthy loading values to prevent suspense on cached content
192
- if (actor !== "action") {
193
- if (fromCache.type === "parallel" && fromCache.loading !== undefined) {
194
- return fromCache;
195
- }
196
- if (fromCache.loading !== undefined && fromCache.loading !== false) {
197
- return { ...fromCache, loading: undefined };
198
- }
199
- }
200
-
187
+ // Return the cached segment as-is, regardless of actor. We used to clear
188
+ // truthy `loading` here to prevent a stale Suspense fallback from
189
+ // committing against cached content, but that swapped the render tree
190
+ // from the LoaderBoundary branch to the plain OutletProvider branch
191
+ // inside renderSegments, causing React to unmount the entire chain
192
+ // (LoaderBoundary > Suspense > LoaderResolver > RouteContentWrapper >
193
+ // Suspender) every time the user opened an intercept or navigated back
194
+ // to a cached page. The flicker is now prevented by renderSegments'
195
+ // promise memoization keeping React's use() in "known fulfilled" state,
196
+ // so preserving `loading` keeps the element tree stable.
201
197
  return fromCache;
202
198
  })
203
199
  .filter(Boolean) as ResolvedSegment[];
@@ -98,8 +98,14 @@ export function buildRouteTrie(
98
98
  }
99
99
 
100
100
  /**
101
- * Insert a route into the trie, handling optional params by forking
102
- * the insertion path (one terminal without the param, one with).
101
+ * Insert a route into the trie. Optional params expand into two branches at
102
+ * registration time (skip-first, then present), so each terminal lives at the
103
+ * correct depth for its number of bound params and carries a branch-local
104
+ * `pa` listing only those names. The trie's single-slot `node.p` is reused
105
+ * across branches because matching ignores `node.p.n` — the leaf's `pa` is
106
+ * the source of truth for naming. Skip-first ordering lets `mergeLeaf`'s
107
+ * last-wins rule produce greedy-leftmost semantics for free at any shared
108
+ * terminal depth.
103
109
  */
104
110
  function insertRoute(
105
111
  node: TrieNode,
@@ -107,14 +113,13 @@ function insertRoute(
107
113
  index: number,
108
114
  leaf: Omit<TrieLeaf, "op" | "cv" | "pa">,
109
115
  ): void {
110
- // Collect param names, optional param names, and constraints across all segments
111
- const paramNames: string[] = [];
116
+ // op (full optional list) and cv (full constraint map) are route-level and
117
+ // identical on every terminal, so compute them once on the shared base.
112
118
  const optionalParams: string[] = [];
113
119
  const constraints: Record<string, string[]> = {};
114
120
 
115
121
  for (const seg of segments) {
116
122
  if (seg.type === "param") {
117
- paramNames.push(seg.value);
118
123
  if (seg.optional) {
119
124
  optionalParams.push(seg.value);
120
125
  }
@@ -124,21 +129,15 @@ function insertRoute(
124
129
  }
125
130
  }
126
131
 
127
- const fullLeaf: TrieLeaf = {
132
+ const leafBase: Omit<TrieLeaf, "pa"> = {
128
133
  ...leaf,
129
- ...(paramNames.length > 0 ? { pa: paramNames } : {}),
130
134
  ...(optionalParams.length > 0 ? { op: optionalParams } : {}),
131
135
  ...(Object.keys(constraints).length > 0 ? { cv: constraints } : {}),
132
136
  };
133
137
 
134
- insertSegments(node, segments, index, fullLeaf);
138
+ insertSegments(node, segments, index, leafBase, []);
135
139
  }
136
140
 
137
- /**
138
- * Recursively insert segments into the trie.
139
- * For optional params, we add a terminal at the current node (param absent)
140
- * AND continue inserting into the param child (param present).
141
- */
142
141
  /**
143
142
  * Extract ancestry map from a built trie by visiting all leaf nodes.
144
143
  * Returns { routeName: ancestryShortCodes[] } for every route in the trie.
@@ -218,15 +217,25 @@ function mergeLeaf(node: TrieNode, leaf: TrieLeaf): void {
218
217
  node.r = mergeLeaves(node.r, leaf);
219
218
  }
220
219
 
220
+ function buildLeaf(
221
+ leafBase: Omit<TrieLeaf, "pa">,
222
+ paramNames: string[],
223
+ ): TrieLeaf {
224
+ return paramNames.length > 0
225
+ ? { ...leafBase, pa: [...paramNames] }
226
+ : { ...leafBase };
227
+ }
228
+
221
229
  function insertSegments(
222
230
  node: TrieNode,
223
231
  segments: ParsedSegment[],
224
232
  index: number,
225
- leaf: TrieLeaf,
233
+ leafBase: Omit<TrieLeaf, "pa">,
234
+ paramNames: string[],
226
235
  ): void {
227
- // Base case: all segments consumed, add terminal
236
+ // Base case: all segments consumed, add terminal with branch-local pa
228
237
  if (index >= segments.length) {
229
- mergeLeaf(node, leaf);
238
+ mergeLeaf(node, buildLeaf(leafBase, paramNames));
230
239
  return;
231
240
  }
232
241
 
@@ -235,12 +244,19 @@ function insertSegments(
235
244
  if (segment.type === "static") {
236
245
  if (!node.s) node.s = {};
237
246
  if (!node.s[segment.value]) node.s[segment.value] = {};
238
- insertSegments(node.s[segment.value], segments, index + 1, leaf);
247
+ insertSegments(
248
+ node.s[segment.value],
249
+ segments,
250
+ index + 1,
251
+ leafBase,
252
+ paramNames,
253
+ );
239
254
  } else if (segment.type === "param") {
240
255
  if (segment.optional) {
241
- // Optional param: add terminal at current node (param absent)
242
- mergeLeaf(node, leaf);
243
- // AND continue with param child (param present)
256
+ // SKIP first: continue at the same node without binding this name.
257
+ // Skip-first ordering means the present-branch's TAKE overwrites any
258
+ // shared terminal later, giving greedy-leftmost semantics.
259
+ insertSegments(node, segments, index + 1, leafBase, paramNames);
244
260
  }
245
261
  if (segment.suffix) {
246
262
  // Suffix param: keyed by suffix string (e.g., ".html")
@@ -248,16 +264,26 @@ function insertSegments(
248
264
  if (!node.xp[segment.suffix]) {
249
265
  node.xp[segment.suffix] = { n: segment.value, c: {} };
250
266
  }
251
- insertSegments(node.xp[segment.suffix].c, segments, index + 1, leaf);
267
+ insertSegments(node.xp[segment.suffix].c, segments, index + 1, leafBase, [
268
+ ...paramNames,
269
+ segment.value,
270
+ ]);
252
271
  } else {
253
272
  if (!node.p) {
254
273
  node.p = { n: segment.value, c: {} };
255
274
  }
256
- insertSegments(node.p.c, segments, index + 1, leaf);
275
+ insertSegments(node.p.c, segments, index + 1, leafBase, [
276
+ ...paramNames,
277
+ segment.value,
278
+ ]);
257
279
  }
258
280
  } else if (segment.type === "wildcard") {
259
- // Wildcard consumes all remaining segments
260
- const wildLeaf = { ...leaf, pn: "*" };
281
+ // Wildcard consumes all remaining segments. Carry any params bound before
282
+ // the wildcard in pa so they zip correctly against paramValues at match.
283
+ const wildLeaf: TrieLeaf & { pn: string } = {
284
+ ...buildLeaf(leafBase, paramNames),
285
+ pn: "*",
286
+ };
261
287
  const existing = node.w ? ({ ...node.w } as TrieLeaf) : undefined;
262
288
  const merged = mergeLeaves(existing, wildLeaf);
263
289
  node.w = merged as TrieLeaf & { pn: string };