@rangojs/router 0.0.0-experimental.66 → 0.0.0-experimental.66cdebe3

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 (123) hide show
  1. package/README.md +112 -17
  2. package/dist/vite/index.js +1462 -422
  3. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  4. package/package.json +7 -5
  5. package/skills/breadcrumbs/SKILL.md +3 -1
  6. package/skills/handler-use/SKILL.md +364 -0
  7. package/skills/hooks/SKILL.md +54 -20
  8. package/skills/i18n/SKILL.md +276 -0
  9. package/skills/intercept/SKILL.md +45 -0
  10. package/skills/layout/SKILL.md +24 -0
  11. package/skills/links/SKILL.md +234 -16
  12. package/skills/loader/SKILL.md +70 -3
  13. package/skills/middleware/SKILL.md +34 -3
  14. package/skills/migrate-nextjs/SKILL.md +562 -0
  15. package/skills/migrate-react-router/SKILL.md +769 -0
  16. package/skills/parallel/SKILL.md +68 -0
  17. package/skills/rango/SKILL.md +26 -22
  18. package/skills/response-routes/SKILL.md +8 -0
  19. package/skills/route/SKILL.md +48 -0
  20. package/skills/server-actions/SKILL.md +739 -0
  21. package/skills/streams-and-websockets/SKILL.md +283 -0
  22. package/skills/typesafety/SKILL.md +9 -1
  23. package/skills/view-transitions/SKILL.md +212 -0
  24. package/src/browser/app-shell.ts +52 -0
  25. package/src/browser/event-controller.ts +44 -4
  26. package/src/browser/navigation-bridge.ts +151 -9
  27. package/src/browser/navigation-client.ts +64 -13
  28. package/src/browser/navigation-store.ts +25 -1
  29. package/src/browser/partial-update.ts +58 -12
  30. package/src/browser/prefetch/cache.ts +129 -21
  31. package/src/browser/prefetch/fetch.ts +148 -16
  32. package/src/browser/prefetch/queue.ts +36 -5
  33. package/src/browser/rango-state.ts +53 -13
  34. package/src/browser/react/Link.tsx +30 -2
  35. package/src/browser/react/NavigationProvider.tsx +95 -44
  36. package/src/browser/react/filter-segment-order.ts +51 -7
  37. package/src/browser/react/index.ts +3 -0
  38. package/src/browser/react/use-navigation.ts +22 -2
  39. package/src/browser/react/use-params.ts +17 -4
  40. package/src/browser/react/use-reverse.ts +99 -0
  41. package/src/browser/react/use-router.ts +8 -1
  42. package/src/browser/react/use-segments.ts +11 -8
  43. package/src/browser/rsc-router.tsx +34 -6
  44. package/src/browser/scroll-restoration.ts +69 -28
  45. package/src/browser/segment-reconciler.ts +36 -14
  46. package/src/browser/types.ts +19 -0
  47. package/src/build/route-trie.ts +52 -25
  48. package/src/cache/cf/cf-cache-store.ts +5 -7
  49. package/src/client.rsc.tsx +3 -0
  50. package/src/client.tsx +87 -175
  51. package/src/href-client.ts +4 -1
  52. package/src/index.rsc.ts +3 -0
  53. package/src/index.ts +44 -9
  54. package/src/outlet-context.ts +1 -1
  55. package/src/response-utils.ts +28 -0
  56. package/src/reverse.ts +62 -36
  57. package/src/route-definition/dsl-helpers.ts +175 -23
  58. package/src/route-definition/helpers-types.ts +63 -14
  59. package/src/route-definition/resolve-handler-use.ts +6 -0
  60. package/src/route-types.ts +7 -0
  61. package/src/router/handler-context.ts +21 -38
  62. package/src/router/lazy-includes.ts +6 -6
  63. package/src/router/loader-resolution.ts +3 -0
  64. package/src/router/manifest.ts +22 -13
  65. package/src/router/match-api.ts +4 -3
  66. package/src/router/match-handlers.ts +1 -0
  67. package/src/router/match-middleware/cache-lookup.ts +2 -1
  68. package/src/router/match-result.ts +101 -4
  69. package/src/router/middleware-types.ts +14 -25
  70. package/src/router/middleware.ts +54 -7
  71. package/src/router/pattern-matching.ts +101 -17
  72. package/src/router/revalidation.ts +15 -1
  73. package/src/router/segment-resolution/fresh.ts +13 -0
  74. package/src/router/segment-resolution/revalidation.ts +135 -101
  75. package/src/router/substitute-pattern-params.ts +56 -0
  76. package/src/router/trie-matching.ts +18 -13
  77. package/src/router/url-params.ts +49 -0
  78. package/src/router.ts +1 -2
  79. package/src/rsc/handler.ts +16 -8
  80. package/src/rsc/helpers.ts +69 -41
  81. package/src/rsc/progressive-enhancement.ts +4 -0
  82. package/src/rsc/response-route-handler.ts +14 -1
  83. package/src/rsc/rsc-rendering.ts +10 -0
  84. package/src/rsc/server-action.ts +4 -0
  85. package/src/rsc/types.ts +6 -0
  86. package/src/segment-content-promise.ts +67 -0
  87. package/src/segment-loader-promise.ts +122 -0
  88. package/src/segment-system.tsx +71 -70
  89. package/src/server/context.ts +26 -3
  90. package/src/server/request-context.ts +10 -42
  91. package/src/ssr/index.tsx +5 -1
  92. package/src/types/handler-context.ts +12 -39
  93. package/src/types/loader-types.ts +5 -6
  94. package/src/types/request-scope.ts +126 -0
  95. package/src/types/route-entry.ts +11 -0
  96. package/src/types/segments.ts +18 -1
  97. package/src/urls/include-helper.ts +24 -14
  98. package/src/urls/path-helper-types.ts +30 -4
  99. package/src/urls/response-types.ts +2 -10
  100. package/src/use-loader.tsx +4 -1
  101. package/src/vite/debug.ts +184 -0
  102. package/src/vite/discovery/discover-routers.ts +31 -3
  103. package/src/vite/discovery/gate-state.ts +171 -0
  104. package/src/vite/discovery/prerender-collection.ts +172 -84
  105. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  106. package/src/vite/plugins/cjs-to-esm.ts +5 -0
  107. package/src/vite/plugins/client-ref-dedup.ts +16 -0
  108. package/src/vite/plugins/client-ref-hashing.ts +16 -4
  109. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  110. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  111. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  112. package/src/vite/plugins/expose-action-id.ts +52 -28
  113. package/src/vite/plugins/expose-id-utils.ts +12 -0
  114. package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
  115. package/src/vite/plugins/expose-internal-ids.ts +545 -304
  116. package/src/vite/plugins/performance-tracks.ts +17 -9
  117. package/src/vite/plugins/use-cache-transform.ts +56 -43
  118. package/src/vite/plugins/version-injector.ts +37 -11
  119. package/src/vite/rango.ts +49 -14
  120. package/src/vite/router-discovery.ts +558 -53
  121. package/src/vite/utils/banner.ts +1 -1
  122. package/src/vite/utils/package-resolution.ts +41 -1
  123. package/src/vite/utils/prerender-utils.ts +21 -6
