@rangojs/router 0.0.0-experimental.77 → 0.0.0-experimental.77ed8945

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 (239) hide show
  1. package/README.md +120 -25
  2. package/dist/bin/rango.js +147 -57
  3. package/dist/vite/index.js +2103 -861
  4. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  5. package/package.json +13 -8
  6. package/skills/api-client/SKILL.md +211 -0
  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/css/SKILL.md +76 -0
  13. package/skills/document-cache/SKILL.md +78 -55
  14. package/skills/handler-use/SKILL.md +3 -1
  15. package/skills/hooks/SKILL.md +229 -20
  16. package/skills/host-router/SKILL.md +66 -20
  17. package/skills/i18n/SKILL.md +276 -0
  18. package/skills/intercept/SKILL.md +26 -4
  19. package/skills/layout/SKILL.md +6 -7
  20. package/skills/links/SKILL.md +247 -17
  21. package/skills/loader/SKILL.md +219 -9
  22. package/skills/middleware/SKILL.md +47 -12
  23. package/skills/migrate-nextjs/SKILL.md +562 -0
  24. package/skills/migrate-react-router/SKILL.md +769 -0
  25. package/skills/mime-routes/SKILL.md +27 -0
  26. package/skills/observability/SKILL.md +137 -0
  27. package/skills/parallel/SKILL.md +12 -6
  28. package/skills/prerender/SKILL.md +14 -33
  29. package/skills/rango/SKILL.md +238 -22
  30. package/skills/react-compiler/SKILL.md +168 -0
  31. package/skills/response-routes/SKILL.md +122 -47
  32. package/skills/route/SKILL.md +33 -4
  33. package/skills/router-setup/SKILL.md +3 -3
  34. package/skills/server-actions/SKILL.md +751 -0
  35. package/skills/streams-and-websockets/SKILL.md +283 -0
  36. package/skills/tailwind/SKILL.md +27 -3
  37. package/skills/typesafety/SKILL.md +319 -27
  38. package/skills/use-cache/SKILL.md +34 -5
  39. package/skills/view-transitions/SKILL.md +294 -0
  40. package/src/__augment-tests__/augment.ts +81 -0
  41. package/src/__augment-tests__/augmented.check.ts +116 -0
  42. package/src/browser/action-coordinator.ts +53 -36
  43. package/src/browser/app-shell.ts +39 -0
  44. package/src/browser/event-controller.ts +86 -70
  45. package/src/browser/history-state.ts +21 -0
  46. package/src/browser/index.ts +3 -3
  47. package/src/browser/navigation-bridge.ts +29 -9
  48. package/src/browser/navigation-client.ts +99 -77
  49. package/src/browser/navigation-store.ts +7 -8
  50. package/src/browser/navigation-transaction.ts +10 -28
  51. package/src/browser/partial-update.ts +60 -40
  52. package/src/browser/prefetch/cache.ts +196 -49
  53. package/src/browser/prefetch/fetch.ts +203 -59
  54. package/src/browser/prefetch/queue.ts +36 -5
  55. package/src/browser/rango-state.ts +37 -13
  56. package/src/browser/react/Link.tsx +18 -13
  57. package/src/browser/react/NavigationProvider.tsx +75 -31
  58. package/src/browser/react/filter-segment-order.ts +51 -7
  59. package/src/browser/react/index.ts +3 -0
  60. package/src/browser/react/location-state-shared.ts +175 -4
  61. package/src/browser/react/location-state.ts +39 -13
  62. package/src/browser/react/use-handle.ts +17 -9
  63. package/src/browser/react/use-navigation.ts +22 -2
  64. package/src/browser/react/use-params.ts +20 -8
  65. package/src/browser/react/use-reverse.ts +106 -0
  66. package/src/browser/react/use-router.ts +23 -2
  67. package/src/browser/react/use-segments.ts +11 -8
  68. package/src/browser/response-adapter.ts +52 -1
  69. package/src/browser/rsc-router.tsx +71 -22
  70. package/src/browser/scroll-restoration.ts +22 -14
  71. package/src/browser/segment-reconciler.ts +10 -14
  72. package/src/browser/segment-structure-assert.ts +2 -2
  73. package/src/browser/server-action-bridge.ts +44 -30
  74. package/src/browser/types.ts +12 -2
  75. package/src/build/collect-fallback-refs.ts +107 -0
  76. package/src/build/generate-manifest.ts +60 -35
  77. package/src/build/generate-route-types.ts +2 -0
  78. package/src/build/index.ts +8 -1
  79. package/src/build/prefix-tree-utils.ts +123 -0
  80. package/src/build/route-trie.ts +45 -1
  81. package/src/build/route-types/codegen.ts +4 -4
  82. package/src/build/route-types/include-resolution.ts +1 -1
  83. package/src/build/route-types/per-module-writer.ts +7 -4
  84. package/src/build/route-types/router-processing.ts +55 -14
  85. package/src/build/route-types/scan-filter.ts +1 -1
  86. package/src/build/route-types/source-scan.ts +118 -0
  87. package/src/build/runtime-discovery.ts +9 -20
  88. package/src/cache/cache-runtime.ts +17 -5
  89. package/src/cache/cache-scope.ts +51 -49
  90. package/src/cache/cf/cf-cache-store.ts +502 -32
  91. package/src/cache/cf/index.ts +3 -0
  92. package/src/cache/handle-snapshot.ts +103 -0
  93. package/src/cache/index.ts +3 -0
  94. package/src/cache/memory-segment-store.ts +3 -2
  95. package/src/cache/types.ts +10 -6
  96. package/src/client.rsc.tsx +3 -0
  97. package/src/client.tsx +96 -205
  98. package/src/context-var.ts +5 -5
  99. package/src/decode-loader-results.ts +36 -0
  100. package/src/errors.ts +30 -4
  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 -21
  107. package/src/index.rsc.ts +10 -6
  108. package/src/index.ts +17 -8
  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/store.ts +9 -7
  115. package/src/prerender.ts +4 -4
  116. package/src/response-utils.ts +37 -0
  117. package/src/reverse.ts +65 -39
  118. package/src/route-content-wrapper.tsx +6 -28
  119. package/src/route-definition/dsl-helpers.ts +253 -265
  120. package/src/route-definition/helper-factories.ts +29 -139
  121. package/src/route-definition/helpers-types.ts +43 -15
  122. package/src/route-definition/resolve-handler-use.ts +6 -0
  123. package/src/route-definition/use-item-types.ts +32 -0
  124. package/src/route-types.ts +26 -41
  125. package/src/router/content-negotiation.ts +15 -2
  126. package/src/router/error-handling.ts +1 -1
  127. package/src/router/find-match.ts +54 -6
  128. package/src/router/handler-context.ts +21 -41
  129. package/src/router/intercept-resolution.ts +4 -18
  130. package/src/router/lazy-includes.ts +41 -22
  131. package/src/router/loader-resolution.ts +82 -36
  132. package/src/router/manifest.ts +41 -19
  133. package/src/router/match-api.ts +4 -3
  134. package/src/router/match-handlers.ts +1 -0
  135. package/src/router/match-middleware/cache-lookup.ts +57 -95
  136. package/src/router/match-middleware/cache-store.ts +3 -2
  137. package/src/router/match-result.ts +53 -32
  138. package/src/router/metrics.ts +1 -1
  139. package/src/router/middleware-types.ts +15 -26
  140. package/src/router/middleware.ts +99 -84
  141. package/src/router/pattern-matching.ts +116 -19
  142. package/src/router/prerender-match.ts +40 -15
  143. package/src/router/preview-match.ts +3 -1
  144. package/src/router/request-classification.ts +40 -37
  145. package/src/router/revalidation.ts +58 -2
  146. package/src/router/router-interfaces.ts +51 -35
  147. package/src/router/router-options.ts +25 -1
  148. package/src/router/router-registry.ts +2 -5
  149. package/src/router/segment-resolution/fresh.ts +27 -6
  150. package/src/router/segment-resolution/revalidation.ts +147 -106
  151. package/src/router/segment-resolution/static-store.ts +19 -5
  152. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  153. package/src/router/substitute-pattern-params.ts +56 -0
  154. package/src/router/trie-matching.ts +40 -16
  155. package/src/router/types.ts +8 -0
  156. package/src/router/url-params.ts +49 -0
  157. package/src/router.ts +37 -25
  158. package/src/rsc/handler-context.ts +2 -2
  159. package/src/rsc/handler.ts +58 -77
  160. package/src/rsc/helpers.ts +72 -43
  161. package/src/rsc/index.ts +1 -1
  162. package/src/rsc/manifest-init.ts +28 -41
  163. package/src/rsc/origin-guard.ts +30 -10
  164. package/src/rsc/progressive-enhancement.ts +4 -0
  165. package/src/rsc/response-error.ts +79 -12
  166. package/src/rsc/response-route-handler.ts +76 -61
  167. package/src/rsc/rsc-rendering.ts +45 -51
  168. package/src/rsc/runtime-warnings.ts +9 -10
  169. package/src/rsc/server-action.ts +33 -39
  170. package/src/rsc/ssr-setup.ts +16 -0
  171. package/src/rsc/types.ts +8 -2
  172. package/src/search-params.ts +4 -4
  173. package/src/segment-content-promise.ts +67 -0
  174. package/src/segment-loader-promise.ts +122 -0
  175. package/src/segment-system.tsx +132 -116
  176. package/src/serialize.ts +243 -0
  177. package/src/server/context.ts +175 -53
  178. package/src/server/cookie-store.ts +28 -4
  179. package/src/server/request-context.ts +57 -51
  180. package/src/ssr/index.tsx +5 -1
  181. package/src/static-handler.ts +1 -1
  182. package/src/types/global-namespace.ts +39 -26
  183. package/src/types/handler-context.ts +68 -50
  184. package/src/types/index.ts +1 -0
  185. package/src/types/loader-types.ts +11 -9
  186. package/src/types/request-scope.ts +126 -0
  187. package/src/types/route-entry.ts +11 -0
  188. package/src/types/segments.ts +35 -2
  189. package/src/urls/include-helper.ts +34 -67
  190. package/src/urls/index.ts +1 -5
  191. package/src/urls/path-helper-types.ts +17 -3
  192. package/src/urls/path-helper.ts +17 -52
  193. package/src/urls/pattern-types.ts +36 -19
  194. package/src/urls/response-types.ts +22 -29
  195. package/src/urls/type-extraction.ts +58 -139
  196. package/src/urls/urls-function.ts +1 -5
  197. package/src/use-loader.tsx +413 -42
  198. package/src/vite/debug.ts +185 -0
  199. package/src/vite/discovery/bundle-postprocess.ts +6 -6
  200. package/src/vite/discovery/discover-routers.ts +106 -75
  201. package/src/vite/discovery/discovery-errors.ts +194 -0
  202. package/src/vite/discovery/gate-state.ts +171 -0
  203. package/src/vite/discovery/prerender-collection.ts +72 -31
  204. package/src/vite/discovery/route-types-writer.ts +40 -84
  205. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  206. package/src/vite/discovery/state.ts +33 -0
  207. package/src/vite/discovery/virtual-module-codegen.ts +13 -23
  208. package/src/vite/index.ts +2 -0
  209. package/src/vite/plugin-types.ts +67 -0
  210. package/src/vite/plugins/cjs-to-esm.ts +8 -7
  211. package/src/vite/plugins/client-ref-dedup.ts +16 -0
  212. package/src/vite/plugins/client-ref-hashing.ts +28 -5
  213. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  214. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  215. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  216. package/src/vite/plugins/expose-action-id.ts +54 -30
  217. package/src/vite/plugins/expose-id-utils.ts +12 -8
  218. package/src/vite/plugins/expose-ids/export-analysis.ts +100 -20
  219. package/src/vite/plugins/expose-ids/handler-transform.ts +8 -61
  220. package/src/vite/plugins/expose-ids/loader-transform.ts +3 -5
  221. package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
  222. package/src/vite/plugins/expose-internal-ids.ts +496 -486
  223. package/src/vite/plugins/performance-tracks.ts +29 -25
  224. package/src/vite/plugins/use-cache-transform.ts +65 -50
  225. package/src/vite/plugins/version-injector.ts +39 -23
  226. package/src/vite/plugins/version-plugin.ts +59 -2
  227. package/src/vite/plugins/virtual-entries.ts +2 -2
  228. package/src/vite/rango.ts +116 -29
  229. package/src/vite/router-discovery.ts +753 -104
  230. package/src/vite/utils/ast-handler-extract.ts +15 -15
  231. package/src/vite/utils/banner.ts +1 -1
  232. package/src/vite/utils/bundle-analysis.ts +4 -2
  233. package/src/vite/utils/client-chunks.ts +190 -0
  234. package/src/vite/utils/forward-user-plugins.ts +193 -0
  235. package/src/vite/utils/manifest-utils.ts +8 -59
  236. package/src/vite/utils/package-resolution.ts +41 -1
  237. package/src/vite/utils/prerender-utils.ts +5 -4
  238. package/src/vite/utils/shared-utils.ts +107 -26
  239. 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
  */
