@rangojs/router 0.0.0-experimental.77 → 0.0.0-experimental.77ed8945

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 (239) hide show
  1. package/README.md +120 -25
  2. package/dist/bin/rango.js +147 -57
  3. package/dist/vite/index.js +2103 -861
  4. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  5. package/package.json +13 -8
  6. package/skills/api-client/SKILL.md +211 -0
  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/css/SKILL.md +76 -0
  13. package/skills/document-cache/SKILL.md +78 -55
  14. package/skills/handler-use/SKILL.md +3 -1
  15. package/skills/hooks/SKILL.md +229 -20
  16. package/skills/host-router/SKILL.md +66 -20
  17. package/skills/i18n/SKILL.md +276 -0
  18. package/skills/intercept/SKILL.md +26 -4
  19. package/skills/layout/SKILL.md +6 -7
  20. package/skills/links/SKILL.md +247 -17
  21. package/skills/loader/SKILL.md +219 -9
  22. package/skills/middleware/SKILL.md +47 -12
  23. package/skills/migrate-nextjs/SKILL.md +562 -0
  24. package/skills/migrate-react-router/SKILL.md +769 -0
  25. package/skills/mime-routes/SKILL.md +27 -0
  26. package/skills/observability/SKILL.md +137 -0
  27. package/skills/parallel/SKILL.md +12 -6
  28. package/skills/prerender/SKILL.md +14 -33
  29. package/skills/rango/SKILL.md +238 -22
  30. package/skills/react-compiler/SKILL.md +168 -0
  31. package/skills/response-routes/SKILL.md +122 -47
  32. package/skills/route/SKILL.md +33 -4
  33. package/skills/router-setup/SKILL.md +3 -3
  34. package/skills/server-actions/SKILL.md +751 -0
  35. package/skills/streams-and-websockets/SKILL.md +283 -0
  36. package/skills/tailwind/SKILL.md +27 -3
  37. package/skills/typesafety/SKILL.md +319 -27
  38. package/skills/use-cache/SKILL.md +34 -5
  39. package/skills/view-transitions/SKILL.md +294 -0
  40. package/src/__augment-tests__/augment.ts +81 -0
  41. package/src/__augment-tests__/augmented.check.ts +116 -0
  42. package/src/browser/action-coordinator.ts +53 -36
  43. package/src/browser/app-shell.ts +39 -0
  44. package/src/browser/event-controller.ts +86 -70
  45. package/src/browser/history-state.ts +21 -0
  46. package/src/browser/index.ts +3 -3
  47. package/src/browser/navigation-bridge.ts +29 -9
  48. package/src/browser/navigation-client.ts +99 -77
  49. package/src/browser/navigation-store.ts +7 -8
  50. package/src/browser/navigation-transaction.ts +10 -28
  51. package/src/browser/partial-update.ts +60 -40
  52. package/src/browser/prefetch/cache.ts +196 -49
  53. package/src/browser/prefetch/fetch.ts +203 -59
  54. package/src/browser/prefetch/queue.ts +36 -5
  55. package/src/browser/rango-state.ts +37 -13
  56. package/src/browser/react/Link.tsx +18 -13
  57. package/src/browser/react/NavigationProvider.tsx +75 -31
  58. package/src/browser/react/filter-segment-order.ts +51 -7
  59. package/src/browser/react/index.ts +3 -0
  60. package/src/browser/react/location-state-shared.ts +175 -4
  61. package/src/browser/react/location-state.ts +39 -13
  62. package/src/browser/react/use-handle.ts +17 -9
  63. package/src/browser/react/use-navigation.ts +22 -2
  64. package/src/browser/react/use-params.ts +20 -8
  65. package/src/browser/react/use-reverse.ts +106 -0
  66. package/src/browser/react/use-router.ts +23 -2
  67. package/src/browser/react/use-segments.ts +11 -8
  68. package/src/browser/response-adapter.ts +52 -1
  69. package/src/browser/rsc-router.tsx +71 -22
  70. package/src/browser/scroll-restoration.ts +22 -14
  71. package/src/browser/segment-reconciler.ts +10 -14
  72. package/src/browser/segment-structure-assert.ts +2 -2
  73. package/src/browser/server-action-bridge.ts +44 -30
  74. package/src/browser/types.ts +12 -2
  75. package/src/build/collect-fallback-refs.ts +107 -0
  76. package/src/build/generate-manifest.ts +60 -35
  77. package/src/build/generate-route-types.ts +2 -0
  78. package/src/build/index.ts +8 -1
  79. package/src/build/prefix-tree-utils.ts +123 -0
  80. package/src/build/route-trie.ts +45 -1
  81. package/src/build/route-types/codegen.ts +4 -4
  82. package/src/build/route-types/include-resolution.ts +1 -1
  83. package/src/build/route-types/per-module-writer.ts +7 -4
  84. package/src/build/route-types/router-processing.ts +55 -14
  85. package/src/build/route-types/scan-filter.ts +1 -1
  86. package/src/build/route-types/source-scan.ts +118 -0
  87. package/src/build/runtime-discovery.ts +9 -20
  88. package/src/cache/cache-runtime.ts +17 -5
  89. package/src/cache/cache-scope.ts +51 -49
  90. package/src/cache/cf/cf-cache-store.ts +502 -32
  91. package/src/cache/cf/index.ts +3 -0
  92. package/src/cache/handle-snapshot.ts +103 -0
  93. package/src/cache/index.ts +3 -0
  94. package/src/cache/memory-segment-store.ts +3 -2
  95. package/src/cache/types.ts +10 -6
  96. package/src/client.rsc.tsx +3 -0
  97. package/src/client.tsx +96 -205
  98. package/src/context-var.ts +5 -5
  99. package/src/decode-loader-results.ts +36 -0
  100. package/src/errors.ts +30 -4
  101. package/src/handle.ts +4 -6
  102. package/src/host/index.ts +2 -2
  103. package/src/host/router.ts +129 -57
  104. package/src/host/types.ts +31 -2
  105. package/src/host/utils.ts +1 -1
  106. package/src/href-client.ts +140 -21
  107. package/src/index.rsc.ts +10 -6
  108. package/src/index.ts +17 -8
  109. package/src/loader-store.ts +500 -0
  110. package/src/loader.rsc.ts +2 -5
  111. package/src/loader.ts +3 -10
  112. package/src/missing-id-error.ts +68 -0
  113. package/src/outlet-context.ts +1 -1
  114. package/src/prerender/store.ts +9 -7
  115. package/src/prerender.ts +4 -4
  116. package/src/response-utils.ts +37 -0
  117. package/src/reverse.ts +65 -39
  118. package/src/route-content-wrapper.tsx +6 -28
  119. package/src/route-definition/dsl-helpers.ts +253 -265
  120. package/src/route-definition/helper-factories.ts +29 -139
  121. package/src/route-definition/helpers-types.ts +43 -15
  122. package/src/route-definition/resolve-handler-use.ts +6 -0
  123. package/src/route-definition/use-item-types.ts +32 -0
  124. package/src/route-types.ts +26 -41
  125. package/src/router/content-negotiation.ts +15 -2
  126. package/src/router/error-handling.ts +1 -1
  127. package/src/router/find-match.ts +54 -6
  128. package/src/router/handler-context.ts +21 -41
  129. package/src/router/intercept-resolution.ts +4 -18
  130. package/src/router/lazy-includes.ts +41 -22
  131. package/src/router/loader-resolution.ts +82 -36
  132. package/src/router/manifest.ts +41 -19
  133. package/src/router/match-api.ts +4 -3
  134. package/src/router/match-handlers.ts +1 -0
  135. package/src/router/match-middleware/cache-lookup.ts +57 -95
  136. package/src/router/match-middleware/cache-store.ts +3 -2
  137. package/src/router/match-result.ts +53 -32
  138. package/src/router/metrics.ts +1 -1
  139. package/src/router/middleware-types.ts +15 -26
  140. package/src/router/middleware.ts +99 -84
  141. package/src/router/pattern-matching.ts +116 -19
  142. package/src/router/prerender-match.ts +40 -15
  143. package/src/router/preview-match.ts +3 -1
  144. package/src/router/request-classification.ts +40 -37
  145. package/src/router/revalidation.ts +58 -2
  146. package/src/router/router-interfaces.ts +51 -35
  147. package/src/router/router-options.ts +25 -1
  148. package/src/router/router-registry.ts +2 -5
  149. package/src/router/segment-resolution/fresh.ts +27 -6
  150. package/src/router/segment-resolution/revalidation.ts +147 -106
  151. package/src/router/segment-resolution/static-store.ts +19 -5
  152. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  153. package/src/router/substitute-pattern-params.ts +56 -0
  154. package/src/router/trie-matching.ts +40 -16
  155. package/src/router/types.ts +8 -0
  156. package/src/router/url-params.ts +49 -0
  157. package/src/router.ts +37 -25
  158. package/src/rsc/handler-context.ts +2 -2
  159. package/src/rsc/handler.ts +58 -77
  160. package/src/rsc/helpers.ts +72 -43
  161. package/src/rsc/index.ts +1 -1
  162. package/src/rsc/manifest-init.ts +28 -41
  163. package/src/rsc/origin-guard.ts +30 -10
  164. package/src/rsc/progressive-enhancement.ts +4 -0
  165. package/src/rsc/response-error.ts +79 -12
  166. package/src/rsc/response-route-handler.ts +76 -61
  167. package/src/rsc/rsc-rendering.ts +45 -51
  168. package/src/rsc/runtime-warnings.ts +9 -10
  169. package/src/rsc/server-action.ts +33 -39
  170. package/src/rsc/ssr-setup.ts +16 -0
  171. package/src/rsc/types.ts +8 -2
  172. package/src/search-params.ts +4 -4
  173. package/src/segment-content-promise.ts +67 -0
  174. package/src/segment-loader-promise.ts +122 -0
  175. package/src/segment-system.tsx +132 -116
  176. package/src/serialize.ts +243 -0
  177. package/src/server/context.ts +175 -53
  178. package/src/server/cookie-store.ts +28 -4
  179. package/src/server/request-context.ts +57 -51
  180. package/src/ssr/index.tsx +5 -1
  181. package/src/static-handler.ts +1 -1
  182. package/src/types/global-namespace.ts +39 -26
  183. package/src/types/handler-context.ts +68 -50
  184. package/src/types/index.ts +1 -0
  185. package/src/types/loader-types.ts +11 -9
  186. package/src/types/request-scope.ts +126 -0
  187. package/src/types/route-entry.ts +11 -0
  188. package/src/types/segments.ts +35 -2
  189. package/src/urls/include-helper.ts +34 -67
  190. package/src/urls/index.ts +1 -5
  191. package/src/urls/path-helper-types.ts +17 -3
  192. package/src/urls/path-helper.ts +17 -52
  193. package/src/urls/pattern-types.ts +36 -19
  194. package/src/urls/response-types.ts +22 -29
  195. package/src/urls/type-extraction.ts +58 -139
  196. package/src/urls/urls-function.ts +1 -5
  197. package/src/use-loader.tsx +413 -42
  198. package/src/vite/debug.ts +185 -0
  199. package/src/vite/discovery/bundle-postprocess.ts +6 -6
  200. package/src/vite/discovery/discover-routers.ts +106 -75
  201. package/src/vite/discovery/discovery-errors.ts +194 -0
  202. package/src/vite/discovery/gate-state.ts +171 -0
  203. package/src/vite/discovery/prerender-collection.ts +72 -31
  204. package/src/vite/discovery/route-types-writer.ts +40 -84
  205. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  206. package/src/vite/discovery/state.ts +33 -0
  207. package/src/vite/discovery/virtual-module-codegen.ts +13 -23
  208. package/src/vite/index.ts +2 -0
  209. package/src/vite/plugin-types.ts +67 -0
  210. package/src/vite/plugins/cjs-to-esm.ts +8 -7
  211. package/src/vite/plugins/client-ref-dedup.ts +16 -0
  212. package/src/vite/plugins/client-ref-hashing.ts +28 -5
  213. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  214. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  215. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  216. package/src/vite/plugins/expose-action-id.ts +54 -30
  217. package/src/vite/plugins/expose-id-utils.ts +12 -8
  218. package/src/vite/plugins/expose-ids/export-analysis.ts +100 -20
  219. package/src/vite/plugins/expose-ids/handler-transform.ts +8 -61
  220. package/src/vite/plugins/expose-ids/loader-transform.ts +3 -5
  221. package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
  222. package/src/vite/plugins/expose-internal-ids.ts +496 -486
  223. package/src/vite/plugins/performance-tracks.ts +29 -25
  224. package/src/vite/plugins/use-cache-transform.ts +65 -50
  225. package/src/vite/plugins/version-injector.ts +39 -23
  226. package/src/vite/plugins/version-plugin.ts +59 -2
  227. package/src/vite/plugins/virtual-entries.ts +2 -2
  228. package/src/vite/rango.ts +116 -29
  229. package/src/vite/router-discovery.ts +753 -104
  230. package/src/vite/utils/ast-handler-extract.ts +15 -15
  231. package/src/vite/utils/banner.ts +1 -1
  232. package/src/vite/utils/bundle-analysis.ts +4 -2
  233. package/src/vite/utils/client-chunks.ts +190 -0
  234. package/src/vite/utils/forward-user-plugins.ts +193 -0
  235. package/src/vite/utils/manifest-utils.ts +8 -59
  236. package/src/vite/utils/package-resolution.ts +41 -1
  237. package/src/vite/utils/prerender-utils.ts +5 -4
  238. package/src/vite/utils/shared-utils.ts +107 -26
  239. package/src/browser/action-response-classifier.ts +0 -99
