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

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 (237) hide show
  1. package/README.md +120 -25
  2. package/dist/bin/rango.js +147 -57
  3. package/dist/vite/index.js +2106 -842
  4. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  5. package/package.json +13 -8
  6. package/skills/breadcrumbs/SKILL.md +3 -1
  7. package/skills/bundle-analysis/SKILL.md +159 -0
  8. package/skills/cache-guide/SKILL.md +222 -30
  9. package/skills/caching/SKILL.md +188 -8
  10. package/skills/composability/SKILL.md +27 -2
  11. package/skills/document-cache/SKILL.md +78 -55
  12. package/skills/handler-use/SKILL.md +364 -0
  13. package/skills/hooks/SKILL.md +229 -20
  14. package/skills/host-router/SKILL.md +45 -20
  15. package/skills/i18n/SKILL.md +276 -0
  16. package/skills/intercept/SKILL.md +46 -4
  17. package/skills/layout/SKILL.md +28 -7
  18. package/skills/links/SKILL.md +247 -17
  19. package/skills/loader/SKILL.md +219 -9
  20. package/skills/middleware/SKILL.md +47 -12
  21. package/skills/migrate-nextjs/SKILL.md +582 -0
  22. package/skills/migrate-react-router/SKILL.md +769 -0
  23. package/skills/mime-routes/SKILL.md +27 -0
  24. package/skills/observability/SKILL.md +137 -0
  25. package/skills/parallel/SKILL.md +71 -6
  26. package/skills/prerender/SKILL.md +14 -33
  27. package/skills/rango/SKILL.md +236 -22
  28. package/skills/react-compiler/SKILL.md +168 -0
  29. package/skills/response-routes/SKILL.md +66 -9
  30. package/skills/route/SKILL.md +57 -4
  31. package/skills/router-setup/SKILL.md +3 -3
  32. package/skills/server-actions/SKILL.md +751 -0
  33. package/skills/streams-and-websockets/SKILL.md +283 -0
  34. package/skills/typesafety/SKILL.md +319 -27
  35. package/skills/use-cache/SKILL.md +36 -5
  36. package/skills/view-transitions/SKILL.md +294 -0
  37. package/src/__augment-tests__/augment.ts +81 -0
  38. package/src/__augment-tests__/augmented.check.ts +117 -0
  39. package/src/browser/action-coordinator.ts +53 -36
  40. package/src/browser/app-shell.ts +52 -0
  41. package/src/browser/event-controller.ts +86 -70
  42. package/src/browser/history-state.ts +21 -0
  43. package/src/browser/index.ts +3 -3
  44. package/src/browser/navigation-bridge.ts +86 -11
  45. package/src/browser/navigation-client.ts +45 -25
  46. package/src/browser/navigation-store.ts +32 -9
  47. package/src/browser/navigation-transaction.ts +10 -28
  48. package/src/browser/partial-update.ts +61 -28
  49. package/src/browser/prefetch/cache.ts +124 -26
  50. package/src/browser/prefetch/fetch.ts +129 -37
  51. package/src/browser/prefetch/queue.ts +36 -5
  52. package/src/browser/rango-state.ts +53 -13
  53. package/src/browser/react/Link.tsx +18 -13
  54. package/src/browser/react/NavigationProvider.tsx +72 -31
  55. package/src/browser/react/filter-segment-order.ts +51 -7
  56. package/src/browser/react/index.ts +3 -0
  57. package/src/browser/react/location-state-shared.ts +175 -4
  58. package/src/browser/react/location-state.ts +39 -13
  59. package/src/browser/react/use-handle.ts +17 -9
  60. package/src/browser/react/use-navigation.ts +22 -2
  61. package/src/browser/react/use-params.ts +20 -8
  62. package/src/browser/react/use-reverse.ts +106 -0
  63. package/src/browser/react/use-router.ts +22 -2
  64. package/src/browser/react/use-segments.ts +11 -8
  65. package/src/browser/response-adapter.ts +25 -0
  66. package/src/browser/rsc-router.tsx +64 -22
  67. package/src/browser/scroll-restoration.ts +22 -14
  68. package/src/browser/segment-reconciler.ts +10 -14
  69. package/src/browser/segment-structure-assert.ts +2 -2
  70. package/src/browser/server-action-bridge.ts +23 -30
  71. package/src/browser/types.ts +21 -0
  72. package/src/build/collect-fallback-refs.ts +107 -0
  73. package/src/build/generate-manifest.ts +60 -35
  74. package/src/build/generate-route-types.ts +2 -0
  75. package/src/build/index.ts +2 -0
  76. package/src/build/route-trie.ts +52 -25
  77. package/src/build/route-types/codegen.ts +4 -4
  78. package/src/build/route-types/include-resolution.ts +1 -1
  79. package/src/build/route-types/per-module-writer.ts +7 -4
  80. package/src/build/route-types/router-processing.ts +55 -14
  81. package/src/build/route-types/scan-filter.ts +1 -1
  82. package/src/build/route-types/source-scan.ts +118 -0
  83. package/src/build/runtime-discovery.ts +9 -20
  84. package/src/cache/cache-error.ts +104 -0
  85. package/src/cache/cache-policy.ts +95 -1
  86. package/src/cache/cache-runtime.ts +79 -13
  87. package/src/cache/cache-scope.ts +77 -46
  88. package/src/cache/cache-tag.ts +135 -0
  89. package/src/cache/cf/cf-cache-store.ts +1067 -176
  90. package/src/cache/cf/index.ts +4 -1
  91. package/src/cache/document-cache.ts +59 -7
  92. package/src/cache/index.ts +6 -0
  93. package/src/cache/memory-segment-store.ts +158 -14
  94. package/src/cache/tag-invalidation.ts +206 -0
  95. package/src/cache/types.ts +27 -0
  96. package/src/client.rsc.tsx +3 -0
  97. package/src/client.tsx +92 -182
  98. package/src/context-var.ts +5 -5
  99. package/src/decode-loader-results.ts +36 -0
  100. package/src/errors.ts +30 -1
  101. package/src/handle.ts +4 -6
  102. package/src/host/index.ts +2 -2
  103. package/src/host/router.ts +129 -57
  104. package/src/host/types.ts +31 -2
  105. package/src/host/utils.ts +1 -1
  106. package/src/href-client.ts +140 -20
  107. package/src/index.rsc.ts +16 -4
  108. package/src/index.ts +65 -15
  109. package/src/loader-store.ts +500 -0
  110. package/src/loader.rsc.ts +2 -5
  111. package/src/loader.ts +3 -10
  112. package/src/missing-id-error.ts +68 -0
  113. package/src/outlet-context.ts +1 -1
  114. package/src/prerender.ts +4 -4
  115. package/src/response-utils.ts +37 -0
  116. package/src/reverse.ts +65 -36
  117. package/src/route-content-wrapper.tsx +6 -28
  118. package/src/route-definition/dsl-helpers.ts +384 -257
  119. package/src/route-definition/helper-factories.ts +29 -139
  120. package/src/route-definition/helpers-types.ts +100 -28
  121. package/src/route-definition/resolve-handler-use.ts +6 -0
  122. package/src/route-definition/use-item-types.ts +32 -0
  123. package/src/route-types.ts +26 -41
  124. package/src/router/content-negotiation.ts +15 -2
  125. package/src/router/error-handling.ts +1 -1
  126. package/src/router/handler-context.ts +21 -38
  127. package/src/router/intercept-resolution.ts +4 -18
  128. package/src/router/lazy-includes.ts +8 -8
  129. package/src/router/loader-resolution.ts +19 -2
  130. package/src/router/manifest.ts +22 -13
  131. package/src/router/match-api.ts +4 -3
  132. package/src/router/match-handlers.ts +1 -0
  133. package/src/router/match-middleware/cache-lookup.ts +46 -92
  134. package/src/router/match-middleware/cache-store.ts +3 -2
  135. package/src/router/match-result.ts +53 -32
  136. package/src/router/metrics.ts +1 -1
  137. package/src/router/middleware-types.ts +15 -26
  138. package/src/router/middleware.ts +99 -84
  139. package/src/router/pattern-matching.ts +101 -17
  140. package/src/router/prerender-match.ts +3 -1
  141. package/src/router/preview-match.ts +3 -1
  142. package/src/router/request-classification.ts +4 -28
  143. package/src/router/revalidation.ts +58 -2
  144. package/src/router/router-interfaces.ts +45 -28
  145. package/src/router/router-options.ts +25 -1
  146. package/src/router/router-registry.ts +2 -5
  147. package/src/router/segment-resolution/fresh.ts +27 -6
  148. package/src/router/segment-resolution/loader-cache.ts +8 -17
  149. package/src/router/segment-resolution/revalidation.ts +147 -106
  150. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  151. package/src/router/substitute-pattern-params.ts +56 -0
  152. package/src/router/trie-matching.ts +18 -13
  153. package/src/router/types.ts +8 -0
  154. package/src/router/url-params.ts +49 -0
  155. package/src/router.ts +23 -18
  156. package/src/rsc/handler-context.ts +2 -2
  157. package/src/rsc/handler.ts +38 -70
  158. package/src/rsc/helpers.ts +72 -43
  159. package/src/rsc/index.ts +1 -1
  160. package/src/rsc/origin-guard.ts +28 -10
  161. package/src/rsc/progressive-enhancement.ts +4 -0
  162. package/src/rsc/response-route-handler.ts +54 -54
  163. package/src/rsc/rsc-rendering.ts +35 -51
  164. package/src/rsc/runtime-warnings.ts +9 -10
  165. package/src/rsc/server-action.ts +17 -37
  166. package/src/rsc/ssr-setup.ts +16 -0
  167. package/src/rsc/types.ts +8 -2
  168. package/src/search-params.ts +4 -4
  169. package/src/segment-content-promise.ts +67 -0
  170. package/src/segment-loader-promise.ts +122 -0
  171. package/src/segment-system.tsx +132 -116
  172. package/src/serialize.ts +243 -0
  173. package/src/server/context.ts +143 -53
  174. package/src/server/cookie-store.ts +28 -4
  175. package/src/server/request-context.ts +46 -44
  176. package/src/ssr/index.tsx +5 -1
  177. package/src/static-handler.ts +1 -1
  178. package/src/types/cache-types.ts +13 -4
  179. package/src/types/error-types.ts +5 -1
  180. package/src/types/global-namespace.ts +39 -26
  181. package/src/types/handler-context.ts +68 -50
  182. package/src/types/index.ts +1 -0
  183. package/src/types/loader-types.ts +5 -6
  184. package/src/types/request-scope.ts +126 -0
  185. package/src/types/route-entry.ts +11 -0
  186. package/src/types/segments.ts +35 -2
  187. package/src/urls/include-helper.ts +34 -67
  188. package/src/urls/index.ts +0 -3
  189. package/src/urls/path-helper-types.ts +41 -7
  190. package/src/urls/path-helper.ts +17 -52
  191. package/src/urls/pattern-types.ts +36 -19
  192. package/src/urls/response-types.ts +22 -29
  193. package/src/urls/type-extraction.ts +26 -116
  194. package/src/urls/urls-function.ts +1 -5
  195. package/src/use-loader.tsx +413 -42
  196. package/src/vite/debug.ts +185 -0
  197. package/src/vite/discovery/bundle-postprocess.ts +6 -6
  198. package/src/vite/discovery/discover-routers.ts +101 -51
  199. package/src/vite/discovery/discovery-errors.ts +194 -0
  200. package/src/vite/discovery/gate-state.ts +171 -0
  201. package/src/vite/discovery/prerender-collection.ts +67 -26
  202. package/src/vite/discovery/route-types-writer.ts +40 -84
  203. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  204. package/src/vite/discovery/state.ts +33 -0
  205. package/src/vite/discovery/virtual-module-codegen.ts +13 -23
  206. package/src/vite/index.ts +2 -0
  207. package/src/vite/plugin-types.ts +67 -0
  208. package/src/vite/plugins/cjs-to-esm.ts +8 -7
  209. package/src/vite/plugins/client-ref-dedup.ts +16 -0
  210. package/src/vite/plugins/client-ref-hashing.ts +28 -5
  211. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  212. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  213. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  214. package/src/vite/plugins/expose-action-id.ts +54 -30
  215. package/src/vite/plugins/expose-id-utils.ts +12 -8
  216. package/src/vite/plugins/expose-ids/export-analysis.ts +100 -20
  217. package/src/vite/plugins/expose-ids/handler-transform.ts +8 -61
  218. package/src/vite/plugins/expose-ids/loader-transform.ts +3 -5
  219. package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
  220. package/src/vite/plugins/expose-internal-ids.ts +496 -486
  221. package/src/vite/plugins/performance-tracks.ts +29 -25
  222. package/src/vite/plugins/use-cache-transform.ts +65 -50
  223. package/src/vite/plugins/version-injector.ts +39 -23
  224. package/src/vite/plugins/version-plugin.ts +59 -2
  225. package/src/vite/plugins/virtual-entries.ts +2 -2
  226. package/src/vite/rango.ts +116 -29
  227. package/src/vite/router-discovery.ts +750 -100
  228. package/src/vite/utils/ast-handler-extract.ts +15 -15
  229. package/src/vite/utils/banner.ts +1 -1
  230. package/src/vite/utils/bundle-analysis.ts +4 -2
  231. package/src/vite/utils/client-chunks.ts +190 -0
  232. package/src/vite/utils/forward-user-plugins.ts +193 -0
  233. package/src/vite/utils/manifest-utils.ts +21 -5
  234. package/src/vite/utils/package-resolution.ts +41 -1
  235. package/src/vite/utils/prerender-utils.ts +21 -6
  236. package/src/vite/utils/shared-utils.ts +107 -26
  237. package/src/browser/action-response-classifier.ts +0 -99
