@rangojs/router 0.0.0-experimental.7dc955ec → 0.0.0-experimental.80

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 (124) hide show
  1. package/README.md +76 -18
  2. package/dist/bin/rango.js +130 -47
  3. package/dist/vite/index.js +700 -236
  4. package/package.json +3 -3
  5. package/skills/handler-use/SKILL.md +362 -0
  6. package/skills/intercept/SKILL.md +20 -0
  7. package/skills/layout/SKILL.md +22 -0
  8. package/skills/links/SKILL.md +3 -1
  9. package/skills/loader/SKILL.md +53 -43
  10. package/skills/middleware/SKILL.md +34 -3
  11. package/skills/migrate-nextjs/SKILL.md +560 -0
  12. package/skills/migrate-react-router/SKILL.md +764 -0
  13. package/skills/parallel/SKILL.md +59 -0
  14. package/skills/prerender/SKILL.md +110 -68
  15. package/skills/rango/SKILL.md +24 -22
  16. package/skills/route/SKILL.md +24 -0
  17. package/skills/router-setup/SKILL.md +87 -2
  18. package/src/__internal.ts +1 -1
  19. package/src/browser/app-version.ts +14 -0
  20. package/src/browser/navigation-bridge.ts +37 -5
  21. package/src/browser/navigation-client.ts +98 -46
  22. package/src/browser/navigation-store.ts +43 -8
  23. package/src/browser/partial-update.ts +41 -7
  24. package/src/browser/prefetch/cache.ts +16 -6
  25. package/src/browser/prefetch/fetch.ts +68 -6
  26. package/src/browser/prefetch/queue.ts +61 -29
  27. package/src/browser/prefetch/resource-ready.ts +77 -0
  28. package/src/browser/react/Link.tsx +67 -8
  29. package/src/browser/react/NavigationProvider.tsx +13 -4
  30. package/src/browser/react/context.ts +7 -2
  31. package/src/browser/react/use-handle.ts +9 -58
  32. package/src/browser/react/use-navigation.ts +22 -2
  33. package/src/browser/react/use-router.ts +21 -8
  34. package/src/browser/rsc-router.tsx +26 -3
  35. package/src/browser/scroll-restoration.ts +10 -8
  36. package/src/browser/segment-reconciler.ts +36 -14
  37. package/src/browser/server-action-bridge.ts +8 -6
  38. package/src/browser/types.ts +27 -5
  39. package/src/build/generate-manifest.ts +6 -6
  40. package/src/build/generate-route-types.ts +3 -0
  41. package/src/build/route-trie.ts +50 -24
  42. package/src/build/route-types/include-resolution.ts +8 -1
  43. package/src/build/route-types/router-processing.ts +211 -72
  44. package/src/build/route-types/scan-filter.ts +8 -1
  45. package/src/client.tsx +84 -230
  46. package/src/handle.ts +40 -0
  47. package/src/index.rsc.ts +3 -1
  48. package/src/index.ts +46 -6
  49. package/src/prerender/store.ts +5 -4
  50. package/src/prerender.ts +138 -77
  51. package/src/reverse.ts +25 -1
  52. package/src/route-definition/dsl-helpers.ts +194 -32
  53. package/src/route-definition/helpers-types.ts +67 -19
  54. package/src/route-definition/index.ts +3 -0
  55. package/src/route-definition/redirect.ts +9 -1
  56. package/src/route-definition/resolve-handler-use.ts +149 -0
  57. package/src/route-types.ts +18 -0
  58. package/src/router/content-negotiation.ts +100 -1
  59. package/src/router/handler-context.ts +51 -15
  60. package/src/router/intercept-resolution.ts +9 -4
  61. package/src/router/lazy-includes.ts +5 -5
  62. package/src/router/loader-resolution.ts +156 -21
  63. package/src/router/manifest.ts +22 -13
  64. package/src/router/match-api.ts +124 -189
  65. package/src/router/match-middleware/cache-lookup.ts +28 -8
  66. package/src/router/match-middleware/segment-resolution.ts +53 -0
  67. package/src/router/match-result.ts +82 -4
  68. package/src/router/middleware-types.ts +0 -6
  69. package/src/router/middleware.ts +0 -3
  70. package/src/router/navigation-snapshot.ts +182 -0
  71. package/src/router/prerender-match.ts +110 -10
  72. package/src/router/preview-match.ts +30 -102
  73. package/src/router/request-classification.ts +310 -0
  74. package/src/router/route-snapshot.ts +245 -0
  75. package/src/router/router-interfaces.ts +36 -4
  76. package/src/router/router-options.ts +37 -11
  77. package/src/router/segment-resolution/fresh.ts +71 -17
  78. package/src/router/segment-resolution/helpers.ts +29 -24
  79. package/src/router/segment-resolution/revalidation.ts +87 -18
  80. package/src/router/types.ts +1 -0
  81. package/src/router.ts +54 -5
  82. package/src/rsc/handler.ts +472 -372
  83. package/src/rsc/loader-fetch.ts +23 -3
  84. package/src/rsc/manifest-init.ts +5 -1
  85. package/src/rsc/progressive-enhancement.ts +14 -2
  86. package/src/rsc/rsc-rendering.ts +10 -1
  87. package/src/rsc/server-action.ts +8 -0
  88. package/src/rsc/ssr-setup.ts +2 -2
  89. package/src/rsc/types.ts +9 -1
  90. package/src/segment-content-promise.ts +67 -0
  91. package/src/segment-loader-promise.ts +122 -0
  92. package/src/segment-system.tsx +11 -61
  93. package/src/server/context.ts +65 -5
  94. package/src/server/handle-store.ts +19 -0
  95. package/src/server/loader-registry.ts +9 -8
  96. package/src/server/request-context.ts +134 -9
  97. package/src/ssr/index.tsx +3 -0
  98. package/src/static-handler.ts +18 -6
  99. package/src/types/cache-types.ts +4 -4
  100. package/src/types/handler-context.ts +30 -20
  101. package/src/types/loader-types.ts +36 -9
  102. package/src/types/route-entry.ts +12 -1
  103. package/src/types/segments.ts +1 -1
  104. package/src/urls/include-helper.ts +24 -14
  105. package/src/urls/path-helper-types.ts +39 -6
  106. package/src/urls/path-helper.ts +47 -12
  107. package/src/urls/pattern-types.ts +12 -0
  108. package/src/urls/response-types.ts +16 -6
  109. package/src/use-loader.tsx +77 -5
  110. package/src/vite/discovery/bundle-postprocess.ts +30 -33
  111. package/src/vite/discovery/discover-routers.ts +5 -1
  112. package/src/vite/discovery/prerender-collection.ts +128 -74
  113. package/src/vite/discovery/state.ts +13 -4
  114. package/src/vite/index.ts +4 -0
  115. package/src/vite/plugin-types.ts +60 -5
  116. package/src/vite/plugins/expose-id-utils.ts +12 -0
  117. package/src/vite/plugins/expose-ids/handler-transform.ts +30 -0
  118. package/src/vite/plugins/expose-internal-ids.ts +257 -40
  119. package/src/vite/plugins/performance-tracks.ts +88 -0
  120. package/src/vite/plugins/refresh-cmd.ts +88 -26
  121. package/src/vite/rango.ts +19 -2
  122. package/src/vite/router-discovery.ts +178 -37
  123. package/src/vite/utils/prerender-utils.ts +37 -5
  124. package/src/vite/utils/shared-utils.ts +3 -2
