@rangojs/router 0.0.0-experimental.b9cb8739 → 0.0.0-experimental.bd6e11bc

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 (285) hide show
  1. package/README.md +196 -43
  2. package/dist/bin/rango.js +277 -99
  3. package/dist/testing/vitest.js +48 -0
  4. package/dist/vite/index.js +2779 -1064
  5. package/dist/vite/index.js.bak +5448 -0
  6. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  7. package/package.json +57 -11
  8. package/skills/breadcrumbs/SKILL.md +3 -1
  9. package/skills/bundle-analysis/SKILL.md +159 -0
  10. package/skills/cache-guide/SKILL.md +243 -21
  11. package/skills/caching/SKILL.md +155 -6
  12. package/skills/composability/SKILL.md +27 -2
  13. package/skills/document-cache/SKILL.md +78 -55
  14. package/skills/handler-use/SKILL.md +364 -0
  15. package/skills/hooks/SKILL.md +229 -20
  16. package/skills/host-router/SKILL.md +45 -20
  17. package/skills/i18n/SKILL.md +276 -0
  18. package/skills/intercept/SKILL.md +46 -4
  19. package/skills/layout/SKILL.md +28 -7
  20. package/skills/links/SKILL.md +249 -17
  21. package/skills/loader/SKILL.md +273 -53
  22. package/skills/middleware/SKILL.md +49 -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 +197 -6
  28. package/skills/prerender/SKILL.md +123 -100
  29. package/skills/rango/SKILL.md +242 -22
  30. package/skills/react-compiler/SKILL.md +168 -0
  31. package/skills/response-routes/SKILL.md +66 -9
  32. package/skills/route/SKILL.md +88 -4
  33. package/skills/router-setup/SKILL.md +90 -5
  34. package/skills/server-actions/SKILL.md +751 -0
  35. package/skills/streams-and-websockets/SKILL.md +283 -0
  36. package/skills/testing/SKILL.md +716 -0
  37. package/skills/typesafety/SKILL.md +329 -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 +117 -0
  42. package/src/__internal.ts +1 -1
  43. package/src/browser/action-coordinator.ts +53 -36
  44. package/src/browser/app-shell.ts +52 -0
  45. package/src/browser/app-version.ts +14 -0
  46. package/src/browser/event-controller.ts +91 -70
  47. package/src/browser/history-state.ts +21 -0
  48. package/src/browser/index.ts +3 -3
  49. package/src/browser/navigation-bridge.ts +102 -16
  50. package/src/browser/navigation-client.ts +164 -59
  51. package/src/browser/navigation-store.ts +75 -17
  52. package/src/browser/navigation-transaction.ts +21 -37
  53. package/src/browser/partial-update.ts +139 -38
  54. package/src/browser/prefetch/cache.ts +175 -15
  55. package/src/browser/prefetch/fetch.ts +180 -33
  56. package/src/browser/prefetch/queue.ts +123 -20
  57. package/src/browser/prefetch/resource-ready.ts +77 -0
  58. package/src/browser/rango-state.ts +53 -13
  59. package/src/browser/react/Link.tsx +81 -9
  60. package/src/browser/react/NavigationProvider.tsx +110 -33
  61. package/src/browser/react/context.ts +7 -2
  62. package/src/browser/react/filter-segment-order.ts +51 -7
  63. package/src/browser/react/index.ts +3 -0
  64. package/src/browser/react/location-state-shared.ts +175 -4
  65. package/src/browser/react/location-state.ts +39 -13
  66. package/src/browser/react/use-handle.ts +23 -64
  67. package/src/browser/react/use-navigation.ts +22 -2
  68. package/src/browser/react/use-params.ts +20 -8
  69. package/src/browser/react/use-reverse.ts +106 -0
  70. package/src/browser/react/use-router.ts +43 -10
  71. package/src/browser/react/use-segments.ts +11 -8
  72. package/src/browser/response-adapter.ts +25 -0
  73. package/src/browser/rsc-router.tsx +191 -74
  74. package/src/browser/scroll-restoration.ts +41 -14
  75. package/src/browser/segment-reconciler.ts +36 -9
  76. package/src/browser/segment-structure-assert.ts +2 -2
  77. package/src/browser/server-action-bridge.ts +31 -36
  78. package/src/browser/types.ts +57 -5
  79. package/src/build/collect-fallback-refs.ts +107 -0
  80. package/src/build/generate-manifest.ts +65 -40
  81. package/src/build/generate-route-types.ts +5 -0
  82. package/src/build/index.ts +2 -0
  83. package/src/build/route-trie.ts +52 -25
  84. package/src/build/route-types/codegen.ts +4 -4
  85. package/src/build/route-types/include-resolution.ts +9 -2
  86. package/src/build/route-types/per-module-writer.ts +7 -4
  87. package/src/build/route-types/router-processing.ts +278 -88
  88. package/src/build/route-types/scan-filter.ts +9 -2
  89. package/src/build/route-types/source-scan.ts +118 -0
  90. package/src/build/runtime-discovery.ts +9 -20
  91. package/src/cache/cache-runtime.ts +15 -11
  92. package/src/cache/cache-scope.ts +76 -49
  93. package/src/cache/cf/cf-cache-store.ts +501 -18
  94. package/src/cache/cf/index.ts +5 -1
  95. package/src/cache/document-cache.ts +17 -7
  96. package/src/cache/index.ts +1 -0
  97. package/src/cache/taint.ts +55 -0
  98. package/src/client.rsc.tsx +3 -0
  99. package/src/client.tsx +94 -238
  100. package/src/context-var.ts +72 -2
  101. package/src/debug.ts +2 -2
  102. package/src/decode-loader-results.ts +36 -0
  103. package/src/errors.ts +30 -1
  104. package/src/handle.ts +65 -12
  105. package/src/host/index.ts +2 -2
  106. package/src/host/router.ts +129 -57
  107. package/src/host/types.ts +31 -2
  108. package/src/host/utils.ts +1 -1
  109. package/src/href-client.ts +140 -20
  110. package/src/index.rsc.ts +12 -5
  111. package/src/index.ts +61 -11
  112. package/src/loader-store.ts +500 -0
  113. package/src/loader.rsc.ts +2 -5
  114. package/src/loader.ts +3 -10
  115. package/src/missing-id-error.ts +68 -0
  116. package/src/outlet-context.ts +1 -1
  117. package/src/prerender/store.ts +5 -4
  118. package/src/prerender.ts +141 -80
  119. package/src/response-utils.ts +37 -0
  120. package/src/reverse.ts +65 -15
  121. package/src/route-content-wrapper.tsx +6 -28
  122. package/src/route-definition/dsl-helpers.ts +435 -260
  123. package/src/route-definition/helper-factories.ts +29 -139
  124. package/src/route-definition/helpers-types.ts +110 -34
  125. package/src/route-definition/index.ts +3 -0
  126. package/src/route-definition/redirect.ts +11 -3
  127. package/src/route-definition/resolve-handler-use.ts +155 -0
  128. package/src/route-definition/use-item-types.ts +32 -0
  129. package/src/route-map-builder.ts +7 -1
  130. package/src/route-types.ts +37 -41
  131. package/src/router/basename.ts +14 -0
  132. package/src/router/content-negotiation.ts +113 -1
  133. package/src/router/error-handling.ts +1 -1
  134. package/src/router/find-match.ts +4 -2
  135. package/src/router/handler-context.ts +77 -38
  136. package/src/router/intercept-resolution.ts +15 -22
  137. package/src/router/lazy-includes.ts +12 -9
  138. package/src/router/loader-resolution.ts +174 -22
  139. package/src/router/logging.ts +5 -2
  140. package/src/router/manifest.ts +31 -16
  141. package/src/router/match-api.ts +128 -192
  142. package/src/router/match-handlers.ts +63 -20
  143. package/src/router/match-middleware/background-revalidation.ts +30 -2
  144. package/src/router/match-middleware/cache-lookup.ts +136 -106
  145. package/src/router/match-middleware/cache-store.ts +54 -10
  146. package/src/router/match-middleware/intercept-resolution.ts +9 -7
  147. package/src/router/match-middleware/segment-resolution.ts +61 -5
  148. package/src/router/match-result.ts +125 -10
  149. package/src/router/metrics.ts +7 -2
  150. package/src/router/middleware-types.ts +21 -34
  151. package/src/router/middleware.ts +103 -90
  152. package/src/router/navigation-snapshot.ts +182 -0
  153. package/src/router/pattern-matching.ts +101 -17
  154. package/src/router/prerender-match.ts +110 -10
  155. package/src/router/preview-match.ts +32 -102
  156. package/src/router/request-classification.ts +286 -0
  157. package/src/router/revalidation.ts +58 -2
  158. package/src/router/route-snapshot.ts +245 -0
  159. package/src/router/router-context.ts +6 -1
  160. package/src/router/router-interfaces.ts +77 -28
  161. package/src/router/router-options.ts +76 -11
  162. package/src/router/router-registry.ts +2 -5
  163. package/src/router/segment-resolution/fresh.ts +223 -24
  164. package/src/router/segment-resolution/helpers.ts +29 -24
  165. package/src/router/segment-resolution/loader-cache.ts +1 -0
  166. package/src/router/segment-resolution/revalidation.ts +466 -285
  167. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  168. package/src/router/segment-wrappers.ts +2 -0
  169. package/src/router/substitute-pattern-params.ts +56 -0
  170. package/src/router/telemetry.ts +99 -0
  171. package/src/router/trie-matching.ts +18 -13
  172. package/src/router/types.ts +9 -0
  173. package/src/router/url-params.ts +49 -0
  174. package/src/router.ts +91 -23
  175. package/src/rsc/handler-context.ts +2 -2
  176. package/src/rsc/handler.ts +440 -381
  177. package/src/rsc/helpers.ts +91 -43
  178. package/src/rsc/index.ts +1 -1
  179. package/src/rsc/loader-fetch.ts +23 -3
  180. package/src/rsc/manifest-init.ts +5 -1
  181. package/src/rsc/origin-guard.ts +28 -10
  182. package/src/rsc/progressive-enhancement.ts +18 -2
  183. package/src/rsc/response-route-handler.ts +46 -53
  184. package/src/rsc/rsc-rendering.ts +41 -48
  185. package/src/rsc/runtime-warnings.ts +9 -10
  186. package/src/rsc/server-action.ts +25 -37
  187. package/src/rsc/ssr-setup.ts +18 -2
  188. package/src/rsc/types.ts +17 -3
  189. package/src/search-params.ts +4 -4
  190. package/src/segment-content-promise.ts +67 -0
  191. package/src/segment-loader-promise.ts +122 -0
  192. package/src/segment-system.tsx +219 -67
  193. package/src/serialize.ts +243 -0
  194. package/src/server/context.ts +277 -61
  195. package/src/server/cookie-store.ts +28 -4
  196. package/src/server/handle-store.ts +19 -0
  197. package/src/server/loader-registry.ts +9 -8
  198. package/src/server/request-context.ts +204 -60
  199. package/src/ssr/index.tsx +9 -1
  200. package/src/static-handler.ts +19 -7
  201. package/src/testing/cache-status.ts +166 -0
  202. package/src/testing/collect-handle.ts +63 -0
  203. package/src/testing/dispatch.ts +440 -0
  204. package/src/testing/dom.entry.ts +22 -0
  205. package/src/testing/e2e/fixture.ts +154 -0
  206. package/src/testing/e2e/index.ts +149 -0
  207. package/src/testing/e2e/matchers.ts +51 -0
  208. package/src/testing/e2e/page-helpers.ts +272 -0
  209. package/src/testing/e2e/parity.ts +306 -0
  210. package/src/testing/e2e/server.ts +183 -0
  211. package/src/testing/flight-matchers.ts +104 -0
  212. package/src/testing/flight-runtime.d.ts +21 -0
  213. package/src/testing/flight.entry.ts +22 -0
  214. package/src/testing/flight.ts +182 -0
  215. package/src/testing/generated-routes.ts +223 -0
  216. package/src/testing/index.ts +106 -0
  217. package/src/testing/internal/context.ts +255 -0
  218. package/src/testing/render-route.tsx +565 -0
  219. package/src/testing/run-loader.ts +296 -0
  220. package/src/testing/run-middleware.ts +179 -0
  221. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  222. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  223. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  224. package/src/testing/vitest-stubs/version.ts +5 -0
  225. package/src/testing/vitest.ts +183 -0
  226. package/src/types/cache-types.ts +4 -4
  227. package/src/types/global-namespace.ts +39 -26
  228. package/src/types/handler-context.ts +194 -72
  229. package/src/types/index.ts +1 -0
  230. package/src/types/loader-types.ts +41 -15
  231. package/src/types/request-scope.ts +126 -0
  232. package/src/types/route-entry.ts +19 -1
  233. package/src/types/segments.ts +37 -1
  234. package/src/urls/include-helper.ts +34 -67
  235. package/src/urls/index.ts +0 -3
  236. package/src/urls/path-helper-types.ts +50 -9
  237. package/src/urls/path-helper.ts +63 -63
  238. package/src/urls/pattern-types.ts +48 -19
  239. package/src/urls/response-types.ts +25 -22
  240. package/src/urls/type-extraction.ts +26 -116
  241. package/src/urls/urls-function.ts +1 -5
  242. package/src/use-loader.tsx +487 -44
  243. package/src/vite/debug.ts +185 -0
  244. package/src/vite/discovery/bundle-postprocess.ts +34 -37
  245. package/src/vite/discovery/discover-routers.ts +105 -51
  246. package/src/vite/discovery/discovery-errors.ts +194 -0
  247. package/src/vite/discovery/gate-state.ts +171 -0
  248. package/src/vite/discovery/prerender-collection.ts +188 -93
  249. package/src/vite/discovery/route-types-writer.ts +40 -84
  250. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  251. package/src/vite/discovery/state.ts +46 -6
  252. package/src/vite/discovery/virtual-module-codegen.ts +13 -23
  253. package/src/vite/index.ts +6 -0
  254. package/src/vite/plugin-types.ts +111 -72
  255. package/src/vite/plugins/cjs-to-esm.ts +8 -7
  256. package/src/vite/plugins/client-ref-dedup.ts +16 -0
  257. package/src/vite/plugins/client-ref-hashing.ts +28 -5
  258. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  259. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  260. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  261. package/src/vite/plugins/expose-action-id.ts +55 -33
  262. package/src/vite/plugins/expose-id-utils.ts +24 -8
  263. package/src/vite/plugins/expose-ids/export-analysis.ts +100 -20
  264. package/src/vite/plugins/expose-ids/handler-transform.ts +12 -35
  265. package/src/vite/plugins/expose-ids/loader-transform.ts +3 -5
  266. package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
  267. package/src/vite/plugins/expose-internal-ids.ts +544 -317
  268. package/src/vite/plugins/performance-tracks.ts +92 -0
  269. package/src/vite/plugins/refresh-cmd.ts +88 -26
  270. package/src/vite/plugins/use-cache-transform.ts +65 -50
  271. package/src/vite/plugins/version-injector.ts +39 -23
  272. package/src/vite/plugins/version-plugin.ts +72 -3
  273. package/src/vite/plugins/virtual-entries.ts +2 -2
  274. package/src/vite/rango.ts +265 -226
  275. package/src/vite/router-discovery.ts +920 -137
  276. package/src/vite/utils/ast-handler-extract.ts +15 -15
  277. package/src/vite/utils/banner.ts +4 -4
  278. package/src/vite/utils/bundle-analysis.ts +4 -2
  279. package/src/vite/utils/client-chunks.ts +190 -0
  280. package/src/vite/utils/forward-user-plugins.ts +193 -0
  281. package/src/vite/utils/manifest-utils.ts +21 -5
  282. package/src/vite/utils/package-resolution.ts +41 -1
  283. package/src/vite/utils/prerender-utils.ts +38 -5
  284. package/src/vite/utils/shared-utils.ts +109 -27
  285. package/src/browser/action-response-classifier.ts +0 -99