@@ -12,7 +12,10 @@ import type {
12
12
  ActionStateListener,
13
13
  HandleData,
14
14
  } from "./types.js";
15
- import { clearPrefetchCache } from "./prefetch/cache.js";
15
+ import {
16
+ clearPrefetchCache,
17
+ clearPrefetchCacheLocal,
18
+ } from "./prefetch/cache.js";
16
19
 
17
20
  /**
18
21
  * Default action state (idle with no payload)
@@ -280,18 +283,17 @@ export function createNavigationStore(
280
283
  /**
281
284
  * Create a debounced function that batches rapid calls
282
285
  */
286
+ // A non-keyed notifier is the keyed one restricted to a single constant key;
287
+ // its own keyed instance means the "" key never collides with action keys.
283
288
  function createDebouncedNotifier<T extends (...args: any[]) => void>(
284
289
  fn: T,
285
290
  ms: number = 20,
286
291
  ): T {
287
- let timeout: ReturnType<typeof setTimeout> | null = null;
288
- return ((...args: Parameters<T>) => {
289
- if (timeout !== null) clearTimeout(timeout);
290
- timeout = setTimeout(() => {
291
- timeout = null;
292
- fn(...args);
293
- }, ms);
294
- }) as T;
292
+ const keyed = createKeyedDebouncedNotifier(
293
+ (_key: string, ...args: any[]) => fn(...args),
294
+ ms,
295
+ );
296
+ return ((...args: Parameters<T>) => keyed("", ...args)) as T;
295
297
  }
