@rangojs/router 0.0.0-experimental.fa8a383a → 0.0.0-experimental.fb4fdc18

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 (175) hide show
  1. package/README.md +188 -35
  2. package/dist/bin/rango.js +130 -47
  3. package/dist/vite/index.js +1884 -537
  4. package/dist/vite/index.js.bak +5448 -0
  5. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  6. package/package.json +7 -5
  7. package/skills/breadcrumbs/SKILL.md +3 -1
  8. package/skills/cache-guide/SKILL.md +32 -0
  9. package/skills/caching/SKILL.md +8 -0
  10. package/skills/handler-use/SKILL.md +362 -0
  11. package/skills/hooks/SKILL.md +33 -20
  12. package/skills/i18n/SKILL.md +276 -0
  13. package/skills/intercept/SKILL.md +20 -0
  14. package/skills/layout/SKILL.md +22 -0
  15. package/skills/links/SKILL.md +93 -17
  16. package/skills/loader/SKILL.md +123 -46
  17. package/skills/middleware/SKILL.md +36 -3
  18. package/skills/migrate-nextjs/SKILL.md +562 -0
  19. package/skills/migrate-react-router/SKILL.md +769 -0
  20. package/skills/parallel/SKILL.md +133 -0
  21. package/skills/prerender/SKILL.md +110 -68
  22. package/skills/rango/SKILL.md +26 -22
  23. package/skills/response-routes/SKILL.md +8 -0
  24. package/skills/route/SKILL.md +75 -0
  25. package/skills/router-setup/SKILL.md +87 -2
  26. package/skills/server-actions/SKILL.md +739 -0
  27. package/skills/streams-and-websockets/SKILL.md +283 -0
  28. package/skills/typesafety/SKILL.md +19 -1
  29. package/src/__internal.ts +1 -1
  30. package/src/browser/app-shell.ts +52 -0
  31. package/src/browser/app-version.ts +14 -0
  32. package/src/browser/event-controller.ts +44 -4
  33. package/src/browser/navigation-bridge.ts +95 -7
  34. package/src/browser/navigation-client.ts +128 -53
  35. package/src/browser/navigation-store.ts +68 -9
  36. package/src/browser/partial-update.ts +93 -12
  37. package/src/browser/prefetch/cache.ts +129 -21
  38. package/src/browser/prefetch/fetch.ts +156 -18
  39. package/src/browser/prefetch/queue.ts +92 -29
  40. package/src/browser/prefetch/resource-ready.ts +77 -0
  41. package/src/browser/rango-state.ts +53 -13
  42. package/src/browser/react/Link.tsx +72 -8
  43. package/src/browser/react/NavigationProvider.tsx +82 -21
  44. package/src/browser/react/context.ts +7 -2
  45. package/src/browser/react/filter-segment-order.ts +51 -7
  46. package/src/browser/react/use-handle.ts +9 -58
  47. package/src/browser/react/use-navigation.ts +22 -2
  48. package/src/browser/react/use-params.ts +17 -4
  49. package/src/browser/react/use-router.ts +29 -9
  50. package/src/browser/react/use-segments.ts +11 -8
  51. package/src/browser/rsc-router.tsx +60 -9
  52. package/src/browser/scroll-restoration.ts +10 -8
  53. package/src/browser/segment-reconciler.ts +36 -14
  54. package/src/browser/server-action-bridge.ts +8 -6
  55. package/src/browser/types.ts +46 -5
  56. package/src/build/generate-manifest.ts +6 -6
  57. package/src/build/generate-route-types.ts +3 -0
  58. package/src/build/route-trie.ts +52 -25
  59. package/src/build/route-types/include-resolution.ts +8 -1
  60. package/src/build/route-types/router-processing.ts +211 -72
  61. package/src/build/route-types/scan-filter.ts +8 -1
  62. package/src/cache/cache-runtime.ts +15 -11
  63. package/src/cache/cache-scope.ts +46 -5
  64. package/src/cache/cf/cf-cache-store.ts +5 -7
  65. package/src/cache/taint.ts +55 -0
  66. package/src/client.tsx +84 -230
  67. package/src/context-var.ts +72 -2
  68. package/src/handle.ts +40 -0
  69. package/src/index.rsc.ts +6 -1
  70. package/src/index.ts +49 -6
  71. package/src/outlet-context.ts +1 -1
  72. package/src/prerender/store.ts +5 -4
  73. package/src/prerender.ts +138 -77
  74. package/src/response-utils.ts +28 -0
  75. package/src/reverse.ts +28 -2
  76. package/src/route-definition/dsl-helpers.ts +210 -35
  77. package/src/route-definition/helpers-types.ts +73 -20
  78. package/src/route-definition/index.ts +3 -0
  79. package/src/route-definition/redirect.ts +9 -1
  80. package/src/route-definition/resolve-handler-use.ts +155 -0
  81. package/src/route-types.ts +18 -0
  82. package/src/router/content-negotiation.ts +100 -1
  83. package/src/router/handler-context.ts +102 -25
  84. package/src/router/intercept-resolution.ts +9 -4
  85. package/src/router/lazy-includes.ts +6 -6
  86. package/src/router/loader-resolution.ts +159 -21
  87. package/src/router/manifest.ts +22 -13
  88. package/src/router/match-api.ts +128 -192
  89. package/src/router/match-handlers.ts +1 -0
  90. package/src/router/match-middleware/background-revalidation.ts +12 -1
  91. package/src/router/match-middleware/cache-lookup.ts +74 -14
  92. package/src/router/match-middleware/cache-store.ts +21 -4
  93. package/src/router/match-middleware/segment-resolution.ts +53 -0
  94. package/src/router/match-result.ts +112 -9
  95. package/src/router/metrics.ts +6 -1
  96. package/src/router/middleware-types.ts +20 -33
  97. package/src/router/middleware.ts +56 -12
  98. package/src/router/navigation-snapshot.ts +182 -0
  99. package/src/router/pattern-matching.ts +101 -17
  100. package/src/router/prerender-match.ts +110 -10
  101. package/src/router/preview-match.ts +30 -102
  102. package/src/router/request-classification.ts +310 -0
  103. package/src/router/revalidation.ts +15 -1
  104. package/src/router/route-snapshot.ts +245 -0
  105. package/src/router/router-context.ts +1 -0
  106. package/src/router/router-interfaces.ts +36 -4
  107. package/src/router/router-options.ts +37 -11
  108. package/src/router/segment-resolution/fresh.ts +114 -18
  109. package/src/router/segment-resolution/helpers.ts +29 -24
  110. package/src/router/segment-resolution/revalidation.ts +257 -127
  111. package/src/router/trie-matching.ts +18 -13
  112. package/src/router/types.ts +1 -0
  113. package/src/router/url-params.ts +49 -0
  114. package/src/router.ts +55 -7
  115. package/src/rsc/handler.ts +478 -383
  116. package/src/rsc/helpers.ts +69 -41
  117. package/src/rsc/loader-fetch.ts +23 -3
  118. package/src/rsc/manifest-init.ts +5 -1
  119. package/src/rsc/progressive-enhancement.ts +18 -2
  120. package/src/rsc/response-route-handler.ts +14 -1
  121. package/src/rsc/rsc-rendering.ts +20 -1
  122. package/src/rsc/server-action.ts +12 -0
  123. package/src/rsc/ssr-setup.ts +2 -2
  124. package/src/rsc/types.ts +15 -1
  125. package/src/segment-content-promise.ts +67 -0
  126. package/src/segment-loader-promise.ts +122 -0
  127. package/src/segment-system.tsx +22 -62
  128. package/src/server/context.ts +76 -4
  129. package/src/server/handle-store.ts +19 -0
  130. package/src/server/loader-registry.ts +9 -8
  131. package/src/server/request-context.ts +185 -57
  132. package/src/ssr/index.tsx +8 -1
  133. package/src/static-handler.ts +18 -6
  134. package/src/types/cache-types.ts +4 -4
  135. package/src/types/handler-context.ts +145 -68
  136. package/src/types/loader-types.ts +41 -15
  137. package/src/types/request-scope.ts +126 -0
  138. package/src/types/route-entry.ts +12 -1
  139. package/src/types/segments.ts +18 -1
  140. package/src/urls/include-helper.ts +24 -14
  141. package/src/urls/path-helper-types.ts +39 -6
  142. package/src/urls/path-helper.ts +47 -12
  143. package/src/urls/pattern-types.ts +12 -0
  144. package/src/urls/response-types.ts +18 -16
  145. package/src/use-loader.tsx +77 -5
  146. package/src/vite/debug.ts +184 -0
  147. package/src/vite/discovery/bundle-postprocess.ts +30 -33
  148. package/src/vite/discovery/discover-routers.ts +36 -4
  149. package/src/vite/discovery/gate-state.ts +171 -0
  150. package/src/vite/discovery/prerender-collection.ts +175 -74
  151. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  152. package/src/vite/discovery/state.ts +13 -4
  153. package/src/vite/index.ts +4 -0
  154. package/src/vite/plugin-types.ts +60 -5
  155. package/src/vite/plugins/cjs-to-esm.ts +5 -0
  156. package/src/vite/plugins/client-ref-dedup.ts +16 -0
  157. package/src/vite/plugins/client-ref-hashing.ts +16 -4
  158. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  159. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  160. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  161. package/src/vite/plugins/expose-action-id.ts +52 -28
  162. package/src/vite/plugins/expose-id-utils.ts +12 -0
  163. package/src/vite/plugins/expose-ids/handler-transform.ts +30 -0
  164. package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
  165. package/src/vite/plugins/expose-internal-ids.ts +563 -316
  166. package/src/vite/plugins/performance-tracks.ts +96 -0
  167. package/src/vite/plugins/refresh-cmd.ts +88 -26
  168. package/src/vite/plugins/use-cache-transform.ts +56 -43
  169. package/src/vite/plugins/version-injector.ts +37 -11
  170. package/src/vite/rango.ts +63 -11
  171. package/src/vite/router-discovery.ts +732 -86
  172. package/src/vite/utils/banner.ts +1 -1
  173. package/src/vite/utils/package-resolution.ts +41 -1
  174. package/src/vite/utils/prerender-utils.ts +38 -5
  175. package/src/vite/utils/shared-utils.ts +3 -2
