@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
@@ -97,6 +97,31 @@ export interface LinkProps extends Omit<
97
97
  * @default "none"
98
98
  */
99
99
  prefetch?: PrefetchStrategy;
100
+ /**
101
+ * Opt-in override for the prefetch cache scope.
102
+ *
103
+ * The default cache is source-agnostic: one shared entry per target,
104
+ * keyed on Rango state + target URL. This is correct for routes whose
105
+ * response shape doesn't depend on where the user navigates from.
106
+ *
107
+ * Set `":source"` when this Link's response would legitimately differ
108
+ * based on the source page — typically when the target route (or one
109
+ * of its layouts) uses a custom `revalidate()` handler that reads
110
+ * `currentUrl` / `currentParams`, and the wildcard entry would
111
+ * therefore serve the wrong diff to a navigation from a different
112
+ * source.
113
+ *
114
+ * Intercept responses are auto-scoped to the source via a server-side
115
+ * tag, so `":source"` is only needed for custom revalidation logic.
116
+ *
117
+ * @example
118
+ * ```tsx
119
+ * // Route uses a `revalidate()` that branches on currentUrl — opt in
120
+ * // so prefetches don't bleed across source pages.
121
+ * <Link to="/dashboard" prefetch="hover" prefetchKey=":source" />
122
+ * ```
123
+ */
124
+ prefetchKey?: ":source";
100
125
  /**
101
126
  * State to pass to history.pushState/replaceState.
102
127
  * Accessible via useLocationState() hook.
@@ -184,6 +209,7 @@ export const Link: ForwardRefExoticComponent<
184
209
  reloadDocument = false,
185
210
  revalidate,
186
211
  prefetch = "none",
212
+ prefetchKey,
187
213
  state,
188
214
  children,
189
215
  onClick,
@@ -320,9 +346,10 @@ export const Link: ForwardRefExoticComponent<
320
346
  segmentState.currentSegmentIds,
321
347
  getAppVersion(),
322
348
  ctx.store.getRouterId?.(),
349
+ prefetchKey,
323
350
  );
324
351
  }
325
- }, [resolvedStrategy, resolvedTo, isExternal, ctx]);
352
+ }, [resolvedStrategy, resolvedTo, isExternal, ctx, prefetchKey]);
326
353
 
327
354
  // Viewport/render prefetch: waits for idle before starting,
328
355
  // uses concurrency-limited queue to avoid flooding.
@@ -344,6 +371,7 @@ export const Link: ForwardRefExoticComponent<
344
371
  segmentState.currentSegmentIds,
345
372
  getAppVersion(),
346
373
  ctx.store.getRouterId?.(),
374
+ prefetchKey,
347
375
  );
348
376
  };
349
377
 
@@ -383,7 +411,7 @@ export const Link: ForwardRefExoticComponent<
383
411
  unobserveForPrefetch(observedElement);
384
412
  }
385
413
  };
386
- }, [resolvedStrategy, resolvedTo, isExternal, ctx]);
414
+ }, [resolvedStrategy, resolvedTo, isExternal, ctx, prefetchKey]);
387
415
 
388
416
  return (
389
417
  <a
@@ -3,10 +3,8 @@
3
3
  import React, {
4
4
  useState,
5
5
  useEffect,
6
- useLayoutEffect,
7
6
  useCallback,
8
7
  useMemo,
9
- useRef,
10
8
  use,
11
9
  type ReactNode,
12
10
  } from "react";
@@ -28,6 +26,7 @@ import { NonceContext } from "./nonce-context.js";
28
26
  import type { ResolvedThemeConfig, Theme } from "../../theme/types.js";
29
27
  import { cancelAllPrefetches } from "../prefetch/queue.js";
30
28
  import { handleNavigationEnd } from "../scroll-restoration.js";
29
+ import type { AppShellRef } from "../app-shell.js";
31
30
 
32
31
  /**
33
32
  * Process handles from an async generator, updating the event controller
@@ -46,10 +45,22 @@ async function processHandles(
46
45
  store: NavigationStore;
47
46
  matched?: string[];
48
47
  isPartial?: boolean;
48
+ /** Server's `resolvedIds`: every segment re-resolved this request,
49
+ * including null-component ones excluded from `diff`/`segments`.
50
+ * Drives cleanup of stale handle buckets when a re-resolved segment
51
+ * pushed nothing. */
52
+ resolvedIds?: string[];
49
53
  historyKey: string;
