@rangojs/router 0.0.0-experimental.b02a2fec → 0.0.0-experimental.b30bbf02

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 (112) hide show
  1. package/README.md +112 -17
  2. package/dist/vite/index.js +1338 -462
  3. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  4. package/package.json +7 -5
  5. package/skills/breadcrumbs/SKILL.md +3 -1
  6. package/skills/handler-use/SKILL.md +362 -0
  7. package/skills/hooks/SKILL.md +33 -20
  8. package/skills/intercept/SKILL.md +20 -0
  9. package/skills/layout/SKILL.md +22 -0
  10. package/skills/links/SKILL.md +90 -16
  11. package/skills/loader/SKILL.md +70 -3
  12. package/skills/middleware/SKILL.md +34 -3
  13. package/skills/migrate-nextjs/SKILL.md +562 -0
  14. package/skills/migrate-react-router/SKILL.md +769 -0
  15. package/skills/parallel/SKILL.md +66 -0
  16. package/skills/rango/SKILL.md +25 -22
  17. package/skills/response-routes/SKILL.md +8 -0
  18. package/skills/route/SKILL.md +24 -0
  19. package/skills/server-actions/SKILL.md +739 -0
  20. package/skills/streams-and-websockets/SKILL.md +283 -0
  21. package/skills/typesafety/SKILL.md +3 -1
  22. package/src/browser/app-shell.ts +52 -0
  23. package/src/browser/event-controller.ts +44 -4
  24. package/src/browser/navigation-bridge.ts +71 -5
  25. package/src/browser/navigation-client.ts +64 -13
  26. package/src/browser/navigation-store.ts +25 -1
  27. package/src/browser/partial-update.ts +34 -3
  28. package/src/browser/prefetch/cache.ts +129 -21
  29. package/src/browser/prefetch/fetch.ts +148 -16
  30. package/src/browser/prefetch/queue.ts +36 -5
  31. package/src/browser/rango-state.ts +53 -13
  32. package/src/browser/react/Link.tsx +30 -2
  33. package/src/browser/react/NavigationProvider.tsx +70 -18
  34. package/src/browser/react/filter-segment-order.ts +51 -7
  35. package/src/browser/react/use-navigation.ts +22 -2
  36. package/src/browser/react/use-params.ts +11 -1
  37. package/src/browser/react/use-router.ts +8 -1
  38. package/src/browser/react/use-segments.ts +11 -8
  39. package/src/browser/rsc-router.tsx +34 -6
  40. package/src/browser/segment-reconciler.ts +36 -14
  41. package/src/browser/types.ts +19 -0
  42. package/src/build/route-trie.ts +50 -24
  43. package/src/cache/cf/cf-cache-store.ts +5 -7
  44. package/src/client.tsx +82 -174
  45. package/src/index.rsc.ts +3 -0
  46. package/src/index.ts +40 -9
  47. package/src/outlet-context.ts +1 -1
  48. package/src/response-utils.ts +28 -0
  49. package/src/reverse.ts +7 -3
  50. package/src/route-definition/dsl-helpers.ts +175 -23
  51. package/src/route-definition/helpers-types.ts +63 -14
  52. package/src/route-definition/resolve-handler-use.ts +6 -0
  53. package/src/route-types.ts +7 -0
  54. package/src/router/handler-context.ts +24 -4
  55. package/src/router/lazy-includes.ts +6 -6
  56. package/src/router/loader-resolution.ts +3 -0
  57. package/src/router/manifest.ts +22 -13
  58. package/src/router/match-api.ts +4 -3
  59. package/src/router/match-handlers.ts +1 -0
  60. package/src/router/match-result.ts +21 -2
  61. package/src/router/middleware-types.ts +2 -22
  62. package/src/router/middleware.ts +54 -7
  63. package/src/router/pattern-matching.ts +87 -17
  64. package/src/router/revalidation.ts +15 -1
  65. package/src/router/segment-resolution/fresh.ts +8 -0
  66. package/src/router/segment-resolution/revalidation.ts +128 -100
  67. package/src/router/trie-matching.ts +18 -13
  68. package/src/router/url-params.ts +49 -0
  69. package/src/router.ts +1 -2
  70. package/src/rsc/handler.ts +8 -4
  71. package/src/rsc/helpers.ts +69 -41
  72. package/src/rsc/progressive-enhancement.ts +4 -0
  73. package/src/rsc/response-route-handler.ts +14 -1
  74. package/src/rsc/rsc-rendering.ts +10 -0
  75. package/src/rsc/server-action.ts +4 -0
  76. package/src/rsc/types.ts +6 -0
  77. package/src/segment-content-promise.ts +67 -0
  78. package/src/segment-loader-promise.ts +122 -0
  79. package/src/segment-system.tsx +11 -61
  80. package/src/server/context.ts +26 -3
  81. package/src/server/request-context.ts +10 -42
  82. package/src/ssr/index.tsx +5 -1
  83. package/src/types/handler-context.ts +12 -39
  84. package/src/types/loader-types.ts +5 -6
  85. package/src/types/request-scope.ts +126 -0
  86. package/src/types/route-entry.ts +11 -0
  87. package/src/types/segments.ts +17 -1
  88. package/src/urls/include-helper.ts +24 -14
  89. package/src/urls/path-helper-types.ts +30 -4
  90. package/src/urls/response-types.ts +2 -10
  91. package/src/vite/debug.ts +184 -0
  92. package/src/vite/discovery/discover-routers.ts +31 -3
  93. package/src/vite/discovery/gate-state.ts +171 -0
  94. package/src/vite/discovery/prerender-collection.ts +48 -1
  95. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  96. package/src/vite/plugins/cjs-to-esm.ts +5 -0
  97. package/src/vite/plugins/client-ref-dedup.ts +16 -0
  98. package/src/vite/plugins/client-ref-hashing.ts +16 -4
  99. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  100. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  101. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  102. package/src/vite/plugins/expose-action-id.ts +52 -28
  103. package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
  104. package/src/vite/plugins/expose-internal-ids.ts +516 -486
  105. package/src/vite/plugins/performance-tracks.ts +17 -9
  106. package/src/vite/plugins/use-cache-transform.ts +56 -43
  107. package/src/vite/plugins/version-injector.ts +37 -11
  108. package/src/vite/rango.ts +49 -14
  109. package/src/vite/router-discovery.ts +558 -53
  110. package/src/vite/utils/banner.ts +1 -1
  111. package/src/vite/utils/package-resolution.ts +41 -1
  112. package/src/vite/utils/prerender-utils.ts +20 -6