@@ -28,6 +28,7 @@ import {
28
28
  isInterceptSegment,
29
29
  splitInterceptSegments,
30
30
  } from "./intercept-utils.js";
31
+ import { createAppShellRef } from "./app-shell.js";
31
32
 
32
33
  // Vite HMR types are provided by vite/client
33
34
 
@@ -114,6 +115,13 @@ export interface BrowserAppContext {
114
115
  warmupEnabled?: boolean;
115
116
  /** App version for prefetch version mismatch detection */
116
117
  version?: string;
118
+ /**
119
+ * Live app-shell ref. Cross-app navigations replace its contents so the
120
+ * NavigationProvider and renderSegments pick up the target app's
121
+ * rootLayout, basename, and version without consumer rerenders. Theme,
122
+ * warmup, and prefetch TTL are document-lifetime (see AppShell).
123
+ */
124
+ appShellRef?: import("./app-shell.js").AppShellRef;
117
125
  }
118
126
 
119
127
  // Module-level state for the initialized app
@@ -204,13 +212,23 @@ export async function initBrowserApp(
204
212
  // Create composable utilities
205
213
  const client = createNavigationClient(deps);
206
214
 
207
- // Extract rootLayout and version from metadata for browser-side re-renders
208
- const rootLayout = initialPayload.metadata?.rootLayout;
215
+ // Capture the per-router app-shell so cross-app navigations can replace
216
+ // it atomically. rootLayout, basename, and version live here and are
217
+ // read through the ref at call time rather than closed over. Theme,
218
+ // warmup, and prefetch TTL are deliberately excluded — they are
219
+ // document-lifetime and stay stable across smooth cross-app transitions.
209
220
  const version = initialPayload.metadata?.version;
221
+ const appShellRef = createAppShellRef({
222
+ routerId: initialPayload.metadata?.routerId,
223
+ rootLayout: initialPayload.metadata?.rootLayout,
224
+ basename: initialPayload.metadata?.basename,
225
+ version,
226
+ });
210
227
 
211
228
  // Initialize the localStorage state key for cache invalidation.
212
- // Uses the build version so a new deploy automatically busts all cached prefetches.
213
- initRangoState(version ?? "0");
229
+ // The build version busts cached prefetches on deploy; the routerId
230
+ // namespaces the key so sibling apps on the same origin don't collide.
231
+ initRangoState(version ?? "0", initialPayload.metadata?.routerId);
214
232
  setAppVersion(version);
215
233
 
216
234
  // Initialize the in-memory prefetch cache TTL from server config.
@@ -220,11 +238,17 @@ export async function initBrowserApp(
220
238
  initPrefetchCache(prefetchCacheTTL);
221
239
  }
222
240
 
223
- // Create a bound renderSegments that includes rootLayout
241
+ // Create a bound renderSegments that reads rootLayout through the shell
242
+ // ref. On app switch the ref is updated before the tree re-renders, so
243
+ // the new app's Document (rootLayout) replaces the previous one.
224
244
  const renderSegments = (
225
245
  segments: ResolvedSegment[],
226
246
  options?: RenderSegmentsOptions,
227
- ) => baseRenderSegments(segments, { ...options, rootLayout });
247
+ ) =>
248
+ baseRenderSegments(segments, {
249
+ ...options,
250
+ rootLayout: appShellRef.get().rootLayout,
251
+ });
228
252
 