@@ -3,32 +3,60 @@
3
3
  *
4
4
  * Fetch-based prefetch logic used by Link (hover/viewport/render strategies)
5
5
  * and useRouter().prefetch(). Sends the same headers and segment IDs as a
6
- * real navigation so the server returns a proper diff. The Response is fully
7
- * buffered and stored in an in-memory cache for instant consumption on
8
- * subsequent navigation.
6
+ * real navigation so the server returns a proper diff. The response is fetched
7
+ * AND eagerly decoded (createFromFetch) up front: decoding the Flight stream
8
+ * resolves the route's client references, so the route's JS chunks are imported
9
+ * during prefetch rather than on click. The decoded payload is stored in an
10
+ * in-memory cache and reused verbatim by navigation, so a prefetched click
11
+ * loads no new code.
9
12
  *
10
13
  * In-flight promises are tracked in the cache so that navigation can reuse
11
- * a prefetch that is still downloading instead of starting a duplicate request.
14
+ * a prefetch that is still downloading/decoding instead of starting a
15
+ * duplicate request.
12
16
  */
13
17
 
14
18
  import {
15
19
  buildPrefetchKey,
20
+ buildSourceKey,
16
21
  hasPrefetch,
17
22
  markPrefetchInflight,
18
- setInflightPromise,
23
+ setInflightPromiseWithAliases,
19
24
  storePrefetch,
20
25
  clearPrefetchInflight,
21
26
  currentGeneration,
27
+ type DecodedPrefetch,
22
28
  } from "./cache.js";