296
298
 
297
299
  /**
@@ -335,6 +337,18 @@ export function createNavigationStore(
335
337
  clearPrefetchCache();
336
338
  }
337
339
 
340
+ /**
341
+ * Drop this tab's navigation + prefetch caches without broadcasting or
342
+ * rotating shared state. Used when the local session changes in a way that
343
+ * doesn't affect other tabs — e.g. this tab crosses into a different app
344
+ * via a cross-router navigation. Other tabs in the old app keep their
345
+ * caches and their X-Rango-State token.
346
+ */
347
+ function clearCacheInternalLocal(): void {
348
+ historyCache.length = 0;
349
+ clearPrefetchCacheLocal();
350
+ }
351
+
338
352
  /**
339
353
  * Mark all cache entries as stale (internal - does not broadcast)
340
354
  */
@@ -668,6 +682,15 @@ export function createNavigationStore(
668
682
  clearCacheAndBroadcast();
669
683
  },
670
684
 
685
+ /**
686
+ * Drop this tab's navigation + prefetch caches locally without
687
+ * broadcasting or rotating shared state. Intended for cross-app
688
+ * transitions where the session state diverges for this tab only.
689
+ */
690
+ clearHistoryCacheLocal(): void {
691
+ clearCacheInternalLocal();
692
+ },
693
+
671
694
  /**
672
695
  * Mark cache as stale and broadcast to other tabs
673
696
  * Called after server actions - allows SWR pattern for popstate
@@ -11,7 +11,7 @@ import {
11
11
  } from "./scroll-restoration.js";
12
12
  import type { EventController, NavigationHandle } from "./event-controller.js";
13
13
  import { debugLog } from "./logging.js";
14
- import { buildHistoryState } from "./history-state.js";
14
+ import { buildHistoryState, pushHistoryWithIdx } from "./history-state.js";
15
15
 
16
16
  // Re-export for consumers that import from navigation-transaction
17
17
  export { resolveNavigationState } from "./history-state.js";
@@ -186,12 +186,8 @@ export function createNavigationTransaction(
186
186
  // Used to detect when location state is being cleared.
187
187
  const oldState = window.history.state;
188
188
 
189
- // Update browser URL
190
- if (replace) {
191
- window.history.replaceState(historyState, "", url);
192
- } else {
193
- window.history.pushState(historyState, "", url);
194
- }
189
+ // Update browser URL (stamps history.state.idx for back() first-entry detection)
190
+ pushHistoryWithIdx(historyState, url, replace ?? false);
195
191
  // Ensure new history entry has a scroll restoration key
196
192
  ensureHistoryKey();
197
193
 
@@ -240,30 +236,16 @@ export function createNavigationTransaction(
240
236
  segments: ResolvedSegment[],
241
237
  overrides?: BoundCommitOverrides,
242
238
  ) => {
243
- // Allow overrides to disable scroll (e.g., for intercepts)
244
- const finalScroll =
245
- overrides?.scroll !== undefined ? overrides.scroll : opts.scroll;
246
- // Allow overrides to force replace (e.g., for intercepts)
247
- const finalReplace =
248
- overrides?.replace !== undefined ? overrides.replace : opts.replace;
249
- // Intercept info: overrides take precedence, fallback to opts
250
- const intercept =
251
- overrides?.intercept !== undefined
252
- ? overrides.intercept
253
- : opts.intercept;
239
+ const finalScroll = overrides?.scroll ?? opts.scroll;
240
+ const finalReplace = overrides?.replace ?? opts.replace;
241
+ const intercept = overrides?.intercept ?? opts.intercept;
254
242
  const interceptSourceUrl =
255
- overrides?.interceptSourceUrl !== undefined
256
- ? overrides.interceptSourceUrl
257
- : opts.interceptSourceUrl;
258
- // Cache-only mode: overrides take precedence, fallback to opts
259
- const cacheOnly =
260
- overrides?.cacheOnly !== undefined
261
- ? overrides.cacheOnly
262
- : opts.cacheOnly;
263
- // User state: overrides take precedence, fallback to opts
243
+ overrides?.interceptSourceUrl ?? opts.interceptSourceUrl;
244
+ const cacheOnly = overrides?.cacheOnly ?? opts.cacheOnly;
245
+ // state is `unknown` (null is meaningful) so `??` would wrongly drop a
246
+ // null override; serverState always comes from overrides, never opts.
264
247
  const state =
265
248
  overrides?.state !== undefined ? overrides.state : opts.state;
266
- // Server-set location state: only from overrides (set by partial-update)
267
249
  const serverState = overrides?.serverState;
268
250
  return commit({
269
251
  ...opts,
@@ -14,7 +14,10 @@ const addTransitionType: ((type: string) => void) | undefined =
14
14
  import type { RenderSegmentsOptions } from "../segment-system.js";
15
15
  import { reconcileSegments } from "./segment-reconciler.js";
16
16
  import type { ReconcileActor } from "./segment-reconciler.js";
17
- import { hasActiveIntercept as hasActiveInterceptSlots } from "./intercept-utils.js";
17
+ import {
18
+ hasActiveIntercept as hasActiveInterceptSlots,
19
+ isInterceptSegment,
20
+ } from "./intercept-utils.js";
18
21
  import type { BoundTransaction } from "./navigation-transaction.js";
19
22
  import { ServerRedirect } from "../errors.js";
20
23
  import { debugLog } from "./logging.js";
@@ -28,6 +31,23 @@ function toScrollPayload(
28
31
  return { enabled: scroll !== false ? scroll : false };
29
32
  }
30
33
 
34
+ /**
35
+ * Whether to wrap an update in startViewTransition.
36
+ *
37
+ * Intercept-driven updates only mutate the parallel slot — the main outlet
38
+ * shows the same content — so transitions on the underlying main segments
39
+ * shouldn't fire (otherwise their elements get hoisted above the modal).
40
+ */
41
+ function shouldStartViewTransition(segments: ResolvedSegment[]): boolean {
42
+ let hasIntercept = false;
43
+ let hasTransition = false;
44
+ for (const s of segments) {
45
+ if (isInterceptSegment(s)) hasIntercept = true;
46
+ else if (s.transition) hasTransition = true;
47
+ }
48
+ return !hasIntercept && hasTransition;
49
+ }
50
+
31
51
  /**
32
52
  * Configuration for creating a partial updater
33
53
  */