229
253
  // Lazy reference for navigation bridge — the action bridge is created first
230
254
  // but may need to trigger SPA navigation for action redirects.
@@ -256,6 +280,7 @@ export async function initBrowserApp(
256
280
  onUpdate: (update) => store.emitUpdate(update),
257
281
  renderSegments,
258
282
  version: version,
283
+ appShellRef,
259
284
  });
260
285
 
261
286
  // Connect action redirect → navigation bridge (now that both are initialized)
@@ -416,6 +441,7 @@ export async function initBrowserApp(
416
441
  initialTheme: effectiveInitialTheme,
417
442
  warmupEnabled: initialPayload.metadata?.warmupEnabled ?? true,
418
443
  version,
444
+ appShellRef,
419
445
  };
420
446
  browserAppContext = context;
421
447
 
@@ -481,6 +507,7 @@ export function RSCRouter(_props: RSCRouterProps): React.ReactElement {
481
507
  initialTheme,
482
508
  warmupEnabled,
483
509
  version,
510
+ appShellRef,
484
511
  } = getBrowserAppContext();
485
512
 
486
513
  // Signal that the React tree has hydrated. useEffect only fires after
@@ -501,6 +528,7 @@ export function RSCRouter(_props: RSCRouterProps): React.ReactElement {
501
528
  warmupEnabled={warmupEnabled}
502
529
  version={version}
503
530
  basename={initialPayload.metadata?.basename}
531
+ appShellRef={appShellRef}
504
532
  />
505
533
  );
