@rangojs/router 0.0.0-experimental.20 → 0.0.0-experimental.20dbba0c

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 (189) hide show
  1. package/AGENTS.md +4 -0
  2. package/README.md +172 -50
  3. package/dist/bin/rango.js +138 -50
  4. package/dist/vite/index.js +1160 -508
  5. package/dist/vite/index.js.bak +5448 -0
  6. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  7. package/package.json +17 -16
  8. package/skills/breadcrumbs/SKILL.md +252 -0
  9. package/skills/cache-guide/SKILL.md +32 -0
  10. package/skills/caching/SKILL.md +49 -8
  11. package/skills/document-cache/SKILL.md +2 -2
  12. package/skills/handler-use/SKILL.md +362 -0
  13. package/skills/hooks/SKILL.md +61 -51
  14. package/skills/host-router/SKILL.md +218 -0
  15. package/skills/intercept/SKILL.md +20 -0
  16. package/skills/layout/SKILL.md +22 -0
  17. package/skills/links/SKILL.md +91 -17
  18. package/skills/loader/SKILL.md +107 -24
  19. package/skills/middleware/SKILL.md +34 -3
  20. package/skills/migrate-nextjs/SKILL.md +560 -0
  21. package/skills/migrate-react-router/SKILL.md +765 -0
  22. package/skills/parallel/SKILL.md +185 -0
  23. package/skills/prerender/SKILL.md +112 -70
  24. package/skills/rango/SKILL.md +24 -23
  25. package/skills/response-routes/SKILL.md +8 -0
  26. package/skills/route/SKILL.md +58 -4
  27. package/skills/router-setup/SKILL.md +95 -5
  28. package/skills/streams-and-websockets/SKILL.md +283 -0
  29. package/skills/typesafety/SKILL.md +38 -24
  30. package/src/__internal.ts +92 -0
  31. package/src/browser/app-shell.ts +52 -0
  32. package/src/browser/app-version.ts +14 -0
  33. package/src/browser/event-controller.ts +5 -0
  34. package/src/browser/link-interceptor.ts +4 -0
  35. package/src/browser/navigation-bridge.ts +175 -17
  36. package/src/browser/navigation-client.ts +177 -44
  37. package/src/browser/navigation-store.ts +68 -9
  38. package/src/browser/navigation-transaction.ts +11 -9
  39. package/src/browser/partial-update.ts +113 -17
  40. package/src/browser/prefetch/cache.ts +275 -28
  41. package/src/browser/prefetch/fetch.ts +191 -46
  42. package/src/browser/prefetch/policy.ts +6 -0
  43. package/src/browser/prefetch/queue.ts +123 -20
  44. package/src/browser/prefetch/resource-ready.ts +77 -0
  45. package/src/browser/rango-state.ts +53 -13
  46. package/src/browser/react/Link.tsx +98 -14
  47. package/src/browser/react/NavigationProvider.tsx +89 -14
  48. package/src/browser/react/context.ts +7 -2
  49. package/src/browser/react/use-handle.ts +9 -58
  50. package/src/browser/react/use-navigation.ts +22 -2
  51. package/src/browser/react/use-params.ts +11 -1
  52. package/src/browser/react/use-router.ts +29 -9
  53. package/src/browser/rsc-router.tsx +177 -66
  54. package/src/browser/scroll-restoration.ts +41 -42
  55. package/src/browser/segment-reconciler.ts +36 -9
  56. package/src/browser/server-action-bridge.ts +8 -6
  57. package/src/browser/types.ts +73 -5
  58. package/src/build/generate-manifest.ts +6 -6
  59. package/src/build/generate-route-types.ts +3 -0
  60. package/src/build/route-trie.ts +67 -25
  61. package/src/build/route-types/include-resolution.ts +8 -1
  62. package/src/build/route-types/router-processing.ts +223 -74
  63. package/src/build/route-types/scan-filter.ts +8 -1
  64. package/src/cache/cache-runtime.ts +15 -11
  65. package/src/cache/cache-scope.ts +48 -7
  66. package/src/cache/cf/cf-cache-store.ts +455 -15
  67. package/src/cache/cf/index.ts +5 -1
  68. package/src/cache/document-cache.ts +17 -7
  69. package/src/cache/index.ts +1 -0
  70. package/src/cache/taint.ts +55 -0
  71. package/src/client.rsc.tsx +2 -1
  72. package/src/client.tsx +85 -276
  73. package/src/context-var.ts +72 -2
  74. package/src/debug.ts +2 -2
  75. package/src/handle.ts +40 -0
  76. package/src/handles/breadcrumbs.ts +66 -0
  77. package/src/handles/index.ts +1 -0
  78. package/src/host/index.ts +0 -3
  79. package/src/index.rsc.ts +9 -36
  80. package/src/index.ts +79 -70
  81. package/src/outlet-context.ts +1 -1
  82. package/src/prerender/store.ts +57 -15
  83. package/src/prerender.ts +138 -77
  84. package/src/response-utils.ts +28 -0
  85. package/src/reverse.ts +27 -2
  86. package/src/route-definition/dsl-helpers.ts +240 -40
  87. package/src/route-definition/helpers-types.ts +67 -19
  88. package/src/route-definition/index.ts +3 -3
  89. package/src/route-definition/redirect.ts +11 -3
  90. package/src/route-definition/resolve-handler-use.ts +155 -0
  91. package/src/route-map-builder.ts +7 -1
  92. package/src/route-types.ts +18 -0
  93. package/src/router/content-negotiation.ts +100 -1
  94. package/src/router/find-match.ts +4 -2
  95. package/src/router/handler-context.ts +129 -26
  96. package/src/router/intercept-resolution.ts +11 -4
  97. package/src/router/lazy-includes.ts +10 -7
  98. package/src/router/loader-resolution.ts +160 -22
  99. package/src/router/logging.ts +5 -2
  100. package/src/router/manifest.ts +31 -16
  101. package/src/router/match-api.ts +128 -193
  102. package/src/router/match-middleware/background-revalidation.ts +30 -2
  103. package/src/router/match-middleware/cache-lookup.ts +94 -17
  104. package/src/router/match-middleware/cache-store.ts +53 -10
  105. package/src/router/match-middleware/intercept-resolution.ts +9 -7
  106. package/src/router/match-middleware/segment-resolution.ts +61 -5
  107. package/src/router/match-result.ts +103 -18
  108. package/src/router/metrics.ts +238 -13
  109. package/src/router/middleware-types.ts +48 -27
  110. package/src/router/middleware.ts +201 -86
  111. package/src/router/navigation-snapshot.ts +182 -0
  112. package/src/router/pattern-matching.ts +77 -11
  113. package/src/router/prerender-match.ts +114 -10
  114. package/src/router/preview-match.ts +30 -102
  115. package/src/router/request-classification.ts +310 -0
  116. package/src/router/revalidation.ts +27 -7
  117. package/src/router/route-snapshot.ts +245 -0
  118. package/src/router/router-context.ts +6 -1
  119. package/src/router/router-interfaces.ts +50 -5
  120. package/src/router/router-options.ts +50 -19
  121. package/src/router/segment-resolution/fresh.ts +215 -19
  122. package/src/router/segment-resolution/helpers.ts +30 -25
  123. package/src/router/segment-resolution/loader-cache.ts +1 -0
  124. package/src/router/segment-resolution/revalidation.ts +454 -301
  125. package/src/router/segment-wrappers.ts +2 -0
  126. package/src/router/trie-matching.ts +30 -6
  127. package/src/router/types.ts +1 -0
  128. package/src/router/url-params.ts +49 -0
  129. package/src/router.ts +89 -17
  130. package/src/rsc/handler.ts +563 -364
  131. package/src/rsc/helpers.ts +69 -41
  132. package/src/rsc/index.ts +0 -20
  133. package/src/rsc/loader-fetch.ts +23 -3
  134. package/src/rsc/manifest-init.ts +5 -1
  135. package/src/rsc/progressive-enhancement.ts +37 -10
  136. package/src/rsc/response-route-handler.ts +14 -1
  137. package/src/rsc/rsc-rendering.ts +47 -44
  138. package/src/rsc/server-action.ts +24 -10
  139. package/src/rsc/ssr-setup.ts +128 -0
  140. package/src/rsc/types.ts +11 -1
  141. package/src/search-params.ts +16 -13
  142. package/src/segment-content-promise.ts +67 -0
  143. package/src/segment-loader-promise.ts +122 -0
  144. package/src/segment-system.tsx +109 -23
  145. package/src/server/context.ts +174 -19
  146. package/src/server/handle-store.ts +19 -0
  147. package/src/server/loader-registry.ts +9 -8
  148. package/src/server/request-context.ts +218 -65
  149. package/src/server.ts +6 -0
  150. package/src/ssr/index.tsx +4 -0
  151. package/src/static-handler.ts +18 -6
  152. package/src/theme/index.ts +4 -13
  153. package/src/types/cache-types.ts +4 -4
  154. package/src/types/handler-context.ts +140 -72
  155. package/src/types/loader-types.ts +41 -15
  156. package/src/types/request-scope.ts +126 -0
  157. package/src/types/route-config.ts +17 -8
  158. package/src/types/route-entry.ts +19 -1
  159. package/src/types/segments.ts +2 -5
  160. package/src/urls/include-helper.ts +24 -14
  161. package/src/urls/path-helper-types.ts +39 -6
  162. package/src/urls/path-helper.ts +48 -13
  163. package/src/urls/pattern-types.ts +12 -0
  164. package/src/urls/response-types.ts +18 -16
  165. package/src/use-loader.tsx +77 -5
  166. package/src/vite/discovery/bundle-postprocess.ts +61 -89
  167. package/src/vite/discovery/discover-routers.ts +7 -4
  168. package/src/vite/discovery/prerender-collection.ts +162 -88
  169. package/src/vite/discovery/state.ts +17 -13
  170. package/src/vite/index.ts +8 -3
  171. package/src/vite/plugin-types.ts +51 -79
  172. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  173. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  174. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  175. package/src/vite/plugins/expose-action-id.ts +1 -3
  176. package/src/vite/plugins/expose-id-utils.ts +12 -0
  177. package/src/vite/plugins/expose-ids/handler-transform.ts +30 -0
  178. package/src/vite/plugins/expose-internal-ids.ts +257 -40
  179. package/src/vite/plugins/performance-tracks.ts +88 -0
  180. package/src/vite/plugins/refresh-cmd.ts +127 -0
  181. package/src/vite/plugins/version-plugin.ts +13 -1
  182. package/src/vite/rango.ts +190 -217
  183. package/src/vite/router-discovery.ts +241 -45
  184. package/src/vite/utils/banner.ts +4 -4
  185. package/src/vite/utils/package-resolution.ts +34 -1
  186. package/src/vite/utils/prerender-utils.ts +97 -5
  187. package/src/vite/utils/shared-utils.ts +3 -2
  188. package/skills/testing/SKILL.md +0 -226
  189. package/src/route-definition/route-function.ts +0 -119