23
29
  import { getRangoState } from "../rango-state.js";
24
30
  import { enqueuePrefetch } from "./queue.js";
25
31
  import { shouldPrefetch } from "./policy.js";
26
32
  import { debugLog } from "../logging.js";
33
+ import { teeWithCompletion, isForeignRouterId } from "../response-adapter.js";
34
+ import type { RscPayload } from "../types.js";
35
+
36
+ /**
37
+ * Decoder injected at app startup (see setPrefetchDecoder). This is
38
+ * `deps.createFromFetch` — decoupled from the RSC runtime exactly like the
39
+ * navigation client. Prefetch decodes through it so the route's client chunks
40
+ * are pulled during the prefetch, not on click.
41
+ */
42
+ type PrefetchDecoder = (response: Promise<Response>) => Promise<RscPayload>;
43
+
44
+ let decoder: PrefetchDecoder | null = null;
45
+
46
+ /**
47
+ * Wire the RSC decoder used to eagerly decode prefetched responses. Called
48
+ * once from initBrowserApp with the same createFromFetch the navigation client
49
+ * uses. Until set, prefetch warming is inert (prefetches are skipped) — the
50
+ * browser app always sets it before any Link can fire.
51
+ */
52
+ export function setPrefetchDecoder(fn: PrefetchDecoder): void {
53
+ decoder = fn;
54
+ }
27
55
 