@@ -19,6 +19,7 @@ import {
19
19
  } from "./response-adapter.js";
20
20
  import {
21
21
  buildPrefetchKey,
22
+ buildSourceKey,
22
23
  consumeInflightPrefetch,
23
24
  consumePrefetch,
24
25
  } from "./prefetch/cache.js";
@@ -30,8 +31,10 @@ import {
30
31
  * deserializing the response using the RSC runtime.
31
32
  *
32
33
  * Checks the in-memory prefetch cache before making a network request.
33
- * The cache key is source-dependent (includes the previous URL) so
34
- * prefetch responses match the exact diff the server would produce.
34
+ * Tries the source-scoped key first (populated when the server tagged
35
+ * the response as source-sensitive via `X-RSC-Prefetch-Scope: source`)
36
+ * and falls back to the Rango-state-keyed wildcard slot used for the
37
+ * common source-agnostic case.
35
38
  *
36
39
  * @param deps - RSC browser dependencies (createFromFetch)
37
40
  * @returns NavigationClient instance
@@ -61,6 +64,7 @@ export function createNavigationClient(
61
64
  staleRevalidation,
62
65
  interceptSourceUrl,
63
66
  version,
67
+ routerId,
64
68
  hmr,
65
69
  } = options;
66
70
 
@@ -88,25 +92,100 @@ export function createNavigationClient(
88
92
  if (version) {
89
93
  fetchUrl.searchParams.set("_rsc_v", version);
90
94
  }
95
+ if (routerId) {
96
+ fetchUrl.searchParams.set("_rsc_rid", routerId);
97
+ }
91
98
 
92
- // Check completed in-memory prefetch cache before making a network request.
93
- // The cache key includes the source URL (previousUrl) because the
94
- // server's diff response depends on the source page context.
99
+ // Check completed in-memory prefetch cache before making a network
100
+ // request. Try the source-scoped key first (populated when the server
101
+ // tagged the prefetch response as source-sensitive, e.g. intercepts,
102
+ // or when a Link opted in with `prefetchKey=":source"`), then fall
103
+ // back to the wildcard slot shared across source pages.
104
+ // Both keys embed the Rango state, so state rotation (deploy or
105
+ // server-action invalidation) auto-invalidates both scopes.
95
106
  // Skip cache for stale revalidation (needs fresh data), HMR (needs
96
107
  // fresh modules), and intercept contexts (source-dependent responses).
97
- //
98
108
  const canUsePrefetch = !staleRevalidation && !hmr && !interceptSourceUrl;
99
- const cacheKey = buildPrefetchKey(previousUrl, fetchUrl);
100
- const cachedResponse = canUsePrefetch ? consumePrefetch(cacheKey) : null;
101
- const inflightResponsePromise = canUsePrefetch
102
- ? consumeInflightPrefetch(cacheKey)
103
- : null;
109
+ const rangoState = getRangoState();
110
+ const wildcardKey = buildPrefetchKey(rangoState, fetchUrl);
111
+ const cacheKey = buildSourceKey(rangoState, previousUrl, fetchUrl);
112
+
113
+ let cachedResponse: Response | null = null;
114
+ let hitKey: string | null = null;
115
+ if (canUsePrefetch) {
116
+ cachedResponse = consumePrefetch(cacheKey);
117
+ if (cachedResponse) {
118
+ hitKey = cacheKey;
119
+ } else {
120
+ cachedResponse = consumePrefetch(wildcardKey);
121
+ if (cachedResponse) hitKey = wildcardKey;
122
+ }
123
+ }
124
+
125
+ let inflightResponsePromise: Promise<Response | null> | null = null;
126
+ if (canUsePrefetch && !cachedResponse) {
127
+ inflightResponsePromise = consumeInflightPrefetch(cacheKey);
128
+ if (inflightResponsePromise) {
129
+ hitKey = cacheKey;
130
+ } else {
131
+ inflightResponsePromise = consumeInflightPrefetch(wildcardKey);
132
+ if (inflightResponsePromise) hitKey = wildcardKey;
133
+ }
134
+ }
104
135
  // Track when the stream completes
105
136
  let resolveStreamComplete: () => void;
106
137
  const streamComplete = new Promise<void>((resolve) => {
107
138
  resolveStreamComplete = resolve;
108
139
  });
109
140
 
141
+ /**
142
+ * Validate RSC control headers on any response (fresh, cached, or
143
+ * in-flight). Handles version-mismatch reloads and server redirects.
144
+ * Returns the response unchanged when no control header is present.
145
+ */
146
+ const validateRscHeaders = (
147
+ response: Response,
148
+ source: string,
149
+ ): Response | Promise<Response> => {
150
+ // Version mismatch — server wants a full page reload
151
+ const reload = extractRscHeaderUrl(response, "X-RSC-Reload");
152
+ if (reload === "blocked") {
153
+ resolveStreamComplete();
154
+ return emptyResponse();
155
+ }
156
+ if (reload) {
157
+ if (tx) {
158
+ browserDebugLog(tx, `version mismatch, reloading (${source})`, {
159
+ reloadUrl: reload.url,
160
+ });
161
+ }
162
+ window.location.href = reload.url;
163
+ // Block further processing — page is reloading
164
+ return new Promise<Response>(() => {});
165
+ }
166
+
167
+ // Server-side redirect without state: the server returned 204 with
168
+ // X-RSC-Redirect instead of a 3xx (which fetch would auto-follow
169
+ // to a URL rendering full HTML). Throw ServerRedirect so the
170
+ // navigation bridge catches it and re-navigates with _skipCache.
171
+ const redirect = extractRscHeaderUrl(response, "X-RSC-Redirect");
172
+ if (redirect === "blocked") {
173
+ resolveStreamComplete();
174
+ return emptyResponse();
175
+ }
176
+ if (redirect) {
177
+ if (tx) {
178
+ browserDebugLog(tx, `server redirect (${source})`, {
179
+ redirectUrl: redirect.url,
180
+ });
181
+ }
182
+ resolveStreamComplete();
183
+ throw new ServerRedirect(redirect.url, undefined);
184
+ }
185
+
186
+ return response;
187
+ };
188
+
110
189
  /** Start a fresh navigation fetch (no cache / inflight hit). */
111
190
  const doFreshFetch = (): Promise<Response> => {
112
191
  if (tx) {
@@ -127,43 +206,11 @@ export function createNavigationClient(
127
206
  },
128
207
  signal,
129
208
  }).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
- }
209
+ const validated = validateRscHeaders(response, "fetch");
210
+ if (validated instanceof Promise) return validated;
164
211
 
165
212
  return teeWithCompletion(
166
- response,
213
+ validated,
167
214
  () => {
168
215
  if (tx) browserDebugLog(tx, "stream complete");
169
216
  resolveStreamComplete();
@@ -177,13 +224,17 @@ export function createNavigationClient(
177
224
 
178
225
  if (cachedResponse) {
179
226
  if (tx) {
180
- browserDebugLog(tx, "prefetch cache hit", { key: cacheKey });
227
+ browserDebugLog(tx, "prefetch cache hit", {
228
+ key: hitKey,
229
+ wildcard: hitKey === wildcardKey,
230
+ });
181
231
  }
182
- // Cached response body is already fully buffered (arrayBuffer),
183
- // so stream completion is immediate.
184
232
  responsePromise = Promise.resolve(cachedResponse).then((response) => {
233
+ const validated = validateRscHeaders(response, "prefetch cache");
234
+ if (validated instanceof Promise) return validated;
235
+
185
236
  return teeWithCompletion(
186
- response,
237
+ validated,
187
238
  () => {
188
239
  if (tx) browserDebugLog(tx, "stream complete (from cache)");
189
240
  resolveStreamComplete();
@@ -193,8 +244,12 @@ export function createNavigationClient(
193
244
  });
194
245
  } else if (inflightResponsePromise) {
195
246
  if (tx) {
196
- browserDebugLog(tx, "reusing inflight prefetch", { key: cacheKey });
247
+ browserDebugLog(tx, "reusing inflight prefetch", {
248
+ key: hitKey,
249
+ wildcard: hitKey === wildcardKey,
250
+ });
197
251
  }
252
+ const adoptedViaWildcard = hitKey === wildcardKey;
198
253
  responsePromise = inflightResponsePromise.then(async (response) => {
199
254
  if (!response) {
200
255
  if (tx) {
@@ -203,8 +258,28 @@ export function createNavigationClient(
203
258
  return doFreshFetch();
204
259
  }
205
260
 
261
+ // Cross-source safety: an inflight promise adopted via the
262
+ // wildcard key may turn out to be source-scoped (server emitted
263
+ // `X-RSC-Prefetch-Scope: source`), which means it was built for
264
+ // a different source page. Discard and refetch.
265
+ if (
266
+ adoptedViaWildcard &&
267
+ response.headers.get("x-rsc-prefetch-scope") === "source"
268
+ ) {
269
+ if (tx) {
270
+ browserDebugLog(
271
+ tx,
272
+ "wildcard inflight turned out source-scoped, refetching",
273
+ );
274
+ }
275
+ return doFreshFetch();
276
+ }
277
+
278
+ const validated = validateRscHeaders(response, "inflight prefetch");
279
+ if (validated instanceof Promise) return validated;
280
+
206
281
  return teeWithCompletion(
207
- response,
282
+ validated,
208
283
  () => {
209
284
  if (tx) {
210
285
  browserDebugLog(tx, "stream complete (from inflight prefetch)");
@@ -219,8 +294,8 @@ export function createNavigationClient(
219
294
  }
220
295
 
221
296
  try {
222
- // Deserialize RSC payload
223
297
  const payload = await deps.createFromFetch<RscPayload>(responsePromise);
298
+
224
299
  if (tx) {
225
300
  browserDebugLog(tx, "response received", {
226
301
  isPartial: payload.metadata?.isPartial,
@@ -12,7 +12,10 @@ import type {
12
12
  ActionStateListener,
13
13
  HandleData,
14
14
  } from "./types.js";
15
- import { clearPrefetchCache } from "./prefetch/cache.js";
15
+ import {
16
+ clearPrefetchCache,
17
+ clearPrefetchCacheLocal,
18
+ } from "./prefetch/cache.js";
16
19
 
17
20
  /**
18
21
  * Default action state (idle with no payload)
@@ -28,9 +31,15 @@ const DEFAULT_ACTION_STATE: TrackedActionState = {
28
31
  // Maximum number of history entries to cache (URLs visited)
29
32
  const HISTORY_CACHE_SIZE = 20;
30
33
 
31
- // Cache entry: [url-key, segments, stale, handleData?]
34
+ // Cache entry: [url-key, segments, stale, handleData?, routerId?]
32
35
  // stale=true means the data may be outdated and should be revalidated on access
33
- type HistoryCacheEntry = [string, ResolvedSegment[], boolean, HandleData?];
36
+ type HistoryCacheEntry = [
37
+ string,
38
+ ResolvedSegment[],
39
+ boolean,
40
+ HandleData?,
41
+ string?,
42
+ ];
34
43
 
35
44
  /**
36
45
  * Shallow clone handleData to avoid reference sharing between cache entries.
@@ -258,6 +267,11 @@ export function createNavigationStore(
258
267
  // Used to maintain intercept context during action revalidation
259
268
  let interceptSourceUrl: string | null = null;
260
269
 
270
+ // Router identity - tracks which router is currently active.
271
+ // When this changes on a partial response, the client forces a full
272
+ // tree replacement instead of reconciling with stale segments.
273
+ let currentRouterId: string | undefined;
274
+
261
275
  // Action state tracking (for useAction hook)
262
276
  // Maps action function ID to its tracked state
263
277
  const actionStates = new Map<string, TrackedActionState>();
@@ -324,6 +338,18 @@ export function createNavigationStore(
324
338
  clearPrefetchCache();
325
339
  }
326
340
 
341
+ /**
342
+ * Drop this tab's navigation + prefetch caches without broadcasting or
343
+ * rotating shared state. Used when the local session changes in a way that
344
+ * doesn't affect other tabs — e.g. this tab crosses into a different app
345
+ * via a cross-router navigation. Other tabs in the old app keep their
346
+ * caches and their X-Rango-State token.
347
+ */
348
+ function clearCacheInternalLocal(): void {
349
+ historyCache.length = 0;
350
+ clearPrefetchCacheLocal();
351
+ }
352
+
327
353
  /**
328
354
  * Mark all cache entries as stale (internal - does not broadcast)
329
355
  */
@@ -571,10 +597,17 @@ export function createNavigationStore(
571
597
  segments,
572
598
  false,
573
599
  clonedHandleData,
600
+ currentRouterId,
574
601
  ];
575
602
  } else {
576
603
  // Add new entry at the end (not stale)
577
- historyCache.push([historyKey, segments, false, clonedHandleData]);
604
+ historyCache.push([
605
+ historyKey,
606
+ segments,
607
+ false,
608
+ clonedHandleData,
609
+ currentRouterId,
610
+ ]);
578
611
  // Remove oldest entries if over limit
579
612
  while (historyCache.length > cacheSize) {
580
613
  historyCache.shift();
@@ -586,14 +619,22 @@ export function createNavigationStore(
586
619
  * Get cached segments for a history entry
587
620
  * Returns { segments, stale, handleData } or undefined if not cached
588
621
  */
589
- getCachedSegments(
590
- historyKey: string,
591
- ):
592
- | { segments: ResolvedSegment[]; stale: boolean; handleData?: HandleData }
622
+ getCachedSegments(historyKey: string):
623
+ | {
624
+ segments: ResolvedSegment[];
625
+ stale: boolean;
626
+ handleData?: HandleData;
627
+ routerId?: string;
628
+ }
593
629
  | undefined {
594
630
  const entry = historyCache.find(([key]) => key === historyKey);
595
631
  if (!entry) return undefined;
596
- return { segments: entry[1], stale: entry[2], handleData: entry[3] };
632
+ return {
633
+ segments: entry[1],
634
+ stale: entry[2],
635
+ handleData: entry[3],
636
+ routerId: entry[4],
637
+ };
597
638
  },
598
639
 
599
640
  /**
@@ -621,6 +662,7 @@ export function createNavigationStore(
621
662
  entry[1],
622
663
  entry[2],
623
664
  clonedHandleData,
665
+ entry[4], // preserve routerId
624
666
  ];
625
667
  }
626
668
  },
@@ -641,6 +683,15 @@ export function createNavigationStore(
641
683
  clearCacheAndBroadcast();
642
684
  },
643
685
 
686
+ /**
687
+ * Drop this tab's navigation + prefetch caches locally without
688
+ * broadcasting or rotating shared state. Intended for cross-app
689
+ * transitions where the session state diverges for this tab only.
690
+ */
691
+ clearHistoryCacheLocal(): void {
692
+ clearCacheInternalLocal();
693
+ },
694
+
644
695
  /**
645
696
  * Mark cache as stale and broadcast to other tabs
646
697
  * Called after server actions - allows SWR pattern for popstate
@@ -687,6 +738,14 @@ export function createNavigationStore(
687
738
  interceptSourceUrl = url;
688
739
  },
689
740
 
741
+ getRouterId(): string | undefined {
742
+ return currentRouterId;
743
+ },
744
+
745
+ setRouterId(id: string): void {
746
+ currentRouterId = id;
747
+ },
748
+
690
749
  // ========================================================================
691
750
  // UI Update Notifications
692
751
  // ========================================================================
@@ -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
  */
@@ -39,8 +59,15 @@ export interface PartialUpdateConfig {
39
59
  segments: ResolvedSegment[],
40
60
  options?: RenderSegmentsOptions,
41
61
  ) => Promise<ReactNode> | ReactNode;
42
- /** RSC version received from server (from initial payload metadata) */
43
- 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;
44
71
  }
45
72
 
46
73
  /**
@@ -104,7 +131,14 @@ export type PartialUpdater = (
104
131
  export function createPartialUpdater(
105
132
  config: PartialUpdateConfig,
106
133
  ): PartialUpdater {
107
- const { store, client, onUpdate, renderSegments, version } = config;
134
+ const {
135
+ store,
136
+ client,
137
+ onUpdate,
138
+ renderSegments,
139
+ getVersion = () => undefined,
140
+ applyAppShell,
141
+ } = config;
108
142
 
109
143
  /**
110
144
  * Get current page's cached segments as an array
@@ -161,9 +195,16 @@ export function createPartialUpdater(
161
195
  segments = segmentIds ?? segmentState.currentSegmentIds;
162
196
  }
163
197
 
164
- // For intercept revalidation, use the intercept source URL as previousUrl
198
+ // For intercept revalidation, use the intercept source URL as previousUrl.
199
+ // For leave-intercept, tx.currentUrl captures window.location.href at tx
200
+ // creation, which on popstate is already the destination URL and would
201
+ // tell the server "from == to". segmentState.currentUrl still points at
202
+ // the URL the cached segments render (the intercept URL), which is the
203
+ // correct "from" for the server's diff computation.
165
204
  const previousUrl =
166
- interceptSourceUrl || tx.currentUrl || segmentState.currentUrl;
205
+ mode.type === "leave-intercept"
206
+ ? segmentState.currentUrl || tx.currentUrl
207
+ : interceptSourceUrl || tx.currentUrl || segmentState.currentUrl;
167
208
 
168
209
  debugLog(`\n[Browser] >>> NAVIGATION`);
169
210
  debugLog(`[Browser] From: ${previousUrl}`);
@@ -182,6 +223,11 @@ export function createPartialUpdater(
182
223
  targetCache && targetCache.length > 0
183
224
  ? targetCache
184
225
  : getCurrentCachedSegments();
226
+ const cachedSegsSource =
227
+ targetCache && targetCache.length > 0 ? "history-cache" : "current-page";
228
+ debugLog(
229
+ `[Browser] cachedSegs source: ${cachedSegsSource} (${cachedSegs.length} segments: ${cachedSegs.map((s) => s.id).join(", ")})`,
230
+ );
185
231
 
186
232
  // Fetch partial payload (no abort signal - RSC doesn't support it well)
187
233
  let fetchResult: Awaited<ReturnType<NavigationClient["fetchPartial"]>>;
@@ -193,7 +239,8 @@ export function createPartialUpdater(
193
239
  // (action redirect sends empty segments for a fresh render).
194
240
  staleRevalidation:
195
241
  mode.type === "stale-revalidation" || segments.length === 0,
196
- version,
242
+ version: getVersion(),
243
+ routerId: store.getRouterId?.(),
197
244
  });
198
245
  // Mark navigation as streaming (response received, now parsing RSC).
199
246
  // Called after fetchPartial so pendingUrl stays set during the network wait,
@@ -206,6 +253,32 @@ export function createPartialUpdater(
206
253
  streamingToken.end();
207
254
  });
208
255
 
256
+ // Detect app switch: if routerId changed, the navigation crossed into
257
+ // a different router (e.g., via host router path mount). Downgrade
258
+ // partial to full so the entire tree is replaced without reconciliation
259
+ // against stale segments from the previous app, and replace the app
260
+ // shell (rootLayout, basename, version) so the target app's document
261
+ // and router config take effect instead of remaining captured from the
262
+ // initial load. Theme, warmup, and prefetch TTL are intentionally
263
+ // document-lifetime (see AppShell doc); a new document navigation
264
+ // applies them.
265
+ if (payload.metadata?.routerId) {
266
+ const prevRouterId = store.getRouterId?.();
267
+ if (prevRouterId && prevRouterId !== payload.metadata.routerId) {
268
+ debugLog(
269
+ `[Browser] App switch detected (${prevRouterId} → ${payload.metadata.routerId}), forcing full update`,
270
+ );
271
+ payload.metadata.isPartial = false;
272
+ applyAppShell?.({
273
+ routerId: payload.metadata.routerId,
274
+ rootLayout: payload.metadata.rootLayout,
275
+ basename: payload.metadata.basename,
276
+ version: payload.metadata.version,
277
+ });
278
+ }
279
+ store.setRouterId?.(payload.metadata.routerId);
280
+ }
281
+
209
282
  // Handle server-side redirect with state
210
283
  if (payload.metadata?.redirect) {
211
284
  if (signal?.aborted) {
@@ -259,6 +332,17 @@ export function createPartialUpdater(
259
332
  existingSegments,
260
333
  );
261
334
 
335
+ // tx.commit() cached the source page's handleData because
336
+ // eventController hasn't been updated yet. Overwrite with the
337
+ // correct cached handleData to prevent cache corruption on
338
+ // subsequent navigations to this same URL.
339
+ if (mode.targetCacheHandleData) {
340
+ store.updateCacheHandleData(
341
+ store.getHistoryKey(),
342
+ mode.targetCacheHandleData,
343
+ );
344
+ }
345
+
262
346
  // Include cachedHandleData in metadata so NavigationProvider can restore
263
347
  // breadcrumbs and other handle data from cache.
264
348
  // Remove `handles` from metadata to prevent NavigationProvider from
@@ -274,10 +358,7 @@ export function createPartialUpdater(
274
358
  scroll: toScrollPayload(commitScroll),
275
359
  };
276
360
 
277
- const cachedHasTransition = existingSegments.some(
278
- (s) => s.transition,
279
- );
280
- if (cachedHasTransition) {
361
+ if (shouldStartViewTransition(existingSegments)) {
281
362
  startTransition(() => {
282
363
  if (addTransitionType) {
283
364
  addTransitionType("navigation");
@@ -463,7 +544,7 @@ export function createPartialUpdater(
463
544
 
464
545
  // Emit update to trigger React render.
465
546
  // Scroll info is included so NavigationProvider applies it after React commits.
466
- const hasTransition = reconciled.mainSegments.some((s) => s.transition);
547
+ const hasTransition = shouldStartViewTransition(reconciled.segments);
467
548
  const scrollPayload = toScrollPayload(navScroll);
468
549
 
469
550
  if (mode.type === "action" || mode.type === "stale-revalidation") {