@@ -76,7 +96,7 @@ export type UpdateMode =
76
96
  /** Source URL for intercept restore (popstate cache miss) */
77
97
  interceptSourceUrl?: string;
78
98
  }
79
- | { type: "leave-intercept" }
99
+ | { type: "leave-intercept"; interceptSourceUrl?: string }
80
100
  | { type: "stale-revalidation"; interceptSourceUrl?: string }
81
101
  | { type: "action"; interceptSourceUrl?: string };
82
102
 
@@ -141,13 +161,7 @@ export function createPartialUpdater(
141
161
  // Capture history key at start for stale revalidation consistency check
142
162
  const historyKeyAtStart = store.getHistoryKey();
143
163
 
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;
164
+ const interceptSourceUrl = mode.interceptSourceUrl;
151
165
 
152
166
  // When leaving intercept, filter out intercept-specific segments
153
167
  let segments: string[];
@@ -167,9 +181,16 @@ export function createPartialUpdater(
167
181
  segments = segmentIds ?? segmentState.currentSegmentIds;
168
182
  }
169
183
 
170
- // For intercept revalidation, use the intercept source URL as previousUrl
184
+ // For intercept revalidation, use the intercept source URL as previousUrl.
185
+ // For leave-intercept, tx.currentUrl captures window.location.href at tx
186
+ // creation, which on popstate is already the destination URL and would
187
+ // tell the server "from == to". segmentState.currentUrl still points at
188
+ // the URL the cached segments render (the intercept URL), which is the
189
+ // correct "from" for the server's diff computation.
171
190
  const previousUrl =
172
- interceptSourceUrl || tx.currentUrl || segmentState.currentUrl;
191
+ mode.type === "leave-intercept"
192
+ ? segmentState.currentUrl || tx.currentUrl
193
+ : interceptSourceUrl || tx.currentUrl || segmentState.currentUrl;
173
194
 
174
195
  debugLog(`\n[Browser] >>> NAVIGATION`);
175
196
  debugLog(`[Browser] From: ${previousUrl}`);
@@ -183,13 +204,11 @@ export function createPartialUpdater(
183
204
  // When navigating with targetCacheSegments, use those for consistency.
184
205
  // Otherwise fall back to current page's segments (for same-route revalidation).
185
206
  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";
207
+ mode.type === "navigate" && mode.targetCacheSegments?.length
208
+ ? mode.targetCacheSegments
209
+ : undefined;
210
+ const cachedSegs = targetCache ?? getCurrentCachedSegments();
211
+ const cachedSegsSource = targetCache ? "history-cache" : "current-page";
193
212
  debugLog(
194
213
  `[Browser] cachedSegs source: ${cachedSegsSource} (${cachedSegs.length} segments: ${cachedSegs.map((s) => s.id).join(", ")})`,
195
214
  );
@@ -218,19 +237,25 @@ export function createPartialUpdater(
218
237
  streamingToken.end();
219
238
  });
220
239
 
221
- // Detect app switch: if routerId changed, the navigation crossed into
222
- // a different router (e.g., via host router path mount). Downgrade
223
- // partial to full so the entire tree is replaced without reconciliation
224
- // against stale segments from the previous app.
225
- if (payload.metadata?.routerId) {
226
- const prevRouterId = store.getRouterId?.();
227
- if (prevRouterId && prevRouterId !== payload.metadata.routerId) {
228
- debugLog(
229
- `[Browser] App switch detected (${prevRouterId} → ${payload.metadata.routerId}), forcing full update`,
230
- );
231
- payload.metadata.isPartial = false;
232
- }
233
- store.setRouterId?.(payload.metadata.routerId);
240
+ // Integrity guard (defense in depth). The server redirects on a cross-app
241
+ // routerId mismatch (X-RSC-Reload), so a partial payload's routerId must
242
+ // match this client's. If it doesn't a stale/edge cache keyed without the
243
+ // routerId, a proxy mixing app responses, or a server classification bug —
244
+ // do NOT splice a foreign app's segments and client references into this
245
+ // document. Force a full reload so the server re-establishes the
246
+ // authoritative document for this URL.
247
+ const currentRouterId = store.getRouterId?.();
248
+ if (
249
+ payload.metadata?.routerId &&
250
+ currentRouterId &&
251
+ payload.metadata.routerId !== currentRouterId
252
+ ) {
253
+ console.error(
254
+ `[rango] Partial response router id "${payload.metadata.routerId}" does not ` +
255
+ `match this client ("${currentRouterId}"); discarding it and reloading to re-sync.`,
256
+ );
257
+ window.location.href = url;
258
+ return;
234
259
  }
235
260
 
236
261
  // Handle server-side redirect with state
@@ -272,7 +297,7 @@ export function createPartialUpdater(
272
297
  .filter(Boolean) as ResolvedSegment[];
273
298
 
274
299
  // When navigating with cached segments to a different route, render them.
275
- if (mode.type === "navigate" && targetCache && targetCache.length > 0) {
300
+ if (mode.type === "navigate" && targetCache) {
276
301
  debugLog(
277
302
  "[Browser] No diff but navigating with cached segments - rendering target route",
278
303
  );
@@ -312,10 +337,7 @@ export function createPartialUpdater(
312
337
  scroll: toScrollPayload(commitScroll),
313
338
  };
314
339
 
315
- const cachedHasTransition = existingSegments.some(
316
- (s) => s.transition,
317
- );
318
- if (cachedHasTransition) {
340
+ if (shouldStartViewTransition(existingSegments)) {
319
341
  startTransition(() => {
320
342
  if (addTransitionType) {
321
343
  addTransitionType("navigation");
@@ -501,7 +523,7 @@ export function createPartialUpdater(
501
523
 
502
524
  // Emit update to trigger React render.
503
525
  // Scroll info is included so NavigationProvider applies it after React commits.
504
- const hasTransition = reconciled.mainSegments.some((s) => s.transition);
526
+ const hasTransition = shouldStartViewTransition(reconciled.segments);
505
527
  const scrollPayload = toScrollPayload(navScroll);
506
528
 
507
529
  if (mode.type === "action" || mode.type === "stale-revalidation") {
@@ -563,9 +585,7 @@ export function createPartialUpdater(
563
585
  })
564
586
  : tx.commit(segmentIds, segments);
565
587
 
566
- const fullHasTransition = segments.some(
567
- (s: ResolvedSegment) => s.transition,
568
- );
588
+ const fullHasTransition = shouldStartViewTransition(segments);
569
589
  const fullScrollPayload = toScrollPayload(fullScroll);
570
590
 
571
591
  if (mode.type === "stale-revalidation") {
@@ -1,14 +1,33 @@
1
1
  /**
2
2
  * Prefetch Cache
3
3
  *
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.
4
+ * In-memory cache storing eagerly-decoded prefetch payloads for instant,
5
+ * already-warm cache hits on subsequent navigation. A prefetch fetches the
6
+ * RSC partial AND decodes it (createFromFetch) up front decoding the Flight
7
+ * stream resolves the route's client references, so the route's JS chunks are
8
+ * imported during prefetch rather than on click. The decoded payload is reused
9
+ * verbatim by navigation, so a prefetched click loads no new code. Two key
10
+ * scopes are in play:
11
+ * - Wildcard (default): built by `buildPrefetchKey(rangoState, target)` —
12
+ * shape `rangoState\0/target?...`. Shared across all source pages and
13
+ * invalidated automatically when Rango state bumps (deploy or
14
+ * server-action invalidation).
15
+ * - Source-scoped: built by `buildSourceKey(rangoState, sourceHref, target)`
16
+ * — shape `rangoState\0sourceHref\0/target?...`. Embeds the Rango state
17
+ * (so rotation invalidates source-scoped entries too) plus the source
18
+ * href (so each originating page gets its own slot). Populated when the
19
+ * server tags a response with `X-RSC-Prefetch-Scope: source` (intercept
20
+ * modals etc.), OR when a Link opts in with `prefetchKey=":source"` — in
21
+ * both cases so source-sensitive responses cannot bleed into navigations
22
+ * from other pages.
8
23
  *
9
24
  * Also tracks in-flight prefetch promises. Each promise resolves to the
10
- * navigation branch of a tee'd Response, allowing navigation to adopt a
11
- * still-downloading prefetch without reparsing or buffering the body.
25
+ * decoded prefetch entry (or null), letting navigation adopt a
26
+ * still-downloading prefetch without issuing a duplicate request. A
27
+ * single promise can be registered under multiple alias keys (see
28
+ * `setInflightPromiseWithAliases`) so same-source navigations adopt via
29
+ * their source key while cross-source ones fall through to the wildcard
30
+ * alias — with consume/clear atomically removing every alias.
12
31
  *
13
32
  * Replaces the previous browser HTTP cache approach which was unreliable
14
33
  * due to response draining race conditions and browser inconsistencies.
@@ -16,6 +35,31 @@
16
35
 
17
36
  import { abortAllPrefetches } from "./queue.js";
18
37
  import { invalidateRangoState } from "../rango-state.js";
38
+ import type { RscPayload } from "../types.js";
39
+
40
+ /**
41
+ * A prefetch that has been fetched AND eagerly decoded. Storing the decoded
42
+ * payload (not the raw Response) is what makes a prefetched navigation "warm":
43
+ * decoding the Flight stream during prefetch pulls the route's client chunks,
44
+ * so the click reuses ready elements and loads no new JS.
45
+ */
46
+ export interface DecodedPrefetch {
47
+ /** The eagerly-decoded RSC payload. Reused verbatim by navigation. */
48
+ payload: Promise<RscPayload>;
49
+ /**
50
+ * Resolves when the underlying RSC stream finishes draining. Navigation
51
+ * forwards this as its streamComplete so scroll/revalidation gating is
52
+ * unchanged from the fresh-fetch path.
53
+ */
54
+ streamComplete: Promise<void>;
55
+ /**
56
+ * Prefetch scope as tagged by the server via `X-RSC-Prefetch-Scope`.
57
+ * `"source"` means the response is source-page-sensitive and must not be
58
+ * reused by a navigation from a different page — navigation enforces this
59
+ * when it adopted an inflight entry through the wildcard key.
60
+ */
61
+ scope: "source" | "wildcard";
62
+ }
19
63
 
20
64
  // Default TTL: 5 minutes. Overridden by initPrefetchCache() with
21
65
  // the server-configured prefetchCacheTTL from router options.
@@ -41,7 +85,7 @@ export function isPrefetchCacheDisabled(): boolean {
41
85
  const MAX_PREFETCH_CACHE_SIZE = 50;
42
86
 
43
87
  interface PrefetchCacheEntry {
44
- response: Response;
88
+ entry: DecodedPrefetch;
45
89
  timestamp: number;
46
90
  }
47
91
 
@@ -49,11 +93,36 @@ const cache = new Map<string, PrefetchCacheEntry>();
49
93
  const inflight = new Set<string>();
50
94
 
51
95
  /**
52
- * In-flight promise map. When a prefetch fetch is in progress, its
53
- * Promise<Response | null> is stored here so navigation can await
54
- * it instead of starting a duplicate request.
96
+ * In-flight promise map. When a prefetch fetch+decode is in progress, its
97
+ * Promise<DecodedPrefetch | null> is stored here so navigation can await it
98
+ * instead of starting a duplicate request. Resolves to null when the prefetch
99
+ * failed, was aborted, or carried a control header (reload/redirect) that the
100
+ * navigation must re-fetch to act on.
101
+ */
102
+ const inflightPromises = new Map<string, Promise<DecodedPrefetch | null>>();
103
+
104
+ /**
105
+ * Alias map for in-flight promises registered under multiple keys (see
106
+ * dual inflight in prefetch/fetch.ts). Records each key's sibling set so
107
+ * that consuming or clearing any one key atomically removes every alias —
108
+ * guaranteeing a single consumer for the shared decode.
55
109
  */
56
- const inflightPromises = new Map<string, Promise<Response | null>>();
110
+ const inflightAliases = new Map<string, string[]>();
111
+
112
+ /**
113
+ * Keys whose in-flight prefetch promise was adopted by a navigation (via
114
+ * `consumeInflightPrefetch`). A `DecodedPrefetch` carries a single-use
115
+ * `metadata.handles` async generator; the adopter drains it. The same entry is
116
+ * also published to the `cache` map by `storePrefetch` when the fetch resolves
117
+ * — which runs AFTER adoption (adoption only succeeds while the fetch is still
118
+ * in flight, so the entry is not yet cached). Without this guard the adopted,
119
+ * now-drained entry would be left in the cache and served to a later navigation
120
+ * whose handle generator yields nothing, silently dropping that route's
121
+ * breadcrumbs. Recording the adopted keys lets `storePrefetch` skip publishing
122
+ * them, keeping the existing one-time-consumption contract (a consumed prefetch
123
+ * is gone; the next navigation re-fetches).
124
+ */
125
+ const adoptedKeys = new Set<string>();
57
126
 
58
127
  // Generation counter incremented on each clearPrefetchCache(). Fetches that
59
128
  // started before a clear carry a stale generation and must not store their
@@ -61,23 +130,57 @@ const inflightPromises = new Map<string, Promise<Response | null>>();
61
130
  let generation = 0;
62
131
 
63
132
  /**
64
- * Build a cache key for prefetched responses.
133
+ * Build a cache key by combining a scope prefix with the target URL.
134
+ *
135
+ * Low-level primitive — callers that want a specific scope should use
136
+ * one of:
137
+ * - Wildcard (source-agnostic): prefix is the Rango state value from
138
+ * `getRangoState()`. Shared across all source pages. Invalidated
139
+ * automatically when Rango state bumps (deploy or server-action).
140
+ * Key shape: `rangoState\0/target?...`.
141
+ * - Source-scoped: use `buildSourceKey()`. Key shape:
142
+ * `rangoState\0sourceHref\0/target?...` — embeds the Rango state so
143
+ * rotation invalidates source-scoped entries alongside wildcard ones,
144
+ * plus the source page href so the key is unique per originating page.
145
+ * Populated either when the server tags a response with
146
+ * `X-RSC-Prefetch-Scope: source` (intercept modals, etc.) or when a
147
+ * Link opts in via `prefetchKey=":source"`.
65
148
  *
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).
149
+ * The `_rsc_segments` query param that travels in the target URL means
150
+ * clients with different mounted segment trees naturally get different
151
+ * keys so segment-level diffs remain consistent across both scopes.
152
+ */
153
+ export function buildPrefetchKey(prefix: string, targetUrl: URL): string {
154
+ return prefix + "\0" + targetUrl.pathname + targetUrl.search;
155
+ }
156
+
157
+ /**
158
+ * Build a source-scoped cache key. Key shape:
159
+ * `rangoState\0sourceHref\0/target?...`.
69
160
  *
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.
161
+ * - `rangoState` is included so state rotation invalidates source-scoped
162
+ * entries alongside wildcard ones.
163
+ * - `sourceHref` makes the key unique per originating page.
73
164
  */
74
- export function buildPrefetchKey(
165
+ export function buildSourceKey(
166
+ rangoState: string,
75
167
  sourceHref: string,
76
168
  targetUrl: URL,
77
- prefetchKey?: string | ((from: string) => string),
78
169
  ): string {
79
- const source = prefetchKey != null ? "*" : sourceHref;
80
- return source + "\0" + targetUrl.pathname + targetUrl.search;
170
+ return buildPrefetchKey(rangoState + "\0" + sourceHref, targetUrl);
171
+ }
172
+
173
+ /**
174
+ * Walk an inflight key plus any sibling aliases registered via
175
+ * `setInflightPromiseWithAliases`, invoking `fn` for each.
176
+ */
177
+ function forEachAlias(key: string, fn: (k: string) => void): void {
178
+ const aliases = inflightAliases.get(key);
179
+ if (aliases) {
180
+ for (const k of aliases) fn(k);
181
+ } else {
182
+ fn(key);
183
+ }
81
184
  }
82
185
 
83
186
  /**
@@ -96,14 +199,14 @@ export function hasPrefetch(key: string): boolean {
96
199
  }
97
200
 
98
201
  /**
99
- * Consume a cached prefetch response. Returns null if not found or expired.
100
- * One-time consumption: the entry is deleted after retrieval.
202
+ * Consume a cached, eagerly-decoded prefetch. Returns null if not found or
203
+ * expired. One-time consumption: the entry is deleted after retrieval.
101
204
  * Returns null when caching is disabled (TTL <= 0).
102
205
  *
103
206
  * Does NOT check in-flight prefetches — use consumeInflightPrefetch()
104
- * for that (returns a Promise instead of a Response).
207
+ * for that (returns a Promise instead of a resolved entry).
105
208
  */
106
- export function consumePrefetch(key: string): Response | null {
209
+ export function consumePrefetch(key: string): DecodedPrefetch | null {
107
210
  if (cacheTTL <= 0) return null;
108
211
  const entry = cache.get(key);
109
212
  if (!entry) return null;
@@ -112,52 +215,72 @@ export function consumePrefetch(key: string): Response | null {
112
215
  return null;
113
216
  }
114
217
  cache.delete(key);
115
- return entry.response;
218
+ return entry.entry;
116
219
  }
117
220
 
118
221
  /**
119
222
  * Consume an in-flight prefetch promise. Returns null if no prefetch is
120
- * in-flight for this key. The returned Promise resolves to the buffered
121
- * Response (or null if the fetch failed/was aborted).
223
+ * in-flight for this key. The returned Promise resolves to the decoded
224
+ * prefetch entry (or null if the fetch failed/was aborted, or carried a
225
+ * control header the navigation must re-fetch to honor).
122
226
  *
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().
227
+ * One-time consumption: the promise entry is removed (along with any
228
+ * sibling aliases registered via `setInflightPromiseWithAliases`) so a
229
+ * second call on any alias returns null only one caller can adopt the
230
+ * shared Response stream. The `inflight` set entry is intentionally
231
+ * kept so that `hasPrefetch()` continues to return true while the
232
+ * underlying fetch is still downloading this prevents
233
+ * `prefetchDirect()` or other callers from starting a duplicate request
234
+ * during the handoff window. The inflight flag is cleaned up naturally
235
+ * by `clearPrefetchInflight()` in the fetch's `.finally()`.
130
236
  */
131
237
  export function consumeInflightPrefetch(
132
238
  key: string,
133
- ): Promise<Response | null> | null {
239
+ ): Promise<DecodedPrefetch | null> | null {
134
240
  const promise = inflightPromises.get(key);
135
241
  if (!promise) return null;
136
- // Remove the promise (one-time consumption) but keep the inflight flag.
137
- inflightPromises.delete(key);
242
+ // Remove the promise under every alias so a second consumer cannot
243
+ // adopt the same stream and race on the body, and mark every alias as
244
+ // adopted so the pending `storePrefetch` (which resolves later, after this
245
+ // adoption) does not leave the now-owned, single-use entry in the cache map.
246
+ // `inflightAliases` is intentionally preserved — `clearPrefetchInflight()` in
247
+ // the fetch's `.finally()` still needs it to clear every inflight flag and
248
+ // adopted marker; deleting here would strand the sibling's flag forever.
249
+ forEachAlias(key, (k) => {
250
+ inflightPromises.delete(k);
251
+ adoptedKeys.add(k);
252
+ });
138
253
  return promise;
139
254
  }
140
255
 
141
256
  /**
142
- * Store a prefetch response in the in-memory cache.
143
- * The response should be a clone() of the original so the caller can
144
- * still consume the body. The clone's body streams independently.
257
+ * Store an eagerly-decoded prefetch in the in-memory cache.
145
258
  *
146
259
  * Skips storage if the generation has changed since the fetch started
147
260
  * (a server action invalidated the cache mid-flight).
148
261
  */
149
262
  export function storePrefetch(
150
263
  key: string,
151
- response: Response,
264
+ entry: DecodedPrefetch,
152
265
  fetchGeneration: number,
153
266
  ): void {
154
267
  if (cacheTTL <= 0) return;
155
268
  if (fetchGeneration !== generation) return;
156
269
 
270
+ // If a navigation already adopted this prefetch's in-flight promise, it owns
271
+ // the single-use entry (and has drained its handle generator). Do NOT also
272
+ // publish it to the cache map, or a later navigation would be served the
273
+ // exhausted entry and lose that route's handles. Clear the marker (under all
274
+ // aliases) now that the decision is made.
275
+ if (adoptedKeys.has(key)) {
276
+ forEachAlias(key, (k) => adoptedKeys.delete(k));
277
+ return;
278
+ }
279
+
157
280
  // Evict expired entries
158
281
  const now = Date.now();
159
- for (const [k, entry] of cache) {
160
- if (now - entry.timestamp > cacheTTL) {
282
+ for (const [k, cached] of cache) {
283
+ if (now - cached.timestamp > cacheTTL) {
161
284
  cache.delete(k);
162
285
  }
163
286
  }
@@ -168,7 +291,7 @@ export function storePrefetch(
168
291
  if (oldest) cache.delete(oldest);
169
292
  }
170
293
 
171
- cache.set(key, { response, timestamp: now });
294
+ cache.set(key, { entry, timestamp: now });
172
295
  }
173
296
 
174
297
  /**
@@ -188,14 +311,36 @@ export function markPrefetchInflight(key: string): void {
188
311
  */
189
312
  export function setInflightPromise(
190
313
  key: string,
191
- promise: Promise<Response | null>,
314
+ promise: Promise<DecodedPrefetch | null>,
192
315
  ): void {
193
316
  inflightPromises.set(key, promise);
194
317
  }
195
318
 
319
+ /**
320
+ * Store the same in-flight Promise under multiple keys, recording them
321
+ * as sibling aliases. Consuming or clearing any one alias atomically
322
+ * removes every entry, guaranteeing the shared Response stream has a
323
+ * single consumer even when navigation looks up either key.
324
+ */
325
+ export function setInflightPromiseWithAliases(
326
+ keys: string[],
327
+ promise: Promise<DecodedPrefetch | null>,
328
+ ): void {
329
+ for (const k of keys) {
330
+ inflightPromises.set(k, promise);
331
+ inflightAliases.set(k, keys);
332
+ }
333
+ }
334
+
196
335
  export function clearPrefetchInflight(key: string): void {
197
- inflight.delete(key);
198
- inflightPromises.delete(key);
336
+ forEachAlias(key, (k) => {
337
+ inflight.delete(k);
338
+ inflightPromises.delete(k);
339
+ inflightAliases.delete(k);
340
+ // Clear any adopted marker too, so a fetch that failed before storePrefetch
341
+ // (the marker's normal consumer) does not strand it across the next prefetch.
342
+ adoptedKeys.delete(k);
343
+ });
199
344
  }
200
345
 
201
346
  /**
@@ -210,6 +355,8 @@ export function clearPrefetchCache(): void {
210
355
  generation++;
211
356
  inflight.clear();
212
357
  inflightPromises.clear();
358
+ inflightAliases.clear();
359
+ adoptedKeys.clear();
213
360
  cache.clear();
214
361
  abortAllPrefetches();
215
362
  invalidateRangoState();