@rangojs/router 0.0.0-experimental.d7eeaa75 → 0.0.0-experimental.dacec167

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 (255) hide show
  1. package/README.md +120 -25
  2. package/dist/bin/rango.js +147 -57
  3. package/dist/testing/vitest.js +82 -0
  4. package/dist/vite/index.js +2151 -846
  5. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  6. package/package.json +57 -11
  7. package/skills/breadcrumbs/SKILL.md +3 -1
  8. package/skills/bundle-analysis/SKILL.md +159 -0
  9. package/skills/cache-guide/SKILL.md +220 -30
  10. package/skills/caching/SKILL.md +116 -8
  11. package/skills/composability/SKILL.md +27 -2
  12. package/skills/document-cache/SKILL.md +78 -55
  13. package/skills/handler-use/SKILL.md +364 -0
  14. package/skills/hooks/SKILL.md +229 -20
  15. package/skills/host-router/SKILL.md +45 -20
  16. package/skills/i18n/SKILL.md +276 -0
  17. package/skills/intercept/SKILL.md +46 -4
  18. package/skills/layout/SKILL.md +28 -7
  19. package/skills/links/SKILL.md +247 -17
  20. package/skills/loader/SKILL.md +219 -9
  21. package/skills/middleware/SKILL.md +47 -12
  22. package/skills/migrate-nextjs/SKILL.md +562 -0
  23. package/skills/migrate-react-router/SKILL.md +769 -0
  24. package/skills/mime-routes/SKILL.md +27 -0
  25. package/skills/observability/SKILL.md +137 -0
  26. package/skills/parallel/SKILL.md +71 -6
  27. package/skills/prerender/SKILL.md +14 -33
  28. package/skills/rango/SKILL.md +242 -22
  29. package/skills/react-compiler/SKILL.md +168 -0
  30. package/skills/response-routes/SKILL.md +66 -9
  31. package/skills/route/SKILL.md +57 -4
  32. package/skills/router-setup/SKILL.md +3 -3
  33. package/skills/server-actions/SKILL.md +751 -0
  34. package/skills/streams-and-websockets/SKILL.md +283 -0
  35. package/skills/testing/SKILL.md +778 -0
  36. package/skills/typesafety/SKILL.md +319 -27
  37. package/skills/use-cache/SKILL.md +34 -5
  38. package/skills/view-transitions/SKILL.md +294 -0
  39. package/src/__augment-tests__/augment.ts +81 -0
  40. package/src/__augment-tests__/augmented.check.ts +117 -0
  41. package/src/browser/action-coordinator.ts +53 -36
  42. package/src/browser/app-shell.ts +52 -0
  43. package/src/browser/event-controller.ts +86 -70
  44. package/src/browser/history-state.ts +21 -0
  45. package/src/browser/index.ts +3 -3
  46. package/src/browser/navigation-bridge.ts +84 -11
  47. package/src/browser/navigation-client.ts +76 -28
  48. package/src/browser/navigation-store.ts +32 -9
  49. package/src/browser/navigation-transaction.ts +10 -28
  50. package/src/browser/partial-update.ts +64 -26
  51. package/src/browser/prefetch/cache.ts +129 -21
  52. package/src/browser/prefetch/fetch.ts +148 -16
  53. package/src/browser/prefetch/queue.ts +36 -5
  54. package/src/browser/rango-state.ts +53 -13
  55. package/src/browser/react/Link.tsx +30 -2
  56. package/src/browser/react/NavigationProvider.tsx +72 -31
  57. package/src/browser/react/filter-segment-order.ts +51 -7
  58. package/src/browser/react/index.ts +3 -0
  59. package/src/browser/react/location-state-shared.ts +175 -4
  60. package/src/browser/react/location-state.ts +39 -13
  61. package/src/browser/react/use-handle.ts +17 -9
  62. package/src/browser/react/use-navigation.ts +22 -2
  63. package/src/browser/react/use-params.ts +20 -8
  64. package/src/browser/react/use-reverse.ts +106 -0
  65. package/src/browser/react/use-router.ts +22 -2
  66. package/src/browser/react/use-segments.ts +11 -8
  67. package/src/browser/response-adapter.ts +25 -0
  68. package/src/browser/rsc-router.tsx +64 -22
  69. package/src/browser/scroll-restoration.ts +22 -14
  70. package/src/browser/segment-reconciler.ts +36 -14
  71. package/src/browser/segment-structure-assert.ts +2 -2
  72. package/src/browser/server-action-bridge.ts +23 -30
  73. package/src/browser/types.ts +21 -0
  74. package/src/build/collect-fallback-refs.ts +107 -0
  75. package/src/build/generate-manifest.ts +60 -35
  76. package/src/build/generate-route-types.ts +2 -0
  77. package/src/build/index.ts +2 -0
  78. package/src/build/route-trie.ts +52 -25
  79. package/src/build/route-types/codegen.ts +4 -4
  80. package/src/build/route-types/include-resolution.ts +1 -1
  81. package/src/build/route-types/per-module-writer.ts +7 -4
  82. package/src/build/route-types/router-processing.ts +55 -14
  83. package/src/build/route-types/scan-filter.ts +1 -1
  84. package/src/build/route-types/source-scan.ts +118 -0
  85. package/src/build/runtime-discovery.ts +9 -20
  86. package/src/cache/cache-scope.ts +28 -42
  87. package/src/cache/cf/cf-cache-store.ts +54 -13
  88. package/src/client.rsc.tsx +3 -0
  89. package/src/client.tsx +92 -182
  90. package/src/context-var.ts +5 -5
  91. package/src/decode-loader-results.ts +36 -0
  92. package/src/errors.ts +30 -1
  93. package/src/handle.ts +26 -13
  94. package/src/host/index.ts +2 -2
  95. package/src/host/router.ts +129 -57
  96. package/src/host/types.ts +31 -2
  97. package/src/host/utils.ts +1 -1
  98. package/src/href-client.ts +140 -20
  99. package/src/index.rsc.ts +9 -4
  100. package/src/index.ts +53 -15
  101. package/src/loader-store.ts +500 -0
  102. package/src/loader.rsc.ts +21 -6
  103. package/src/loader.ts +3 -10
  104. package/src/missing-id-error.ts +68 -0
  105. package/src/outlet-context.ts +1 -1
  106. package/src/prerender.ts +4 -4
  107. package/src/response-utils.ts +37 -0
  108. package/src/reverse.ts +65 -36
  109. package/src/route-content-wrapper.tsx +6 -28
  110. package/src/route-definition/dsl-helpers.ts +384 -257
  111. package/src/route-definition/helper-factories.ts +29 -139
  112. package/src/route-definition/helpers-types.ts +100 -28
  113. package/src/route-definition/resolve-handler-use.ts +6 -0
  114. package/src/route-definition/use-item-types.ts +32 -0
  115. package/src/route-types.ts +26 -41
  116. package/src/router/basename.ts +14 -0
  117. package/src/router/content-negotiation.ts +15 -2
  118. package/src/router/error-handling.ts +1 -1
  119. package/src/router/handler-context.ts +21 -38
  120. package/src/router/intercept-resolution.ts +4 -18
  121. package/src/router/lazy-includes.ts +8 -8
  122. package/src/router/loader-resolution.ts +19 -2
  123. package/src/router/manifest.ts +22 -13
  124. package/src/router/match-api.ts +4 -3
  125. package/src/router/match-handlers.ts +63 -20
  126. package/src/router/match-middleware/cache-lookup.ts +44 -91
  127. package/src/router/match-middleware/cache-store.ts +3 -2
  128. package/src/router/match-result.ts +53 -32
  129. package/src/router/metrics.ts +1 -1
  130. package/src/router/middleware-types.ts +15 -26
  131. package/src/router/middleware.ts +99 -84
  132. package/src/router/pattern-matching.ts +101 -17
  133. package/src/router/prerender-match.ts +1 -1
  134. package/src/router/preview-match.ts +3 -1
  135. package/src/router/request-classification.ts +4 -28
  136. package/src/router/revalidation.ts +58 -2
  137. package/src/router/router-interfaces.ts +45 -28
  138. package/src/router/router-options.ts +40 -1
  139. package/src/router/router-registry.ts +2 -5
  140. package/src/router/segment-resolution/fresh.ts +27 -6
  141. package/src/router/segment-resolution/revalidation.ts +147 -106
  142. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  143. package/src/router/substitute-pattern-params.ts +56 -0
  144. package/src/router/telemetry.ts +99 -0
  145. package/src/router/trie-matching.ts +18 -13
  146. package/src/router/types.ts +8 -0
  147. package/src/router/url-params.ts +49 -0
  148. package/src/router.ts +38 -23
  149. package/src/rsc/handler-context.ts +2 -2
  150. package/src/rsc/handler.ts +28 -69
  151. package/src/rsc/helpers.ts +91 -43
  152. package/src/rsc/index.ts +1 -1
  153. package/src/rsc/origin-guard.ts +28 -10
  154. package/src/rsc/progressive-enhancement.ts +4 -0
  155. package/src/rsc/response-route-handler.ts +46 -53
  156. package/src/rsc/rsc-rendering.ts +35 -51
  157. package/src/rsc/runtime-warnings.ts +9 -10
  158. package/src/rsc/server-action.ts +17 -37
  159. package/src/rsc/ssr-setup.ts +16 -0
  160. package/src/rsc/types.ts +8 -2
  161. package/src/search-params.ts +4 -4
  162. package/src/segment-content-promise.ts +67 -0
  163. package/src/segment-loader-promise.ts +122 -0
  164. package/src/segment-system.tsx +132 -116
  165. package/src/serialize.ts +243 -0
  166. package/src/server/context.ts +143 -53
  167. package/src/server/cookie-store.ts +28 -4
  168. package/src/server/request-context.ts +20 -42
  169. package/src/ssr/index.tsx +5 -1
  170. package/src/static-handler.ts +1 -1
  171. package/src/testing/cache-status.ts +166 -0
  172. package/src/testing/collect-handle.ts +63 -0
  173. package/src/testing/dispatch.ts +440 -0
  174. package/src/testing/dom.entry.ts +22 -0
  175. package/src/testing/e2e/fixture.ts +154 -0
  176. package/src/testing/e2e/index.ts +149 -0
  177. package/src/testing/e2e/matchers.ts +51 -0
  178. package/src/testing/e2e/page-helpers.ts +272 -0
  179. package/src/testing/e2e/parity.ts +306 -0
  180. package/src/testing/e2e/server.ts +183 -0
  181. package/src/testing/flight-matchers.ts +104 -0
  182. package/src/testing/flight-runtime.d.ts +57 -0
  183. package/src/testing/flight-tree.ts +320 -0
  184. package/src/testing/flight.entry.ts +39 -0
  185. package/src/testing/flight.ts +197 -0
  186. package/src/testing/generated-routes.ts +223 -0
  187. package/src/testing/index.ts +106 -0
  188. package/src/testing/internal/context.ts +331 -0
  189. package/src/testing/internal/flight-client-globals.ts +30 -0
  190. package/src/testing/render-route.tsx +565 -0
  191. package/src/testing/run-loader.ts +341 -0
  192. package/src/testing/run-middleware.ts +188 -0
  193. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  194. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  195. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  196. package/src/testing/vitest-stubs/version.ts +5 -0
  197. package/src/testing/vitest.ts +270 -0
  198. package/src/types/global-namespace.ts +39 -26
  199. package/src/types/handler-context.ts +68 -50
  200. package/src/types/index.ts +1 -0
  201. package/src/types/loader-types.ts +5 -6
  202. package/src/types/request-scope.ts +126 -0
  203. package/src/types/route-entry.ts +11 -0
  204. package/src/types/segments.ts +35 -2
  205. package/src/urls/include-helper.ts +34 -67
  206. package/src/urls/index.ts +0 -3
  207. package/src/urls/path-helper-types.ts +41 -7
  208. package/src/urls/path-helper.ts +17 -52
  209. package/src/urls/pattern-types.ts +36 -19
  210. package/src/urls/response-types.ts +22 -29
  211. package/src/urls/type-extraction.ts +26 -116
  212. package/src/urls/urls-function.ts +1 -5
  213. package/src/use-loader.tsx +413 -42
  214. package/src/vite/debug.ts +185 -0
  215. package/src/vite/discovery/bundle-postprocess.ts +6 -6
  216. package/src/vite/discovery/discover-routers.ts +101 -51
  217. package/src/vite/discovery/discovery-errors.ts +194 -0
  218. package/src/vite/discovery/gate-state.ts +171 -0
  219. package/src/vite/discovery/prerender-collection.ts +67 -26
  220. package/src/vite/discovery/route-types-writer.ts +40 -84
  221. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  222. package/src/vite/discovery/state.ts +33 -0
  223. package/src/vite/discovery/virtual-module-codegen.ts +13 -23
  224. package/src/vite/index.ts +2 -0
  225. package/src/vite/plugin-types.ts +67 -0
  226. package/src/vite/plugins/cjs-to-esm.ts +8 -7
  227. package/src/vite/plugins/client-ref-dedup.ts +16 -0
  228. package/src/vite/plugins/client-ref-hashing.ts +28 -5
  229. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  230. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  231. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  232. package/src/vite/plugins/expose-action-id.ts +54 -30
  233. package/src/vite/plugins/expose-id-utils.ts +12 -8
  234. package/src/vite/plugins/expose-ids/export-analysis.ts +100 -20
  235. package/src/vite/plugins/expose-ids/handler-transform.ts +8 -61
  236. package/src/vite/plugins/expose-ids/loader-transform.ts +3 -5
  237. package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
  238. package/src/vite/plugins/expose-internal-ids.ts +496 -486
  239. package/src/vite/plugins/performance-tracks.ts +29 -25
  240. package/src/vite/plugins/use-cache-transform.ts +65 -50
  241. package/src/vite/plugins/version-injector.ts +39 -23
  242. package/src/vite/plugins/version-plugin.ts +59 -2
  243. package/src/vite/plugins/virtual-entries.ts +2 -2
  244. package/src/vite/rango.ts +116 -29
  245. package/src/vite/router-discovery.ts +750 -100
  246. package/src/vite/utils/ast-handler-extract.ts +15 -15
  247. package/src/vite/utils/banner.ts +1 -1
  248. package/src/vite/utils/bundle-analysis.ts +4 -2
  249. package/src/vite/utils/client-chunks.ts +190 -0
  250. package/src/vite/utils/forward-user-plugins.ts +193 -0
  251. package/src/vite/utils/manifest-utils.ts +21 -5
  252. package/src/vite/utils/package-resolution.ts +41 -1
  253. package/src/vite/utils/prerender-utils.ts +21 -6
  254. package/src/vite/utils/shared-utils.ts +107 -26
  255. package/src/browser/action-response-classifier.ts +0 -99