@@ -14,11 +14,39 @@ 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";
21
24
  import { validateRedirectOrigin } from "./validate-redirect-origin.js";
25
+ import type { NavigationUpdate } from "./types.js";
26
+
27
+ /** Build a scroll payload from the commit's scroll option */
28
+ function toScrollPayload(
29
+ scroll: boolean | undefined,
30
+ ): NonNullable<NavigationUpdate["scroll"]> {
31
+ return { enabled: scroll !== false ? scroll : false };
32
+ }
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
+ }
22
50
 
23
51
  /**
24
52
  * Configuration for creating a partial updater
@@ -31,8 +59,15 @@ export interface PartialUpdateConfig {
31
59
  segments: ResolvedSegment[],
32
60
  options?: RenderSegmentsOptions,
33
61
  ) => Promise<ReactNode> | ReactNode;
34
- /** RSC version received from server (from initial payload metadata) */
35
- version?: string;
62
+ /** RSC version getter returns the current version (may change after HMR) */
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;
36
71
  }
37
72
 
38
73
  /**
@@ -68,7 +103,7 @@ export type UpdateMode =
68
103
  /** Source URL for intercept restore (popstate cache miss) */
69
104
  interceptSourceUrl?: string;
70
105
  }
71
- | { type: "leave-intercept" }
106
+ | { type: "leave-intercept"; interceptSourceUrl?: string }
72
107
  | { type: "stale-revalidation"; interceptSourceUrl?: string }