50
54
  },
51
55
  ): Promise<void> {
52
- const { eventController, store, matched, isPartial, historyKey } = opts;
56
+ const {
57
+ eventController,
58
+ store,
59
+ matched,
60
+ isPartial,
61
+ resolvedIds,
62
+ historyKey,
63
+ } = opts;
53
64
 
54
65
  let yieldCount = 0;
55
66
  for await (const handleData of handlesGenerator) {
@@ -64,7 +75,7 @@ async function processHandles(
64
75
  }
65
76
 
66
77
  yieldCount++;
67
- eventController.setHandleData(handleData, matched, isPartial);
78
+ eventController.setHandleData(handleData, matched, isPartial, resolvedIds);
68
79
  }
69
80
 
70
81
  // Check again before final updates
@@ -72,12 +83,11 @@ async function processHandles(
72
83
  return;
73
84
  }
74
85
 
75
- // For partial updates where the generator yielded nothing (cached handlers),
76
- // we still need to update the segment order to clean up stale handle data.
77
- // This happens when navigating away from a route - the handlers for the new
78
- // route might not push any breadcrumbs, but we still need to remove the old ones.
86
+ // For partial updates where the generator yielded nothing (every
87
+ // re-resolved handler pushed nothing), still call setHandleData so the
88
+ // cleanup pass can clear out stale buckets for those segments.
79
89
  if (yieldCount === 0 && matched) {
80
- eventController.setHandleData({}, matched, true);
90
+ eventController.setHandleData({}, matched, true, resolvedIds);
81
91
  }
82
92
 
83
93
  // After handles processing completes, update the cache's handleData.
@@ -133,15 +143,23 @@ export interface NavigationProviderProps {
133
143
  warmupEnabled?: boolean;
134
144
 
135
145
  /**
136
- * App version from server payload (stable, immutable).
137
- * Forwarded to context for cache key building.
146
+ * App version from server payload.
147
+ * Used only as a fallback when `appShellRef` is not supplied.
138
148
  */
139
149
  version?: string;
140
150
 
141
151
  /**
142
152
  * URL prefix for all routes (from createRouter({ basename })).
153
+ * Used only as a fallback when `appShellRef` is not supplied.
143
154
  */
144
155
  basename?: string;
156
+
157
+ /**
158
+ * Live app-shell ref. When provided, the context's `basename` and `version`
159
+ * properties become live getters that track app-switch updates without
160
+ * invalidating the memoized context value.
161
+ */
162
+ appShellRef?: AppShellRef;
145
163
  }
146
164
 
147
165
  /**
@@ -175,6 +193,7 @@ export function NavigationProvider({
175
193
  warmupEnabled,
176
194
  version,
177
195
  basename,
196
+ appShellRef,
178
197
  }: NavigationProviderProps): ReactNode {
179
198
  // Track current payload for rendering (this triggers re-renders)
180
199
  const [payload, setPayload] = useState(initialPayload);
@@ -196,18 +215,39 @@ export function NavigationProvider({
196
215
  await bridge.refresh();
197
216
  }, []);
198
217
 
199
- // Context value is stable (store, eventController, navigate, refresh never change)
200
- const contextValue = useMemo<NavigationStoreContextValue>(
201
- () => ({
218
+ // Context value is stable (store, eventController, navigate, refresh never
219
+ // change). When an appShellRef is supplied, `basename` and `version` are
220
+ // installed as live getters so app-switch transitions (which update the ref)
221
+ // propagate to consumers without forcing a tree-wide rerender.
222
+ const contextValue = useMemo<NavigationStoreContextValue>(() => {
223
+ if (appShellRef) {
224
+ const value = {
225
+ store,
226
+ eventController,
227
+ navigate,
228
+ refresh,
229
+ } as NavigationStoreContextValue;
230
+ Object.defineProperty(value, "basename", {
231
+ configurable: true,
232
+ enumerable: true,
233
+ get: () => appShellRef.get().basename,
234
+ });
235
+ Object.defineProperty(value, "version", {
236
+ configurable: true,
237
+ enumerable: true,
238
+ get: () => appShellRef.get().version,
239
+ });
240
+ return value;
241
+ }
242
+ return {
202
243
  store,
203
244
  eventController,
204
245
  navigate,
205
246
  refresh,
206
247
  version,
207
248
  basename,
208
- }),
209
- [],
210
- );
249
+ };
250
+ }, []);
211
251
 
212
252
  // Connection warmup: keep TLS alive after idle periods.
213
253
  // After 60s of no user interaction, marks connection as "cold".
@@ -313,40 +353,45 @@ export function NavigationProvider({
313
353
  return unsub;
314
354
  }, [eventController]);
315
355
 
316
- // Pending scroll action to apply after React commits
317
- const pendingScrollRef = useRef<NavigationUpdate["scroll"]>(undefined);
318
-
319
- // Apply scroll after React commits the new content to the DOM
320
- useLayoutEffect(() => {
321
- const scrollAction = pendingScrollRef.current;
322
- if (!scrollAction) return;
323
- pendingScrollRef.current = undefined;
324
-
325
- if (scrollAction.enabled === false) return;
326
-
327
- handleNavigationEnd({
328
- restore: scrollAction.restore,
329
- scroll: scrollAction.enabled,
330
- isStreaming: scrollAction.isStreaming,
331
- });
332
- });
333
-
334
356
  // Subscribe to UI updates (for re-rendering the tree)
335
357
  useEffect(() => {
336
358
  const unsubscribe = store.onUpdate((update) => {
337
- // Capture scroll intent — it will be applied in useLayoutEffect
338
- // after React commits this state update to the DOM.
339
- // Always assign (even undefined) to clear stale scroll from prior navigations,
340
- // so server actions or error updates don't accidentally replay old scroll.
341
- pendingScrollRef.current = update.scroll;
342
-
343
359
  setPayload({
344
360
  root: update.root,
345
361
  metadata: update.metadata,
346
362
  });
347
363
 
348
- // Update route params
349
- eventController.setParams(update.metadata.params ?? {});
364
+ // Dispatch scroll handling on a microtask so it runs after the
365
+ // synchronous portion of this subscriber returns but before React
366
+ // processes the next macrotask. handleNavigationEnd is robust to
367
+ // commit timing — its scrollToTop/scrollToHash branches are
368
+ // synchronous against the (possibly old) DOM and reach Y=0 / a hash
369
+ // element regardless of layout state, while restoreScrollPosition
370
+ // internally rAFs the scrollTo so the new tree's layout has settled.
371
+ // (Prior to this, scroll dispatch went through a useRef +
372
+ // useLayoutEffect dance: subscriber wrote pendingScrollRef and
373
+ // setPayload, useLayoutEffect read the ref after commit. That dance
374
+ // missed popstate cache-restore commits — useLayoutEffect either
375
+ // never ran for the resulting commit, or the ref was already
376
+ // consumed/cleared by a prior render's effect — resulting in
377
+ // back-nav having NO scrollTo call at all.)
378
+ if (update.scroll && update.scroll.enabled !== false) {
379
+ const scrollAction = update.scroll;
380
+ queueMicrotask(() => {
381
+ handleNavigationEnd({
382
+ restore: scrollAction.restore,
383
+ scroll: scrollAction.enabled,
384
+ isStreaming: scrollAction.isStreaming,
385
+ });
386
+ });
387
+ }
388
+
389
+ // Update route params. Only reset when the server actually sends a params
390
+ // map — an absent `params` field means "no change" (e.g., legacy action
391
+ // responses that omitted params). Explicit `{}` still clears correctly.
392
+ if (update.metadata.params !== undefined) {
393
+ eventController.setParams(update.metadata.params);
394
+ }
350
395
 
351
396
  // Update handle data progressively as it streams in
352
397
  if (update.metadata.handles) {
@@ -359,6 +404,7 @@ export function NavigationProvider({
359
404
  store,
360
405
  matched: update.metadata.matched,
361
406
  isPartial: update.metadata.isPartial,
407
+ resolvedIds: update.metadata.resolvedIds,
362
408
  historyKey,
363
409
  }).catch((err) =>
364
410
  console.error("[NavigationProvider] Error consuming handles:", err),
@@ -377,6 +423,7 @@ export function NavigationProvider({
377
423
  {}, // Empty data - all existing data not in matched will be cleaned up
378
424
  update.metadata.matched,
379
425
  true, // partial update - will clean up segments not in matched
426
+ update.metadata.resolvedIds,
380
427
  );
381
428
  }
382
429
  });
@@ -398,7 +445,11 @@ export function NavigationProvider({
398
445
  // Build the content tree
399
446
  let content = <RootErrorBoundary>{root}</RootErrorBoundary>;
400
447
 
401
- // Wrap with ThemeProvider when theme is enabled
448
+ // Wrap with ThemeProvider when theme is enabled. The ThemeProvider is
449
+ // document-lifetime: its config comes from the initial load and does NOT
450
+ // swap on cross-app transitions, because the ThemeProvider sits above the
451
+ // segment tree and a smooth (no-reload) app switch cannot safely remount
452
+ // it. A new theme config only takes effect on a full document load.
402
453
  if (themeConfig) {
403
454
  content = (
404
455
  <ThemeProvider config={themeConfig} initialTheme={initialTheme}>
@@ -1,11 +1,55 @@
1
1
  /**
2
- * Filter segment IDs to only include routes and layouts.
3
- * Excludes parallels (contain .@) and loaders (contain D followed by digit).
2
+ * Build the handle-collection segment order from a raw `matched` list.
3
+ *
4
+ * Two responsibilities:
5
+ *
6
+ * 1. Drop loader sub-ids ("D" followed by a digit, e.g. "M0L0D1.user") —
7
+ * loaders never push handles.
8
+ *
9
+ * 2. Place each parallel slot id (contains ".@") immediately after its
10
+ * parent layout/route id. Raw segment-resolution emission order does NOT
11
+ * guarantee this: route-mounted parallels are resolved/pushed BEFORE the
12
+ * route handler's segment is appended (see fresh.ts:resolveSegment for
13
+ * routes, and revalidation.ts ~915-919), so matched can read
14
+ * `[..., R0.@panel, R0]`. collectHandleData consumes segmentOrder verbatim
15
+ * with later-wins semantics, so without normalization the route handler's
16
+ * Meta would override the slot's more-specific Meta — backwards.
17
+ *
18
+ * Slot-id format is `<parentShortCode>.@<slotName>`; `parentShortCode` never
19
+ * contains ".@", so splitting at the first ".@" reliably yields the parent.
4
20
  */
5
21
  export function filterSegmentOrder(matched: string[]): string[] {
6
- return matched.filter((id) => {
7
- if (id.includes(".@")) return false;
8
- if (/D\d+\./.test(id)) return false;
9
- return true;
10
- });
22
+ const slotsByParent = new Map<string, string[]>();
23
+ const nonSlots: string[] = [];
24
+ const nonSlotSet = new Set<string>();
25
+
26
+ for (const id of matched) {
27
+ if (/D\d+\./.test(id)) continue;
28
+ const slotIdx = id.indexOf(".@");
29
+ if (slotIdx >= 0) {
30
+ const parent = id.slice(0, slotIdx);
31
+ const list = slotsByParent.get(parent);
32
+ if (list) {
33
+ list.push(id);
34
+ } else {
35
+ slotsByParent.set(parent, [id]);
36
+ }
37
+ } else {
38
+ nonSlots.push(id);
39
+ nonSlotSet.add(id);
40
+ }
41
+ }
42
+
43
+ const result: string[] = [];
44
+ for (const id of nonSlots) {
45
+ result.push(id);
46
+ const slots = slotsByParent.get(id);
47
+ if (slots) result.push(...slots);
48
+ }
49
+ // Defensive: any slot whose parent is missing from the filtered list still
50
+ // gets included rather than silently dropped. Shouldn't happen in practice.
51
+ for (const [parent, slots] of slotsByParent) {
52
+ if (!nonSlotSet.has(parent)) result.push(...slots);
53
+ }
54
+ return result;
11
55
  }
@@ -20,6 +20,9 @@ export { useSegments, type SegmentsState } from "./use-segments.js";
20
20
  // Handle data hook
21
21
  export { useHandle } from "./use-handle.js";
22
22
 
23
+ // Mount-aware reverse hook
24
+ export { useReverse } from "./use-reverse.js";
25
+
23
26
  // Client cache controls hook
24
27
  export {
25
28
  useClientCache,
@@ -53,6 +53,12 @@ export function useNavigation<T>(
53
53
  });
54
54
  const prevState = useRef(baseValue);
55
55
 
56
+ // Tracks whether the most recent setOptimisticValue call pinned the value
57
+ // to a non-idle state. Used to decide whether to emit a release update when
58
+ // returning to idle, so the optimistic store doesn't stay pinned if a
59
+ // parent transition (e.g. <Link> click) is still pending.
60
+ const optimisticPinnedRef = useRef(false);
61
+
56
62
  // useOptimistic allows immediate updates during transitions/actions
57
63
  const [value, setOptimisticValue] = useOptimistic(baseValue);
58
64
 
@@ -82,11 +88,25 @@ export function useNavigation<T>(
82
88
  const hasInflightActions =
83
89
  ctx.eventController.getInflightActions().size > 0;
84
90
 
85
- if (hasInflightActions || publicState.state !== "idle") {
86
- // Use optimistic update for immediate feedback during transitions
91
+ const shouldPin = hasInflightActions || publicState.state !== "idle";
92
+
93
+ if (shouldPin) {
94
+ // Pin the optimistic store so the loading value shows immediately
95
+ // even if a parent transition (e.g. <Link> click) defers the
96
+ // urgent setBaseValue commit.
97
+ startTransition(() => {
98
+ setOptimisticValue(nextSelected);
99
+ });
100
+ optimisticPinnedRef.current = true;
101
+ } else if (optimisticPinnedRef.current) {
102
+ // Release a previously-pinned optimistic value. Without this,
103
+ // useOptimistic keeps returning the stale loading value while
104
+ // any parent transition is still pending, even after baseValue
105
+ // flipped to idle.
87
106
  startTransition(() => {
88
107
  setOptimisticValue(nextSelected);
89
108
  });
109
+ optimisticPinnedRef.current = false;
90
110
  }
91
111
 
92
112
  // Always update base state so UI reflects current state
@@ -16,17 +16,30 @@ import { shallowEqual } from "./shallow-equal.js";
16
16
  * const params = useParams();
17
17
  * // { productId: "123" }
18
18
  *
19
+ * // Annotate the expected shape via a generic
20
+ * const { productId } = useParams<{ productId: string }>();
21
+ *
19
22
  * // With selector
20
23
  * const productId = useParams(p => p.productId);
21
24
  * ```
22
25
  */
23
- export function useParams(): Record<string, string>;
26
+ // `T extends object` (not `Record<string, string | undefined>`) so that
27
+ // interface shapes pass the constraint — interfaces lack an implicit
28
+ // index signature and would otherwise be rejected. The generic is a
29
+ // shape annotation, not a runtime check; the body always returns the
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.
34
+ export function useParams<
35
+ T extends object = Record<string, string | undefined>,
36
+ >(): Readonly<T>;
24
37
  export function useParams<T>(
25
- selector: (params: Record<string, string>) => T,
38
+ selector: (params: Record<string, string | undefined>) => T,
26
39
  ): T;
27
40
  export function useParams<T>(
28
- selector?: (params: Record<string, string>) => T,
29
- ): T | Record<string, string> {
41
+ selector?: (params: Record<string, string | undefined>) => T,
42
+ ): T | Record<string, string | undefined> {
30
43
  const ctx = useContext(NavigationStoreContext);
31
44
 
32
45
  const [value, setValue] = useState<T | Record<string, string>>(() => {
@@ -0,0 +1,99 @@
1
+ "use client";
2
+
3
+ import { useCallback } from "react";
4
+ import type { LocalReverseFunction } from "../../reverse.js";
5
+ import { substitutePatternParams } from "../../router/substitute-pattern-params.js";
6
+ import { serializeSearchParams } from "../../search-params.js";
7
+ import { useMount } from "./use-mount.js";
8
+ import { useParams } from "./use-params.js";
9
+
10
+ type RouteEntry = string | { readonly path: string };
11
+ type LocalRouteMap = Readonly<Record<string, RouteEntry>>;
12
+
13
+ function getPattern(entry: RouteEntry | undefined): string | undefined {
14
+ if (entry === undefined) return undefined;
15
+ return typeof entry === "string" ? entry : entry.path;
16
+ }
17
+
18
+ /**
19
+ * Join an include mount prefix with a mount-relative pattern.
20
+ *
21
+ * `pattern === "/"` is the index of the local module — under a non-root
22
+ * mount it must collapse so `/` under `/blog` becomes `/blog`, not
23
+ * `/blog/`. This matches `ctx.reverse(".index")` on the server.
24
+ */
25
+ function joinMount(mount: string, pattern: string): string {
26
+ if (pattern === "/") {
27
+ if (mount === "" || mount === "/") return "/";
28
+ return mount.endsWith("/") ? mount.slice(0, -1) : mount;
29
+ }
30
+ const normalizedMount = mount === "/" ? "" : mount.replace(/\/+$/, "");
31
+ return normalizedMount + pattern;
32
+ }
33
+
34
+ /**
35
+ * Mount-aware reverse function for a locally-imported `routes` map.
36
+ *
37
+ * Resolves dot-prefixed route names against the passed `routes` (typically
38
+ * a generated `routes` from a `urls()` module's `.gen.ts`), prefixes the
39
+ * result with the surrounding `include()` mount path, and substitutes
40
+ * params — auto-filling from the current matched route's params and
41
+ * letting explicit params override.
42
+ *
43
+ * @example
44
+ * ```tsx
45
+ * "use client";
46
+ * import { Link, useReverse } from "@rangojs/router/client";
47
+ * import { routes as blogRoutes } from "../urls/blog.gen.js";
48
+ *
49
+ * function BlogNav() {
50
+ * const reverse = useReverse(blogRoutes);
51
+ * return (
52
+ * <>
53
+ * <Link to={reverse(".index")}>Blog</Link>
54
+ * <Link to={reverse(".post", { postId: "hello" })}>Post</Link>
55
+ * </>
56
+ * );
57
+ * }
58
+ * ```
59
+ */
60
+ export function useReverse<const TRoutes extends LocalRouteMap>(
61
+ routes: TRoutes,
62
+ ): LocalReverseFunction<TRoutes> {
63
+ const mount = useMount();
64
+ const currentParams = useParams();
65
+
66
+ return useCallback(
67
+ ((
68
+ name: string,
69
+ explicitParams?: Record<string, string | undefined>,
70
+ search?: Record<string, unknown>,
71
+ ): string => {
72
+ if (!name.startsWith(".")) {
73
+ throw new Error(`Local route names must start with ".": "${name}"`);
74
+ }
75
+ const lookupName = name.slice(1);
76
+ const entry = (routes as LocalRouteMap)[lookupName];
77
+ const pattern = getPattern(entry);
78
+ if (pattern === undefined) {
79
+ throw new Error(`Unknown local route: "${name}"`);
80
+ }
81
+
82
+ const joined = joinMount(mount, pattern);
83
+
84
+ const mergedParams = explicitParams
85
+ ? { ...currentParams, ...explicitParams }
86
+ : currentParams;
87
+
88
+ const substituted = substitutePatternParams(joined, mergedParams, name);
89
+
90
+ if (search) {
91
+ const qs = serializeSearchParams(search);
92
+ if (qs) return `${substituted}?${qs}`;
93
+ }
94
+
95
+ return substituted;
96
+ }) as LocalReverseFunction<TRoutes>,
97
+ [routes, mount, currentParams],
98
+ );
99
+ }
@@ -13,6 +13,10 @@ import type { RouterInstance, RouterNavigateOptions } from "../types.js";
13
13
  * useRouter() do not re-render on navigation state changes.
14
14
  * For reactive navigation state, use useNavigation() instead.
15
15
  *
16
+ * Methods read `basename` from the live context on each call so that
17
+ * cross-app navigation (app-switch) sees the current app's basename
18
+ * rather than the one captured at mount time.
19
+ *
16
20
  * @example
17
21
  * ```tsx
18
22
  * const router = useRouter();
@@ -29,7 +33,10 @@ export function useRouter(): RouterInstance {
29
33
  throw new Error("useRouter must be used within NavigationProvider");
30
34
  }
31
35
 
32
- // Stable reference: ctx is itself stable (NavigationProvider memoizes with [])
36
+ // Stable reference: ctx itself is stable, and reads on each method call
37
+ // pick up live basename values from the context (backed by a live ref
38
+ // in NavigationProvider), so app-switch transitions are reflected without
39
+ // recreating this object.
33
40
  return useMemo<RouterInstance>(() => {
34
41
  /** Prefix a root-relative path with basename if not already prefixed. */
35
42
  function withBasename(url: string): string {
@@ -25,15 +25,18 @@ function parsePathname(pathname: string): string[] {
25
25
  }
26
26
 
27
27
  /**
28
- * Build segments state from event controller
28
+ * Build segments state from event controller. `segmentIds` is the
29
+ * route-only list (parallels and loaders stripped) — distinct from the
30
+ * controller's `segmentOrder` which drives handle collection and includes
31
+ * parallel slot ids.
29
32
  */
30
33
  function buildSegmentsState(
31
34
  location: URL,
32
- segmentOrder: string[],
35
+ routeSegmentIds: string[],
33
36
  ): SegmentsState {
34
37
  return {
35
38
  path: parsePathname(location.pathname),
36
- segmentIds: segmentOrder,
39
+ segmentIds: routeSegmentIds,
37
40
  location,
38
41
  };
39
42
  }
@@ -74,7 +77,7 @@ export function useSegments<T>(
74
77
  const handleState = ctx.eventController.getHandleState();
75
78
  const segmentsState = buildSegmentsState(
76
79
  location as URL,
77
- handleState.segmentOrder,
80
+ handleState.routeSegmentIds,
78
81
  );
79
82
  return selector ? selector(segmentsState) : segmentsState;
80
83
  });
@@ -94,7 +97,7 @@ export function useSegments<T>(
94
97
  // render-time setState calls.
95
98
  const segmentsCache = useRef<{
96
99
  location: URL;
97
- segmentOrder: string[];
100
+ routeSegmentIds: string[];
98
101
  state: SegmentsState;
99
102
  } | null>(null);
100
103
 
@@ -113,17 +116,17 @@ export function useSegments<T>(
113
116
  if (
114
117
  cache &&
115
118
  cache.location === location &&
116
- cache.segmentOrder === handleState.segmentOrder
119
+ cache.routeSegmentIds === handleState.routeSegmentIds
117
120
  ) {
118
121
  segmentsState = cache.state;
119
122
  } else {
120
123
  segmentsState = buildSegmentsState(
121
124
  location as URL,
122
- handleState.segmentOrder,
125
+ handleState.routeSegmentIds,
123
126
  );
124
127
  segmentsCache.current = {
125
128
  location: location as URL,
126
- segmentOrder: handleState.segmentOrder,
129
+ routeSegmentIds: handleState.routeSegmentIds,
127
130
  state: segmentsState,
128
131
  };
129
132
  }