506
534
  }
@@ -10,15 +10,6 @@
10
10
 
11
11
  import { debugLog } from "./logging.js";
12
12
 
13
- /**
14
- * Defers a callback to the next animation frame.
15
- * Falls back to setTimeout(0) in environments without requestAnimationFrame.
16
- */
17
- const deferToNextPaint: (fn: () => void) => void =
18
- typeof requestAnimationFrame === "function"
19
- ? requestAnimationFrame
20
- : (fn) => setTimeout(fn, 0);
21
-
22
13
  const SCROLL_STORAGE_KEY = "rsc-router-scroll-positions";
23
14
 
24
15
  /**
@@ -294,16 +285,69 @@ export function restoreScrollPosition(options?: {
294
285
  return true;
295
286
  }
296
287
 
297
- // Not streaming scroll after React commits and browser paints.
298
- // startTransition defers the DOM commit, so scrolling synchronously
299
- // would be overwritten when React replaces the content.
300
- deferToNextPaint(() => {
288
+ // Defer the actual scrollTo to the next animation frame so the new
289
+ // tree's layout has been measured. Synchronous scrollTo from inside
290
+ // useLayoutEffect runs against an as-yet-unmeasured DOM and silently
291
+ // clamps savedY to the old (or zero) maxScroll — a tall destination
292
+ // restored from a short source then lands at Y=0 instead of the
293
+ // saved position.
294
+ //
295
+ // (scrollToTop / scrollToHash on forward navigations stay synchronous
296
+ // because Y=0 / a hash element are robust against unmeasured layout —
297
+ // they don't depend on the new tree's full scrollHeight, and sync
298
+ // scrolling lands before startViewTransition's snapshot capture.)
299
+ //
300
+ // If a view transition is in flight, scrollTo lands on the live DOM
301
+ // but the user sees the VT snapshot pseudos. When the VT finishes and
302
+ // pseudos are removed, the browser settles scrollY at whatever the
303
+ // pseudo-element layout left it at — often the new tree's max scroll
304
+ // — overwriting the restore. Detect active VT animations and re-apply
305
+ // scrollTo once they finish.
306
+ if (typeof requestAnimationFrame === "function") {
307
+ requestAnimationFrame(() => {
308
+ if (typeof window.scrollTo === "function") {
309
+ window.scrollTo(0, savedY);
310
+ debugLog("[Scroll] Restored position:", savedY, "for key:", key);
311
+ }
312
+ const vtAnimations = getActiveViewTransitionAnimations();
313
+ if (vtAnimations.length > 0) {
314
+ Promise.allSettled(vtAnimations.map((a) => a.finished))
315
+ .then(() => {
316
+ if (typeof window.scrollTo === "function") {
317
+ window.scrollTo(0, savedY);
318
+ debugLog("[Scroll] Re-applied position after VT settle:", savedY);
319
+ }
320
+ })
321
+ .catch(() => {
322
+ // Animations.finished can reject if the animation is canceled
323
+ // (e.g., the user starts another navigation). In that case
324
+ // the next nav's scroll handling takes over.
325
+ });
326
+ }
327
+ });
328
+ } else if (typeof window.scrollTo === "function") {
301
329
  window.scrollTo(0, savedY);
302
330
  debugLog("[Scroll] Restored position:", savedY, "for key:", key);
303
- });
331
+ }
304
332
  return true;
305
333
  }
306
334
 
335
+ /**
336
+ * Find any in-flight View Transition pseudo-element animations. Used to
337
+ * sequence scroll restoration after the transition settles, so VT
338
+ * pseudo-element teardown doesn't overwrite our scrollTo with the live
339
+ * DOM's post-transition scroll position.
340
+ */
341
+ function getActiveViewTransitionAnimations(): Animation[] {
342
+ if (typeof document === "undefined") return [];
343
+ if (typeof document.getAnimations !== "function") return [];
344
+ return document.getAnimations().filter((a) => {
345
+ const effect = a.effect as KeyframeEffect | null;
346
+ const pseudo = effect?.pseudoElement;
347
+ return typeof pseudo === "string" && pseudo.startsWith("::view-transition");
348
+ });
349
+ }
350
+
307
351
  /**
308
352
  * Handle hash link scrolling.
309
353
  * Scrolls to element with matching ID if hash is present.
@@ -332,6 +376,8 @@ export function scrollToHash(): boolean {
332
376
  * Scroll to top of page
333
377
  */