@@ -13,9 +13,10 @@
13
13
 
14
14
  import {
15
15
  buildPrefetchKey,
16
+ buildSourceKey,
16
17
  hasPrefetch,
17
18
  markPrefetchInflight,
18
- setInflightPromise,
19
+ setInflightPromiseWithAliases,
19
20
  storePrefetch,
20
21
  clearPrefetchInflight,
21
22
  currentGeneration,
@@ -23,6 +24,24 @@ import {
23
24
  import { getRangoState } from "../rango-state.js";
24
25
  import { enqueuePrefetch } from "./queue.js";
25
26
  import { shouldPrefetch } from "./policy.js";
27
+ import { debugLog } from "../logging.js";
28
+
29
+ /**
30
+ * Check if a URL resolves to the current page (same pathname + search).
31
+ * Used to prevent same-page prefetching, which produces a trivial diff
32
+ * that would corrupt the (default wildcard) prefetch cache entry.
33
+ */
34
+ function isSamePage(url: string): boolean {
35
+ try {
36
+ const target = new URL(url, window.location.origin);
37
+ return (
38
+ target.pathname + target.search ===
39
+ window.location.pathname + window.location.search
40
+ );
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
26
45
 
27
46
  /**
28
47
  * Build an RSC partial URL for prefetching.
@@ -63,14 +82,33 @@ function buildPrefetchUrl(
63
82
  * one branch in the in-memory cache. The returned Promise resolves to the
64
83
  * sibling navigation branch (or null on failure) so navigation can safely
65
84
  * reuse an in-flight prefetch via consumeInflightPrefetch().
85
+ *
86
+ * Inflight + storage key selection:
87
+ *
88
+ * - `forceSourceScope` (Link opted in with `prefetchKey=":source"`): single
89
+ * inflight registration under `sourceKey`; response stored under
90
+ * `sourceKey`. No wildcard leak is possible.
91
+ *
92
+ * - Otherwise: dual inflight registration under both `wildcardKey` and
93
+ * `sourceKey` so same-source navigations adopt directly via their own
94
+ * source key. Storage key is chosen at response time from the
95
+ * `X-RSC-Prefetch-Scope` header — `"source"` → `sourceKey` (intercept
96
+ * modals etc.), anything else → `wildcardKey`. Cross-source navigations
97
+ * that adopted via `wildcardKey` must bail out in `navigation-client.ts`
98
+ * if the adopted response turns out to be source-scoped.
66
99
  */
67
100
  function executePrefetchFetch(
68
- key: string,
101
+ wildcardKey: string,
102
+ sourceKey: string,
69
103
  fetchUrl: string,
104
+ forceSourceScope: boolean,
70
105
  signal?: AbortSignal,
71
106
  ): Promise<Response | null> {
72
107
  const gen = currentGeneration();
73
- markPrefetchInflight(key);
108
+ const inflightKeys = forceSourceScope
109
+ ? [sourceKey]
110
+ : [wildcardKey, sourceKey];
111
+ for (const k of inflightKeys) markPrefetchInflight(k);
74
112
 
75
113
  const promise: Promise<Response | null> = fetch(fetchUrl, {
76
114
  priority: "low" as RequestPriority,
@@ -92,59 +130,153 @@ function executePrefetchFetch(
92
130
  status: response.status,
93
131
  statusText: response.statusText,
94
132
  };
95
- storePrefetch(key, new Response(cacheStream, responseInit), gen);
133
+ let storageKey: string;
134
+ if (forceSourceScope) {
135
+ storageKey = sourceKey;
136
+ } else {
137
+ const scope = response.headers.get("x-rsc-prefetch-scope");
138
+ storageKey = scope === "source" ? sourceKey : wildcardKey;
139
+ }
140
+ storePrefetch(storageKey, new Response(cacheStream, responseInit), gen);
96
141
  return new Response(navStream, responseInit);
97
142
  })
98
143
  .catch(() => null)
99
144
  .finally(() => {
100
- clearPrefetchInflight(key);
145
+ clearPrefetchInflight(inflightKeys[0]!);
101
146
  });
102
147
 
103
- setInflightPromise(key, promise);
148
+ setInflightPromiseWithAliases(inflightKeys, promise);
104
149
  return promise;
105
150
  }
106
151
 
152
+ /**
153
+ * Dedup check for prefetch entry presence.
154
+ *
155
+ * Forced `:source` must NOT dedupe against a pre-existing wildcard entry —
156
+ * otherwise the source slot would stay unpopulated and navigation from
157
+ * this source would fall through to the (potentially wrong) wildcard
158
+ * response, defeating the opt-out.
159
+ */
160
+ function hasPrefetchHit(
161
+ forceSourceScope: boolean,
162
+ wildcardKey: string,
163
+ sourceKey: string,
164
+ ): boolean {
165
+ return forceSourceScope
166
+ ? hasPrefetch(sourceKey)
167
+ : hasPrefetch(wildcardKey) || hasPrefetch(sourceKey);
168
+ }
169
+
107
170
  /**
108
171
  * Prefetch (direct): fetch with low priority and store in in-memory cache.
109
172
  * Used by hover strategy -- fires immediately without queueing.
173
+ *
174
+ * By default the wildcard key (Rango-state-keyed) is used for inflight
175
+ * dedup and for responses that are not source-sensitive; source-scoped
176
+ * storage is automatic when the server emits `X-RSC-Prefetch-Scope: source`.
177
+ *
178
+ * Pass `prefetchKey=":source"` to force source-scoped inflight + storage
179
+ * (e.g. when the target uses a custom `revalidate()` that reads
180
+ * `currentUrl` and the wildcard slot would serve the wrong diff).
110
181
  */
111
182
  export function prefetchDirect(
112
183
  url: string,
113
184
  segmentIds: string[],
114
185
  version?: string,
115
186
  routerId?: string,
187
+ prefetchKey?: ":source",
116
188
  ): void {
117
189
  if (!shouldPrefetch()) return;
118
190
 
119
191
  const targetUrl = buildPrefetchUrl(url, segmentIds, version, routerId);
120
192
  if (!targetUrl) return;
121
- const key = buildPrefetchKey(window.location.href, targetUrl);
122
- if (hasPrefetch(key)) return;
123
- executePrefetchFetch(key, targetUrl.toString());
193
+ const forceSourceScope = prefetchKey === ":source";
194
+ // Skip same-page prefetch — a same-page diff is trivial and would corrupt
195
+ // the wildcard cache entry used for cross-page navigation.
196
+ // When `:source` is forced the entry is source-scoped (single-aliased to
197
+ // itself), so it cannot poison any shared slot — allow it.
198
+ if (!forceSourceScope && isSamePage(url)) {
199
+ return;
200
+ }
201
+ const sourceHref = window.location.href;
202
+ const rangoState = getRangoState();
203
+ const wildcardKey = buildPrefetchKey(rangoState, targetUrl);
204
+ const sourceKey = buildSourceKey(rangoState, sourceHref, targetUrl);
205
+ if (hasPrefetchHit(forceSourceScope, wildcardKey, sourceKey)) {
206
+ debugLog("[prefetch] direct dedup (key already exists)", {
207
+ url,
208
+ wildcardKey,
209
+ sourceKey,
210
+ forceSourceScope,
211
+ });
212
+ return;
213
+ }
214
+ debugLog("[prefetch] direct fetch", {
215
+ url,
216
+ wildcardKey,
217
+ sourceKey,
218
+ source: sourceHref,
219
+ forceSourceScope,
220
+ });
221
+ executePrefetchFetch(
222
+ wildcardKey,
223
+ sourceKey,
224
+ targetUrl.toString(),
225
+ forceSourceScope,
226
+ );
124
227
  }
125
228
 
126
229
  /**
127
230
  * Prefetch (queued): goes through the concurrency-limited queue.
128
231
  * Used by viewport/render strategies to avoid flooding the server.
129
- * Returns the cache key for use in cleanup.
232
+ * Returns the inflight key (wildcard by default, source-scoped when
233
+ * `prefetchKey=":source"` is passed).
130
234
  */
131
235
  export function prefetchQueued(
132
236
  url: string,
133
237
  segmentIds: string[],
134
238
  version?: string,
135
239
  routerId?: string,
240
+ prefetchKey?: ":source",
136
241
  ): string {
137
242
  if (!shouldPrefetch()) return "";
138
243
  const targetUrl = buildPrefetchUrl(url, segmentIds, version, routerId);
139
244
  if (!targetUrl) return "";
140
- const key = buildPrefetchKey(window.location.href, targetUrl);
141
- if (hasPrefetch(key)) return key;
245
+ const forceSourceScope = prefetchKey === ":source";
246
+ if (!forceSourceScope && isSamePage(url)) {
247
+ return "";
248
+ }
249
+ const sourceHref = window.location.href;
250
+ const rangoState = getRangoState();
251
+ const wildcardKey = buildPrefetchKey(rangoState, targetUrl);
252
+ const sourceKey = buildSourceKey(rangoState, sourceHref, targetUrl);
253
+ const queueKey = forceSourceScope ? sourceKey : wildcardKey;
254
+ if (hasPrefetchHit(forceSourceScope, wildcardKey, sourceKey)) {
255
+ debugLog("[prefetch] queued dedup (key already exists)", {
256
+ url,
257
+ wildcardKey,
258
+ sourceKey,
259
+ forceSourceScope,
260
+ });
261
+ return queueKey;
262
+ }
142
263
  const fetchUrlStr = targetUrl.toString();
143
- enqueuePrefetch(key, (signal) => {
264
+ enqueuePrefetch(queueKey, (signal) => {
144
265
  // Re-check at execution time: a hover-triggered prefetchDirect may
145
266
  // have started or completed this key while the item sat in the queue.
146
- if (hasPrefetch(key)) return Promise.resolve();
147
- return executePrefetchFetch(key, fetchUrlStr, signal).then(() => {});
267
+ if (hasPrefetchHit(forceSourceScope, wildcardKey, sourceKey)) {
268
+ return Promise.resolve();
269
+ }
270
+ if (!forceSourceScope && isSamePage(url)) {
271
+ return Promise.resolve();
272
+ }
273
+ return executePrefetchFetch(
274
+ wildcardKey,
275
+ sourceKey,
276
+ fetchUrlStr,
277
+ forceSourceScope,
278
+ signal,
279
+ ).then(() => {});
148
280
  });
149
- return key;
281
+ return queueKey;
150
282
  }
@@ -108,10 +108,29 @@ export function enqueuePrefetch(
108
108
  scheduleDrain();
109
109
  }
110
110
 
111
+ /**
112
+ * Normalize a URL-like string for keep-alive matching: parse against a
113
+ * placeholder origin and strip internal `_rsc_*` query params. Returns
114
+ * `pathname + search` so comparisons ignore hash and the internal params
115
+ * that prefetch appends to targets (`_rsc_partial`, `_rsc_segments`,
116
+ * `_rsc_v`, `_rsc_rid`, `_rsc_stale`).
117
+ */
118
+ function normalizeForMatch(urlish: string): string {
119
+ try {
120
+ const u = new URL(urlish, "http://placeholder");
121
+ for (const k of [...u.searchParams.keys()]) {
122
+ if (k.startsWith("_rsc_")) u.searchParams.delete(k);
123
+ }
124
+ return u.pathname + u.search;
125
+ } catch {
126
+ return urlish;
127
+ }
128
+ }
129
+
111
130
  /**
112
131
  * Cancel queued prefetches and abort in-flight ones that don't match
113
132
  * the current navigation target. If `keepUrl` is provided, the
114
- * executing prefetch whose key contains that URL is kept alive so
133
+ * executing prefetch whose key targets that URL is kept alive so
115
134
  * navigation can reuse its response via consumeInflightPrefetch.
116
135
  *
117
136
  * Called when a navigation starts via the NavigationProvider's
@@ -124,11 +143,23 @@ export function cancelAllPrefetches(keepUrl?: string | null): void {
124
143
  drainGeneration++;
125
144
 
126
145
  // Abort in-flight prefetches that aren't for the navigation target.
127
- // Keys use format "sourceHref\0targetPathname+search" — match the
128
- // target portion (after \0) against keepUrl.
146
+ // Key shapes (see prefetch/cache.ts buildPrefetchKey):
147
+ // wildcard: "rangoState\0/target?..."
148
+ // source-scoped: "rangoState\0sourceHref\0/target?..."
149
+ // The target portion is always the final \0-delimited segment and
150
+ // includes internal `_rsc_*` params (from buildPrefetchUrl); keepUrl
151
+ // comes from NavigationProvider's pendingUrl which is the bare
152
+ // navigation target. Normalize both sides before comparing.
153
+ const normalizedKeep = keepUrl ? normalizeForMatch(keepUrl) : null;
129
154
  for (const [key, ac] of abortControllers) {
130
- const target = key.split("\0")[1];
131
- if (keepUrl && target && keepUrl.startsWith(target)) continue;
155
+ const lastNul = key.lastIndexOf("\0");
156
+ const target = lastNul >= 0 ? key.substring(lastNul + 1) : "";
157
+ if (
158
+ normalizedKeep &&
159
+ target &&
160
+ normalizeForMatch(target) === normalizedKeep
161
+ )
162
+ continue;
132
163
  ac.abort();
133
164
  abortControllers.delete(key);
134
165
  if (executing.delete(key)) {
@@ -6,21 +6,37 @@
6
6
  * navigation requests. The server responds with `Vary: X-Rango-State`,
7
7
  * so the browser HTTP cache keys responses by (URL, X-Rango-State value).
8
8
  *
9
- * Format: `{buildVersion}:{invalidationTimestamp}`
9
+ * Value format: `{buildVersion}:{invalidationTimestamp}`
10
10
  * - Build version changes on deploy, busting all cached prefetches.
11
11
  * - Timestamp changes on server action invalidation.
12
12
  *
13
- * localStorage is cross-tab and survives page refresh, so:
14
- * - One tab's prefetch warms the cache for all tabs.
15
- * - Invalidation in one tab is picked up by other tabs on next fetch.
13
+ * Storage key is namespaced per routerId (`rango-state:{routerId}`) so
14
+ * tabs in different apps on the same origin do not collide. Two tabs in
15
+ * the same app share a key → one tab's invalidation is picked up by the
16
+ * other via the `storage` event. A smooth cross-app transition in this
17
+ * tab rebinds to the target app's key; other tabs still in the old app
18
+ * keep their own key intact.
19
+ *
20
+ * If no routerId is supplied, falls back to a single legacy key for
21
+ * backward compatibility (single-app deployments unaffected).
16
22
  */
17
23
 
18
- const STORAGE_KEY = "rango-state";
24
+ const LEGACY_STORAGE_KEY = "rango-state";
25
+
26
+ function buildStorageKey(routerId: string | undefined): string {
27
+ return routerId ? `${LEGACY_STORAGE_KEY}:${routerId}` : LEGACY_STORAGE_KEY;
28
+ }
19
29
 
20
30
  // Module-level cache avoids hitting localStorage on every getRangoState() call.
21
31
  // Initialized from localStorage on first access or by initRangoState().
22
32
  let cachedState: string | null = null;
23
33
 
34
+ // The localStorage key this tab is currently bound to. Rebinds on
35
+ // initRangoState (document boot) and setRangoStateLocal (smooth app
36
+ // switch). The storage listener filters cross-tab events by this key so
37
+ // events from tabs in a different app are ignored.
38
+ let currentStorageKey: string = LEGACY_STORAGE_KEY;
39
+
24
40
  // Cross-tab sync: the `storage` event fires in OTHER tabs when one tab writes
25
41
  // to localStorage, keeping cachedState fresh without polling.
26
42
  let storageListenerAttached = false;
@@ -28,7 +44,10 @@ let storageListenerAttached = false;
28
44
  function attachStorageListener(): void {
29
45
  if (storageListenerAttached || typeof window === "undefined") return;
30
46
  window.addEventListener("storage", (e) => {
31
- if (e.key !== STORAGE_KEY) return;
47
+ // Only react to events for this tab's current app namespace. Events
48
+ // under other routerId-scoped keys belong to other apps and must not
49
+ // clobber this tab's state.
50
+ if (e.key !== currentStorageKey) return;
32
51
  cachedState = e.newValue;
33
52
  });
34
53
  storageListenerAttached = true;
@@ -37,16 +56,22 @@ function attachStorageListener(): void {
37
56
  /**
38
57
  * Initialize the Rango state key in localStorage.
39
58
  * Called once at app startup with the build version from the server.
40
- * If localStorage already has a key with matching version prefix, keeps it
41
- * (preserves invalidation state across refresh). Otherwise writes a new key.
59
+ * The routerId scopes the storage key to this app; in multi-app setups
60
+ * each app owns its own `rango-state:{routerId}` key and cannot observe
61
+ * invalidations from sibling apps on the same origin.
62
+ *
63
+ * If localStorage already has a matching-version entry under the key,
64
+ * keeps it (preserves invalidation state across refresh). Otherwise
65
+ * writes a new value.
42
66
  */
43
- export function initRangoState(version: string): void {
67
+ export function initRangoState(version: string, routerId?: string): void {
68
+ currentStorageKey = buildStorageKey(routerId);
44
69
  if (typeof window === "undefined") return;
45
70
 
46
71
  attachStorageListener();
47
72
 
48
73
  try {
49
- const existing = localStorage.getItem(STORAGE_KEY);
74
+ const existing = localStorage.getItem(currentStorageKey);
50
75
  if (existing) {
51
76
  const colonIdx = existing.indexOf(":");
52
77
  if (colonIdx > 0) {
@@ -59,7 +84,7 @@ export function initRangoState(version: string): void {
59
84
  }
60
85
  // New version or first load
61
86
  const newState = `${version}:${Date.now()}`;
62
- localStorage.setItem(STORAGE_KEY, newState);
87
+ localStorage.setItem(currentStorageKey, newState);
63
88
  cachedState = newState;
64
89
  } catch {
65
90
  // localStorage may be unavailable (private browsing in some browsers)
@@ -77,7 +102,7 @@ export function getRangoState(): string {
77
102
  if (typeof window === "undefined") return "0:0";
78
103
 
79
104
  try {
80
- const stored = localStorage.getItem(STORAGE_KEY);
105
+ const stored = localStorage.getItem(currentStorageKey);
81
106
  if (stored) {
82
107
  cachedState = stored;
83
108
  return stored;
@@ -89,6 +114,21 @@ export function getRangoState(): string {
89
114
  return "0:0";
90
115
  }
91
116
 
117
+ /**
118
+ * Update the in-memory rango-state to a new version WITHOUT writing
119
+ * localStorage. Intended for smooth cross-app transitions in this tab only:
120
+ * subsequent requests from this tab send the new token, but other tabs
121
+ * still in the previous app do not observe a storage event. Rebinds this
122
+ * tab's storage key to the target app's namespace (`rango-state:{routerId}`)
123
+ * so subsequent storage events only reflect the new app. On the next hard
124
+ * reload, initRangoState reconciles localStorage from the server's
125
+ * authoritative version.
126
+ */
127
+ export function setRangoStateLocal(version: string, routerId?: string): void {
128
+ currentStorageKey = buildStorageKey(routerId);
129
+ cachedState = `${version}:${Date.now()}`;
130
+ }
131
+
92
132
  /**
93
133
  * Invalidate the Rango state key. Called when server actions mutate data.
94
134
  * Updates the timestamp portion while keeping the version prefix.
@@ -105,7 +145,7 @@ export function invalidateRangoState(): void {
105
145
  if (typeof window === "undefined") return;
106
146
 
107
147
  try {
108
- localStorage.setItem(STORAGE_KEY, newState);
148
+ localStorage.setItem(currentStorageKey, newState);
109
149
  } catch {
110
150
  // Silently handle localStorage errors
111
151
  }
@@ -97,6 +97,31 @@ export interface LinkProps extends Omit<
97
97
  * @default "none"
98
98
  */
99
99
  prefetch?: PrefetchStrategy;
100
+ /**
101
+ * Opt-in override for the prefetch cache scope.
102
+ *
103
+ * The default cache is source-agnostic: one shared entry per target,
104
+ * keyed on Rango state + target URL. This is correct for routes whose
105
+ * response shape doesn't depend on where the user navigates from.
106
+ *
107
+ * Set `":source"` when this Link's response would legitimately differ
108
+ * based on the source page — typically when the target route (or one
109
+ * of its layouts) uses a custom `revalidate()` handler that reads
110
+ * `currentUrl` / `currentParams`, and the wildcard entry would
111
+ * therefore serve the wrong diff to a navigation from a different
112
+ * source.
113
+ *
114
+ * Intercept responses are auto-scoped to the source via a server-side
115
+ * tag, so `":source"` is only needed for custom revalidation logic.
116
+ *
117
+ * @example
118
+ * ```tsx
119
+ * // Route uses a `revalidate()` that branches on currentUrl — opt in
120
+ * // so prefetches don't bleed across source pages.
121
+ * <Link to="/dashboard" prefetch="hover" prefetchKey=":source" />
122
+ * ```
123
+ */
124
+ prefetchKey?: ":source";
100
125
  /**
101
126
  * State to pass to history.pushState/replaceState.
102
127
  * Accessible via useLocationState() hook.
@@ -184,6 +209,7 @@ export const Link: ForwardRefExoticComponent<
184
209
  reloadDocument = false,
185
210
  revalidate,
186
211
  prefetch = "none",
212
+ prefetchKey,
187
213
  state,
188
214
  children,
189
215
  onClick,
@@ -320,9 +346,10 @@ export const Link: ForwardRefExoticComponent<
320
346
  segmentState.currentSegmentIds,
321
347
  getAppVersion(),
322
348
  ctx.store.getRouterId?.(),
349
+ prefetchKey,
323
350
  );
324
351
  }
325
- }, [resolvedStrategy, resolvedTo, isExternal, ctx]);
352
+ }, [resolvedStrategy, resolvedTo, isExternal, ctx, prefetchKey]);
326
353
 
327
354
  // Viewport/render prefetch: waits for idle before starting,
328
355
  // uses concurrency-limited queue to avoid flooding.
@@ -344,6 +371,7 @@ export const Link: ForwardRefExoticComponent<
344
371
  segmentState.currentSegmentIds,
345
372
  getAppVersion(),
346
373
  ctx.store.getRouterId?.(),
374
+ prefetchKey,
347
375
  );
348
376
  };
349
377
 
@@ -383,7 +411,7 @@ export const Link: ForwardRefExoticComponent<
383
411
  unobserveForPrefetch(observedElement);
384
412
  }
385
413
  };
386
- }, [resolvedStrategy, resolvedTo, isExternal, ctx]);
414
+ }, [resolvedStrategy, resolvedTo, isExternal, ctx, prefetchKey]);
387
415
 
388
416
  return (
389
417
  <a
@@ -28,6 +28,7 @@ import { NonceContext } from "./nonce-context.js";
28
28
  import type { ResolvedThemeConfig, Theme } from "../../theme/types.js";
29
29
  import { cancelAllPrefetches } from "../prefetch/queue.js";
30
30
  import { handleNavigationEnd } from "../scroll-restoration.js";
31
+ import type { AppShellRef } from "../app-shell.js";
31
32
 
32
33
  /**
33
34
  * Process handles from an async generator, updating the event controller
@@ -46,10 +47,22 @@ async function processHandles(
46
47
  store: NavigationStore;
47
48
  matched?: string[];
48
49
  isPartial?: boolean;
50
+ /** Server's `resolvedIds`: every segment re-resolved this request,
51
+ * including null-component ones excluded from `diff`/`segments`.
52
+ * Drives cleanup of stale handle buckets when a re-resolved segment
53
+ * pushed nothing. */
54
+ resolvedIds?: string[];
49
55
  historyKey: string;
50
56
  },
51
57
  ): Promise<void> {
52
- const { eventController, store, matched, isPartial, historyKey } = opts;
58
+ const {
59
+ eventController,
60
+ store,
61
+ matched,
62
+ isPartial,
63
+ resolvedIds,
64
+ historyKey,
65
+ } = opts;
53
66
 
54
67
  let yieldCount = 0;
55
68
  for await (const handleData of handlesGenerator) {
@@ -64,7 +77,7 @@ async function processHandles(
64
77
  }
65
78
 
66
79
  yieldCount++;
67
- eventController.setHandleData(handleData, matched, isPartial);
80
+ eventController.setHandleData(handleData, matched, isPartial, resolvedIds);
68
81
  }
69
82
 
70
83
  // Check again before final updates
@@ -72,12 +85,11 @@ async function processHandles(
72
85
  return;
73
86
  }
74
87
 
75
- // For partial updates where the generator yielded nothing (cached handlers),
76
- // we still need to update the segment order to clean up stale handle data.
77
- // This happens when navigating away from a route - the handlers for the new
78
- // route might not push any breadcrumbs, but we still need to remove the old ones.
88
+ // For partial updates where the generator yielded nothing (every
89
+ // re-resolved handler pushed nothing), still call setHandleData so the
90
+ // cleanup pass can clear out stale buckets for those segments.
79
91
  if (yieldCount === 0 && matched) {
80
- eventController.setHandleData({}, matched, true);
92
+ eventController.setHandleData({}, matched, true, resolvedIds);
81
93
  }
82
94
 
83
95
  // After handles processing completes, update the cache's handleData.
@@ -133,15 +145,23 @@ export interface NavigationProviderProps {
133
145
  warmupEnabled?: boolean;
134
146
 
135
147
  /**
136
- * App version from server payload (stable, immutable).
137
- * Forwarded to context for cache key building.
148
+ * App version from server payload.
149
+ * Used only as a fallback when `appShellRef` is not supplied.
138
150
  */
139
151
  version?: string;
140
152
 
141
153
  /**
142
154
  * URL prefix for all routes (from createRouter({ basename })).
155
+ * Used only as a fallback when `appShellRef` is not supplied.
143
156
  */
144
157
  basename?: string;
158
+
159
+ /**
160
+ * Live app-shell ref. When provided, the context's `basename` and `version`
161
+ * properties become live getters that track app-switch updates without
162
+ * invalidating the memoized context value.
163
+ */
164
+ appShellRef?: AppShellRef;
145
165
  }
146
166
 
147
167
  /**
@@ -175,6 +195,7 @@ export function NavigationProvider({
175
195
  warmupEnabled,
176
196
  version,
177
197
  basename,
198
+ appShellRef,
178
199
  }: NavigationProviderProps): ReactNode {
179
200
  // Track current payload for rendering (this triggers re-renders)
180
201
  const [payload, setPayload] = useState(initialPayload);
@@ -196,18 +217,39 @@ export function NavigationProvider({
196
217
  await bridge.refresh();
197
218
  }, []);
198
219
 
199
- // Context value is stable (store, eventController, navigate, refresh never change)
200
- const contextValue = useMemo<NavigationStoreContextValue>(
201
- () => ({
220
+ // Context value is stable (store, eventController, navigate, refresh never
221
+ // change). When an appShellRef is supplied, `basename` and `version` are
222
+ // installed as live getters so app-switch transitions (which update the ref)
223
+ // propagate to consumers without forcing a tree-wide rerender.
224
+ const contextValue = useMemo<NavigationStoreContextValue>(() => {
225
+ if (appShellRef) {
226
+ const value = {
227
+ store,
228
+ eventController,
229
+ navigate,
230
+ refresh,
231
+ } as NavigationStoreContextValue;
232
+ Object.defineProperty(value, "basename", {
233
+ configurable: true,
234
+ enumerable: true,
235
+ get: () => appShellRef.get().basename,
236
+ });
237
+ Object.defineProperty(value, "version", {
238
+ configurable: true,
239
+ enumerable: true,
240
+ get: () => appShellRef.get().version,
241
+ });
242
+ return value;
243
+ }
244
+ return {
202
245
  store,
203
246
  eventController,
204
247
  navigate,
205
248
  refresh,
206
249
  version,
207
250
  basename,
208
- }),
209
- [],
210
- );
251
+ };
252
+ }, []);
211
253
 
212
254
  // Connection warmup: keep TLS alive after idle periods.
213
255
  // After 60s of no user interaction, marks connection as "cold".
@@ -345,8 +387,12 @@ export function NavigationProvider({
345
387
  metadata: update.metadata,
346
388
  });
347
389
 
348
- // Update route params
349
- eventController.setParams(update.metadata.params ?? {});
390
+ // Update route params. Only reset when the server actually sends a params
391
+ // map — an absent `params` field means "no change" (e.g., legacy action
392
+ // responses that omitted params). Explicit `{}` still clears correctly.
393
+ if (update.metadata.params !== undefined) {
394
+ eventController.setParams(update.metadata.params);
395
+ }
350
396
 
351
397
  // Update handle data progressively as it streams in
352
398
  if (update.metadata.handles) {
@@ -359,6 +405,7 @@ export function NavigationProvider({
359
405
  store,
360
406
  matched: update.metadata.matched,
361
407
  isPartial: update.metadata.isPartial,
408
+ resolvedIds: update.metadata.resolvedIds,
362
409
  historyKey,
363
410
  }).catch((err) =>
364
411
  console.error("[NavigationProvider] Error consuming handles:", err),
@@ -377,6 +424,7 @@ export function NavigationProvider({
377
424
  {}, // Empty data - all existing data not in matched will be cleaned up
378
425
  update.metadata.matched,
379
426
  true, // partial update - will clean up segments not in matched
427
+ update.metadata.resolvedIds,
380
428
  );
381
429
  }
382
430
  });
@@ -398,7 +446,11 @@ export function NavigationProvider({
398
446
  // Build the content tree
399
447
  let content = <RootErrorBoundary>{root}</RootErrorBoundary>;
400
448
 
401
- // Wrap with ThemeProvider when theme is enabled
449
+ // Wrap with ThemeProvider when theme is enabled. The ThemeProvider is
450
+ // document-lifetime: its config comes from the initial load and does NOT
451
+ // swap on cross-app transitions, because the ThemeProvider sits above the
452
+ // segment tree and a smooth (no-reload) app switch cannot safely remount
453
+ // it. A new theme config only takes effect on a full document load.
402
454
  if (themeConfig) {
403
455
  content = (
404
456
  <ThemeProvider config={themeConfig} initialTheme={initialTheme}>