@rangojs/router 0.0.0-experimental.79 → 0.0.0-experimental.7d061845

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 (252) hide show
  1. package/README.md +120 -25
  2. package/dist/bin/rango.js +147 -57
  3. package/dist/testing/vitest.js +82 -0
  4. package/dist/vite/index.js +2138 -841
  5. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  6. package/package.json +68 -21
  7. package/skills/breadcrumbs/SKILL.md +3 -1
  8. package/skills/bundle-analysis/SKILL.md +159 -0
  9. package/skills/cache-guide/SKILL.md +220 -30
  10. package/skills/caching/SKILL.md +116 -8
  11. package/skills/composability/SKILL.md +27 -2
  12. package/skills/document-cache/SKILL.md +78 -55
  13. package/skills/handler-use/SKILL.md +3 -1
  14. package/skills/hooks/SKILL.md +229 -20
  15. package/skills/host-router/SKILL.md +45 -20
  16. package/skills/i18n/SKILL.md +276 -0
  17. package/skills/intercept/SKILL.md +26 -4
  18. package/skills/layout/SKILL.md +6 -7
  19. package/skills/links/SKILL.md +247 -17
  20. package/skills/loader/SKILL.md +219 -9
  21. package/skills/middleware/SKILL.md +15 -9
  22. package/skills/migrate-nextjs/SKILL.md +4 -2
  23. package/skills/migrate-react-router/SKILL.md +5 -0
  24. package/skills/mime-routes/SKILL.md +27 -0
  25. package/skills/observability/SKILL.md +137 -0
  26. package/skills/parallel/SKILL.md +12 -6
  27. package/skills/prerender/SKILL.md +14 -33
  28. package/skills/rango/SKILL.md +242 -24
  29. package/skills/react-compiler/SKILL.md +168 -0
  30. package/skills/response-routes/SKILL.md +66 -9
  31. package/skills/route/SKILL.md +33 -4
  32. package/skills/router-setup/SKILL.md +3 -3
  33. package/skills/server-actions/SKILL.md +751 -0
  34. package/skills/streams-and-websockets/SKILL.md +283 -0
  35. package/skills/testing/SKILL.md +816 -0
  36. package/skills/typesafety/SKILL.md +319 -27
  37. package/skills/use-cache/SKILL.md +34 -5
  38. package/skills/view-transitions/SKILL.md +294 -0
  39. package/src/__augment-tests__/augment.ts +81 -0
  40. package/src/__augment-tests__/augmented.check.ts +117 -0
  41. package/src/browser/action-coordinator.ts +53 -36
  42. package/src/browser/app-shell.ts +52 -0
  43. package/src/browser/event-controller.ts +86 -70
  44. package/src/browser/history-state.ts +21 -0
  45. package/src/browser/index.ts +3 -3
  46. package/src/browser/navigation-bridge.ts +65 -9
  47. package/src/browser/navigation-client.ts +45 -25
  48. package/src/browser/navigation-store.ts +32 -9
  49. package/src/browser/navigation-transaction.ts +10 -28
  50. package/src/browser/partial-update.ts +52 -26
  51. package/src/browser/prefetch/cache.ts +124 -26
  52. package/src/browser/prefetch/fetch.ts +114 -38
  53. package/src/browser/prefetch/queue.ts +36 -5
  54. package/src/browser/rango-state.ts +53 -13
  55. package/src/browser/react/Link.tsx +18 -13
  56. package/src/browser/react/NavigationProvider.tsx +72 -31
  57. package/src/browser/react/filter-segment-order.ts +51 -7
  58. package/src/browser/react/index.ts +3 -0
  59. package/src/browser/react/location-state-shared.ts +175 -4
  60. package/src/browser/react/location-state.ts +39 -13
  61. package/src/browser/react/use-handle.ts +17 -9
  62. package/src/browser/react/use-navigation.ts +22 -2
  63. package/src/browser/react/use-params.ts +20 -8
  64. package/src/browser/react/use-reverse.ts +106 -0
  65. package/src/browser/react/use-router.ts +22 -2
  66. package/src/browser/react/use-segments.ts +11 -8
  67. package/src/browser/response-adapter.ts +25 -0
  68. package/src/browser/rsc-router.tsx +64 -22
  69. package/src/browser/scroll-restoration.ts +22 -14
  70. package/src/browser/segment-structure-assert.ts +2 -2
  71. package/src/browser/server-action-bridge.ts +23 -30
  72. package/src/browser/types.ts +21 -0
  73. package/src/build/collect-fallback-refs.ts +107 -0
  74. package/src/build/generate-manifest.ts +60 -35
  75. package/src/build/generate-route-types.ts +2 -0
  76. package/src/build/index.ts +2 -0
  77. package/src/build/route-trie.ts +2 -1
  78. package/src/build/route-types/codegen.ts +4 -4
  79. package/src/build/route-types/include-resolution.ts +1 -1
  80. package/src/build/route-types/per-module-writer.ts +7 -4
  81. package/src/build/route-types/router-processing.ts +55 -14
  82. package/src/build/route-types/scan-filter.ts +1 -1
  83. package/src/build/route-types/source-scan.ts +118 -0
  84. package/src/build/runtime-discovery.ts +9 -20
  85. package/src/cache/cache-scope.ts +28 -42
  86. package/src/cache/cf/cf-cache-store.ts +54 -13
  87. package/src/client.rsc.tsx +3 -0
  88. package/src/client.tsx +10 -8
  89. package/src/context-var.ts +5 -5
  90. package/src/decode-loader-results.ts +36 -0
  91. package/src/errors.ts +30 -1
  92. package/src/handle.ts +26 -13
  93. package/src/host/index.ts +2 -2
  94. package/src/host/router.ts +129 -57
  95. package/src/host/types.ts +31 -2
  96. package/src/host/utils.ts +1 -1
  97. package/src/href-client.ts +140 -20
  98. package/src/index.rsc.ts +9 -4
  99. package/src/index.ts +16 -6
  100. package/src/loader-store.ts +500 -0
  101. package/src/loader.rsc.ts +21 -6
  102. package/src/loader.ts +3 -10
  103. package/src/missing-id-error.ts +68 -0
  104. package/src/outlet-context.ts +1 -1
  105. package/src/prerender.ts +4 -4
  106. package/src/response-utils.ts +37 -0
  107. package/src/reverse.ts +65 -39
  108. package/src/route-content-wrapper.tsx +6 -28
  109. package/src/route-definition/dsl-helpers.ts +253 -265
  110. package/src/route-definition/helper-factories.ts +29 -139
  111. package/src/route-definition/helpers-types.ts +43 -15
  112. package/src/route-definition/resolve-handler-use.ts +6 -0
  113. package/src/route-definition/use-item-types.ts +32 -0
  114. package/src/route-types.ts +19 -41
  115. package/src/router/basename.ts +14 -0
  116. package/src/router/content-negotiation.ts +15 -2
  117. package/src/router/error-handling.ts +1 -1
  118. package/src/router/handler-context.ts +21 -41
  119. package/src/router/intercept-resolution.ts +4 -18
  120. package/src/router/lazy-includes.ts +3 -3
  121. package/src/router/loader-resolution.ts +19 -2
  122. package/src/router/match-api.ts +4 -3
  123. package/src/router/match-handlers.ts +63 -20
  124. package/src/router/match-middleware/cache-lookup.ts +44 -91
  125. package/src/router/match-middleware/cache-store.ts +3 -2
  126. package/src/router/match-result.ts +53 -32
  127. package/src/router/metrics.ts +1 -1
  128. package/src/router/middleware-types.ts +15 -26
  129. package/src/router/middleware.ts +99 -84
  130. package/src/router/pattern-matching.ts +101 -17
  131. package/src/router/prerender-match.ts +1 -1
  132. package/src/router/preview-match.ts +3 -1
  133. package/src/router/request-classification.ts +4 -28
  134. package/src/router/revalidation.ts +58 -2
  135. package/src/router/router-interfaces.ts +45 -28
  136. package/src/router/router-options.ts +40 -1
  137. package/src/router/router-registry.ts +2 -5
  138. package/src/router/segment-resolution/fresh.ts +27 -6
  139. package/src/router/segment-resolution/revalidation.ts +147 -106
  140. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  141. package/src/router/substitute-pattern-params.ts +56 -0
  142. package/src/router/telemetry.ts +99 -0
  143. package/src/router/trie-matching.ts +18 -13
  144. package/src/router/types.ts +8 -0
  145. package/src/router/url-params.ts +49 -0
  146. package/src/router.ts +38 -23
  147. package/src/rsc/handler-context.ts +2 -2
  148. package/src/rsc/handler.ts +28 -69
  149. package/src/rsc/helpers.ts +91 -43
  150. package/src/rsc/index.ts +1 -1
  151. package/src/rsc/origin-guard.ts +28 -10
  152. package/src/rsc/progressive-enhancement.ts +4 -0
  153. package/src/rsc/response-route-handler.ts +46 -53
  154. package/src/rsc/rsc-rendering.ts +35 -51
  155. package/src/rsc/runtime-warnings.ts +9 -10
  156. package/src/rsc/server-action.ts +17 -37
  157. package/src/rsc/ssr-setup.ts +16 -0
  158. package/src/rsc/types.ts +8 -2
  159. package/src/search-params.ts +4 -4
  160. package/src/segment-system.tsx +122 -56
  161. package/src/serialize.ts +243 -0
  162. package/src/server/context.ts +118 -51
  163. package/src/server/cookie-store.ts +28 -4
  164. package/src/server/request-context.ts +20 -42
  165. package/src/ssr/index.tsx +5 -1
  166. package/src/static-handler.ts +1 -1
  167. package/src/testing/cache-status.ts +166 -0
  168. package/src/testing/collect-handle.ts +63 -0
  169. package/src/testing/dispatch.ts +440 -0
  170. package/src/testing/dom.entry.ts +22 -0
  171. package/src/testing/e2e/fixture.ts +154 -0
  172. package/src/testing/e2e/index.ts +149 -0
  173. package/src/testing/e2e/matchers.ts +51 -0
  174. package/src/testing/e2e/page-helpers.ts +272 -0
  175. package/src/testing/e2e/parity.ts +306 -0
  176. package/src/testing/e2e/server.ts +183 -0
  177. package/src/testing/flight-matchers.ts +104 -0
  178. package/src/testing/flight-runtime.d.ts +57 -0
  179. package/src/testing/flight-tree.ts +332 -0
  180. package/src/testing/flight.entry.ts +46 -0
  181. package/src/testing/flight.ts +224 -0
  182. package/src/testing/generated-routes.ts +223 -0
  183. package/src/testing/index.ts +106 -0
  184. package/src/testing/internal/context.ts +304 -0
  185. package/src/testing/internal/flight-client-globals.ts +30 -0
  186. package/src/testing/internal/seed-vars.ts +42 -0
  187. package/src/testing/render-handler.ts +267 -0
  188. package/src/testing/render-route.tsx +565 -0
  189. package/src/testing/run-loader.ts +341 -0
  190. package/src/testing/run-middleware.ts +188 -0
  191. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  192. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  193. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  194. package/src/testing/vitest-stubs/version.ts +5 -0
  195. package/src/testing/vitest.ts +270 -0
  196. package/src/types/global-namespace.ts +39 -26
  197. package/src/types/handler-context.ts +68 -50
  198. package/src/types/index.ts +1 -0
  199. package/src/types/loader-types.ts +5 -6
  200. package/src/types/request-scope.ts +126 -0
  201. package/src/types/segments.ts +35 -1
  202. package/src/urls/include-helper.ts +10 -53
  203. package/src/urls/index.ts +0 -3
  204. package/src/urls/path-helper-types.ts +11 -3
  205. package/src/urls/path-helper.ts +17 -52
  206. package/src/urls/pattern-types.ts +36 -19
  207. package/src/urls/response-types.ts +22 -29
  208. package/src/urls/type-extraction.ts +26 -116
  209. package/src/urls/urls-function.ts +1 -5
  210. package/src/use-loader.tsx +413 -42
  211. package/src/vite/debug.ts +185 -0
  212. package/src/vite/discovery/bundle-postprocess.ts +6 -6
  213. package/src/vite/discovery/discover-routers.ts +101 -51
  214. package/src/vite/discovery/discovery-errors.ts +194 -0
  215. package/src/vite/discovery/gate-state.ts +171 -0
  216. package/src/vite/discovery/prerender-collection.ts +67 -26
  217. package/src/vite/discovery/route-types-writer.ts +40 -84
  218. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  219. package/src/vite/discovery/state.ts +33 -0
  220. package/src/vite/discovery/virtual-module-codegen.ts +13 -23
  221. package/src/vite/index.ts +2 -0
  222. package/src/vite/plugin-types.ts +67 -0
  223. package/src/vite/plugins/cjs-to-esm.ts +8 -7
  224. package/src/vite/plugins/client-ref-dedup.ts +16 -0
  225. package/src/vite/plugins/client-ref-hashing.ts +28 -5
  226. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  227. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  228. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  229. package/src/vite/plugins/expose-action-id.ts +54 -30
  230. package/src/vite/plugins/expose-id-utils.ts +12 -8
  231. package/src/vite/plugins/expose-ids/export-analysis.ts +100 -20
  232. package/src/vite/plugins/expose-ids/handler-transform.ts +8 -61
  233. package/src/vite/plugins/expose-ids/loader-transform.ts +3 -5
  234. package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
  235. package/src/vite/plugins/expose-internal-ids.ts +496 -486
  236. package/src/vite/plugins/performance-tracks.ts +29 -25
  237. package/src/vite/plugins/use-cache-transform.ts +65 -50
  238. package/src/vite/plugins/version-injector.ts +39 -23
  239. package/src/vite/plugins/version-plugin.ts +59 -2
  240. package/src/vite/plugins/virtual-entries.ts +2 -2
  241. package/src/vite/rango.ts +116 -29
  242. package/src/vite/router-discovery.ts +750 -100
  243. package/src/vite/utils/ast-handler-extract.ts +15 -15
  244. package/src/vite/utils/banner.ts +1 -1
  245. package/src/vite/utils/bundle-analysis.ts +4 -2
  246. package/src/vite/utils/client-chunks.ts +190 -0
  247. package/src/vite/utils/forward-user-plugins.ts +193 -0
  248. package/src/vite/utils/manifest-utils.ts +21 -5
  249. package/src/vite/utils/package-resolution.ts +41 -1
  250. package/src/vite/utils/prerender-utils.ts +5 -4
  251. package/src/vite/utils/shared-utils.ts +107 -26
  252. package/src/browser/action-response-classifier.ts +0 -99
