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

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 (278) hide show
  1. package/README.md +120 -25
  2. package/dist/bin/rango.js +147 -57
  3. package/dist/testing/vitest.js +82 -0
  4. package/dist/vite/index.js +2154 -861
  5. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  6. package/package.json +57 -11
  7. package/skills/api-client/SKILL.md +211 -0
  8. package/skills/breadcrumbs/SKILL.md +3 -1
  9. package/skills/bundle-analysis/SKILL.md +159 -0
  10. package/skills/cache-guide/SKILL.md +220 -30
  11. package/skills/caching/SKILL.md +116 -8
  12. package/skills/composability/SKILL.md +27 -2
  13. package/skills/document-cache/SKILL.md +78 -55
  14. package/skills/handler-use/SKILL.md +364 -0
  15. package/skills/hooks/SKILL.md +229 -20
  16. package/skills/host-router/SKILL.md +45 -20
  17. package/skills/i18n/SKILL.md +276 -0
  18. package/skills/intercept/SKILL.md +46 -4
  19. package/skills/layout/SKILL.md +28 -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 +71 -6
  28. package/skills/prerender/SKILL.md +14 -33
  29. package/skills/rango/SKILL.md +243 -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 +57 -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/testing/SKILL.md +128 -0
  37. package/skills/testing/bindings.md +89 -0
  38. package/skills/testing/cache-prerender.md +98 -0
  39. package/skills/testing/client-components.md +121 -0
  40. package/skills/testing/e2e-parity.md +124 -0
  41. package/skills/testing/flight.md +89 -0
  42. package/skills/testing/handles.md +127 -0
  43. package/skills/testing/loader.md +108 -0
  44. package/skills/testing/middleware.md +97 -0
  45. package/skills/testing/render-handler.md +102 -0
  46. package/skills/testing/response-routes.md +94 -0
  47. package/skills/testing/reverse-and-types.md +83 -0
  48. package/skills/testing/server-actions.md +89 -0
  49. package/skills/testing/server-tree.md +128 -0
  50. package/skills/testing/setup.md +120 -0
  51. package/skills/typesafety/SKILL.md +319 -27
  52. package/skills/use-cache/SKILL.md +34 -5
  53. package/skills/view-transitions/SKILL.md +294 -0
  54. package/src/__augment-tests__/augment.ts +81 -0
  55. package/src/__augment-tests__/augmented.check.ts +116 -0
  56. package/src/browser/action-coordinator.ts +53 -36
  57. package/src/browser/app-shell.ts +52 -0
  58. package/src/browser/event-controller.ts +86 -70
  59. package/src/browser/history-state.ts +21 -0
  60. package/src/browser/index.ts +3 -3
  61. package/src/browser/navigation-bridge.ts +84 -11
  62. package/src/browser/navigation-client.ts +104 -68
  63. package/src/browser/navigation-store.ts +32 -9
  64. package/src/browser/navigation-transaction.ts +10 -28
  65. package/src/browser/partial-update.ts +64 -26
  66. package/src/browser/prefetch/cache.ts +183 -44
  67. package/src/browser/prefetch/fetch.ts +228 -37
  68. package/src/browser/prefetch/queue.ts +36 -5
  69. package/src/browser/rango-state.ts +53 -13
  70. package/src/browser/react/Link.tsx +30 -2
  71. package/src/browser/react/NavigationProvider.tsx +72 -31
  72. package/src/browser/react/filter-segment-order.ts +51 -7
  73. package/src/browser/react/index.ts +3 -0
  74. package/src/browser/react/location-state-shared.ts +175 -4
  75. package/src/browser/react/location-state.ts +39 -13
  76. package/src/browser/react/use-handle.ts +17 -9
  77. package/src/browser/react/use-navigation.ts +22 -2
  78. package/src/browser/react/use-params.ts +20 -8
  79. package/src/browser/react/use-reverse.ts +106 -0
  80. package/src/browser/react/use-router.ts +22 -2
  81. package/src/browser/react/use-segments.ts +11 -8
  82. package/src/browser/response-adapter.ts +32 -1
  83. package/src/browser/rsc-router.tsx +69 -22
  84. package/src/browser/scroll-restoration.ts +22 -14
  85. package/src/browser/segment-reconciler.ts +36 -14
  86. package/src/browser/segment-structure-assert.ts +2 -2
  87. package/src/browser/server-action-bridge.ts +23 -30
  88. package/src/browser/types.ts +21 -0
  89. package/src/build/collect-fallback-refs.ts +107 -0
  90. package/src/build/generate-manifest.ts +60 -35
  91. package/src/build/generate-route-types.ts +2 -0
  92. package/src/build/index.ts +8 -1
  93. package/src/build/prefix-tree-utils.ts +123 -0
  94. package/src/build/route-trie.ts +95 -25
  95. package/src/build/route-types/codegen.ts +4 -4
  96. package/src/build/route-types/include-resolution.ts +1 -1
  97. package/src/build/route-types/per-module-writer.ts +7 -4
  98. package/src/build/route-types/router-processing.ts +55 -14
  99. package/src/build/route-types/scan-filter.ts +1 -1
  100. package/src/build/route-types/source-scan.ts +118 -0
  101. package/src/build/runtime-discovery.ts +9 -20
  102. package/src/cache/cache-scope.ts +28 -42
  103. package/src/cache/cf/cf-cache-store.ts +54 -13
  104. package/src/client.rsc.tsx +3 -0
  105. package/src/client.tsx +96 -205
  106. package/src/context-var.ts +5 -5
  107. package/src/decode-loader-results.ts +36 -0
  108. package/src/errors.ts +30 -4
  109. package/src/handle.ts +32 -14
  110. package/src/host/index.ts +2 -2
  111. package/src/host/router.ts +129 -57
  112. package/src/host/types.ts +31 -2
  113. package/src/host/utils.ts +1 -1
  114. package/src/href-client.ts +140 -21
  115. package/src/index.rsc.ts +10 -6
  116. package/src/index.ts +54 -17
  117. package/src/loader-store.ts +500 -0
  118. package/src/loader.rsc.ts +25 -7
  119. package/src/loader.ts +16 -9
  120. package/src/missing-id-error.ts +68 -0
  121. package/src/outlet-context.ts +1 -1
  122. package/src/prerender.ts +27 -6
  123. package/src/response-utils.ts +37 -0
  124. package/src/reverse.ts +65 -36
  125. package/src/route-content-wrapper.tsx +6 -28
  126. package/src/route-definition/dsl-helpers.ts +384 -257
  127. package/src/route-definition/helper-factories.ts +29 -139
  128. package/src/route-definition/helpers-types.ts +100 -28
  129. package/src/route-definition/resolve-handler-use.ts +6 -0
  130. package/src/route-definition/use-item-types.ts +32 -0
  131. package/src/route-types.ts +26 -41
  132. package/src/router/basename.ts +14 -0
  133. package/src/router/content-negotiation.ts +15 -2
  134. package/src/router/error-handling.ts +1 -1
  135. package/src/router/find-match.ts +54 -6
  136. package/src/router/handler-context.ts +21 -38
  137. package/src/router/intercept-resolution.ts +4 -18
  138. package/src/router/lazy-includes.ts +41 -22
  139. package/src/router/loader-resolution.ts +82 -36
  140. package/src/router/manifest.ts +41 -19
  141. package/src/router/match-api.ts +4 -3
  142. package/src/router/match-handlers.ts +63 -20
  143. package/src/router/match-middleware/cache-lookup.ts +44 -91
  144. package/src/router/match-middleware/cache-store.ts +3 -2
  145. package/src/router/match-result.ts +53 -32
  146. package/src/router/metrics.ts +1 -1
  147. package/src/router/middleware-types.ts +15 -26
  148. package/src/router/middleware.ts +99 -84
  149. package/src/router/pattern-matching.ts +116 -19
  150. package/src/router/prerender-match.ts +1 -1
  151. package/src/router/preview-match.ts +3 -1
  152. package/src/router/request-classification.ts +4 -28
  153. package/src/router/revalidation.ts +58 -2
  154. package/src/router/router-interfaces.ts +45 -28
  155. package/src/router/router-options.ts +40 -1
  156. package/src/router/router-registry.ts +2 -5
  157. package/src/router/segment-resolution/fresh.ts +27 -6
  158. package/src/router/segment-resolution/revalidation.ts +147 -106
  159. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  160. package/src/router/substitute-pattern-params.ts +56 -0
  161. package/src/router/telemetry.ts +99 -0
  162. package/src/router/trie-matching.ts +40 -16
  163. package/src/router/types.ts +8 -0
  164. package/src/router/url-params.ts +49 -0
  165. package/src/router.ts +52 -30
  166. package/src/rsc/handler-context.ts +2 -2
  167. package/src/rsc/handler.ts +28 -69
  168. package/src/rsc/helpers.ts +91 -43
  169. package/src/rsc/index.ts +1 -1
  170. package/src/rsc/manifest-init.ts +28 -41
  171. package/src/rsc/origin-guard.ts +28 -10
  172. package/src/rsc/progressive-enhancement.ts +4 -0
  173. package/src/rsc/response-error.ts +79 -12
  174. package/src/rsc/response-route-handler.ts +57 -61
  175. package/src/rsc/rsc-rendering.ts +35 -51
  176. package/src/rsc/runtime-warnings.ts +9 -10
  177. package/src/rsc/server-action.ts +17 -37
  178. package/src/rsc/ssr-setup.ts +16 -0
  179. package/src/rsc/types.ts +8 -2
  180. package/src/runtime-env.ts +18 -0
  181. package/src/search-params.ts +4 -4
  182. package/src/segment-content-promise.ts +67 -0
  183. package/src/segment-loader-promise.ts +122 -0
  184. package/src/segment-system.tsx +132 -116
  185. package/src/serialize.ts +243 -0
  186. package/src/server/context.ts +175 -53
  187. package/src/server/cookie-store.ts +28 -4
  188. package/src/server/request-context.ts +67 -51
  189. package/src/ssr/index.tsx +5 -1
  190. package/src/static-handler.ts +25 -3
  191. package/src/testing/cache-status.ts +166 -0
  192. package/src/testing/collect-handle.ts +63 -0
  193. package/src/testing/dispatch.ts +581 -0
  194. package/src/testing/dom.entry.ts +22 -0
  195. package/src/testing/e2e/fixture.ts +188 -0
  196. package/src/testing/e2e/index.ts +149 -0
  197. package/src/testing/e2e/matchers.ts +51 -0
  198. package/src/testing/e2e/page-helpers.ts +272 -0
  199. package/src/testing/e2e/parity.ts +326 -0
  200. package/src/testing/e2e/server.ts +195 -0
  201. package/src/testing/flight-matchers.ts +110 -0
  202. package/src/testing/flight-normalize.ts +38 -0
  203. package/src/testing/flight-runtime.d.ts +57 -0
  204. package/src/testing/flight-tree.ts +682 -0
  205. package/src/testing/flight.entry.ts +51 -0
  206. package/src/testing/flight.ts +234 -0
  207. package/src/testing/generated-routes.ts +223 -0
  208. package/src/testing/index.ts +106 -0
  209. package/src/testing/internal/context.ts +304 -0
  210. package/src/testing/internal/flight-client-globals.ts +30 -0
  211. package/src/testing/internal/seed-vars.ts +42 -0
  212. package/src/testing/render-handler.ts +323 -0
  213. package/src/testing/render-route.tsx +590 -0
  214. package/src/testing/run-loader.ts +363 -0
  215. package/src/testing/run-middleware.ts +205 -0
  216. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  217. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  218. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  219. package/src/testing/vitest-stubs/version.ts +5 -0
  220. package/src/testing/vitest.ts +285 -0
  221. package/src/types/global-namespace.ts +39 -26
  222. package/src/types/handler-context.ts +68 -50
  223. package/src/types/index.ts +1 -0
  224. package/src/types/loader-types.ts +11 -9
  225. package/src/types/request-scope.ts +126 -0
  226. package/src/types/route-entry.ts +11 -0
  227. package/src/types/segments.ts +35 -2
  228. package/src/urls/include-helper.ts +34 -67
  229. package/src/urls/index.ts +1 -5
  230. package/src/urls/path-helper-types.ts +41 -7
  231. package/src/urls/path-helper.ts +17 -52
  232. package/src/urls/pattern-types.ts +36 -19
  233. package/src/urls/response-types.ts +22 -29
  234. package/src/urls/type-extraction.ts +58 -139
  235. package/src/urls/urls-function.ts +1 -5
  236. package/src/use-loader.tsx +413 -42
  237. package/src/vite/debug.ts +185 -0
  238. package/src/vite/discovery/bundle-postprocess.ts +6 -6
  239. package/src/vite/discovery/discover-routers.ts +106 -75
  240. package/src/vite/discovery/discovery-errors.ts +194 -0
  241. package/src/vite/discovery/gate-state.ts +171 -0
  242. package/src/vite/discovery/prerender-collection.ts +67 -26
  243. package/src/vite/discovery/route-types-writer.ts +40 -84
  244. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  245. package/src/vite/discovery/state.ts +33 -0
  246. package/src/vite/discovery/virtual-module-codegen.ts +13 -23
  247. package/src/vite/index.ts +2 -0
  248. package/src/vite/plugin-types.ts +67 -0
  249. package/src/vite/plugins/cjs-to-esm.ts +8 -7
  250. package/src/vite/plugins/client-ref-dedup.ts +16 -0
  251. package/src/vite/plugins/client-ref-hashing.ts +28 -5
  252. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  253. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  254. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  255. package/src/vite/plugins/expose-action-id.ts +54 -30
  256. package/src/vite/plugins/expose-id-utils.ts +12 -8
  257. package/src/vite/plugins/expose-ids/export-analysis.ts +100 -20
  258. package/src/vite/plugins/expose-ids/handler-transform.ts +8 -61
  259. package/src/vite/plugins/expose-ids/loader-transform.ts +3 -5
  260. package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
  261. package/src/vite/plugins/expose-internal-ids.ts +496 -486
  262. package/src/vite/plugins/performance-tracks.ts +29 -25
  263. package/src/vite/plugins/use-cache-transform.ts +65 -50
  264. package/src/vite/plugins/version-injector.ts +39 -23
  265. package/src/vite/plugins/version-plugin.ts +59 -2
  266. package/src/vite/plugins/virtual-entries.ts +2 -2
  267. package/src/vite/rango.ts +116 -29
  268. package/src/vite/router-discovery.ts +750 -100
  269. package/src/vite/utils/ast-handler-extract.ts +15 -15
  270. package/src/vite/utils/banner.ts +1 -1
  271. package/src/vite/utils/bundle-analysis.ts +4 -2
  272. package/src/vite/utils/client-chunks.ts +190 -0
  273. package/src/vite/utils/forward-user-plugins.ts +193 -0
  274. package/src/vite/utils/manifest-utils.ts +8 -59
  275. package/src/vite/utils/package-resolution.ts +41 -1
  276. package/src/vite/utils/prerender-utils.ts +21 -6
  277. package/src/vite/utils/shared-utils.ts +107 -26
  278. package/src/browser/action-response-classifier.ts +0 -99