334
378
  export function scrollToTop(): void {
379
+ if (typeof window === "undefined") return;
380
+ if (typeof window.scrollTo !== "function") return;
335
381
  window.scrollTo(0, 0);
336
382
  }
337
383
 
@@ -374,20 +420,15 @@ export function handleNavigationEnd(options: {
374
420
  // Fall through to hash or top if no saved position
375
421
  }
376
422
 
377
- // Defer hash and scroll-to-top to after React paints the new content,
378
- // so the user doesn't see the current page jump before the new route appears.
379
- deferToNextPaint(() => {
380
- // Re-check: the deferred callback may fire after environment teardown
381
- if (typeof window === "undefined") return;
382
-
383
- // Try hash scrolling first
384
- if (scrollToHash()) {
385
- return;
386
- }
387
-
388
- // Default: scroll to top
389
- scrollToTop();
390
- });
423
+ // Sync hash scroll / scroll-to-top see the long comment in
424
+ // restoreScrollPosition above. handleNavigationEnd runs from
425
+ // NavigationProvider's useLayoutEffect (post-commit, pre-paint) and from
426
+ // bridge sites that don't trigger a React commit, so a synchronous
427
+ // scrollTo is captured by the next paint / startViewTransition snapshot.
428
+ if (scrollToHash()) {
429
+ return;
430
+ }
431
+ scrollToTop();
391
432
  }
392
433
 