@@ -17,6 +17,12 @@ import {
17
17
  emptyResponse,
18
18
  teeWithCompletion,
19
19
  } from "./response-adapter.js";
20
+ import {
21
+ buildPrefetchKey,
22
+ buildSourceKey,
23
+ consumeInflightPrefetch,
24
+ consumePrefetch,
25
+ } from "./prefetch/cache.js";
20
26
 
21
27
  /**
22
28
  * Create a navigation client for fetching RSC payloads
@@ -24,21 +30,14 @@ import {
24
30
  * The client handles building URLs with RSC parameters and
25
31
  * deserializing the response using the RSC runtime.
26
32
  *
33
+ * Checks the in-memory prefetch cache before making a network request.
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.
38
+ *
27
39
  * @param deps - RSC browser dependencies (createFromFetch)
28
40
  * @returns NavigationClient instance
29
- *
30
- * @example
31
- * ```typescript
32
- * import { createFromFetch } from "@vitejs/plugin-rsc/browser";
33
- *
34
- * const client = createNavigationClient({ createFromFetch });
35
- *
36
- * const payload = await client.fetchPartial({
37
- * targetUrl: "/shop/products",
38
- * segmentIds: ["root", "shop"],
39
- * previousUrl: "/",
40
- * });
41
- * ```
42
41
  */