28
56
  /**
29
57
  * Check if a URL resolves to the current page (same pathname + search).
30
- * Used to prevent same-page prefetching with prefetchKey, which would
31
- * produce a trivial diff that corrupts the wildcard cache.
58
+ * Used to prevent same-page prefetching, which produces a trivial diff
59
+ * that would corrupt the (default wildcard) prefetch cache entry.
32
60
  */
33
61
  function isSamePage(url: string): boolean {
34
62
  try {
@@ -77,20 +105,50 @@ function buildPrefetchUrl(
77
105
  }
78
106
 
79
107
  /**
80
- * Core prefetch fetch logic. Fetches the response, tees the body, and stores
81
- * one branch in the in-memory cache. The returned Promise resolves to the
82
- * sibling navigation branch (or null on failure) so navigation can safely
83
- * reuse an in-flight prefetch via consumeInflightPrefetch().
108
+ * Core prefetch fetch logic. Fetches the response, eagerly decodes it, and
109
+ * stores the decoded payload in the in-memory cache. The returned Promise
110
+ * resolves to the decoded entry (or null on failure / control header) so
111
+ * navigation can safely reuse an in-flight prefetch via
112
+ * consumeInflightPrefetch().
113
+ *
114
+ * Eager decode is the warming step: createFromFetch parses the Flight stream,
115
+ * which resolves the route's client references and imports its JS chunks. The
116
+ * stored payload is reused as-is by navigation, so the click loads no new code.
117
+ *
118
+ * Control headers are NOT acted on here. A speculative prefetch must never
119
+ * reload the page or throw a redirect — if the response carries X-RSC-Reload
120
+ * or X-RSC-Redirect, we drop it (resolve null) and let the real navigation
121
+ * re-fetch and honor it.
122
+ *
123
+ * Inflight + storage key selection:
124
+ *
125
+ * - `forceSourceScope` (Link opted in with `prefetchKey=":source"`): single
126
+ * inflight registration under `sourceKey`; entry stored under `sourceKey`.
127
+ * No wildcard leak is possible.
128
+ *
129
+ * - Otherwise: dual inflight registration under both `wildcardKey` and
130
+ * `sourceKey` so same-source navigations adopt directly via their own
131
+ * source key. Storage key is chosen at response time from the
132
+ * `X-RSC-Prefetch-Scope` header — `"source"` → `sourceKey` (intercept
133
+ * modals etc.), anything else → `wildcardKey`. The entry records its scope
134
+ * so cross-source navigations that adopted via `wildcardKey` can bail out
135
+ * in `navigation-client.ts` when the adopted entry turns out source-scoped.
84
136
  */
85
137
  function executePrefetchFetch(
86
- key: string,
138
+ wildcardKey: string,
139
+ sourceKey: string,
87
140
  fetchUrl: string,
141
+ forceSourceScope: boolean,
142
+ expectedRouterId?: string,
88
143
  signal?: AbortSignal,
89
- ): Promise<Response | null> {
144
+ ): Promise<DecodedPrefetch | null> {
90
145
  const gen = currentGeneration();
91
- markPrefetchInflight(key);
146
+ const inflightKeys = forceSourceScope
147
+ ? [sourceKey]
148
+ : [wildcardKey, sourceKey];
149
+ for (const k of inflightKeys) markPrefetchInflight(k);
92
150
 
93
- const promise: Promise<Response | null> = fetch(fetchUrl, {
151
+ const promise: Promise<DecodedPrefetch | null> = fetch(fetchUrl, {
94
152
  priority: "low" as RequestPriority,
95
153
  signal,
96
154
  headers: {
@@ -100,107 +158,193 @@ function executePrefetchFetch(
100
158
  },
101
159
  })
102
160
  .then((response) => {
103
- if (!response.ok) return null;
104
- // Don't buffer with arrayBuffer() that blocks until the entire
105
- // body downloads, defeating streaming for slow loaders.
106
- // Tee the body: one branch for navigation, one for cache storage.
107
- const [navStream, cacheStream] = response.body!.tee();
108
- const responseInit = {
109
- headers: response.headers,
110
- status: response.status,
111
- statusText: response.statusText,
112
- };
113
- storePrefetch(key, new Response(cacheStream, responseInit), gen);
114
- return new Response(navStream, responseInit);
161
+ if (!response.ok || !decoder) return null;
162
+ // Control headers mean this response is stale (reload) or redirecting.
163
+ // Don't warm it drop so navigation re-fetches and acts on the header.
164
+ if (
165
+ response.headers.has("X-RSC-Reload") ||
166
+ response.headers.has("X-RSC-Redirect")
167
+ ) {
168
+ return null;
169
+ }
170
+ // Integrity check: never warm (or decode/import the chunks of) a foreign
171
+ // app's payload. A speculative prefetch must never reload — just drop it;
172
+ // navigation re-fetches and the server steers it.
173
+ if (isForeignRouterId(response, expectedRouterId)) {
174
+ return null;
175
+ }
176
+
177
+ const scope: "source" | "wildcard" =
178
+ forceSourceScope ||
179
+ response.headers.get("x-rsc-prefetch-scope") === "source"
180
+ ? "source"
181
+ : "wildcard";
182
+ const storageKey = scope === "source" ? sourceKey : wildcardKey;
183
+
184
+ // Track stream completion off a tee so navigation's scroll/revalidation
185
+ // gating matches the fresh-fetch path; decode the other branch.
186
+ let resolveStreamComplete!: () => void;
187
+ const streamComplete = new Promise<void>((resolve) => {
188
+ resolveStreamComplete = resolve;
189
+ });
190
+ const tracked = teeWithCompletion(
191
+ response,
192
+ () => resolveStreamComplete(),
193
+ signal,
194
+ // Speculative prefetch: a never-consumed/aborted stream error is benign.
195
+ true,
196
+ );
197
+
198
+ // Eager decode: parsing the Flight stream imports the route's client
199
+ // chunks now, not on click.
200
+ const payload = decoder(Promise.resolve(tracked));
201
+ // Mark handled so an unconsumed prefetch decode error stays quiet; the
202
+ // error is still surfaced to navigation if it consumes the entry.
203
+ payload.catch(() => {});
204
+
205
+ const entry: DecodedPrefetch = { payload, streamComplete, scope };
206
+ storePrefetch(storageKey, entry, gen);
207
+ return entry;
115
208
  })
116
209
  .catch(() => null)
117
210
  .finally(() => {
118
- clearPrefetchInflight(key);
211
+ clearPrefetchInflight(inflightKeys[0]!);
119
212
  });
120
213
 
121
- setInflightPromise(key, promise);
214
+ setInflightPromiseWithAliases(inflightKeys, promise);
122
215
  return promise;
123
216
  }
124
217
 
218
+ /**
219
+ * Dedup check for prefetch entry presence.
220
+ *
221
+ * Forced `:source` must NOT dedupe against a pre-existing wildcard entry —
222
+ * otherwise the source slot would stay unpopulated and navigation from
223
+ * this source would fall through to the (potentially wrong) wildcard
224
+ * response, defeating the opt-out.
225
+ */
226
+ function hasPrefetchHit(
227
+ forceSourceScope: boolean,
228
+ wildcardKey: string,
229
+ sourceKey: string,
230
+ ): boolean {
231
+ return forceSourceScope
232
+ ? hasPrefetch(sourceKey)
233
+ : hasPrefetch(wildcardKey) || hasPrefetch(sourceKey);
234
+ }
235
+
125
236
  /**
126
237
  * Prefetch (direct): fetch with low priority and store in in-memory cache.
127
238
  * Used by hover strategy -- fires immediately without queueing.
239
+ *
240
+ * By default the wildcard key (Rango-state-keyed) is used for inflight
241
+ * dedup and for responses that are not source-sensitive; source-scoped
242
+ * storage is automatic when the server emits `X-RSC-Prefetch-Scope: source`.
243
+ *
244
+ * Pass `prefetchKey=":source"` to force source-scoped inflight + storage
245
+ * (e.g. when the target uses a custom `revalidate()` that reads
246
+ * `currentUrl` and the wildcard slot would serve the wrong diff).
128
247
  */
129
248
  export function prefetchDirect(
130
249
  url: string,
131
250
  segmentIds: string[],
132
251
  version?: string,
133
252
  routerId?: string,
134
- prefetchKey?: string | ((from: string) => string),
253
+ prefetchKey?: ":source",
135
254
  ): void {
136
255
  if (!shouldPrefetch()) return;
137
256
 
138
257
  const targetUrl = buildPrefetchUrl(url, segmentIds, version, routerId);
139
258
  if (!targetUrl) return;
140
- // Skip same-page prefetch with prefetchKey a same-page diff is trivial
141
- // and would corrupt the wildcard cache entry for cross-page navigation.
142
- if (prefetchKey != null && isSamePage(url)) {
259
+ const forceSourceScope = prefetchKey === ":source";
260
+ // Skip same-page prefetch a same-page diff is trivial and would corrupt
261
+ // the wildcard cache entry used for cross-page navigation.
262
+ // When `:source` is forced the entry is source-scoped (single-aliased to
263
+ // itself), so it cannot poison any shared slot — allow it.
264
+ if (!forceSourceScope && isSamePage(url)) {
143
265
  return;
144
266
  }
145
- const key = buildPrefetchKey(window.location.href, targetUrl, prefetchKey);
146
- if (hasPrefetch(key)) {
267
+ const sourceHref = window.location.href;
268
+ const rangoState = getRangoState();
269
+ const wildcardKey = buildPrefetchKey(rangoState, targetUrl);
270
+ const sourceKey = buildSourceKey(rangoState, sourceHref, targetUrl);
271
+ if (hasPrefetchHit(forceSourceScope, wildcardKey, sourceKey)) {
147
272
  debugLog("[prefetch] direct dedup (key already exists)", {
148
273
  url,
149
- key,
150
- prefetchKey: prefetchKey != null ? String(prefetchKey) : undefined,
274
+ wildcardKey,
275
+ sourceKey,
276
+ forceSourceScope,
151
277
  });
152
278
  return;
153
279
  }
154
280
  debugLog("[prefetch] direct fetch", {
155
281
  url,
156
- key,
157
- source: window.location.href,
158
- prefetchKey: prefetchKey != null ? String(prefetchKey) : undefined,
282
+ wildcardKey,
283
+ sourceKey,
284
+ source: sourceHref,
285
+ forceSourceScope,
159
286
  });
160
- executePrefetchFetch(key, targetUrl.toString());
287
+ executePrefetchFetch(
288
+ wildcardKey,
289
+ sourceKey,
290
+ targetUrl.toString(),
291
+ forceSourceScope,
292
+ routerId,
293
+ );
161
294
  }
162
295
 
163
296
  /**
164
297
  * Prefetch (queued): goes through the concurrency-limited queue.
165
298
  * Used by viewport/render strategies to avoid flooding the server.
166
- * Returns the cache key for use in cleanup.
299
+ * Returns the inflight key (wildcard by default, source-scoped when
300
+ * `prefetchKey=":source"` is passed).
167
301
  */
168
302
  export function prefetchQueued(
169
303
  url: string,
170
304
  segmentIds: string[],
171
305
  version?: string,
172
306
  routerId?: string,
173
- prefetchKey?: string | ((from: string) => string),
307
+ prefetchKey?: ":source",
174
308
  ): string {
175
309
  if (!shouldPrefetch()) return "";
176
310
  const targetUrl = buildPrefetchUrl(url, segmentIds, version, routerId);
177
311
  if (!targetUrl) return "";
178
- // Skip same-page prefetch with prefetchKey a same-page diff is trivial
179
- // and would corrupt the wildcard cache entry for cross-page navigation.
180
- if (prefetchKey != null && isSamePage(url)) {
312
+ const forceSourceScope = prefetchKey === ":source";
313
+ if (!forceSourceScope && isSamePage(url)) {
181
314
  return "";
182
315
  }
183
- const key = buildPrefetchKey(window.location.href, targetUrl, prefetchKey);
184
- if (hasPrefetch(key)) {
316
+ const sourceHref = window.location.href;
317
+ const rangoState = getRangoState();
318
+ const wildcardKey = buildPrefetchKey(rangoState, targetUrl);
319
+ const sourceKey = buildSourceKey(rangoState, sourceHref, targetUrl);
320
+ const queueKey = forceSourceScope ? sourceKey : wildcardKey;
321
+ if (hasPrefetchHit(forceSourceScope, wildcardKey, sourceKey)) {
185
322
  debugLog("[prefetch] queued dedup (key already exists)", {
186
323
  url,
187
- key,
188
- prefetchKey: prefetchKey != null ? String(prefetchKey) : undefined,
324
+ wildcardKey,
325
+ sourceKey,
326
+ forceSourceScope,
189
327
  });
190
- return key;
328
+ return queueKey;
191
329
  }
192
330
  const fetchUrlStr = targetUrl.toString();
193
- enqueuePrefetch(key, (signal) => {
331
+ enqueuePrefetch(queueKey, (signal) => {
194
332
  // Re-check at execution time: a hover-triggered prefetchDirect may
195
333
  // have started or completed this key while the item sat in the queue.
196
- if (hasPrefetch(key)) return Promise.resolve();
197
- // By execution time, the user may have navigated to the target page.
198
- // A same-page prefetch produces a trivial diff that would overwrite
199
- // the useful cross-page entry in the wildcard cache.
200
- if (prefetchKey != null && isSamePage(url)) {
334
+ if (hasPrefetchHit(forceSourceScope, wildcardKey, sourceKey)) {
335
+ return Promise.resolve();
336
+ }
337
+ if (!forceSourceScope && isSamePage(url)) {
201
338
  return Promise.resolve();
202
339
  }
203
- return executePrefetchFetch(key, fetchUrlStr, signal).then(() => {});
340
+ return executePrefetchFetch(
341
+ wildcardKey,
342
+ sourceKey,
343
+ fetchUrlStr,
344
+ forceSourceScope,
345
+ routerId,
346
+ signal,
347
+ ).then(() => {});
204
348
  });
205
- return key;
349
+ return queueKey;
206
350
  }
@@ -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,36 @@
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. The key is bound once at document init; a
17
+ * cross-app navigation is a full document load (X-RSC-Reload), so the target
18
+ * app's document binds its own key on load (tabs in the old app keep theirs).
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. Bound on
35
+ // initRangoState (document boot). The storage listener filters cross-tab
36
+ // events by this key so events from tabs in a different app are ignored.
37
+ let currentStorageKey: string = LEGACY_STORAGE_KEY;
38
+
24
39
  // Cross-tab sync: the `storage` event fires in OTHER tabs when one tab writes
25
40
  // to localStorage, keeping cachedState fresh without polling.
26
41
  let storageListenerAttached = false;
@@ -28,7 +43,10 @@ let storageListenerAttached = false;
28
43
  function attachStorageListener(): void {
29
44
  if (storageListenerAttached || typeof window === "undefined") return;
30
45
  window.addEventListener("storage", (e) => {
31
- if (e.key !== STORAGE_KEY) return;
46
+ // Only react to events for this tab's current app namespace. Events
47
+ // under other routerId-scoped keys belong to other apps and must not
48
+ // clobber this tab's state.
49
+ if (e.key !== currentStorageKey) return;
32
50
  cachedState = e.newValue;
33
51
  });
34
52
  storageListenerAttached = true;
@@ -37,16 +55,22 @@ function attachStorageListener(): void {
37
55
  /**
38
56
  * Initialize the Rango state key in localStorage.
39
57
  * 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.
58
+ * The routerId scopes the storage key to this app; in multi-app setups
59
+ * each app owns its own `rango-state:{routerId}` key and cannot observe
60
+ * invalidations from sibling apps on the same origin.
61
+ *
62
+ * If localStorage already has a matching-version entry under the key,
63
+ * keeps it (preserves invalidation state across refresh). Otherwise
64
+ * writes a new value.
42
65
  */
43
- export function initRangoState(version: string): void {
66
+ export function initRangoState(version: string, routerId?: string): void {
67
+ currentStorageKey = buildStorageKey(routerId);
44
68
  if (typeof window === "undefined") return;
45
69
 
46
70
  attachStorageListener();
47
71
 
48
72
  try {
49
- const existing = localStorage.getItem(STORAGE_KEY);
73
+ const existing = localStorage.getItem(currentStorageKey);
50
74
  if (existing) {
51
75
  const colonIdx = existing.indexOf(":");
52
76
  if (colonIdx > 0) {
@@ -59,7 +83,7 @@ export function initRangoState(version: string): void {
59
83
  }
60
84
  // New version or first load
61
85
  const newState = `${version}:${Date.now()}`;
62
- localStorage.setItem(STORAGE_KEY, newState);
86
+ localStorage.setItem(currentStorageKey, newState);
63
87
  cachedState = newState;
64
88
  } catch {
65
89
  // localStorage may be unavailable (private browsing in some browsers)
@@ -77,7 +101,7 @@ export function getRangoState(): string {
77
101
  if (typeof window === "undefined") return "0:0";
78
102
 
79
103
  try {
80
- const stored = localStorage.getItem(STORAGE_KEY);
104
+ const stored = localStorage.getItem(currentStorageKey);
81
105
  if (stored) {
82
106
  cachedState = stored;
83
107
  return stored;
@@ -105,7 +129,7 @@ export function invalidateRangoState(): void {
105
129
  if (typeof window === "undefined") return;
106
130
 
107
131
  try {
108
- localStorage.setItem(STORAGE_KEY, newState);
132
+ localStorage.setItem(currentStorageKey, newState);
109
133
  } catch {
110
134
  // Silently handle localStorage errors
111
135
  }
@@ -98,25 +98,30 @@ export interface LinkProps extends Omit<
98
98
  */
99
99
  prefetch?: PrefetchStrategy;
100
100
  /**
101
- * Custom prefetch cache key for source-agnostic cache reuse.
102
- * When set, prefetch responses are cached independently of the current
103
- * page URL, so navigating to the same target from different source pages
104
- * reuses the cached prefetch.
101
+ * Opt-in override for the prefetch cache scope.
105
102
  *
106
- * - String: static group name (e.g., `"pages"`)
107
- * - Function: receives current URL (`window.location.href`), returns a
108
- * normalized source key
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.
109
116
  *
110
117
  * @example
111
118
  * ```tsx
112
- * // Static group all "pages" links share one cache entry per target
113
- * <Link to="/page/3" prefetch="hover" prefetchKey="pages" />
114
- *
115
- * // Normalize — strip trailing page number from source URL
116
- * <Link to="/page/3" prefetch="hover" prefetchKey={(from) => from.replace(/\/\d+$/, '')} />
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" />
117
122
  * ```
118
123
  */
119
- prefetchKey?: string | ((from: string) => string);
124
+ prefetchKey?: ":source";
120
125
  /**
121
126
  * State to pass to history.pushState/replaceState.
122
127
  * Accessible via useLocationState() hook.