package/src/__internal.ts CHANGED
@@ -225,7 +225,7 @@ export type {
225
225
  * @internal
226
226
  * Type guard for prerender handler definitions.
227
227
  */
228
- export { isPrerenderHandler } from "./prerender.js";
228
+ export { isPrerenderHandler, isPassthroughHandler } from "./prerender.js";
229
229
 
230
230
  /**
231
231
  * @internal
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Mutable app version — updated after HMR revalidation.
3
+ * Read by prefetch, navigation, and context code.
4
+ */
5
+
6
+ let currentVersion: string | undefined;
7
+
8
+ export function getAppVersion(): string | undefined {
9
+ return currentVersion;
10
+ }
11
+
12
+ export function setAppVersion(version: string | undefined): void {
13
+ currentVersion = version;
14
+ }
@@ -4,6 +4,7 @@ import type {
4
4
  NavigateOptionsInternal,
5
5
  ResolvedSegment,
6
6
  } from "./types.js";
7
+ import { setAppVersion } from "./app-version.js";
7
8
  import * as React from "react";
8
9
  import { startTransition } from "react";
9
10
  import {
@@ -67,8 +68,8 @@ export interface NavigationBridgeConfigWithController extends NavigationBridgeCo
67
68
  export function createNavigationBridge(
68
69
  config: NavigationBridgeConfigWithController,
69
70
  ): NavigationBridge {
70
- const { store, client, eventController, onUpdate, renderSegments, version } =
71
- config;
71
+ const { store, client, eventController, onUpdate, renderSegments } = config;
72
+ let version = config.version;
72
73
 
73
74
  // Create shared partial updater
74
75
  const fetchPartialUpdate = createPartialUpdater({
@@ -76,7 +77,7 @@ export function createNavigationBridge(
76
77
  client,
77
78
  onUpdate,
78
79
  renderSegments,
79
- version,
80
+ getVersion: () => version,
80
81
  });
81
82
 
82
83
  return {
@@ -260,18 +261,24 @@ export function createNavigationBridge(
260
261
  // 2. routes that CAN be intercepted - we don't know if this navigation will intercept
261
262
  // 3. when leaving intercept - we need fresh non-intercept segments from server
262
263
  // 4. redirect-with-state - force re-render so hooks read fresh state
264
+ // 5. stale cache - server action invalidated it, need fresh data with loading state
263
265
  const hasUsableCache =
264
266
  cachedSegments &&
265
267
  cachedSegments.length > 0 &&
266
268
  !isInterceptOnlyCache(cachedSegments) &&
267
269
  !hasInterceptCache &&
268
270
  !isLeavingIntercept &&
271
+ !cached?.stale &&
269
272
  !options?._skipCache;
270
273
 
274
+ // Forward navigations always await fetchPartialUpdate before rendering,
275
+ // so useNavigation should always report "loading". skipLoadingState is
276
+ // only used for popstate background revalidation (line ~526) where
277
+ // cached content renders instantly without a network wait.
271
278
  const tx = createNavigationTransaction(store, eventController, url, {
272
279
  ...options,
273
280
  state: resolvedState,
274
- skipLoadingState: hasUsableCache,
281
+ skipLoadingState: false,
275
282
  });
276
283
 
277
284
  // REVALIDATE: Fetch fresh data from server
@@ -411,6 +418,15 @@ export function createNavigationBridge(
411
418
  eventController.abortAllActions();
412
419
  }
413
420
 
421
+ // Popstate that exits an intercept to a non-intercept destination. The
422
+ // fallback fetch path below needs `leave-intercept` mode so it filters
423
+ // the cached @modal segment from the request and forces a re-render —
424
+ // otherwise a cache-miss popstate whose server response has an empty
425
+ // diff hits the "no changes" branch in partial-update and the modal
426
+ // stays on screen.
427
+ const isLeavingIntercept =
428
+ !isIntercept && currentInterceptSource !== null;
429
+
414
430
  // Compute history key from URL (with intercept suffix if applicable)
415
431
  const historyKey = generateHistoryKey(url, { intercept: isIntercept });
416
432
 
@@ -447,6 +463,12 @@ export function createNavigationBridge(
447
463
  store.setCurrentUrl(url);
448
464
  store.setPath(new URL(url).pathname);
449
465
 
466
+ // Restore router identity from cache so subsequent navigations
467
+ // don't falsely detect an app switch.
468
+ if (cached?.routerId) {
469
+ store.setRouterId?.(cached.routerId);
470
+ }
471
+
450
472
  // Render from cache - force await to skip loading fallbacks
451
473
  try {
452
474
  const root = await renderSegments(cachedSegments, {
@@ -555,7 +577,11 @@ export function createNavigationBridge(
555
577
  intercept: isIntercept,
556
578
  interceptSourceUrl,
557
579
  }),
558
- isIntercept ? { type: "navigate", interceptSourceUrl } : undefined,
580
+ isIntercept
581
+ ? { type: "navigate", interceptSourceUrl }
582
+ : isLeavingIntercept
583
+ ? { type: "leave-intercept" }
584
+ : undefined,
559
585
  );
560
586
  // Restore scroll position after fetch completes
561
587
  handleNavigationEnd({ restore: true, isStreaming });
@@ -632,6 +658,12 @@ export function createNavigationBridge(
632
658
  window.removeEventListener("pageshow", handlePageShow);
633
659
  };
634
660
  },
661
+
662
+ updateVersion(newVersion: string): void {
663
+ version = newVersion;
664
+ setAppVersion(newVersion);
665
+ store.clearHistoryCache();
666
+ },
635
667
  };
636
668
  }
637
669
 
@@ -61,6 +61,7 @@ export function createNavigationClient(
61
61
  staleRevalidation,
62
62
  interceptSourceUrl,
63
63
  version,
64
+ routerId,
64
65
  hmr,
65
66
  } = options;
66
67
 
@@ -88,6 +89,9 @@ export function createNavigationClient(
88
89
  if (version) {
89
90
  fetchUrl.searchParams.set("_rsc_v", version);
90
91
  }
92
+ if (routerId) {
93
+ fetchUrl.searchParams.set("_rsc_rid", routerId);
94
+ }
91
95
 
92
96
  // Check completed in-memory prefetch cache before making a network request.
93
97
  // The cache key includes the source URL (previousUrl) because the
@@ -97,16 +101,86 @@ export function createNavigationClient(
97
101
  //
98
102
  const canUsePrefetch = !staleRevalidation && !hmr && !interceptSourceUrl;
99
103
  const cacheKey = buildPrefetchKey(previousUrl, fetchUrl);
100
- const cachedResponse = canUsePrefetch ? consumePrefetch(cacheKey) : null;
101
- const inflightResponsePromise = canUsePrefetch
102
- ? consumeInflightPrefetch(cacheKey)
103
- : null;
104
+ // Wildcard key matches prefetch entries stored with a custom prefetchKey
105
+ // (Link's prefetchKey prop stores under "*" instead of the source URL).
106
+ const wildcardKey = "*\0" + fetchUrl.pathname + fetchUrl.search;
107
+
108
+ let cachedResponse: Response | null = null;
109
+ let hitKey: string | null = null;
110
+ if (canUsePrefetch) {
111
+ cachedResponse = consumePrefetch(cacheKey);
112
+ if (cachedResponse) {
113
+ hitKey = cacheKey;
114
+ } else {
115
+ cachedResponse = consumePrefetch(wildcardKey);
116
+ if (cachedResponse) hitKey = wildcardKey;
117
+ }
118
+ }
119
+
120
+ let inflightResponsePromise: Promise<Response | null> | null = null;
121
+ if (canUsePrefetch && !cachedResponse) {
122
+ inflightResponsePromise = consumeInflightPrefetch(cacheKey);
123
+ if (inflightResponsePromise) {
124
+ hitKey = cacheKey;
125
+ } else {
126
+ inflightResponsePromise = consumeInflightPrefetch(wildcardKey);
127
+ if (inflightResponsePromise) hitKey = wildcardKey;
128
+ }
129
+ }
104
130
  // Track when the stream completes
105
131
  let resolveStreamComplete: () => void;
106
132
  const streamComplete = new Promise<void>((resolve) => {
107
133
  resolveStreamComplete = resolve;
108
134
  });
109
135
 
136
+ /**
137
+ * Validate RSC control headers on any response (fresh, cached, or
138
+ * in-flight). Handles version-mismatch reloads and server redirects.
139
+ * Returns the response unchanged when no control header is present.
140
+ */
141
+ const validateRscHeaders = (
142
+ response: Response,
143
+ source: string,
144
+ ): Response | Promise<Response> => {
145
+ // Version mismatch — server wants a full page reload
146
+ const reload = extractRscHeaderUrl(response, "X-RSC-Reload");
147
+ if (reload === "blocked") {
148
+ resolveStreamComplete();
149
+ return emptyResponse();
150
+ }
151
+ if (reload) {
152
+ if (tx) {
153
+ browserDebugLog(tx, `version mismatch, reloading (${source})`, {
154
+ reloadUrl: reload.url,
155
+ });
156
+ }
157
+ window.location.href = reload.url;
158
+ // Block further processing — page is reloading
159
+ return new Promise<Response>(() => {});
160
+ }
161
+
162
+ // Server-side redirect without state: the server returned 204 with
163
+ // X-RSC-Redirect instead of a 3xx (which fetch would auto-follow
164
+ // to a URL rendering full HTML). Throw ServerRedirect so the
165
+ // navigation bridge catches it and re-navigates with _skipCache.
166
+ const redirect = extractRscHeaderUrl(response, "X-RSC-Redirect");
167
+ if (redirect === "blocked") {
168
+ resolveStreamComplete();
169
+ return emptyResponse();
170
+ }
171
+ if (redirect) {
172
+ if (tx) {
173
+ browserDebugLog(tx, `server redirect (${source})`, {
174
+ redirectUrl: redirect.url,
175
+ });
176
+ }
177
+ resolveStreamComplete();
178
+ throw new ServerRedirect(redirect.url, undefined);
179
+ }
180
+
181
+ return response;
182
+ };
183
+
110
184
  /** Start a fresh navigation fetch (no cache / inflight hit). */
111
185
  const doFreshFetch = (): Promise<Response> => {
112
186
  if (tx) {
@@ -127,43 +201,11 @@ export function createNavigationClient(
127
201
  },
128
202
  signal,
129
203
  }).then((response) => {
130
- // Check for version mismatch - server wants us to reload
131
- const reload = extractRscHeaderUrl(response, "X-RSC-Reload");
132
- if (reload === "blocked") {
133
- resolveStreamComplete();
134
- return emptyResponse();
135
- }
136
- if (reload) {
137
- if (tx) {
138
- browserDebugLog(tx, "version mismatch, reloading", {
139
- reloadUrl: reload.url,
140
- });
141
- }
142
- window.location.href = reload.url;
143
- return new Promise<Response>(() => {});
144
- }
145
-
146
- // Server-side redirect without state: the server returned 204 with
147
- // X-RSC-Redirect instead of a 3xx (which fetch would auto-follow
148
- // to a URL rendering full HTML). Throw ServerRedirect so the
149
- // navigation bridge catches it and re-navigates with _skipCache.
150
- const redirect = extractRscHeaderUrl(response, "X-RSC-Redirect");
151
- if (redirect === "blocked") {
152
- resolveStreamComplete();
153
- return emptyResponse();
154
- }
155
- if (redirect) {
156
- if (tx) {
157
- browserDebugLog(tx, "server redirect", {
158
- redirectUrl: redirect.url,
159
- });
160
- }
161
- resolveStreamComplete();
162
- throw new ServerRedirect(redirect.url, undefined);
163
- }
204
+ const validated = validateRscHeaders(response, "fetch");
205
+ if (validated instanceof Promise) return validated;
164
206
 
165
207
  return teeWithCompletion(
166
- response,
208
+ validated,
167
209
  () => {
168
210
  if (tx) browserDebugLog(tx, "stream complete");
169
211
  resolveStreamComplete();
@@ -177,13 +219,17 @@ export function createNavigationClient(
177
219
 
178
220
  if (cachedResponse) {
179
221
  if (tx) {
180
- browserDebugLog(tx, "prefetch cache hit", { key: cacheKey });
222
+ browserDebugLog(tx, "prefetch cache hit", {
223
+ key: hitKey,
224
+ wildcard: hitKey === wildcardKey,
225
+ });
181
226
  }
182
- // Cached response body is already fully buffered (arrayBuffer),
183
- // so stream completion is immediate.
184
227
  responsePromise = Promise.resolve(cachedResponse).then((response) => {
228
+ const validated = validateRscHeaders(response, "prefetch cache");
229
+ if (validated instanceof Promise) return validated;
230
+
185
231
  return teeWithCompletion(
186
- response,
232
+ validated,
187
233
  () => {
188
234
  if (tx) browserDebugLog(tx, "stream complete (from cache)");
189
235
  resolveStreamComplete();
@@ -193,7 +239,10 @@ export function createNavigationClient(
193
239
  });
194
240
  } else if (inflightResponsePromise) {
195
241
  if (tx) {
196
- browserDebugLog(tx, "reusing inflight prefetch", { key: cacheKey });
242
+ browserDebugLog(tx, "reusing inflight prefetch", {
243
+ key: hitKey,
244
+ wildcard: hitKey === wildcardKey,
245
+ });
197
246
  }
198
247
  responsePromise = inflightResponsePromise.then(async (response) => {
199
248
  if (!response) {
@@ -203,8 +252,11 @@ export function createNavigationClient(
203
252
  return doFreshFetch();
204
253
  }
205
254
 
255
+ const validated = validateRscHeaders(response, "inflight prefetch");
256
+ if (validated instanceof Promise) return validated;
257
+
206
258
  return teeWithCompletion(
207
- response,
259
+ validated,
208
260
  () => {
209
261
  if (tx) {
210
262
  browserDebugLog(tx, "stream complete (from inflight prefetch)");
@@ -219,8 +271,8 @@ export function createNavigationClient(
219
271
  }
220
272
 
221
273
  try {
222
- // Deserialize RSC payload
223
274
  const payload = await deps.createFromFetch<RscPayload>(responsePromise);
275
+
224
276
  if (tx) {
225
277
  browserDebugLog(tx, "response received", {
226
278
  isPartial: payload.metadata?.isPartial,
@@ -28,9 +28,15 @@ const DEFAULT_ACTION_STATE: TrackedActionState = {
28
28
  // Maximum number of history entries to cache (URLs visited)
29
29
  const HISTORY_CACHE_SIZE = 20;
30
30
 
31
- // Cache entry: [url-key, segments, stale, handleData?]
31
+ // Cache entry: [url-key, segments, stale, handleData?, routerId?]
32
32
  // stale=true means the data may be outdated and should be revalidated on access
33
- type HistoryCacheEntry = [string, ResolvedSegment[], boolean, HandleData?];
33
+ type HistoryCacheEntry = [
34
+ string,
35
+ ResolvedSegment[],
36
+ boolean,
37
+ HandleData?,
38
+ string?,
39
+ ];
34
40
 
35
41
  /**
36
42
  * Shallow clone handleData to avoid reference sharing between cache entries.
@@ -258,6 +264,11 @@ export function createNavigationStore(
258
264
  // Used to maintain intercept context during action revalidation
259
265
  let interceptSourceUrl: string | null = null;
260
266
 
267
+ // Router identity - tracks which router is currently active.
268
+ // When this changes on a partial response, the client forces a full
269
+ // tree replacement instead of reconciling with stale segments.
270
+ let currentRouterId: string | undefined;
271
+
261
272
  // Action state tracking (for useAction hook)
262
273
  // Maps action function ID to its tracked state
263
274
  const actionStates = new Map<string, TrackedActionState>();
@@ -571,10 +582,17 @@ export function createNavigationStore(
571
582
  segments,
572
583
  false,
573
584
  clonedHandleData,
585
+ currentRouterId,
574
586
  ];
575
587
  } else {
576
588
  // Add new entry at the end (not stale)
577
- historyCache.push([historyKey, segments, false, clonedHandleData]);
589
+ historyCache.push([
590
+ historyKey,
591
+ segments,
592
+ false,
593
+ clonedHandleData,
594
+ currentRouterId,
595
+ ]);
578
596
  // Remove oldest entries if over limit
579
597
  while (historyCache.length > cacheSize) {
580
598
  historyCache.shift();
@@ -586,14 +604,22 @@ export function createNavigationStore(
586
604
  * Get cached segments for a history entry
587
605
  * Returns { segments, stale, handleData } or undefined if not cached
588
606
  */
589
- getCachedSegments(
590
- historyKey: string,
591
- ):
592
- | { segments: ResolvedSegment[]; stale: boolean; handleData?: HandleData }
607
+ getCachedSegments(historyKey: string):
608
+ | {
609
+ segments: ResolvedSegment[];
610
+ stale: boolean;
611
+ handleData?: HandleData;
612
+ routerId?: string;
613
+ }
593
614
  | undefined {
594
615
  const entry = historyCache.find(([key]) => key === historyKey);
595
616
  if (!entry) return undefined;
596
- return { segments: entry[1], stale: entry[2], handleData: entry[3] };
617
+ return {
618
+ segments: entry[1],
619
+ stale: entry[2],
620
+ handleData: entry[3],
621
+ routerId: entry[4],
622
+ };
597
623
  },
598
624
 
599
625
  /**
@@ -621,6 +647,7 @@ export function createNavigationStore(
621
647
  entry[1],
622
648
  entry[2],
623
649
  clonedHandleData,
650
+ entry[4], // preserve routerId
624
651
  ];
625
652
  }
626
653
  },
@@ -687,6 +714,14 @@ export function createNavigationStore(
687
714
  interceptSourceUrl = url;
688
715
  },
689
716
 
717
+ getRouterId(): string | undefined {
718
+ return currentRouterId;
719
+ },
720
+
721
+ setRouterId(id: string): void {
722
+ currentRouterId = id;
723
+ },
724
+
690
725
  // ========================================================================
691
726
  // UI Update Notifications
692
727
  // ========================================================================
@@ -39,8 +39,8 @@ export interface PartialUpdateConfig {
39
39
  segments: ResolvedSegment[],
40
40
  options?: RenderSegmentsOptions,
41
41
  ) => Promise<ReactNode> | ReactNode;
42
- /** RSC version received from server (from initial payload metadata) */
43
- version?: string;
42
+ /** RSC version getter returns the current version (may change after HMR) */
43
+ getVersion?: () => string | undefined;
44
44
  }
45
45
 
46
46
  /**
@@ -104,7 +104,13 @@ export type PartialUpdater = (
104
104
  export function createPartialUpdater(
105
105
  config: PartialUpdateConfig,
106
106
  ): PartialUpdater {
107
- const { store, client, onUpdate, renderSegments, version } = config;
107
+ const {
108
+ store,
109
+ client,
110
+ onUpdate,
111
+ renderSegments,
112
+ getVersion = () => undefined,
113
+ } = config;
108
114
 
109
115
  /**
110
116
  * Get current page's cached segments as an array
@@ -161,9 +167,16 @@ export function createPartialUpdater(
161
167
  segments = segmentIds ?? segmentState.currentSegmentIds;
162
168
  }
163
169
 
164
- // For intercept revalidation, use the intercept source URL as previousUrl
170
+ // For intercept revalidation, use the intercept source URL as previousUrl.
171
+ // For leave-intercept, tx.currentUrl captures window.location.href at tx
172
+ // creation, which on popstate is already the destination URL and would
173
+ // tell the server "from == to". segmentState.currentUrl still points at
174
+ // the URL the cached segments render (the intercept URL), which is the
175
+ // correct "from" for the server's diff computation.
165
176
  const previousUrl =
166
- interceptSourceUrl || tx.currentUrl || segmentState.currentUrl;
177
+ mode.type === "leave-intercept"
178
+ ? segmentState.currentUrl || tx.currentUrl
179
+ : interceptSourceUrl || tx.currentUrl || segmentState.currentUrl;
167
180
 
168
181
  debugLog(`\n[Browser] >>> NAVIGATION`);
169
182
  debugLog(`[Browser] From: ${previousUrl}`);
@@ -182,6 +195,11 @@ export function createPartialUpdater(
182
195
  targetCache && targetCache.length > 0
183
196
  ? targetCache
184
197
  : getCurrentCachedSegments();
198
+ const cachedSegsSource =
199
+ targetCache && targetCache.length > 0 ? "history-cache" : "current-page";
200
+ debugLog(
201
+ `[Browser] cachedSegs source: ${cachedSegsSource} (${cachedSegs.length} segments: ${cachedSegs.map((s) => s.id).join(", ")})`,
202
+ );
185
203
 
186
204
  // Fetch partial payload (no abort signal - RSC doesn't support it well)
187
205
  let fetchResult: Awaited<ReturnType<NavigationClient["fetchPartial"]>>;
@@ -193,7 +211,8 @@ export function createPartialUpdater(
193
211
  // (action redirect sends empty segments for a fresh render).
194
212
  staleRevalidation:
195
213
  mode.type === "stale-revalidation" || segments.length === 0,
196
- version,
214
+ version: getVersion(),
215
+ routerId: store.getRouterId?.(),
197
216
  });
198
217
  // Mark navigation as streaming (response received, now parsing RSC).
199
218
  // Called after fetchPartial so pendingUrl stays set during the network wait,
@@ -206,6 +225,21 @@ export function createPartialUpdater(
206
225
  streamingToken.end();
207
226
  });
208
227
 
228
+ // Detect app switch: if routerId changed, the navigation crossed into
229
+ // a different router (e.g., via host router path mount). Downgrade
230
+ // partial to full so the entire tree is replaced without reconciliation
231
+ // against stale segments from the previous app.
232
+ if (payload.metadata?.routerId) {
233
+ const prevRouterId = store.getRouterId?.();
234
+ if (prevRouterId && prevRouterId !== payload.metadata.routerId) {
235
+ debugLog(
236
+ `[Browser] App switch detected (${prevRouterId} → ${payload.metadata.routerId}), forcing full update`,
237
+ );
238
+ payload.metadata.isPartial = false;
239
+ }
240
+ store.setRouterId?.(payload.metadata.routerId);
241
+ }
242
+
209
243
  // Handle server-side redirect with state
210
244
  if (payload.metadata?.redirect) {
211
245
  if (signal?.aborted) {
@@ -259,7 +293,7 @@ export function createPartialUpdater(
259
293
  existingSegments,
260
294
  );
261
295
 
262
- // Fix: tx.commit() cached the source page's handleData because
296
+ // tx.commit() cached the source page's handleData because
263
297
  // eventController hasn't been updated yet. Overwrite with the
264
298
  // correct cached handleData to prevent cache corruption on
265
299
  // subsequent navigations to this same URL.
@@ -61,13 +61,23 @@ const inflightPromises = new Map<string, Promise<Response | null>>();
61
61
  let generation = 0;
62
62
 
63
63
  /**
64
- * Build a source-dependent cache key.
65
- * Includes the source page href so the same target prefetched from
66
- * different pages gets separate entries the server response varies
67
- * based on the source page context (diff-based rendering).
64
+ * Build a cache key for prefetched responses.
65
+ *
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).
69
+ *
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.
68
73
  */
69
- export function buildPrefetchKey(sourceHref: string, targetUrl: URL): string {
70
- return sourceHref + "\0" + targetUrl.pathname + targetUrl.search;
74
+ export function buildPrefetchKey(
75
+ sourceHref: string,
76
+ targetUrl: URL,
77
+ prefetchKey?: string | ((from: string) => string),
78
+ ): string {
79
+ const source = prefetchKey != null ? "*" : sourceHref;
80
+ return source + "\0" + targetUrl.pathname + targetUrl.search;
71
81
  }
72
82
 
73
83
  /**