43
42
  export function createNavigationClient(
44
43
  deps: Pick<RscBrowserDependencies, "createFromFetch">,
@@ -47,8 +46,9 @@ export function createNavigationClient(
47
46
  /**
48
47
  * Fetch a partial RSC payload for navigation
49
48
  *
50
- * Sends current segment IDs to the server so it can determine
51
- * which segments need to be re-rendered (diff).
49
+ * First checks the in-memory prefetch cache for a matching entry.
50
+ * If found, uses the cached response instantly. Otherwise sends
51
+ * current segment IDs to the server for diff-based rendering.
52
52
  *
53
53
  * @param options - Fetch options
54
54
  * @returns RSC payload with segments and metadata, plus stream completion promise
@@ -64,6 +64,7 @@ export function createNavigationClient(
64
64
  staleRevalidation,
65
65
  interceptSourceUrl,
66
66
  version,
67
+ routerId,
67
68
  hmr,
68
69
  } = options;
69
70
 
@@ -80,7 +81,8 @@ export function createNavigationClient(
80
81
  });
81
82
  }
82
83
 
83
- // Build fetch URL with partial rendering params
84
+ // Build fetch URL with partial rendering params (used for both
85
+ // cache key lookup and actual fetch if cache misses)
84
86
  const fetchUrl = new URL(targetUrl, window.location.origin);
85
87
  fetchUrl.searchParams.set("_rsc_partial", "true");
86
88
  fetchUrl.searchParams.set("_rsc_segments", segmentIds.join(","));
@@ -90,32 +92,62 @@ export function createNavigationClient(
90
92
  if (version) {
91
93
  fetchUrl.searchParams.set("_rsc_v", version);
92
94
  }
93
- if (tx) {
94
- browserDebugLog(tx, "fetching", {
95
- path: `${fetchUrl.pathname}${fetchUrl.search}`,
96
- });
95
+ if (routerId) {
96
+ fetchUrl.searchParams.set("_rsc_rid", routerId);
97
+ }
98
+
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.
106
+ // Skip cache for stale revalidation (needs fresh data), HMR (needs
107
+ // fresh modules), and intercept contexts (source-dependent responses).
108
+ const canUsePrefetch = !staleRevalidation && !hmr && !interceptSourceUrl;
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
+ }
97
123
  }
98
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
+ }
99
135
  // Track when the stream completes
100
136
  let resolveStreamComplete: () => void;
101
137
  const streamComplete = new Promise<void>((resolve) => {
102
138
  resolveStreamComplete = resolve;
103
139
  });
104
140
 
105
- // Create a response promise that tracks stream completion
106
- const responsePromise = fetch(fetchUrl, {
107
- headers: {
108
- "X-RSC-Router-Client-Path": previousUrl,
109
- "X-Rango-State": getRangoState(),
110
- ...(tx && { "X-RSC-Router-Request-Id": tx.requestId }),
111
- ...(interceptSourceUrl && {
112
- "X-RSC-Router-Intercept-Source": interceptSourceUrl,
113
- }),
114
- ...(hmr && { "X-RSC-HMR": "1" }),
115
- },
116
- signal,
117
- }).then((response) => {
118
- // Check for version mismatch - server wants us to reload
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
119
151
  const reload = extractRscHeaderUrl(response, "X-RSC-Reload");
120
152
  if (reload === "blocked") {
121
153
  resolveStreamComplete();
@@ -123,11 +155,12 @@ export function createNavigationClient(
123
155
  }
124
156
  if (reload) {
125
157
  if (tx) {
126
- browserDebugLog(tx, "version mismatch, reloading", {
158
+ browserDebugLog(tx, `version mismatch, reloading (${source})`, {
127
159
  reloadUrl: reload.url,
128
160
  });
129
161
  }
130
162
  window.location.href = reload.url;
163
+ // Block further processing — page is reloading
131
164
  return new Promise<Response>(() => {});
132
165
  }
133
166
 
@@ -142,7 +175,7 @@ export function createNavigationClient(
142
175
  }
143
176
  if (redirect) {
144
177
  if (tx) {
145
- browserDebugLog(tx, "server redirect", {
178
+ browserDebugLog(tx, `server redirect (${source})`, {
146
179
  redirectUrl: redirect.url,
147
180
  });
148
181
  }
@@ -150,19 +183,119 @@ export function createNavigationClient(
150
183
  throw new ServerRedirect(redirect.url, undefined);
151
184
  }
152
185
 
153
- return teeWithCompletion(
154
- response,
155
- () => {
156
- if (tx) browserDebugLog(tx, "stream complete");
157
- resolveStreamComplete();
186
+ return response;
187
+ };
188
+
189
+ /** Start a fresh navigation fetch (no cache / inflight hit). */
190
+ const doFreshFetch = (): Promise<Response> => {
191
+ if (tx) {
192
+ browserDebugLog(tx, "fetching", {
193
+ path: `${fetchUrl.pathname}${fetchUrl.search}`,
194
+ });
195
+ }
196
+
197
+ return fetch(fetchUrl, {
198
+ headers: {
199
+ "X-RSC-Router-Client-Path": previousUrl,
200
+ "X-Rango-State": getRangoState(),
201
+ ...(tx && { "X-RSC-Router-Request-Id": tx.requestId }),
202
+ ...(interceptSourceUrl && {
203
+ "X-RSC-Router-Intercept-Source": interceptSourceUrl,
204
+ }),
205
+ ...(hmr && { "X-RSC-HMR": "1" }),
158
206
  },
159
207
  signal,
160
- );
161
- });
208
+ }).then((response) => {
209
+ const validated = validateRscHeaders(response, "fetch");
210
+ if (validated instanceof Promise) return validated;
211
+
212
+ return teeWithCompletion(
213
+ validated,
214
+ () => {
215
+ if (tx) browserDebugLog(tx, "stream complete");
216
+ resolveStreamComplete();
217
+ },
218
+ signal,
219
+ );
220
+ });
221
+ };
222
+
223
+ let responsePromise: Promise<Response>;
224
+
225
+ if (cachedResponse) {
226
+ if (tx) {
227
+ browserDebugLog(tx, "prefetch cache hit", {
228
+ key: hitKey,
229
+ wildcard: hitKey === wildcardKey,
230
+ });
231
+ }
232
+ responsePromise = Promise.resolve(cachedResponse).then((response) => {
233
+ const validated = validateRscHeaders(response, "prefetch cache");
234
+ if (validated instanceof Promise) return validated;
235
+
236
+ return teeWithCompletion(
237
+ validated,
238
+ () => {
239
+ if (tx) browserDebugLog(tx, "stream complete (from cache)");
240
+ resolveStreamComplete();
241
+ },
242
+ signal,
243
+ );
244
+ });
245
+ } else if (inflightResponsePromise) {
246
+ if (tx) {
247
+ browserDebugLog(tx, "reusing inflight prefetch", {
248
+ key: hitKey,
249
+ wildcard: hitKey === wildcardKey,
250
+ });
251
+ }
252
+ const adoptedViaWildcard = hitKey === wildcardKey;
253
+ responsePromise = inflightResponsePromise.then(async (response) => {
254
+ if (!response) {
255
+ if (tx) {
256
+ browserDebugLog(tx, "inflight prefetch unavailable, refetching");
257
+ }
258
+ return doFreshFetch();
259
+ }
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
+
281
+ return teeWithCompletion(
282
+ validated,
283
+ () => {
284
+ if (tx) {
285
+ browserDebugLog(tx, "stream complete (from inflight prefetch)");
286
+ }
287
+ resolveStreamComplete();
288
+ },
289
+ signal,
290
+ );
291
+ });
292
+ } else {
293
+ responsePromise = doFreshFetch();
294
+ }
162
295
 