@@ -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[];
@@ -190,13 +212,11 @@ export function createPartialUpdater(
190
212
  // When navigating with targetCacheSegments, use those for consistency.
191
213
  // Otherwise fall back to current page's segments (for same-route revalidation).
192
214
  const targetCache =
193
- mode.type === "navigate" ? mode.targetCacheSegments : undefined;
194
- const cachedSegs =
195
- targetCache && targetCache.length > 0
196
- ? targetCache
197
- : getCurrentCachedSegments();
198
- const cachedSegsSource =
199
- 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";
200
220
  debugLog(
201
221
  `[Browser] cachedSegs source: ${cachedSegsSource} (${cachedSegs.length} segments: ${cachedSegs.map((s) => s.id).join(", ")})`,
202
222
  );
@@ -228,7 +248,12 @@ export function createPartialUpdater(
228
248
  // Detect app switch: if routerId changed, the navigation crossed into
229
249
  // a different router (e.g., via host router path mount). Downgrade
230
250
  // partial to full so the entire tree is replaced without reconciliation
231
- // 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.
232
257
  if (payload.metadata?.routerId) {
233
258
  const prevRouterId = store.getRouterId?.();
234
259
  if (prevRouterId && prevRouterId !== payload.metadata.routerId) {
@@ -236,6 +261,12 @@ export function createPartialUpdater(
236
261
  `[Browser] App switch detected (${prevRouterId} → ${payload.metadata.routerId}), forcing full update`,
237
262
  );
238
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
+ });
239
270
  }
240
271
  store.setRouterId?.(payload.metadata.routerId);
241
272
  }