73
108
  | { type: "action"; interceptSourceUrl?: string };
74
109
 
@@ -96,7 +131,14 @@ export type PartialUpdater = (
96
131
  export function createPartialUpdater(
97
132
  config: PartialUpdateConfig,
98
133
  ): PartialUpdater {
99
- const { store, client, onUpdate, renderSegments, version } = config;
134
+ const {
135
+ store,
136
+ client,
137
+ onUpdate,
138
+ renderSegments,
139
+ getVersion = () => undefined,
140
+ applyAppShell,
141
+ } = config;
100
142
 
101
143
  /**
102
144
  * Get current page's cached segments as an array
@@ -127,13 +169,7 @@ export function createPartialUpdater(
127
169
  // Capture history key at start for stale revalidation consistency check
128
170
  const historyKeyAtStart = store.getHistoryKey();
129
171
 
130
- // Derive interceptSourceUrl from modes that carry it
131
- const interceptSourceUrl =
132
- mode.type === "stale-revalidation" ||
133
- mode.type === "action" ||
134
- mode.type === "navigate"
135
- ? mode.interceptSourceUrl
136
- : undefined;
172
+ const interceptSourceUrl = mode.interceptSourceUrl;
137
173
 
138
174
  // When leaving intercept, filter out intercept-specific segments
139
175
  let segments: string[];
@@ -153,9 +189,16 @@ export function createPartialUpdater(
153
189
  segments = segmentIds ?? segmentState.currentSegmentIds;
154
190
  }
155
191
 
156
- // 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.
157
198
  const previousUrl =
158
- interceptSourceUrl || tx.currentUrl || segmentState.currentUrl;
199
+ mode.type === "leave-intercept"
200
+ ? segmentState.currentUrl || tx.currentUrl
201
+ : interceptSourceUrl || tx.currentUrl || segmentState.currentUrl;
159
202
 
160
203
  debugLog(`\n[Browser] >>> NAVIGATION`);
161
204
  debugLog(`[Browser] From: ${previousUrl}`);
@@ -169,11 +212,14 @@ export function createPartialUpdater(
169
212
  // When navigating with targetCacheSegments, use those for consistency.
170
213
  // Otherwise fall back to current page's segments (for same-route revalidation).
171
214
  const targetCache =
172
- mode.type === "navigate" ? mode.targetCacheSegments : undefined;
173
- const cachedSegs =
174
- targetCache && targetCache.length > 0
175
- ? targetCache
176
- : getCurrentCachedSegments();
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";
220
+ debugLog(
221
+ `[Browser] cachedSegs source: ${cachedSegsSource} (${cachedSegs.length} segments: ${cachedSegs.map((s) => s.id).join(", ")})`,
222
+ );
177
223
 
178
224
  // Fetch partial payload (no abort signal - RSC doesn't support it well)
179
225
  let fetchResult: Awaited<ReturnType<NavigationClient["fetchPartial"]>>;
@@ -185,7 +231,8 @@ export function createPartialUpdater(
185
231
  // (action redirect sends empty segments for a fresh render).
186
232
  staleRevalidation:
187
233
  mode.type === "stale-revalidation" || segments.length === 0,
188
- version,
234
+ version: getVersion(),
235
+ routerId: store.getRouterId?.(),
189
236
  });
190
237
  // Mark navigation as streaming (response received, now parsing RSC).
191
238
  // Called after fetchPartial so pendingUrl stays set during the network wait,
@@ -198,6 +245,32 @@ export function createPartialUpdater(
198
245
  streamingToken.end();
199
246
  });
200
247
 
248
+ // Detect app switch: if routerId changed, the navigation crossed into
249
+ // a different router (e.g., via host router path mount). Downgrade
250
+ // partial to full so the entire tree is replaced without reconciliation
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.
257
+ if (payload.metadata?.routerId) {
258
+ const prevRouterId = store.getRouterId?.();
259
+ if (prevRouterId && prevRouterId !== payload.metadata.routerId) {
260
+ debugLog(
261
+ `[Browser] App switch detected (${prevRouterId} → ${payload.metadata.routerId}), forcing full update`,
262
+ );
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
+ });
270
+ }
271
+ store.setRouterId?.(payload.metadata.routerId);
272
+ }
273
+
201
274
  // Handle server-side redirect with state
202
275
  if (payload.metadata?.redirect) {
203
276
  if (signal?.aborted) {
@@ -237,7 +310,7 @@ export function createPartialUpdater(
237
310
  .filter(Boolean) as ResolvedSegment[];
238
311
 
239
312
  // When navigating with cached segments to a different route, render them.
240
- if (mode.type === "navigate" && targetCache && targetCache.length > 0) {
313
+ if (mode.type === "navigate" && targetCache) {
241
314
  debugLog(
242
315
  "[Browser] No diff but navigating with cached segments - rendering target route",
243
316
  );
@@ -246,7 +319,21 @@ export function createPartialUpdater(
246
319
  forceAwait: true,
247
320
  });
248
321
 
249
- tx.commit(matchedIds, existingSegments);
322
+ const { scroll: commitScroll } = tx.commit(
323
+ matchedIds,
324
+ existingSegments,
325
+ );
326
+
327
+ // tx.commit() cached the source page's handleData because
328
+ // eventController hasn't been updated yet. Overwrite with the
329
+ // correct cached handleData to prevent cache corruption on
330
+ // subsequent navigations to this same URL.
331
+ if (mode.targetCacheHandleData) {
332
+ store.updateCacheHandleData(
333
+ store.getHistoryKey(),
334
+ mode.targetCacheHandleData,
335
+ );
336
+ }
250
337
 
251
338
  // Include cachedHandleData in metadata so NavigationProvider can restore
252
339
  // breadcrumbs and other handle data from cache.
@@ -260,12 +347,10 @@ export function createPartialUpdater(
260
347
  ...metadataWithoutHandles,
261
348
  cachedHandleData: mode.targetCacheHandleData,
262
349
  },
350
+ scroll: toScrollPayload(commitScroll),
263
351
  };
264
352
 
265
- const cachedHasTransition = existingSegments.some(
266
- (s) => s.transition,
267
- );
268
- if (cachedHasTransition) {
353
+ if (shouldStartViewTransition(existingSegments)) {
269
354
  startTransition(() => {
270
355
  if (addTransitionType) {
271
356
  addTransitionType("navigation");
@@ -290,11 +375,15 @@ export function createPartialUpdater(
290
375
  forceAwait: true,
291
376
  });
292
377
 
293
- tx.commit(matchedIds, existingSegments);
378
+ const { scroll: leaveScroll } = tx.commit(
379
+ matchedIds,
380
+ existingSegments,
381
+ );
294
382
 
295
383
  onUpdate({
296
384
  root: newTree,
297
385
  metadata: payload.metadata,
386
+ scroll: toScrollPayload(leaveScroll),
298
387
  });
299
388
 
300
389
  debugLog("[Browser] Navigation complete (left intercept)");
@@ -426,7 +515,11 @@ export function createPartialUpdater(
426
515
  : serverLocationState
427
516
  ? { serverState: serverLocationState }
428
517
  : undefined;
429
- tx.commit(allSegmentIds, reconciled.segments, overrides);
518
+ const { scroll: navScroll } = tx.commit(
519
+ allSegmentIds,
520
+ reconciled.segments,
521
+ overrides,
522
+ );
430
523
 
431
524
  // For stale revalidation: verify history key hasn't changed before updating UI
432
525
  if (mode.type === "stale-revalidation") {
@@ -441,8 +534,10 @@ export function createPartialUpdater(
441
534
 
442
535
  debugLog("[partial-update] updating document");
443
536
 
444
- // Emit update to trigger React render
445
- const hasTransition = reconciled.mainSegments.some((s) => s.transition);
537
+ // Emit update to trigger React render.
538
+ // Scroll info is included so NavigationProvider applies it after React commits.
539
+ const hasTransition = shouldStartViewTransition(reconciled.segments);
540
+ const scrollPayload = toScrollPayload(navScroll);
446
541
 
447
542
  if (mode.type === "action" || mode.type === "stale-revalidation") {
448
543
  startTransition(() => {
@@ -452,6 +547,7 @@ export function createPartialUpdater(
452
547
  onUpdate({
453
548
  root: newTree,
454
549
  metadata: payload.metadata!,
550
+ scroll: scrollPayload,
455
551
  });
456
552
  });
457
553
  } else if (hasTransition) {
@@ -462,12 +558,14 @@ export function createPartialUpdater(
462
558
  onUpdate({
463
559
  root: newTree,
464
560
  metadata: payload.metadata!,
561
+ scroll: scrollPayload,
465
562
  });
466
563
  });
467
564
  } else {
468
565
  onUpdate({
469
566
  root: newTree,
470
567
  metadata: payload.metadata!,
568
+ scroll: scrollPayload,
471
569
  });
472
570
  }
473
571
 
@@ -494,15 +592,14 @@ export function createPartialUpdater(
494
592
  }
495
593
 
496
594
  const fullUpdateServerState = payload.metadata?.locationState;
497
- if (fullUpdateServerState) {
498
- tx.commit(segmentIds, segments, { serverState: fullUpdateServerState });
499
- } else {
500
- tx.commit(segmentIds, segments);
501
- }
595
+ const { scroll: fullScroll } = fullUpdateServerState
596
+ ? tx.commit(segmentIds, segments, {
597
+ serverState: fullUpdateServerState,
598
+ })
599
+ : tx.commit(segmentIds, segments);
502
600
 
503
- const fullHasTransition = segments.some(
504
- (s: ResolvedSegment) => s.transition,
505
- );
601
+ const fullHasTransition = shouldStartViewTransition(segments);
602
+ const fullScrollPayload = toScrollPayload(fullScroll);
506
603
 
507
604
  if (mode.type === "stale-revalidation") {
508
605
  await rawStreamComplete;
@@ -513,6 +610,7 @@ export function createPartialUpdater(
513
610
  onUpdate({
514
611
  root: newTree,
515
612
  metadata: payload.metadata!,
613
+ scroll: fullScrollPayload,
516
614
  });
517
615
  });
518
616
  } else if (mode.type === "action") {
@@ -523,6 +621,7 @@ export function createPartialUpdater(
523
621
  onUpdate({
524
622
  root: newTree,
525
623
  metadata: payload.metadata!,
624
+ scroll: fullScrollPayload,
526
625
  });
527
626
  });
528
627
  } else if (fullHasTransition) {
@@ -533,12 +632,14 @@ export function createPartialUpdater(
533
632
  onUpdate({
534
633
  root: newTree,
535
634
  metadata: payload.metadata!,
635
+ scroll: fullScrollPayload,
536
636
  });
537
637
  });
538
638
  } else {
539
639
  onUpdate({
540
640
  root: newTree,
541
641
  metadata: payload.metadata!,
642
+ scroll: fullScrollPayload,
542
643
  });
543
644
  }
544
645
 
@@ -1,16 +1,34 @@
1
1
  /**
2
2
  * Prefetch Cache
3
3
  *
4
- * In-memory cache storing prefetch 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 prefetched Response objects for instant cache hits
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.
18
+ *
19
+ * Also tracks in-flight prefetch promises. Each promise resolves to the
20
+ * navigation branch of a tee'd Response, allowing navigation to adopt a
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.
8
26
  *
9
27
  * Replaces the previous browser HTTP cache approach which was unreliable
10
28
  * due to response draining race conditions and browser inconsistencies.
11
29
  */
12
30
 
13
- import { cancelAllPrefetches } from "./queue.js";
31
+ import { abortAllPrefetches } from "./queue.js";
14
32
  import { invalidateRangoState } from "../rango-state.js";
15
33
 
16
34
  // Default TTL: 5 minutes. Overridden by initPrefetchCache() with
@@ -44,19 +62,78 @@ interface PrefetchCacheEntry {
44
62
  const cache = new Map<string, PrefetchCacheEntry>();
45
63
  const inflight = new Set<string>();
46
64
 
65
+ /**
66
+ * In-flight promise map. When a prefetch fetch is in progress, its
67
+ * Promise<Response | null> is stored here so navigation can await
68
+ * it instead of starting a duplicate request.
69
+ */
70
+ const inflightPromises = new Map<string, Promise<Response | null>>();
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
+
47
80
  // Generation counter incremented on each clearPrefetchCache(). Fetches that
48
81
  // started before a clear carry a stale generation and must not store their
49
82
  // response (the data may be stale due to a server action invalidation).
50
83
  let generation = 0;
51
84
 
52
85
  /**
53
- * Build a source-dependent cache key.
54
- * Includes the source page href so the same target prefetched from
55
- * different pages gets separate entries the server response varies
56
- * based on the source page context (diff-based rendering).
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"`.
101
+ *
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?...`.
113
+ *
114
+ * - `rangoState` is included so state rotation invalidates source-scoped
115
+ * entries alongside wildcard ones.
116
+ * - `sourceHref` makes the key unique per originating page.
117
+ */
118
+ export function buildSourceKey(
119
+ rangoState: string,
120
+ sourceHref: string,
121
+ targetUrl: URL,
122
+ ): string {
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.
57
129
  */
58
- export function buildPrefetchKey(sourceHref: string, targetUrl: URL): string {
59
- return sourceHref + "\0" + targetUrl.pathname + targetUrl.search;
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
+ }
60
137
  }
61
138
 
62
139
  /**
@@ -78,6 +155,9 @@ export function hasPrefetch(key: string): boolean {
78
155
  * Consume a cached prefetch response. Returns null if not found or expired.
79
156
  * One-time consumption: the entry is deleted after retrieval.
80
157
  * Returns null when caching is disabled (TTL <= 0).
158
+ *
159
+ * Does NOT check in-flight prefetches — use consumeInflightPrefetch()
160
+ * for that (returns a Promise instead of a Response).
81
161
  */
82
162
  export function consumePrefetch(key: string): Response | null {
83
163
  if (cacheTTL <= 0) return null;
@@ -91,10 +171,39 @@ export function consumePrefetch(key: string): Response | null {
91
171
  return entry.response;
92
172
  }
93
173
 
174
+ /**
175
+ * Consume an in-flight prefetch promise. Returns null if no prefetch is
176
+ * in-flight for this key. The returned Promise resolves to the buffered
177
+ * Response (or null if the fetch failed/was aborted).
178
+ *
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()`.
188
+ */
189
+ export function consumeInflightPrefetch(
190
+ key: string,
191
+ ): Promise<Response | null> | null {
192
+ const promise = inflightPromises.get(key);
193
+ if (!promise) return null;
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));
200
+ return promise;
201
+ }
202
+
94
203
  /**
95
204
  * Store a prefetch response in the in-memory cache.
96
- * The response body must be fully buffered (e.g. via arrayBuffer()) before
97
- * storing, so the cached Response is self-contained and network-independent.
205
+ * The response should be a clone() of the original so the caller can
206
+ * still consume the body. The clone's body streams independently.
98
207
  *
99
208
  * Skips storage if the generation has changed since the fetch started
100
209
  * (a server action invalidated the cache mid-flight).
@@ -136,19 +245,70 @@ export function markPrefetchInflight(key: string): void {
136
245
  inflight.add(key);
137
246
  }
138
247
 
248
+ /**
249
+ * Store the in-flight Promise for a prefetch so navigation can reuse it.
250
+ */
251
+ export function setInflightPromise(
252
+ key: string,
253
+ promise: Promise<Response | null>,
254
+ ): void {
255
+ inflightPromises.set(key, promise);
256
+ }
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
+
139
274
  export function clearPrefetchInflight(key: string): void {
140
- inflight.delete(key);
275
+ forEachAlias(key, (k) => {
276
+ inflight.delete(k);
277
+ inflightPromises.delete(k);
278
+ inflightAliases.delete(k);
279
+ });
141
280
  }
142
281
 
143
282
  /**
144
283
  * Invalidate all prefetch state. Called when server actions mutate data.
145
284
  * Clears the in-memory cache, cancels in-flight prefetches, and rotates
146
285
  * the Rango state key so CDN-cached responses are also invalidated.
286
+ *
287
+ * Uses abortAllPrefetches (hard cancel) because in-flight responses
288
+ * may contain stale data after a mutation.
147
289
  */
148
290
  export function clearPrefetchCache(): void {
149
291
  generation++;
150
292
  inflight.clear();
293
+ inflightPromises.clear();
294
+ inflightAliases.clear();
151
295
  cache.clear();
152
- cancelAllPrefetches();
296
+ abortAllPrefetches();
153
297
  invalidateRangoState();
154
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
+ }