393
434
  /**
@@ -6,6 +6,7 @@ import {
6
6
  } from "./merge-segment-loaders.js";
7
7
  import { assertSegmentStructure } from "./segment-structure-assert.js";
8
8
  import { splitInterceptSegments } from "./intercept-utils.js";
9
+ import { debugLog } from "./logging.js";
9
10
 
10
11
  /**
11
12
  * Determines the merging behavior for segment reconciliation.
@@ -85,14 +86,29 @@ export function reconcileSegments(input: ReconcileInput): ReconcileResult {
85
86
  const cachedSegments = new Map<string, ResolvedSegment>();
86
87
  input.cachedSegments.forEach((s) => cachedSegments.set(s.id, s));
87
88
 
89
+ const diffSet = new Set(diff);
90
+ debugLog(
91
+ `[reconcile] actor=${actor}, matched=${matched.length}, diff=${diff.length}`,
92
+ );
93
+ debugLog(
94
+ `[reconcile] server segments: ${[...serverSegments.keys()].join(", ")}`,
95
+ );
96
+ debugLog(
97
+ `[reconcile] cached segments: ${[...cachedSegments.keys()].join(", ")}`,
98
+ );
99
+
88
100
  const segments = matched
89
101
  .map((segId: string) => {
90
102
  const fromServer = serverSegments.get(segId);
91
103
  const fromCache = cachedSegments.get(segId);
92
104
 
93
105
  if (fromServer) {
106
+ const inDiff = diffSet.has(segId);
94
107
  // Merge partial loader data when server returns fewer loaders than cached
95
108
  if (shouldMergeLoaders && needsLoaderMerge(fromServer, fromCache)) {
109
+ debugLog(
110
+ `[reconcile] ${segId}: MERGE loaders (server partial, ${inDiff ? "in diff" : "not in diff"})`,
111
+ );
96
112
  return mergeSegmentLoaders(fromServer, fromCache);
97
113
  }
98
114
 
@@ -143,8 +159,14 @@ export function reconcileSegments(input: ReconcileInput): ReconcileResult {
143
159
  // above fails to preserve a value it should have.
144
160
  assertSegmentStructure(fromCache, merged, context);
145
161
 
162
+ debugLog(
163
+ `[reconcile] ${segId}: SERVER+CACHE merge (${inDiff ? "in diff" : "not in diff"}, type=${fromServer.type}, component=${fromServer.component === null ? "null→cached" : "server"})`,
164
+ );
146
165
  return merged;
147
166
  }
167
+ debugLog(
168
+ `[reconcile] ${segId}: SERVER only (${inDiff ? "in diff" : "not in diff"}, type=${fromServer.type}, no cache entry)`,
169
+ );
148
170
  return fromServer;
149
171
  }
150
172
 
@@ -158,20 +180,20 @@ export function reconcileSegments(input: ReconcileInput): ReconcileResult {
158
180
  return fromCache;
159
181
  }
160
182
 
161
- // For non-action actors: cached segments the server decided not to re-render.
162
- // - Preserve loading=false (suppressed boundary) to maintain tree structure
163
- // - Preserve parallel segment loading so renderSegments can reconstruct
164
- // parallel-owned loader markers from the cached slot metadata
165
- // - Clear other truthy loading values to prevent suspense on cached content
166
- if (actor !== "action") {
167
- if (fromCache.type === "parallel" && fromCache.loading !== undefined) {
168
- return fromCache;
169
- }
170
- if (fromCache.loading !== undefined && fromCache.loading !== false) {
171
- return { ...fromCache, loading: undefined };
172
- }
173
- }
174
-
183
+ debugLog(
184
+ `[reconcile] ${segId}: CACHE only (not from server, type=${fromCache.type}, component=${fromCache.component != null ? "yes" : "null"})`,
185
+ );
186
+
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.
175
197
  return fromCache;
176
198
  })
177
199
  .filter(Boolean) as ResolvedSegment[];
@@ -39,6 +39,12 @@ export interface RscMetadata {
39
39
  isError?: boolean;
40
40
  matched?: string[];
41
41
  diff?: string[];
42
+ /**
43
+ * All segment ids re-resolved on the server, including null-component
44
+ * ones excluded from `segments`/`diff`. Drives client-side handle-bucket
45
+ * cleanup. Superset of `diff`. See MatchResult.resolvedIds.
46
+ */
47
+ resolvedIds?: string[];
42
48
  /** Merged route params from the matched route */
43
49
  params?: Record<string, string>;
44
50
  /**
@@ -427,6 +433,12 @@ export interface NavigationStore {
427
433
  markCacheAsStale(): void;
428
434
  markCacheAsStaleAndBroadcast(): void;
429
435
  clearHistoryCache(): void;
436
+ /**
437
+ * Clear this tab's nav + prefetch caches without broadcasting or rotating
438
+ * shared state. Intended for app-switch transitions that affect only this
439
+ * tab's session.
440
+ */
441
+ clearHistoryCacheLocal(): void;
430
442
  broadcastCacheInvalidation(): void;
431
443
 
432
444
  // Cross-tab refresh callback (set by navigation bridge)
@@ -542,6 +554,13 @@ export interface NavigationBridge {
542
554
  registerLinkInterception(): () => void;
543
555
  /** Update the RSC version (e.g. after HMR). Clears prefetch cache. */
544
556
  updateVersion(newVersion: string): void;
557
+ /**
558
+ * Replace the active app-shell snapshot (rootLayout, basename, version)
559
+ * atomically. Used on cross-app navigations when the response's routerId
560
+ * indicates the user entered a different app. Theme, warmup, and prefetch
561
+ * TTL are document-lifetime and not part of the shell.
562
+ */
563
+ updateAppShell(next: import("./app-shell.js").AppShell): void;
545
564
  }