@@ -1,14 +1,33 @@
1
1
  /**
2
2
  * Prefetch Cache
3
3
  *
4
- * In-memory cache storing prefetched Response objects for instant cache hits
5
- * on subsequent navigation. Cache key is source-dependent (includes the
6
- * current page URL) because the server's diff-based response depends on
7
- * where the user navigates from.
4
+ * In-memory cache storing eagerly-decoded prefetch payloads for instant,
5
+ * already-warm cache hits on subsequent navigation. A prefetch fetches the
6
+ * RSC partial AND decodes it (createFromFetch) up front decoding the Flight
7
+ * stream resolves the route's client references, so the route's JS chunks are
8
+ * imported during prefetch rather than on click. The decoded payload is reused
9
+ * verbatim by navigation, so a prefetched click loads no new code. Two key
10
+ * scopes are in play:
11
+ * - Wildcard (default): built by `buildPrefetchKey(rangoState, target)` —
12
+ * shape `rangoState\0/target?...`. Shared across all source pages and
13
+ * invalidated automatically when Rango state bumps (deploy or
14
+ * server-action invalidation).
15
+ * - Source-scoped: built by `buildSourceKey(rangoState, sourceHref, target)`
16
+ * — shape `rangoState\0sourceHref\0/target?...`. Embeds the Rango state
17
+ * (so rotation invalidates source-scoped entries too) plus the source
18
+ * href (so each originating page gets its own slot). Populated when the
19
+ * server tags a response with `X-RSC-Prefetch-Scope: source` (intercept
20
+ * modals etc.), OR when a Link opts in with `prefetchKey=":source"` — in
21
+ * both cases so source-sensitive responses cannot bleed into navigations
22
+ * from other pages.
8
23
  *
9
24
  * Also tracks in-flight prefetch promises. Each promise resolves to the
10
- * navigation branch of a tee'd Response, allowing navigation to adopt a
11
- * still-downloading prefetch without reparsing or buffering the body.
25
+ * decoded prefetch entry (or null), letting navigation adopt a
26
+ * still-downloading prefetch without issuing a duplicate request. A
27
+ * single promise can be registered under multiple alias keys (see
28
+ * `setInflightPromiseWithAliases`) so same-source navigations adopt via
29
+ * their source key while cross-source ones fall through to the wildcard
30
+ * alias — with consume/clear atomically removing every alias.
12
31
  *
13
32
  * Replaces the previous browser HTTP cache approach which was unreliable
14
33
  * due to response draining race conditions and browser inconsistencies.
@@ -16,6 +35,31 @@
16
35
 
17
36
  import { abortAllPrefetches } from "./queue.js";
18
37
  import { invalidateRangoState } from "../rango-state.js";
38
+ import type { RscPayload } from "../types.js";
39
+
40
+ /**
41
+ * A prefetch that has been fetched AND eagerly decoded. Storing the decoded
42
+ * payload (not the raw Response) is what makes a prefetched navigation "warm":
43
+ * decoding the Flight stream during prefetch pulls the route's client chunks,
44
+ * so the click reuses ready elements and loads no new JS.
45
+ */
46
+ export interface DecodedPrefetch {
47
+ /** The eagerly-decoded RSC payload. Reused verbatim by navigation. */
48
+ payload: Promise<RscPayload>;
49
+ /**
50
+ * Resolves when the underlying RSC stream finishes draining. Navigation
51
+ * forwards this as its streamComplete so scroll/revalidation gating is
52
+ * unchanged from the fresh-fetch path.
53
+ */
54
+ streamComplete: Promise<void>;
55
+ /**
56
+ * Prefetch scope as tagged by the server via `X-RSC-Prefetch-Scope`.
57
+ * `"source"` means the response is source-page-sensitive and must not be
58
+ * reused by a navigation from a different page — navigation enforces this
59
+ * when it adopted an inflight entry through the wildcard key.
60
+ */
61
+ scope: "source" | "wildcard";
62
+ }
19
63
 