163
296
  try {
164
- // Deserialize RSC payload
165
297
  const payload = await deps.createFromFetch<RscPayload>(responsePromise);
298
+
166
299
  if (tx) {
167
300
  browserDebugLog(tx, "response received", {
168
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
  // ========================================================================
@@ -7,7 +7,6 @@ import type {
7
7
  import { generateHistoryKey } from "./navigation-store.js";
8
8
  import {
9
9
  handleNavigationStart,
10
- handleNavigationEnd,
11
10
  ensureHistoryKey,
12
11
  } from "./scroll-restoration.js";
13
12
  import type { EventController, NavigationHandle } from "./event-controller.js";
@@ -81,11 +80,12 @@ export interface BoundTransaction {
81
80
  readonly currentUrl: string;
82
81
  /** Start streaming and get a token to end it when the stream completes */
83
82
  startStreaming(): StreamingToken;
83
+ /** Commit the navigation. Returns the effective scroll option for the caller to handle. */
84
84
  commit(
85
85
  segmentIds: string[],
86
86
  segments: ResolvedSegment[],
87
87
  overrides?: BoundCommitOverrides,
88
- ): void;
88
+ ): { scroll?: boolean };
89
89
  }
90
90
 
91
91
  /**
@@ -93,7 +93,7 @@ export interface BoundTransaction {
93
93
  * Uses the event controller handle for lifecycle management
94
94
  */
95
95
  interface NavigationTransaction extends Disposable {
96
- commit(options: CommitOptions): void;
96
+ commit(options: CommitOptions): { scroll?: boolean };
97
97
  with(
98
98
  options: Omit<CommitOptions, "segmentIds" | "segments">,
99
99
  ): BoundTransaction;
@@ -120,7 +120,7 @@ export function createNavigationTransaction(
120
120
  /**
121
121
  * Commit the navigation - updates store and URL atomically
122
122
  */
123
- function commit(opts: CommitOptions): void {
123
+ function commit(opts: CommitOptions): { scroll?: boolean } {
124
124
  committed = true;
125
125
 
126
126
  const {
@@ -150,7 +150,7 @@ export function createNavigationTransaction(
150
150
  // Without this, the entry lingers and weakens state-machine invariants.
151
151
  handle.complete(parsedUrl);
152
152
  debugLog("[Browser] Cache-only commit, historyKey:", historyKey);
153
- return;
153
+ return { scroll: false };
154
154
  }
155
155
 
156
156
  // Save current scroll position before navigating
@@ -172,7 +172,7 @@ export function createNavigationTransaction(
172
172
  debugLog("[Browser] Store updated (action)");
173
173
  // Complete navigation to clear loading state
174
174
  handle.complete(parsedUrl);
175
- return;
175
+ return { scroll: false };
176
176
  }
177
177
 
178
178
  // Build history state - include user state, intercept info, and server-set state
@@ -205,14 +205,16 @@ export function createNavigationTransaction(
205
205
  // Complete the navigation in event controller (sets idle state, updates location)
206
206
  handle.complete(parsedUrl);
207
207
 
208
- // Handle scroll after navigation
209
- handleNavigationEnd({ scroll });
208
+ // NOTE: Scroll is NOT handled here. The caller (partial-update.ts) handles
209
+ // scroll AFTER onUpdate() so React has the new content before we scroll.
210
210
 
211
211
  debugLog(
212
212
  "[Browser] Navigation committed, historyKey:",
213
213
  historyKey,
214
214
  intercept ? "(intercept)" : "",
215
215
  );
216
+
217
+ return { scroll };
216
218
  }
217
219
 
218
220
  return {
@@ -263,7 +265,7 @@ export function createNavigationTransaction(
263
265
  overrides?.state !== undefined ? overrides.state : opts.state;
264
266
  // Server-set location state: only from overrides (set by partial-update)
265
267
  const serverState = overrides?.serverState;
266
- commit({
268
+ return commit({
267
269
  ...opts,
268
270
  segmentIds,
269
271
  segments,