@@ -279,7 +310,7 @@ export function createPartialUpdater(
279
310
  .filter(Boolean) as ResolvedSegment[];
280
311
 
281
312
  // When navigating with cached segments to a different route, render them.
282
- if (mode.type === "navigate" && targetCache && targetCache.length > 0) {
313
+ if (mode.type === "navigate" && targetCache) {
283
314
  debugLog(
284
315
  "[Browser] No diff but navigating with cached segments - rendering target route",
285
316
  );
@@ -319,10 +350,7 @@ export function createPartialUpdater(
319
350
  scroll: toScrollPayload(commitScroll),
320
351
  };
321
352
 
322
- const cachedHasTransition = existingSegments.some(
323
- (s) => s.transition,
324
- );
325
- if (cachedHasTransition) {
353
+ if (shouldStartViewTransition(existingSegments)) {
326
354
  startTransition(() => {
327
355
  if (addTransitionType) {
328
356
  addTransitionType("navigation");
@@ -508,7 +536,7 @@ export function createPartialUpdater(
508
536
 
509
537
  // Emit update to trigger React render.
510
538
  // Scroll info is included so NavigationProvider applies it after React commits.
511
- const hasTransition = reconciled.mainSegments.some((s) => s.transition);
539
+ const hasTransition = shouldStartViewTransition(reconciled.segments);
512
540
  const scrollPayload = toScrollPayload(navScroll);
513
541
 
514
542
  if (mode.type === "action" || mode.type === "stale-revalidation") {
@@ -570,9 +598,7 @@ export function createPartialUpdater(
570
598
  })
571
599
  : tx.commit(segmentIds, segments);
572
600
 
573
- const fullHasTransition = segments.some(
574
- (s: ResolvedSegment) => s.transition,
575
- );
601
+ const fullHasTransition = shouldStartViewTransition(segments);
576
602
  const fullScrollPayload = toScrollPayload(fullScroll);
577
603
 
578
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
+ }