@@ -41,6 +61,13 @@ export interface PartialUpdateConfig {
41
61
  ) => Promise<ReactNode> | ReactNode;
42
62
  /** RSC version getter — returns the current version (may change after HMR) */
43
63
  getVersion?: () => string | undefined;
64
+ /**
65
+ * Replace the active app-shell when a cross-app navigation is detected.
66
+ * Called before the full-update tree replacement renders, so the new
67
+ * payload's rootLayout, basename, and version are picked up. Theme,
68
+ * warmup, and prefetch TTL are not part of the shell — see AppShell.
69
+ */
70
+ applyAppShell?: (next: import("./app-shell.js").AppShell) => void;
44
71
  }
45
72
 
46
73
  /**
@@ -76,7 +103,7 @@ export type UpdateMode =
76
103
  /** Source URL for intercept restore (popstate cache miss) */
77
104
  interceptSourceUrl?: string;
78
105
  }
79
- | { type: "leave-intercept" }
106
+ | { type: "leave-intercept"; interceptSourceUrl?: string }
80
107
  | { type: "stale-revalidation"; interceptSourceUrl?: string }
81
108
  | { type: "action"; interceptSourceUrl?: string };
82
109
 
@@ -110,6 +137,7 @@ export function createPartialUpdater(
110
137
  onUpdate,
111
138
  renderSegments,
112
139
  getVersion = () => undefined,
140
+ applyAppShell,
113
141
  } = config;