20
64
  // Default TTL: 5 minutes. Overridden by initPrefetchCache() with
21
65
  // the server-configured prefetchCacheTTL from router options.
@@ -41,7 +85,7 @@ export function isPrefetchCacheDisabled(): boolean {
41
85
  const MAX_PREFETCH_CACHE_SIZE = 50;
42
86
 
43
87
  interface PrefetchCacheEntry {
44
- response: Response;
88
+ entry: DecodedPrefetch;
45
89
  timestamp: number;
46
90
  }
47
91
 
@@ -49,11 +93,21 @@ const cache = new Map<string, PrefetchCacheEntry>();
49
93
  const inflight = new Set<string>();
50
94
 
51
95
  /**
52
- * In-flight promise map. When a prefetch fetch is in progress, its
53
- * Promise<Response | null> is stored here so navigation can await
54
- * it instead of starting a duplicate request.
96
+ * In-flight promise map. When a prefetch fetch+decode is in progress, its
97
+ * Promise<DecodedPrefetch | null> is stored here so navigation can await it
98
+ * instead of starting a duplicate request. Resolves to null when the prefetch
99
+ * failed, was aborted, or carried a control header (reload/redirect) that the
100
+ * navigation must re-fetch to act on.
55
101
  */
56
- const inflightPromises = new Map<string, Promise<Response | null>>();
102
+ const inflightPromises = new Map<string, Promise<DecodedPrefetch | null>>();
103
+
104
+ /**
105
+ * Alias map for in-flight promises registered under multiple keys (see
106
+ * dual inflight in prefetch/fetch.ts). Records each key's sibling set so
107
+ * that consuming or clearing any one key atomically removes every alias —
108
+ * guaranteeing a single consumer for the shared decode.
109
+ */
110
+ const inflightAliases = new Map<string, string[]>();
57
111
 
58
112
  // Generation counter incremented on each clearPrefetchCache(). Fetches that
59
113
  // started before a clear carry a stale generation and must not store their
@@ -61,13 +115,57 @@ const inflightPromises = new Map<string, Promise<Response | null>>();
61
115
  let generation = 0;
62
116
 
63
117
  /**
64
- * Build a source-dependent cache key.
65
- * Includes the source page href so the same target prefetched from
66
- * different pages gets separate entries the server response varies
67
- * based on the source page context (diff-based rendering).
118
+ * Build a cache key by combining a scope prefix with the target URL.
119
+ *
120
+ * Low-level primitive callers that want a specific scope should use
121
+ * one of:
122
+ * - Wildcard (source-agnostic): prefix is the Rango state value from
123
+ * `getRangoState()`. Shared across all source pages. Invalidated
124
+ * automatically when Rango state bumps (deploy or server-action).
125
+ * Key shape: `rangoState\0/target?...`.
126
+ * - Source-scoped: use `buildSourceKey()`. Key shape:
127
+ * `rangoState\0sourceHref\0/target?...` — embeds the Rango state so
128
+ * rotation invalidates source-scoped entries alongside wildcard ones,
129
+ * plus the source page href so the key is unique per originating page.
130
+ * Populated either when the server tags a response with
131
+ * `X-RSC-Prefetch-Scope: source` (intercept modals, etc.) or when a
132
+ * Link opts in via `prefetchKey=":source"`.
133
+ *
134
+ * The `_rsc_segments` query param that travels in the target URL means
135
+ * clients with different mounted segment trees naturally get different
136
+ * keys — so segment-level diffs remain consistent across both scopes.
137
+ */
138
+ export function buildPrefetchKey(prefix: string, targetUrl: URL): string {
139
+ return prefix + "\0" + targetUrl.pathname + targetUrl.search;
140
+ }
141
+
142
+ /**
143
+ * Build a source-scoped cache key. Key shape:
144
+ * `rangoState\0sourceHref\0/target?...`.
145
+ *
146
+ * - `rangoState` is included so state rotation invalidates source-scoped
147
+ * entries alongside wildcard ones.
148
+ * - `sourceHref` makes the key unique per originating page.
68
149
  */
69
- export function buildPrefetchKey(sourceHref: string, targetUrl: URL): string {
70
- return sourceHref + "\0" + targetUrl.pathname + targetUrl.search;
150
+ export function buildSourceKey(
151
+ rangoState: string,
152
+ sourceHref: string,
153
+ targetUrl: URL,
154
+ ): string {
155
+ return buildPrefetchKey(rangoState + "\0" + sourceHref, targetUrl);
156
+ }
157
+
158
+ /**
159
+ * Walk an inflight key plus any sibling aliases registered via
160
+ * `setInflightPromiseWithAliases`, invoking `fn` for each.
161
+ */
162
+ function forEachAlias(key: string, fn: (k: string) => void): void {
163
+ const aliases = inflightAliases.get(key);
164
+ if (aliases) {
165
+ for (const k of aliases) fn(k);
166
+ } else {
167
+ fn(key);
168
+ }
71
169
  }
72
170
 
73
171
  /**
@@ -86,14 +184,14 @@ export function hasPrefetch(key: string): boolean {
86
184
  }
87
185
 
88
186
  /**
89
- * Consume a cached prefetch response. Returns null if not found or expired.
90
- * One-time consumption: the entry is deleted after retrieval.
187
+ * Consume a cached, eagerly-decoded prefetch. Returns null if not found or
188
+ * expired. One-time consumption: the entry is deleted after retrieval.
91
189
  * Returns null when caching is disabled (TTL <= 0).
92
190
  *
93
191
  * Does NOT check in-flight prefetches — use consumeInflightPrefetch()
94
- * for that (returns a Promise instead of a Response).
192
+ * for that (returns a Promise instead of a resolved entry).
95
193
  */
96
- export function consumePrefetch(key: string): Response | null {
194
+ export function consumePrefetch(key: string): DecodedPrefetch | null {
97
195
  if (cacheTTL <= 0) return null;
98
196
  const entry = cache.get(key);
99
197
  if (!entry) return null;
@@ -102,43 +200,48 @@ export function consumePrefetch(key: string): Response | null {
102
200
  return null;
103
201
  }
104
202
  cache.delete(key);
105
- return entry.response;
203
+ return entry.entry;
106
204
  }
107
205
 
108
206
  /**
109
207
  * Consume an in-flight prefetch promise. Returns null if no prefetch is
110
- * in-flight for this key. The returned Promise resolves to the buffered
111
- * Response (or null if the fetch failed/was aborted).
208
+ * in-flight for this key. The returned Promise resolves to the decoded
209
+ * prefetch entry (or null if the fetch failed/was aborted, or carried a
210
+ * control header the navigation must re-fetch to honor).
112
211
  *
113
- * One-time consumption: the promise entry is removed so a second call
114
- * returns null. The `inflight` set entry is intentionally kept so that
115
- * hasPrefetch() continues to return true while the underlying fetch is
116
- * still downloading this prevents prefetchDirect() or other callers
117
- * from starting a duplicate request during the handoff window. The
118
- * inflight flag is cleaned up naturally by clearPrefetchInflight() in
119
- * the fetch's .finally().
212
+ * One-time consumption: the promise entry is removed (along with any
213
+ * sibling aliases registered via `setInflightPromiseWithAliases`) so a
214
+ * second call on any alias returns null only one caller can adopt the
215
+ * shared Response stream. The `inflight` set entry is intentionally
216
+ * kept so that `hasPrefetch()` continues to return true while the
217
+ * underlying fetch is still downloading this prevents
218
+ * `prefetchDirect()` or other callers from starting a duplicate request
219
+ * during the handoff window. The inflight flag is cleaned up naturally
220
+ * by `clearPrefetchInflight()` in the fetch's `.finally()`.
120
221
  */
121
222
  export function consumeInflightPrefetch(
122
223
  key: string,
123
- ): Promise<Response | null> | null {
224
+ ): Promise<DecodedPrefetch | null> | null {
124
225
  const promise = inflightPromises.get(key);
125
226
  if (!promise) return null;
126
- // Remove the promise (one-time consumption) but keep the inflight flag.
127
- inflightPromises.delete(key);
227
+ // Remove the promise under every alias so a second consumer cannot
228
+ // adopt the same stream and race on the body. `inflightAliases` is
229
+ // intentionally preserved — `clearPrefetchInflight()` in the fetch's
230
+ // `.finally()` still needs it to clear every inflight flag; deleting
231
+ // here would strand the sibling's flag forever.
232
+ forEachAlias(key, (k) => inflightPromises.delete(k));
128
233
  return promise;
129
234
  }
130
235
 
131
236
  /**
132
- * Store a prefetch response in the in-memory cache.
133
- * The response should be a clone() of the original so the caller can
134
- * still consume the body. The clone's body streams independently.
237
+ * Store an eagerly-decoded prefetch in the in-memory cache.
135
238
  *
136
239
  * Skips storage if the generation has changed since the fetch started
137
240
  * (a server action invalidated the cache mid-flight).
138
241
  */
139
242
  export function storePrefetch(
140
243
  key: string,
141
- response: Response,
244
+ entry: DecodedPrefetch,
142
245
  fetchGeneration: number,
143
246
  ): void {
144
247
  if (cacheTTL <= 0) return;
@@ -146,8 +249,8 @@ export function storePrefetch(
146
249
 
147
250
  // Evict expired entries
148
251
  const now = Date.now();
149
- for (const [k, entry] of cache) {
150
- if (now - entry.timestamp > cacheTTL) {
252
+ for (const [k, cached] of cache) {
253
+ if (now - cached.timestamp > cacheTTL) {
151
254
  cache.delete(k);
152
255
  }
153
256
  }
@@ -158,7 +261,7 @@ export function storePrefetch(
158
261
  if (oldest) cache.delete(oldest);
159
262
  }
160
263
 
161
- cache.set(key, { response, timestamp: now });
264
+ cache.set(key, { entry, timestamp: now });
162
265
  }
163
266
 
164
267
  /**
@@ -178,14 +281,33 @@ export function markPrefetchInflight(key: string): void {
178
281
  */
179
282
  export function setInflightPromise(
180
283
  key: string,
181
- promise: Promise<Response | null>,
284
+ promise: Promise<DecodedPrefetch | null>,
182
285
  ): void {
183
286
  inflightPromises.set(key, promise);
184
287
  }
185
288
 
289
+ /**
290
+ * Store the same in-flight Promise under multiple keys, recording them
291
+ * as sibling aliases. Consuming or clearing any one alias atomically
292
+ * removes every entry, guaranteeing the shared Response stream has a
293
+ * single consumer even when navigation looks up either key.
294
+ */
295
+ export function setInflightPromiseWithAliases(
296
+ keys: string[],
297
+ promise: Promise<DecodedPrefetch | null>,
298
+ ): void {
299
+ for (const k of keys) {
300
+ inflightPromises.set(k, promise);
301
+ inflightAliases.set(k, keys);
302
+ }
303
+ }
304
+
186
305
  export function clearPrefetchInflight(key: string): void {
187
- inflight.delete(key);
188
- inflightPromises.delete(key);
306
+ forEachAlias(key, (k) => {
307
+ inflight.delete(k);
308
+ inflightPromises.delete(k);
309
+ inflightAliases.delete(k);
310
+ });
189
311
  }
190
312
 
191
313
  /**
@@ -200,7 +322,24 @@ export function clearPrefetchCache(): void {
200
322
  generation++;
201
323
  inflight.clear();
202
324
  inflightPromises.clear();
325
+ inflightAliases.clear();
203
326
  cache.clear();
204
327
  abortAllPrefetches();
205
328
  invalidateRangoState();
206
329
  }
330
+
331
+ /**
332
+ * Drop all in-memory prefetch state for this tab without rotating rango-state.
333
+ *
334
+ * Use for local-only invalidations (e.g. app switch in this tab) where
335
+ * other tabs should NOT observe a state rotation. Unlike clearPrefetchCache,
336
+ * does not call invalidateRangoState, so the shared X-Rango-State token
337
+ * stays intact and siblings in the old app keep their prefetches.
338
+ */
339
+ export function clearPrefetchCacheLocal(): void {
340
+ generation++;
341
+ inflight.clear();
342
+ inflightPromises.clear();
343
+ cache.clear();
344
+ abortAllPrefetches();
345
+ }
@@ -3,26 +3,72 @@
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";
32
+ import { debugLog } from "../logging.js";
33
+ import { teeWithCompletion } 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
+ }
55
+
56
+ /**
57
+ * Check if a URL resolves to the current page (same pathname + search).
58
+ * Used to prevent same-page prefetching, which produces a trivial diff
59
+ * that would corrupt the (default wildcard) prefetch cache entry.
60
+ */
61
+ function isSamePage(url: string): boolean {
62
+ try {
63
+ const target = new URL(url, window.location.origin);
64
+ return (
65
+ target.pathname + target.search ===
66
+ window.location.pathname + window.location.search
67
+ );
68
+ } catch {
69
+ return false;
70
+ }
71
+ }
26
72
 
27
73
  /**
28
74
  * Build an RSC partial URL for prefetching.
@@ -59,20 +105,49 @@ function buildPrefetchUrl(
59
105
  }
60
106
 
61
107
  /**
62
- * Core prefetch fetch logic. Fetches the response, tees the body, and stores
63
- * one branch in the in-memory cache. The returned Promise resolves to the
64
- * sibling navigation branch (or null on failure) so navigation can safely
65
- * 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.
66
136
  */
67
137
  function executePrefetchFetch(
68
- key: string,
138
+ wildcardKey: string,
139
+ sourceKey: string,
69
140
  fetchUrl: string,
141
+ forceSourceScope: boolean,
70
142
  signal?: AbortSignal,
71
- ): Promise<Response | null> {
143
+ ): Promise<DecodedPrefetch | null> {
72
144
  const gen = currentGeneration();
73
- markPrefetchInflight(key);
145
+ const inflightKeys = forceSourceScope
146
+ ? [sourceKey]
147
+ : [wildcardKey, sourceKey];
148
+ for (const k of inflightKeys) markPrefetchInflight(k);
74
149
 
75
- const promise: Promise<Response | null> = fetch(fetchUrl, {
150
+ const promise: Promise<DecodedPrefetch | null> = fetch(fetchUrl, {
76
151
  priority: "low" as RequestPriority,
77
152
  signal,
78
153
  headers: {
@@ -82,69 +157,185 @@ function executePrefetchFetch(
82
157
  },
83
158
  })
84
159
  .then((response) => {
85
- if (!response.ok) return null;
86
- // Don't buffer with arrayBuffer() that blocks until the entire
87
- // body downloads, defeating streaming for slow loaders.
88
- // Tee the body: one branch for navigation, one for cache storage.
89
- const [navStream, cacheStream] = response.body!.tee();
90
- const responseInit = {
91
- headers: response.headers,
92
- status: response.status,
93
- statusText: response.statusText,
94
- };
95
- storePrefetch(key, new Response(cacheStream, responseInit), gen);
96
- return new Response(navStream, responseInit);
160
+ if (!response.ok || !decoder) return null;
161
+ // Control headers mean this response is stale (reload) or redirecting.
162
+ // Don't warm it drop so navigation re-fetches and acts on the header.
163
+ if (
164
+ response.headers.has("X-RSC-Reload") ||
165
+ response.headers.has("X-RSC-Redirect")
166
+ ) {
167
+ return null;
168
+ }
169
+
170
+ const scope: "source" | "wildcard" =
171
+ forceSourceScope ||
172
+ response.headers.get("x-rsc-prefetch-scope") === "source"
173
+ ? "source"
174
+ : "wildcard";
175
+ const storageKey = scope === "source" ? sourceKey : wildcardKey;
176
+
177
+ // Track stream completion off a tee so navigation's scroll/revalidation
178
+ // gating matches the fresh-fetch path; decode the other branch.
179
+ let resolveStreamComplete!: () => void;
180
+ const streamComplete = new Promise<void>((resolve) => {
181
+ resolveStreamComplete = resolve;
182
+ });
183
+ const tracked = teeWithCompletion(
184
+ response,
185
+ () => resolveStreamComplete(),
186
+ signal,
187
+ // Speculative prefetch: a never-consumed/aborted stream error is benign.
188
+ true,
189
+ );
190
+
191
+ // Eager decode: parsing the Flight stream imports the route's client
192
+ // chunks now, not on click.
193
+ const payload = decoder(Promise.resolve(tracked));
194
+ // Mark handled so an unconsumed prefetch decode error stays quiet; the
195
+ // error is still surfaced to navigation if it consumes the entry.
196
+ payload.catch(() => {});
197
+
198
+ const entry: DecodedPrefetch = { payload, streamComplete, scope };
199
+ storePrefetch(storageKey, entry, gen);
200
+ return entry;
97
201
  })
98
202
  .catch(() => null)
99
203
  .finally(() => {
100
- clearPrefetchInflight(key);
204
+ clearPrefetchInflight(inflightKeys[0]!);
101
205
  });
102
206
 
103
- setInflightPromise(key, promise);
207
+ setInflightPromiseWithAliases(inflightKeys, promise);
104
208
  return promise;
105
209
  }
106
210
 
211
+ /**
212
+ * Dedup check for prefetch entry presence.
213
+ *
214
+ * Forced `:source` must NOT dedupe against a pre-existing wildcard entry —
215
+ * otherwise the source slot would stay unpopulated and navigation from
216
+ * this source would fall through to the (potentially wrong) wildcard
217
+ * response, defeating the opt-out.
218
+ */
219
+ function hasPrefetchHit(
220
+ forceSourceScope: boolean,
221
+ wildcardKey: string,
222
+ sourceKey: string,
223
+ ): boolean {
224
+ return forceSourceScope
225
+ ? hasPrefetch(sourceKey)
226
+ : hasPrefetch(wildcardKey) || hasPrefetch(sourceKey);
227
+ }
228
+
107
229
  /**
108
230
  * Prefetch (direct): fetch with low priority and store in in-memory cache.
109
231
  * Used by hover strategy -- fires immediately without queueing.
232
+ *
233
+ * By default the wildcard key (Rango-state-keyed) is used for inflight
234
+ * dedup and for responses that are not source-sensitive; source-scoped
235
+ * storage is automatic when the server emits `X-RSC-Prefetch-Scope: source`.
236
+ *
237
+ * Pass `prefetchKey=":source"` to force source-scoped inflight + storage
238
+ * (e.g. when the target uses a custom `revalidate()` that reads
239
+ * `currentUrl` and the wildcard slot would serve the wrong diff).
110
240
  */
111
241
  export function prefetchDirect(
112
242
  url: string,
113
243
  segmentIds: string[],
114
244
  version?: string,
115
245
  routerId?: string,
246
+ prefetchKey?: ":source",
116
247
  ): void {
117
248
  if (!shouldPrefetch()) return;
118
249
 
119
250
  const targetUrl = buildPrefetchUrl(url, segmentIds, version, routerId);
120
251
  if (!targetUrl) return;
121
- const key = buildPrefetchKey(window.location.href, targetUrl);
122
- if (hasPrefetch(key)) return;
123
- executePrefetchFetch(key, targetUrl.toString());
252
+ const forceSourceScope = prefetchKey === ":source";
253
+ // Skip same-page prefetch — a same-page diff is trivial and would corrupt
254
+ // the wildcard cache entry used for cross-page navigation.
255
+ // When `:source` is forced the entry is source-scoped (single-aliased to
256
+ // itself), so it cannot poison any shared slot — allow it.
257
+ if (!forceSourceScope && isSamePage(url)) {
258
+ return;
259
+ }
260
+ const sourceHref = window.location.href;
261
+ const rangoState = getRangoState();
262
+ const wildcardKey = buildPrefetchKey(rangoState, targetUrl);
263
+ const sourceKey = buildSourceKey(rangoState, sourceHref, targetUrl);
264
+ if (hasPrefetchHit(forceSourceScope, wildcardKey, sourceKey)) {
265
+ debugLog("[prefetch] direct dedup (key already exists)", {
266
+ url,
267
+ wildcardKey,
268
+ sourceKey,
269
+ forceSourceScope,
270
+ });
271
+ return;
272
+ }
273
+ debugLog("[prefetch] direct fetch", {
274
+ url,
275
+ wildcardKey,
276
+ sourceKey,
277
+ source: sourceHref,
278
+ forceSourceScope,
279
+ });
280
+ executePrefetchFetch(
281
+ wildcardKey,
282
+ sourceKey,
283
+ targetUrl.toString(),
284
+ forceSourceScope,
285
+ );
124
286
  }
125
287
 
126
288
  /**
127
289
  * Prefetch (queued): goes through the concurrency-limited queue.
128
290
  * Used by viewport/render strategies to avoid flooding the server.
129
- * Returns the cache key for use in cleanup.
291
+ * Returns the inflight key (wildcard by default, source-scoped when
292
+ * `prefetchKey=":source"` is passed).
130
293
  */
131
294
  export function prefetchQueued(
132
295
  url: string,
133
296
  segmentIds: string[],
134
297
  version?: string,
135
298
  routerId?: string,
299
+ prefetchKey?: ":source",
136
300
  ): string {
137
301
  if (!shouldPrefetch()) return "";
138
302
  const targetUrl = buildPrefetchUrl(url, segmentIds, version, routerId);
139
303
  if (!targetUrl) return "";
140
- const key = buildPrefetchKey(window.location.href, targetUrl);
141
- if (hasPrefetch(key)) return key;
304
+ const forceSourceScope = prefetchKey === ":source";
305
+ if (!forceSourceScope && isSamePage(url)) {
306
+ return "";
307
+ }
308
+ const sourceHref = window.location.href;
309
+ const rangoState = getRangoState();
310
+ const wildcardKey = buildPrefetchKey(rangoState, targetUrl);
311
+ const sourceKey = buildSourceKey(rangoState, sourceHref, targetUrl);
312
+ const queueKey = forceSourceScope ? sourceKey : wildcardKey;
313
+ if (hasPrefetchHit(forceSourceScope, wildcardKey, sourceKey)) {
314
+ debugLog("[prefetch] queued dedup (key already exists)", {
315
+ url,
316
+ wildcardKey,
317
+ sourceKey,
318
+ forceSourceScope,
319
+ });
320
+ return queueKey;
321
+ }
142
322
  const fetchUrlStr = targetUrl.toString();
143
- enqueuePrefetch(key, (signal) => {
323
+ enqueuePrefetch(queueKey, (signal) => {
144
324
  // Re-check at execution time: a hover-triggered prefetchDirect may
145
325
  // 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(() => {});
326
+ if (hasPrefetchHit(forceSourceScope, wildcardKey, sourceKey)) {
327
+ return Promise.resolve();
328
+ }
329
+ if (!forceSourceScope && isSamePage(url)) {
330
+ return Promise.resolve();
331
+ }
332
+ return executePrefetchFetch(
333
+ wildcardKey,
334
+ sourceKey,
335
+ fetchUrlStr,
336
+ forceSourceScope,
337
+ signal,
338
+ ).then(() => {});
148
339
  });
149
- return key;
340
+ return queueKey;
150
341
  }