546
565
 
547
566
  /**
@@ -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[]>;
@@ -98,8 +99,14 @@ export function buildRouteTrie(
98
99
  }
99
100
 
100
101
  /**
101
- * Insert a route into the trie, handling optional params by forking
102
- * the insertion path (one terminal without the param, one with).
102
+ * Insert a route into the trie. Optional params expand into two branches at
103
+ * registration time (skip-first, then present), so each terminal lives at the
104
+ * correct depth for its number of bound params and carries a branch-local
105
+ * `pa` listing only those names. The trie's single-slot `node.p` is reused
106
+ * across branches because matching ignores `node.p.n` — the leaf's `pa` is
107
+ * the source of truth for naming. Skip-first ordering lets `mergeLeaf`'s
108
+ * last-wins rule produce greedy-leftmost semantics for free at any shared
109
+ * terminal depth.
103
110
  */
104
111
  function insertRoute(
105
112
  node: TrieNode,
@@ -107,14 +114,13 @@ function insertRoute(
107
114
  index: number,
108
115
  leaf: Omit<TrieLeaf, "op" | "cv" | "pa">,
109
116
  ): void {
110
- // Collect param names, optional param names, and constraints across all segments
111
- const paramNames: string[] = [];
117
+ // op (full optional list) and cv (full constraint map) are route-level and
118
+ // identical on every terminal, so compute them once on the shared base.
112
119
  const optionalParams: string[] = [];
113
120
  const constraints: Record<string, string[]> = {};
114
121
 
115
122
  for (const seg of segments) {
116
123
  if (seg.type === "param") {
117
- paramNames.push(seg.value);
118
124
  if (seg.optional) {
119
125
  optionalParams.push(seg.value);
120
126
  }
@@ -124,21 +130,15 @@ function insertRoute(
124
130
  }
125
131
  }
126
132
 
127
- const fullLeaf: TrieLeaf = {
133
+ const leafBase: Omit<TrieLeaf, "pa"> = {
128
134
  ...leaf,
129
- ...(paramNames.length > 0 ? { pa: paramNames } : {}),
130
135
  ...(optionalParams.length > 0 ? { op: optionalParams } : {}),
131
136
  ...(Object.keys(constraints).length > 0 ? { cv: constraints } : {}),
132
137
  };
133
138
 
134
- insertSegments(node, segments, index, fullLeaf);
139
+ insertSegments(node, segments, index, leafBase, []);
135
140
  }
136
141
 
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
142
  /**
143
143
  * Extract ancestry map from a built trie by visiting all leaf nodes.
144
144
  * Returns { routeName: ancestryShortCodes[] } for every route in the trie.
@@ -218,15 +218,25 @@ function mergeLeaf(node: TrieNode, leaf: TrieLeaf): void {
218
218
  node.r = mergeLeaves(node.r, leaf);
219
219
  }
220
220
 
221
+ function buildLeaf(
222
+ leafBase: Omit<TrieLeaf, "pa">,
223
+ paramNames: string[],
224
+ ): TrieLeaf {
225
+ return paramNames.length > 0
226
+ ? { ...leafBase, pa: [...paramNames] }
227
+ : { ...leafBase };
228
+ }
229
+
221
230
  function insertSegments(
222
231
  node: TrieNode,
223
232
  segments: ParsedSegment[],
224
233
  index: number,
225
- leaf: TrieLeaf,
234
+ leafBase: Omit<TrieLeaf, "pa">,
235
+ paramNames: string[],
226
236
  ): void {
227
- // Base case: all segments consumed, add terminal
237
+ // Base case: all segments consumed, add terminal with branch-local pa
228
238
  if (index >= segments.length) {
229
- mergeLeaf(node, leaf);
239
+ mergeLeaf(node, buildLeaf(leafBase, paramNames));
230
240
  return;
231
241
  }
232
242
 
@@ -235,12 +245,19 @@ function insertSegments(
235
245
  if (segment.type === "static") {
236
246
  if (!node.s) node.s = {};
237
247
  if (!node.s[segment.value]) node.s[segment.value] = {};
238
- insertSegments(node.s[segment.value], segments, index + 1, leaf);
248
+ insertSegments(
249
+ node.s[segment.value],
250
+ segments,
251
+ index + 1,
252
+ leafBase,
253
+ paramNames,
254
+ );
239
255
  } else if (segment.type === "param") {
240
256
  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)
257
+ // SKIP first: continue at the same node without binding this name.
258
+ // Skip-first ordering means the present-branch's TAKE overwrites any
259
+ // shared terminal later, giving greedy-leftmost semantics.
260
+ insertSegments(node, segments, index + 1, leafBase, paramNames);
244
261
  }
245
262
  if (segment.suffix) {
246
263
  // Suffix param: keyed by suffix string (e.g., ".html")
@@ -248,16 +265,26 @@ function insertSegments(
248
265
  if (!node.xp[segment.suffix]) {
249
266
  node.xp[segment.suffix] = { n: segment.value, c: {} };
250
267
  }
251
- insertSegments(node.xp[segment.suffix].c, segments, index + 1, leaf);
268
+ insertSegments(node.xp[segment.suffix].c, segments, index + 1, leafBase, [
269
+ ...paramNames,
270
+ segment.value,
271
+ ]);
252
272
  } else {
253
273
  if (!node.p) {
254
274
  node.p = { n: segment.value, c: {} };
255
275
  }
256
- insertSegments(node.p.c, segments, index + 1, leaf);
276
+ insertSegments(node.p.c, segments, index + 1, leafBase, [
277
+ ...paramNames,
278
+ segment.value,
279
+ ]);
257
280
  }
258
281
  } else if (segment.type === "wildcard") {
259
- // Wildcard consumes all remaining segments
260
- const wildLeaf = { ...leaf, pn: "*" };
282
+ // Wildcard consumes all remaining segments. Carry any params bound before
283
+ // the wildcard in pa so they zip correctly against paramValues at match.
284
+ const wildLeaf: TrieLeaf & { pn: string } = {
285
+ ...buildLeaf(leafBase, paramNames),
286
+ pn: "*",
287
+ };
261
288
  const existing = node.w ? ({ ...node.w } as TrieLeaf) : undefined;
262
289
  const merged = mergeLeaves(existing, wildLeaf);
263
290
  node.w = merged as TrieLeaf & { pn: string };
@@ -67,13 +67,11 @@ export const MAX_REVALIDATION_INTERVAL = 30;
67
67
  // Types
68
68
  // ============================================================================
69
69
 
70
- /**
71
- * Cloudflare Workers ExecutionContext (subset we need)
72
- */
73
- export interface ExecutionContext {
74
- waitUntil(promise: Promise<any>): void;
75
- passThroughOnException(): void;
76
- }
70
+ // Re-exported from the canonical home so cf-cache-store consumers keep
71
+ // importing `ExecutionContext` from this module without a second interface
72
+ // drifting over time.
73
+ export type { ExecutionContext } from "../../types/request-scope.js";
74
+ import type { ExecutionContext } from "../../types/request-scope.js";
77
75
 
78
76
  /**
79
77
  * Minimal Cloudflare KV Namespace interface.
@@ -78,6 +78,9 @@ export {
78
78
  // Re-export useHref - it's a "use client" hook
79
79
  export { useHref } from "./browser/react/use-href.js";
80
80
 
81
+ // Re-export useReverse - it's a "use client" hook
82
+ export { useReverse } from "./browser/react/use-reverse.js";
83
+
81
84
  // Re-export useHandle - it's a "use client" hook
82
85
  export { useHandle } from "./browser/react/use-handle.js";
83
86