114
142
 
115
143
  /**
@@ -141,13 +169,7 @@ export function createPartialUpdater(
141
169
  // Capture history key at start for stale revalidation consistency check
142
170
  const historyKeyAtStart = store.getHistoryKey();
143
171
 
144
- // Derive interceptSourceUrl from modes that carry it
145
- const interceptSourceUrl =
146
- mode.type === "stale-revalidation" ||
147
- mode.type === "action" ||
148
- mode.type === "navigate"
149
- ? mode.interceptSourceUrl
150
- : undefined;
172
+ const interceptSourceUrl = mode.interceptSourceUrl;
151
173
 
152
174
  // When leaving intercept, filter out intercept-specific segments
153
175
  let segments: string[];
@@ -167,9 +189,16 @@ export function createPartialUpdater(
167
189
  segments = segmentIds ?? segmentState.currentSegmentIds;
168
190
  }
169
191
 
170
- // For intercept revalidation, use the intercept source URL as previousUrl
192
+ // For intercept revalidation, use the intercept source URL as previousUrl.
193
+ // For leave-intercept, tx.currentUrl captures window.location.href at tx
194
+ // creation, which on popstate is already the destination URL and would
195
+ // tell the server "from == to". segmentState.currentUrl still points at
196
+ // the URL the cached segments render (the intercept URL), which is the
197
+ // correct "from" for the server's diff computation.
171
198
  const previousUrl =
172
- interceptSourceUrl || tx.currentUrl || segmentState.currentUrl;
199
+ mode.type === "leave-intercept"
200
+ ? segmentState.currentUrl || tx.currentUrl
201
+ : interceptSourceUrl || tx.currentUrl || segmentState.currentUrl;
173
202
 
174
203
  debugLog(`\n[Browser] >>> NAVIGATION`);
175
204
  debugLog(`[Browser] From: ${previousUrl}`);
@@ -183,13 +212,11 @@ export function createPartialUpdater(
183
212
  // When navigating with targetCacheSegments, use those for consistency.
184
213
  // Otherwise fall back to current page's segments (for same-route revalidation).
185
214
  const targetCache =
186
- mode.type === "navigate" ? mode.targetCacheSegments : undefined;
187
- const cachedSegs =
188
- targetCache && targetCache.length > 0
189
- ? targetCache
190
- : getCurrentCachedSegments();
191
- const cachedSegsSource =
192
- targetCache && targetCache.length > 0 ? "history-cache" : "current-page";
215
+ mode.type === "navigate" && mode.targetCacheSegments?.length
216
+ ? mode.targetCacheSegments
217
+ : undefined;
218
+ const cachedSegs = targetCache ?? getCurrentCachedSegments();
219
+ const cachedSegsSource = targetCache ? "history-cache" : "current-page";
193
220
  debugLog(
194
221
  `[Browser] cachedSegs source: ${cachedSegsSource} (${cachedSegs.length} segments: ${cachedSegs.map((s) => s.id).join(", ")})`,
195
222
  );
@@ -221,7 +248,12 @@ export function createPartialUpdater(
221
248
  // Detect app switch: if routerId changed, the navigation crossed into
222
249
  // a different router (e.g., via host router path mount). Downgrade
223
250
  // partial to full so the entire tree is replaced without reconciliation
224
- // against stale segments from the previous app.
251
+ // against stale segments from the previous app, and replace the app
252
+ // shell (rootLayout, basename, version) so the target app's document
253
+ // and router config take effect instead of remaining captured from the
254
+ // initial load. Theme, warmup, and prefetch TTL are intentionally
255
+ // document-lifetime (see AppShell doc); a new document navigation
256
+ // applies them.
225
257
  if (payload.metadata?.routerId) {
226
258
  const prevRouterId = store.getRouterId?.();
227
259
  if (prevRouterId && prevRouterId !== payload.metadata.routerId) {
@@ -229,6 +261,12 @@ export function createPartialUpdater(
229
261
  `[Browser] App switch detected (${prevRouterId} → ${payload.metadata.routerId}), forcing full update`,
230
262
  );
231
263
  payload.metadata.isPartial = false;
264
+ applyAppShell?.({
265
+ routerId: payload.metadata.routerId,
266
+ rootLayout: payload.metadata.rootLayout,
267
+ basename: payload.metadata.basename,
268
+ version: payload.metadata.version,
269
+ });
232
270
  }
233
271
  store.setRouterId?.(payload.metadata.routerId);
234
272
  }
@@ -272,7 +310,7 @@ export function createPartialUpdater(
272
310
  .filter(Boolean) as ResolvedSegment[];
273
311
 
274
312
  // When navigating with cached segments to a different route, render them.
275
- if (mode.type === "navigate" && targetCache && targetCache.length > 0) {
313
+ if (mode.type === "navigate" && targetCache) {
276
314
  debugLog(
277
315
  "[Browser] No diff but navigating with cached segments - rendering target route",
278
316
  );
@@ -312,10 +350,7 @@ export function createPartialUpdater(
312
350
  scroll: toScrollPayload(commitScroll),
313
351
  };
314
352
 
315
- const cachedHasTransition = existingSegments.some(
316
- (s) => s.transition,
317
- );
318
- if (cachedHasTransition) {
353
+ if (shouldStartViewTransition(existingSegments)) {
319
354
  startTransition(() => {
320
355
  if (addTransitionType) {
321
356
  addTransitionType("navigation");
@@ -501,7 +536,7 @@ export function createPartialUpdater(
501
536
 
502
537
  // Emit update to trigger React render.
503
538
  // Scroll info is included so NavigationProvider applies it after React commits.
504
- const hasTransition = reconciled.mainSegments.some((s) => s.transition);
539
+ const hasTransition = shouldStartViewTransition(reconciled.segments);
505
540
  const scrollPayload = toScrollPayload(navScroll);
506
541
 
507
542
  if (mode.type === "action" || mode.type === "stale-revalidation") {
@@ -563,9 +598,7 @@ export function createPartialUpdater(
563
598
  })
564
599
  : tx.commit(segmentIds, segments);
565
600
 
566
- const fullHasTransition = segments.some(
567
- (s: ResolvedSegment) => s.transition,
568
- );
601
+ const fullHasTransition = shouldStartViewTransition(segments);
569
602
  const fullScrollPayload = toScrollPayload(fullScroll);
570
603
 
571
604
  if (mode.type === "stale-revalidation") {
@@ -2,13 +2,27 @@
2
2
  * Prefetch Cache
3
3
  *
4
4
  * In-memory cache storing prefetched Response objects for instant cache hits
5
- * on subsequent navigation. Cache key is source-dependent (includes the
6
- * current page URL) because the server's diff-based response depends on
7
- * where the user navigates from.
5
+ * on subsequent navigation. Two key scopes are in play:
6
+ * - Wildcard (default): built by `buildPrefetchKey(rangoState, target)`
7
+ * shape `rangoState\0/target?...`. Shared across all source pages and
8
+ * invalidated automatically when Rango state bumps (deploy or
9
+ * server-action invalidation).
10
+ * - Source-scoped: built by `buildSourceKey(rangoState, sourceHref, target)`
11
+ * — shape `rangoState\0sourceHref\0/target?...`. Embeds the Rango state
12
+ * (so rotation invalidates source-scoped entries too) plus the source
13
+ * href (so each originating page gets its own slot). Populated when the
14
+ * server tags a response with `X-RSC-Prefetch-Scope: source` (intercept
15
+ * modals etc.), OR when a Link opts in with `prefetchKey=":source"` — in
16
+ * both cases so source-sensitive responses cannot bleed into navigations
17
+ * from other pages.
8
18
  *
9
19
  * Also tracks in-flight prefetch promises. Each promise resolves to the
10
20
  * navigation branch of a tee'd Response, allowing navigation to adopt a
11
- * still-downloading prefetch without reparsing or buffering the body.
21
+ * still-downloading prefetch without reparsing or buffering the body. A
22
+ * single promise can be registered under multiple alias keys (see
23
+ * `setInflightPromiseWithAliases`) so same-source navigations adopt via
24
+ * their source key while cross-source ones fall through to the wildcard
25
+ * alias — with consume/clear atomically removing every alias.
12
26
  *
13
27
  * Replaces the previous browser HTTP cache approach which was unreliable
14
28
  * due to response draining race conditions and browser inconsistencies.
@@ -55,29 +69,71 @@ const inflight = new Set<string>();
55
69
  */