@@ -15,10 +15,12 @@ import { getRangoState } from "./rango-state.js";
15
15
  import {
16
16
  extractRscHeaderUrl,
17
17
  emptyResponse,
18
+ handleReloadHeader,
18
19
  teeWithCompletion,
19
20
  } from "./response-adapter.js";
20
21
  import {
21
22
  buildPrefetchKey,
23
+ buildSourceKey,
22
24
  consumeInflightPrefetch,
23
25
  consumePrefetch,
24
26
  } from "./prefetch/cache.js";
@@ -30,8 +32,10 @@ import {
30
32
  * deserializing the response using the RSC runtime.
31
33
  *
32
34
  * 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.
35
+ * Tries the source-scoped key first (populated when the server tagged
36
+ * the response as source-sensitive via `X-RSC-Prefetch-Scope: source`)
37
+ * and falls back to the Rango-state-keyed wildcard slot used for the
38
+ * common source-agnostic case.
35
39
  *
36
40
  * @param deps - RSC browser dependencies (createFromFetch)
37
41
  * @returns NavigationClient instance
@@ -93,18 +97,42 @@ export function createNavigationClient(
93
97
  fetchUrl.searchParams.set("_rsc_rid", routerId);
94
98
  }
95
99
 
96
- // Check completed in-memory prefetch cache before making a network request.
97
- // The cache key includes the source URL (previousUrl) because the
98
- // server's diff response depends on the source page context.
100
+ // Check completed in-memory prefetch cache before making a network
101
+ // request. Try the source-scoped key first (populated when the server
102
+ // tagged the prefetch response as source-sensitive, e.g. intercepts,
103
+ // or when a Link opted in with `prefetchKey=":source"`), then fall
104
+ // back to the wildcard slot shared across source pages.
105
+ // Both keys embed the Rango state, so state rotation (deploy or
106
+ // server-action invalidation) auto-invalidates both scopes.
99
107
  // Skip cache for stale revalidation (needs fresh data), HMR (needs
100
108
  // fresh modules), and intercept contexts (source-dependent responses).
101
- //
102
109
  const canUsePrefetch = !staleRevalidation && !hmr && !interceptSourceUrl;
103
- const cacheKey = buildPrefetchKey(previousUrl, fetchUrl);
104
- const cachedResponse = canUsePrefetch ? consumePrefetch(cacheKey) : null;
105
- const inflightResponsePromise = canUsePrefetch
106
- ? consumeInflightPrefetch(cacheKey)
107
- : null;
110
+ const rangoState = getRangoState();
111
+ const wildcardKey = buildPrefetchKey(rangoState, fetchUrl);
112
+ const cacheKey = buildSourceKey(rangoState, previousUrl, fetchUrl);
113
+
114
+ let cachedResponse: Response | null = null;
115
+ let hitKey: string | null = null;
116
+ if (canUsePrefetch) {
117
+ cachedResponse = consumePrefetch(cacheKey);
118
+ if (cachedResponse) {
119
+ hitKey = cacheKey;
120
+ } else {
121
+ cachedResponse = consumePrefetch(wildcardKey);
122
+ if (cachedResponse) hitKey = wildcardKey;
123
+ }
124
+ }
125
+
126
+ let inflightResponsePromise: Promise<Response | null> | null = null;
127
+ if (canUsePrefetch && !cachedResponse) {
128
+ inflightResponsePromise = consumeInflightPrefetch(cacheKey);
129
+ if (inflightResponsePromise) {
130
+ hitKey = cacheKey;
131
+ } else {
132
+ inflightResponsePromise = consumeInflightPrefetch(wildcardKey);
133
+ if (inflightResponsePromise) hitKey = wildcardKey;
134
+ }
135
+ }
108
136
  // Track when the stream completes
109
137
  let resolveStreamComplete: () => void;
110
138
  const streamComplete = new Promise<void>((resolve) => {
@@ -121,21 +149,17 @@ export function createNavigationClient(
121
149
  source: string,
122
150
  ): Response | Promise<Response> => {
123
151
  // Version mismatch — server wants a full page reload
124
- const reload = extractRscHeaderUrl(response, "X-RSC-Reload");
125
- if (reload === "blocked") {
126
- resolveStreamComplete();
127
- return emptyResponse();
128
- }
129
- if (reload) {
130
- if (tx) {
131
- browserDebugLog(tx, `version mismatch, reloading (${source})`, {
132
- reloadUrl: reload.url,
133
- });
134
- }
135
- window.location.href = reload.url;
136
- // Block further processing — page is reloading
137
- return new Promise<Response>(() => {});
138
- }
152
+ const reloadResult = handleReloadHeader(response, {
153
+ onBlocked: resolveStreamComplete,
154
+ onReload: (url) => {
155
+ if (tx) {
156
+ browserDebugLog(tx, `version mismatch, reloading (${source})`, {
157
+ reloadUrl: url,
158
+ });
159
+ }
160
+ },
161
+ });
162
+ if (reloadResult) return reloadResult;
139
163
 
140
164
  // Server-side redirect without state: the server returned 204 with
141
165
  // X-RSC-Redirect instead of a 3xx (which fetch would auto-follow
@@ -197,7 +221,10 @@ export function createNavigationClient(
197
221
 
198
222
  if (cachedResponse) {
199
223
  if (tx) {
200
- browserDebugLog(tx, "prefetch cache hit", { key: cacheKey });
224
+ browserDebugLog(tx, "prefetch cache hit", {
225
+ key: hitKey,
226
+ wildcard: hitKey === wildcardKey,
227
+ });
201
228
  }
202
229
  responsePromise = Promise.resolve(cachedResponse).then((response) => {
203
230
  const validated = validateRscHeaders(response, "prefetch cache");
@@ -214,8 +241,12 @@ export function createNavigationClient(
214
241
  });
215
242
  } else if (inflightResponsePromise) {
216
243
  if (tx) {
217
- browserDebugLog(tx, "reusing inflight prefetch", { key: cacheKey });
244
+ browserDebugLog(tx, "reusing inflight prefetch", {
245
+ key: hitKey,
246
+ wildcard: hitKey === wildcardKey,
247
+ });
218
248
  }
249
+ const adoptedViaWildcard = hitKey === wildcardKey;
219
250
  responsePromise = inflightResponsePromise.then(async (response) => {
220
251
  if (!response) {
221
252
  if (tx) {
@@ -224,6 +255,23 @@ export function createNavigationClient(
224
255
  return doFreshFetch();
225
256
  }
226
257
 
258
+ // Cross-source safety: an inflight promise adopted via the
259
+ // wildcard key may turn out to be source-scoped (server emitted
260
+ // `X-RSC-Prefetch-Scope: source`), which means it was built for
261
+ // a different source page. Discard and refetch.
262
+ if (
263
+ adoptedViaWildcard &&
264
+ response.headers.get("x-rsc-prefetch-scope") === "source"
265
+ ) {
266
+ if (tx) {
267
+ browserDebugLog(
268
+ tx,
269
+ "wildcard inflight turned out source-scoped, refetching",
270
+ );
271
+ }
272
+ return doFreshFetch();
273
+ }
274
+
227
275
  const validated = validateRscHeaders(response, "inflight prefetch");
228
276
  if (validated instanceof Promise) return validated;
229
277
 
@@ -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)
@@ -280,18 +283,17 @@ export function createNavigationStore(
280
283
  /**
281
284
  * Create a debounced function that batches rapid calls
282
285
  */
286
+ // A non-keyed notifier is the keyed one restricted to a single constant key;
287
+ // its own keyed instance means the "" key never collides with action keys.
283
288
  function createDebouncedNotifier<T extends (...args: any[]) => void>(
284
289
  fn: T,
285
290
  ms: number = 20,
286
291
  ): T {
287
- let timeout: ReturnType<typeof setTimeout> | null = null;
288
- return ((...args: Parameters<T>) => {
289
- if (timeout !== null) clearTimeout(timeout);
290
- timeout = setTimeout(() => {
291
- timeout = null;
292
- fn(...args);
293
- }, ms);
294
- }) as T;
292
+ const keyed = createKeyedDebouncedNotifier(
293
+ (_key: string, ...args: any[]) => fn(...args),
294
+ ms,
295
+ );
296
+ return ((...args: Parameters<T>) => keyed("", ...args)) as T;
295
297
  }
296
298
 
297
299
  /**
@@ -335,6 +337,18 @@ export function createNavigationStore(
335
337
  clearPrefetchCache();
336
338
  }
337
339
 
340
+ /**
341
+ * Drop this tab's navigation + prefetch caches without broadcasting or
342
+ * rotating shared state. Used when the local session changes in a way that
343
+ * doesn't affect other tabs — e.g. this tab crosses into a different app
344
+ * via a cross-router navigation. Other tabs in the old app keep their
345
+ * caches and their X-Rango-State token.
346
+ */
347
+ function clearCacheInternalLocal(): void {
348
+ historyCache.length = 0;
349
+ clearPrefetchCacheLocal();
350
+ }
351
+
338
352
  /**
339
353
  * Mark all cache entries as stale (internal - does not broadcast)
340
354
  */
@@ -668,6 +682,15 @@ export function createNavigationStore(
668
682
  clearCacheAndBroadcast();
669
683
  },
670
684
 
685
+ /**
686
+ * Drop this tab's navigation + prefetch caches locally without
687
+ * broadcasting or rotating shared state. Intended for cross-app
688
+ * transitions where the session state diverges for this tab only.
689
+ */
690
+ clearHistoryCacheLocal(): void {
691
+ clearCacheInternalLocal();
692
+ },
693
+
671
694
  /**
672
695
  * Mark cache as stale and broadcast to other tabs
673
696
  * Called after server actions - allows SWR pattern for popstate
@@ -11,7 +11,7 @@ import {
11
11
  } from "./scroll-restoration.js";
12
12
  import type { EventController, NavigationHandle } from "./event-controller.js";
13
13
  import { debugLog } from "./logging.js";
14
- import { buildHistoryState } from "./history-state.js";
14
+ import { buildHistoryState, pushHistoryWithIdx } from "./history-state.js";
15
15
 
16
16
  // Re-export for consumers that import from navigation-transaction
17
17
  export { resolveNavigationState } from "./history-state.js";
@@ -186,12 +186,8 @@ export function createNavigationTransaction(
186
186
  // Used to detect when location state is being cleared.
187
187
  const oldState = window.history.state;
188
188
 
189
- // Update browser URL
190
- if (replace) {
191
- window.history.replaceState(historyState, "", url);
192
- } else {
193
- window.history.pushState(historyState, "", url);
194
- }
189
+ // Update browser URL (stamps history.state.idx for back() first-entry detection)
190
+ pushHistoryWithIdx(historyState, url, replace ?? false);
195
191
  // Ensure new history entry has a scroll restoration key
196
192
  ensureHistoryKey();
197
193
 
@@ -240,30 +236,16 @@ export function createNavigationTransaction(
240
236
  segments: ResolvedSegment[],
241
237
  overrides?: BoundCommitOverrides,
242
238
  ) => {
243
- // Allow overrides to disable scroll (e.g., for intercepts)
244
- const finalScroll =
245
- overrides?.scroll !== undefined ? overrides.scroll : opts.scroll;
246
- // Allow overrides to force replace (e.g., for intercepts)
247
- const finalReplace =
248
- overrides?.replace !== undefined ? overrides.replace : opts.replace;
249
- // Intercept info: overrides take precedence, fallback to opts
250
- const intercept =
251
- overrides?.intercept !== undefined
252
- ? overrides.intercept
253
- : opts.intercept;
239
+ const finalScroll = overrides?.scroll ?? opts.scroll;
240
+ const finalReplace = overrides?.replace ?? opts.replace;
241
+ const intercept = overrides?.intercept ?? opts.intercept;
254
242
  const interceptSourceUrl =
255
- overrides?.interceptSourceUrl !== undefined
256
- ? overrides.interceptSourceUrl
257
- : opts.interceptSourceUrl;
258
- // Cache-only mode: overrides take precedence, fallback to opts
259
- const cacheOnly =
260
- overrides?.cacheOnly !== undefined
261
- ? overrides.cacheOnly
262
- : opts.cacheOnly;
263
- // User state: overrides take precedence, fallback to opts
243
+ overrides?.interceptSourceUrl ?? opts.interceptSourceUrl;
244
+ const cacheOnly = overrides?.cacheOnly ?? opts.cacheOnly;
245
+ // state is `unknown` (null is meaningful) so `??` would wrongly drop a
246
+ // null override; serverState always comes from overrides, never opts.
264
247
  const state =
265
248
  overrides?.state !== undefined ? overrides.state : opts.state;
266
- // Server-set location state: only from overrides (set by partial-update)
267
249
  const serverState = overrides?.serverState;
268
250
  return commit({
269
251
  ...opts,
@@ -14,7 +14,10 @@ const addTransitionType: ((type: string) => void) | undefined =
14
14
  import type { RenderSegmentsOptions } from "../segment-system.js";
15
15
  import { reconcileSegments } from "./segment-reconciler.js";
16
16
  import type { ReconcileActor } from "./segment-reconciler.js";
17
- import { hasActiveIntercept as hasActiveInterceptSlots } from "./intercept-utils.js";
17
+ import {
18
+ hasActiveIntercept as hasActiveInterceptSlots,
19
+ isInterceptSegment,
20
+ } from "./intercept-utils.js";
18
21
  import type { BoundTransaction } from "./navigation-transaction.js";
19
22
  import { ServerRedirect } from "../errors.js";
20
23
  import { debugLog } from "./logging.js";
@@ -28,6 +31,23 @@ function toScrollPayload(
28
31
  return { enabled: scroll !== false ? scroll : false };
29
32
  }
30
33
 
34
+ /**
35
+ * Whether to wrap an update in startViewTransition.
36
+ *
37
+ * Intercept-driven updates only mutate the parallel slot — the main outlet
38
+ * shows the same content — so transitions on the underlying main segments
39
+ * shouldn't fire (otherwise their elements get hoisted above the modal).
40
+ */
41
+ function shouldStartViewTransition(segments: ResolvedSegment[]): boolean {
42
+ let hasIntercept = false;
43
+ let hasTransition = false;
44
+ for (const s of segments) {
45
+ if (isInterceptSegment(s)) hasIntercept = true;
46
+ else if (s.transition) hasTransition = true;
47
+ }
48
+ return !hasIntercept && hasTransition;
49
+ }
50
+
31
51
  /**
32
52
  * Configuration for creating a partial updater
33
53
  */
@@ -41,6 +61,13 @@ export interface PartialUpdateConfig {
41
61
  ) => Promise<ReactNode> | ReactNode;
42
62
  /** RSC version getter — returns the current version (may change after HMR) */
43
63
  getVersion?: () => string | undefined;
64
+ /**
65
+ * Replace the active app-shell when a cross-app navigation is detected.
66
+ * Called before the full-update tree replacement renders, so the new
67
+ * payload's rootLayout, basename, and version are picked up. Theme,
68
+ * warmup, and prefetch TTL are not part of the shell — see AppShell.
69
+ */
70
+ applyAppShell?: (next: import("./app-shell.js").AppShell) => void;
44
71
  }
45
72
 
46
73
  /**
@@ -76,7 +103,7 @@ export type UpdateMode =
76
103
  /** Source URL for intercept restore (popstate cache miss) */
77
104
  interceptSourceUrl?: string;
78
105
  }
79
- | { type: "leave-intercept" }
106
+ | { type: "leave-intercept"; interceptSourceUrl?: string }
80
107
  | { type: "stale-revalidation"; interceptSourceUrl?: string }
81
108
  | { type: "action"; interceptSourceUrl?: string };
82
109
 
@@ -110,6 +137,7 @@ export function createPartialUpdater(
110
137
  onUpdate,
111
138
  renderSegments,
112
139
  getVersion = () => undefined,
140
+ applyAppShell,
113
141
  } = config;
114
142
 
115
143
  /**
@@ -141,13 +169,7 @@ export function createPartialUpdater(
141
169
  // Capture history key at start for stale revalidation consistency check
142
170
  const historyKeyAtStart = store.getHistoryKey();
143
171
 
144
- // Derive interceptSourceUrl from modes that carry it
145
- const interceptSourceUrl =
146
- mode.type === "stale-revalidation" ||
147
- mode.type === "action" ||
148
- mode.type === "navigate"
149
- ? mode.interceptSourceUrl
150
- : undefined;
172
+ const interceptSourceUrl = mode.interceptSourceUrl;
151
173
 
152
174
  // When leaving intercept, filter out intercept-specific segments
153
175
  let segments: string[];
@@ -167,9 +189,16 @@ export function createPartialUpdater(
167
189
  segments = segmentIds ?? segmentState.currentSegmentIds;
168
190
  }
169
191
 
170
- // 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.
171
198
  const previousUrl =
172
- interceptSourceUrl || tx.currentUrl || segmentState.currentUrl;
199
+ mode.type === "leave-intercept"
200
+ ? segmentState.currentUrl || tx.currentUrl
201
+ : interceptSourceUrl || tx.currentUrl || segmentState.currentUrl;
173
202
 
174
203
  debugLog(`\n[Browser] >>> NAVIGATION`);
175
204
  debugLog(`[Browser] From: ${previousUrl}`);
@@ -183,11 +212,14 @@ export function createPartialUpdater(
183
212
  // When navigating with targetCacheSegments, use those for consistency.
184
213
  // Otherwise fall back to current page's segments (for same-route revalidation).
185
214
  const targetCache =
186
- mode.type === "navigate" ? mode.targetCacheSegments : undefined;
187
- const cachedSegs =
188
- targetCache && targetCache.length > 0
189
- ? targetCache
190
- : 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
+ );
191
223
 
192
224
  // Fetch partial payload (no abort signal - RSC doesn't support it well)
193
225
  let fetchResult: Awaited<ReturnType<NavigationClient["fetchPartial"]>>;
@@ -216,7 +248,12 @@ export function createPartialUpdater(
216
248
  // Detect app switch: if routerId changed, the navigation crossed into
217
249
  // a different router (e.g., via host router path mount). Downgrade
218
250
  // partial to full so the entire tree is replaced without reconciliation
219
- // against stale segments from the previous app.
251
+ // against stale segments from the previous app, and replace the app
252
+ // shell (rootLayout, basename, version) so the target app's document
253
+ // and router config take effect instead of remaining captured from the
254
+ // initial load. Theme, warmup, and prefetch TTL are intentionally
255
+ // document-lifetime (see AppShell doc); a new document navigation
256
+ // applies them.
220
257
  if (payload.metadata?.routerId) {
221
258
  const prevRouterId = store.getRouterId?.();
222
259
  if (prevRouterId && prevRouterId !== payload.metadata.routerId) {
@@ -224,6 +261,12 @@ export function createPartialUpdater(
224
261
  `[Browser] App switch detected (${prevRouterId} → ${payload.metadata.routerId}), forcing full update`,
225
262
  );
226
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
+ });
227
270
  }
228
271
  store.setRouterId?.(payload.metadata.routerId);
229
272
  }
@@ -267,7 +310,7 @@ export function createPartialUpdater(
267
310
  .filter(Boolean) as ResolvedSegment[];
268
311
 
269
312
  // When navigating with cached segments to a different route, render them.
270
- if (mode.type === "navigate" && targetCache && targetCache.length > 0) {
313
+ if (mode.type === "navigate" && targetCache) {
271
314
  debugLog(
272
315
  "[Browser] No diff but navigating with cached segments - rendering target route",
273
316
  );
@@ -307,10 +350,7 @@ export function createPartialUpdater(
307
350
  scroll: toScrollPayload(commitScroll),
308
351
  };
309
352
 
310
- const cachedHasTransition = existingSegments.some(
311
- (s) => s.transition,
312
- );
313
- if (cachedHasTransition) {
353
+ if (shouldStartViewTransition(existingSegments)) {
314
354
  startTransition(() => {
315
355
  if (addTransitionType) {
316
356
  addTransitionType("navigation");
@@ -496,7 +536,7 @@ export function createPartialUpdater(
496
536
 
497
537
  // Emit update to trigger React render.
498
538
  // Scroll info is included so NavigationProvider applies it after React commits.
499
- const hasTransition = reconciled.mainSegments.some((s) => s.transition);
539
+ const hasTransition = shouldStartViewTransition(reconciled.segments);
500
540
  const scrollPayload = toScrollPayload(navScroll);
501
541
 
502
542
  if (mode.type === "action" || mode.type === "stale-revalidation") {
@@ -558,9 +598,7 @@ export function createPartialUpdater(
558
598
  })
559
599
  : tx.commit(segmentIds, segments);
560
600
 
561
- const fullHasTransition = segments.some(
562
- (s: ResolvedSegment) => s.transition,
563
- );
601
+ const fullHasTransition = shouldStartViewTransition(segments);
564
602
  const fullScrollPayload = toScrollPayload(fullScroll);
565
603
 
566
604
  if (mode.type === "stale-revalidation") {