56
70
  const inflightPromises = new Map<string, Promise<Response | null>>();
57
71
 
72
+ /**
73
+ * Alias map for in-flight promises registered under multiple keys (see
74
+ * dual inflight in prefetch/fetch.ts). Records each key's sibling set so
75
+ * that consuming or clearing any one key atomically removes every alias —
76
+ * guaranteeing a single consumer for the shared Response stream.
77
+ */
78
+ const inflightAliases = new Map<string, string[]>();
79
+
58
80
  // Generation counter incremented on each clearPrefetchCache(). Fetches that
59
81
  // started before a clear carry a stale generation and must not store their
60
82
  // response (the data may be stale due to a server action invalidation).
61
83
  let generation = 0;
62
84
 
63
85
  /**
64
- * Build a cache key for prefetched responses.
86
+ * Build a cache key by combining a scope prefix with the target URL.
87
+ *
88
+ * Low-level primitive — callers that want a specific scope should use
89
+ * one of:
90
+ * - Wildcard (source-agnostic): prefix is the Rango state value from
91
+ * `getRangoState()`. Shared across all source pages. Invalidated
92
+ * automatically when Rango state bumps (deploy or server-action).
93
+ * Key shape: `rangoState\0/target?...`.
94
+ * - Source-scoped: use `buildSourceKey()`. Key shape:
95
+ * `rangoState\0sourceHref\0/target?...` — embeds the Rango state so
96
+ * rotation invalidates source-scoped entries alongside wildcard ones,
97
+ * plus the source page href so the key is unique per originating page.
98
+ * Populated either when the server tags a response with
99
+ * `X-RSC-Prefetch-Scope: source` (intercept modals, etc.) or when a
100
+ * Link opts in via `prefetchKey=":source"`.
65
101
  *
66
- * By default the key includes the source page href so the same target
67
- * prefetched from different pages gets separate entries (the server's
68
- * diff response depends on the source page context).
102
+ * The `_rsc_segments` query param that travels in the target URL means
103
+ * clients with different mounted segment trees naturally get different
104
+ * keys so segment-level diffs remain consistent across both scopes.
105
+ */
106
+ export function buildPrefetchKey(prefix: string, targetUrl: URL): string {
107
+ return prefix + "\0" + targetUrl.pathname + targetUrl.search;
108
+ }
109
+
110
+ /**
111
+ * Build a source-scoped cache key. Key shape:
112
+ * `rangoState\0sourceHref\0/target?...`.
69
113
  *
70
- * When `prefetchKey` is provided, the source portion is replaced with
71
- * a `*` sentinel so all custom-keyed entries share one cache slot per
72
- * target enabling source-agnostic cache reuse.
114
+ * - `rangoState` is included so state rotation invalidates source-scoped
115
+ * entries alongside wildcard ones.
116
+ * - `sourceHref` makes the key unique per originating page.
73
117
  */
74
- export function buildPrefetchKey(
118
+ export function buildSourceKey(
119
+ rangoState: string,
75
120
  sourceHref: string,
76
121
  targetUrl: URL,
77
- prefetchKey?: string | ((from: string) => string),
78
122
  ): string {
79
- const source = prefetchKey != null ? "*" : sourceHref;
80
- return source + "\0" + targetUrl.pathname + targetUrl.search;
123
+ return buildPrefetchKey(rangoState + "\0" + sourceHref, targetUrl);
124
+ }
125
+
126
+ /**
127
+ * Walk an inflight key plus any sibling aliases registered via
128
+ * `setInflightPromiseWithAliases`, invoking `fn` for each.
129
+ */
130
+ function forEachAlias(key: string, fn: (k: string) => void): void {
131
+ const aliases = inflightAliases.get(key);
132
+ if (aliases) {
133
+ for (const k of aliases) fn(k);
134
+ } else {
135
+ fn(key);
136
+ }
81
137
  }
82
138
 
83
139
  /**
@@ -120,21 +176,27 @@ export function consumePrefetch(key: string): Response | null {
120
176
  * in-flight for this key. The returned Promise resolves to the buffered
121
177
  * Response (or null if the fetch failed/was aborted).
122
178
  *
123
- * One-time consumption: the promise entry is removed so a second call
124
- * returns null. The `inflight` set entry is intentionally kept so that
125
- * hasPrefetch() continues to return true while the underlying fetch is
126
- * still downloading this prevents prefetchDirect() or other callers
127
- * from starting a duplicate request during the handoff window. The
128
- * inflight flag is cleaned up naturally by clearPrefetchInflight() in
129
- * the fetch's .finally().
179
+ * One-time consumption: the promise entry is removed (along with any
180
+ * sibling aliases registered via `setInflightPromiseWithAliases`) so a
181
+ * second call on any alias returns null only one caller can adopt the
182
+ * shared Response stream. The `inflight` set entry is intentionally
183
+ * kept so that `hasPrefetch()` continues to return true while the
184
+ * underlying fetch is still downloading this prevents
185
+ * `prefetchDirect()` or other callers from starting a duplicate request
186
+ * during the handoff window. The inflight flag is cleaned up naturally
187
+ * by `clearPrefetchInflight()` in the fetch's `.finally()`.
130
188
  */
131
189
  export function consumeInflightPrefetch(
132
190
  key: string,
133
191
  ): Promise<Response | null> | null {
134
192
  const promise = inflightPromises.get(key);
135
193
  if (!promise) return null;
136
- // Remove the promise (one-time consumption) but keep the inflight flag.
137
- inflightPromises.delete(key);
194
+ // Remove the promise under every alias so a second consumer cannot
195
+ // adopt the same stream and race on the body. `inflightAliases` is
196
+ // intentionally preserved — `clearPrefetchInflight()` in the fetch's
197
+ // `.finally()` still needs it to clear every inflight flag; deleting
198
+ // here would strand the sibling's flag forever.
199
+ forEachAlias(key, (k) => inflightPromises.delete(k));
138
200
  return promise;
139
201
  }
140
202
 
@@ -193,9 +255,28 @@ export function setInflightPromise(
193
255
  inflightPromises.set(key, promise);
194
256
  }
195
257
 
258
+ /**
259
+ * Store the same in-flight Promise under multiple keys, recording them
260
+ * as sibling aliases. Consuming or clearing any one alias atomically
261
+ * removes every entry, guaranteeing the shared Response stream has a
262
+ * single consumer even when navigation looks up either key.
263
+ */
264
+ export function setInflightPromiseWithAliases(
265
+ keys: string[],
266
+ promise: Promise<Response | null>,
267
+ ): void {
268
+ for (const k of keys) {
269
+ inflightPromises.set(k, promise);
270
+ inflightAliases.set(k, keys);
271
+ }
272
+ }
273
+
196
274
  export function clearPrefetchInflight(key: string): void {
197
- inflight.delete(key);
198
- inflightPromises.delete(key);
275
+ forEachAlias(key, (k) => {
276
+ inflight.delete(k);
277
+ inflightPromises.delete(k);
278
+ inflightAliases.delete(k);
279
+ });
199
280
  }
200
281
 
201
282
  /**
@@ -210,7 +291,24 @@ export function clearPrefetchCache(): void {
210
291
  generation++;
211
292
  inflight.clear();
212
293
  inflightPromises.clear();
294
+ inflightAliases.clear();
213
295
  cache.clear();
214
296
  abortAllPrefetches();
215
297
  invalidateRangoState();
216
298
  }
299
+
300
+ /**
301
+ * Drop all in-memory prefetch state for this tab without rotating rango-state.
302
+ *
303
+ * Use for local-only invalidations (e.g. app switch in this tab) where
304
+ * other tabs should NOT observe a state rotation. Unlike clearPrefetchCache,
305
+ * does not call invalidateRangoState, so the shared X-Rango-State token
306
+ * stays intact and siblings in the old app keep their prefetches.
307
+ */
308
+ export function clearPrefetchCacheLocal(): void {
309
+ generation++;
310
+ inflight.clear();
311
+ inflightPromises.clear();
312
+ cache.clear();
313
+ abortAllPrefetches();
314
+ }