@rangojs/router 0.0.0-experimental.1b930379 → 0.0.0-experimental.1c0bdfad

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 (400) hide show
  1. package/AGENTS.md +12 -0
  2. package/README.md +245 -49
  3. package/dist/bin/rango.js +441 -134
  4. package/dist/testing/vitest.js +82 -0
  5. package/dist/vite/index.js +3453 -1240
  6. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  7. package/package.json +76 -21
  8. package/skills/api-client/SKILL.md +211 -0
  9. package/skills/breadcrumbs/SKILL.md +64 -2
  10. package/skills/bundle-analysis/SKILL.md +159 -0
  11. package/skills/cache-guide/SKILL.md +247 -23
  12. package/skills/caching/SKILL.md +318 -15
  13. package/skills/composability/SKILL.md +27 -2
  14. package/skills/css/SKILL.md +76 -0
  15. package/skills/debug-manifest/SKILL.md +4 -2
  16. package/skills/document-cache/SKILL.md +78 -55
  17. package/skills/handler-use/SKILL.md +364 -0
  18. package/skills/hooks/SKILL.md +250 -30
  19. package/skills/host-router/SKILL.md +83 -23
  20. package/skills/i18n/SKILL.md +276 -0
  21. package/skills/intercept/SKILL.md +50 -6
  22. package/skills/layout/SKILL.md +35 -9
  23. package/skills/links/SKILL.md +249 -17
  24. package/skills/loader/SKILL.md +279 -53
  25. package/skills/middleware/SKILL.md +52 -13
  26. package/skills/migrate-nextjs/SKILL.md +584 -0
  27. package/skills/migrate-react-router/SKILL.md +771 -0
  28. package/skills/mime-routes/SKILL.md +28 -1
  29. package/skills/observability/SKILL.md +172 -0
  30. package/skills/parallel/SKILL.md +203 -7
  31. package/skills/prerender/SKILL.md +153 -109
  32. package/skills/rango/SKILL.md +251 -22
  33. package/skills/react-compiler/SKILL.md +168 -0
  34. package/skills/response-routes/SKILL.md +123 -48
  35. package/skills/route/SKILL.md +101 -5
  36. package/skills/router-setup/SKILL.md +116 -8
  37. package/skills/scripts/SKILL.md +179 -0
  38. package/skills/server-actions/SKILL.md +775 -0
  39. package/skills/streams-and-websockets/SKILL.md +283 -0
  40. package/skills/tailwind/SKILL.md +27 -3
  41. package/skills/testing/SKILL.md +129 -0
  42. package/skills/testing/bindings.md +103 -0
  43. package/skills/testing/cache-prerender.md +127 -0
  44. package/skills/testing/client-components.md +124 -0
  45. package/skills/testing/e2e-parity.md +125 -0
  46. package/skills/testing/flight.md +91 -0
  47. package/skills/testing/handles.md +129 -0
  48. package/skills/testing/loader.md +128 -0
  49. package/skills/testing/middleware.md +99 -0
  50. package/skills/testing/render-handler.md +121 -0
  51. package/skills/testing/response-routes.md +95 -0
  52. package/skills/testing/reverse-and-types.md +84 -0
  53. package/skills/testing/server-actions.md +107 -0
  54. package/skills/testing/server-tree.md +128 -0
  55. package/skills/testing/setup.md +123 -0
  56. package/skills/typesafety/SKILL.md +332 -29
  57. package/skills/use-cache/SKILL.md +46 -14
  58. package/skills/view-transitions/SKILL.md +294 -0
  59. package/src/__augment-tests__/augment.ts +81 -0
  60. package/src/__augment-tests__/augmented.check.ts +116 -0
  61. package/src/__internal.ts +1 -66
  62. package/src/browser/action-coordinator.ts +53 -36
  63. package/src/browser/action-fence.ts +47 -0
  64. package/src/browser/app-shell.ts +39 -0
  65. package/src/browser/app-version.ts +14 -0
  66. package/src/browser/connection-warmup.ts +134 -0
  67. package/src/browser/cookie-name.ts +140 -0
  68. package/src/browser/event-controller.ts +197 -150
  69. package/src/browser/history-state.ts +21 -0
  70. package/src/browser/index.ts +3 -3
  71. package/src/browser/invalidate-client-cache.ts +52 -0
  72. package/src/browser/navigation-bridge.ts +111 -31
  73. package/src/browser/navigation-client.ts +201 -67
  74. package/src/browser/navigation-store-handle.ts +38 -0
  75. package/src/browser/navigation-store.ts +76 -67
  76. package/src/browser/navigation-transaction.ts +18 -66
  77. package/src/browser/network-error-handler.ts +34 -7
  78. package/src/browser/partial-update.ts +187 -112
  79. package/src/browser/prefetch/cache.ts +230 -35
  80. package/src/browser/prefetch/fetch.ts +338 -39
  81. package/src/browser/prefetch/queue.ts +126 -20
  82. package/src/browser/prefetch/resource-ready.ts +77 -0
  83. package/src/browser/rango-state.ts +158 -76
  84. package/src/browser/react/Link.tsx +111 -16
  85. package/src/browser/react/NavigationProvider.tsx +135 -120
  86. package/src/browser/react/ScrollRestoration.tsx +10 -6
  87. package/src/browser/react/context.ts +7 -2
  88. package/src/browser/react/filter-segment-order.ts +66 -7
  89. package/src/browser/react/index.ts +0 -48
  90. package/src/browser/react/location-state-shared.ts +178 -8
  91. package/src/browser/react/location-state.ts +39 -14
  92. package/src/browser/react/use-action.ts +6 -15
  93. package/src/browser/react/use-handle.ts +23 -69
  94. package/src/browser/react/use-href.tsx +8 -1
  95. package/src/browser/react/use-link-status.ts +33 -8
  96. package/src/browser/react/use-navigation.ts +32 -7
  97. package/src/browser/react/use-params.ts +20 -10
  98. package/src/browser/react/use-reverse.ts +106 -0
  99. package/src/browser/react/use-router.ts +46 -11
  100. package/src/browser/react/use-search-params.ts +0 -5
  101. package/src/browser/react/use-segments.ts +11 -21
  102. package/src/browser/response-adapter.ts +80 -5
  103. package/src/browser/rsc-router.tsx +218 -76
  104. package/src/browser/scroll-restoration.ts +54 -42
  105. package/src/browser/segment-reconciler.ts +36 -9
  106. package/src/browser/segment-structure-assert.ts +2 -2
  107. package/src/browser/server-action-bridge.ts +222 -61
  108. package/src/browser/types.ts +91 -11
  109. package/src/browser/validate-redirect-origin.ts +43 -16
  110. package/src/build/collect-fallback-refs.ts +107 -0
  111. package/src/build/generate-manifest.ts +65 -40
  112. package/src/build/generate-route-types.ts +5 -1
  113. package/src/build/index.ts +8 -2
  114. package/src/build/prefix-tree-utils.ts +123 -0
  115. package/src/build/route-trie.ts +165 -36
  116. package/src/build/route-types/ast-route-extraction.ts +15 -8
  117. package/src/build/route-types/codegen.ts +16 -5
  118. package/src/build/route-types/include-resolution.ts +125 -24
  119. package/src/build/route-types/param-extraction.ts +6 -3
  120. package/src/build/route-types/per-module-writer.ts +22 -6
  121. package/src/build/route-types/router-processing.ts +272 -96
  122. package/src/build/route-types/scan-filter.ts +9 -2
  123. package/src/build/route-types/source-scan.ts +216 -0
  124. package/src/build/runtime-discovery.ts +9 -20
  125. package/src/cache/cache-error.ts +104 -0
  126. package/src/cache/cache-key-utils.ts +29 -13
  127. package/src/cache/cache-policy.ts +108 -34
  128. package/src/cache/cache-runtime.ts +214 -48
  129. package/src/cache/cache-scope.ts +236 -89
  130. package/src/cache/cache-tag.ts +103 -0
  131. package/src/cache/cf/cf-base64.ts +33 -0
  132. package/src/cache/cf/cf-cache-constants.ts +127 -0
  133. package/src/cache/cf/cf-cache-store.ts +2224 -171
  134. package/src/cache/cf/cf-cache-types.ts +349 -0
  135. package/src/cache/cf/cf-kv-utils.ts +46 -0
  136. package/src/cache/cf/cf-tag-marker-memo.ts +105 -0
  137. package/src/cache/cf/index.ts +11 -17
  138. package/src/cache/document-cache.ts +89 -27
  139. package/src/cache/handle-snapshot.ts +70 -0
  140. package/src/cache/index.ts +11 -20
  141. package/src/cache/memory-segment-store.ts +136 -37
  142. package/src/cache/profile-registry.ts +31 -31
  143. package/src/cache/read-through-swr.ts +41 -11
  144. package/src/cache/segment-codec.ts +9 -17
  145. package/src/cache/tag-invalidation.ts +230 -0
  146. package/src/cache/taint.ts +55 -0
  147. package/src/cache/types.ts +37 -100
  148. package/src/client.rsc.tsx +44 -21
  149. package/src/client.tsx +119 -290
  150. package/src/cloudflare/index.ts +11 -0
  151. package/src/cloudflare/tracing.ts +109 -0
  152. package/src/component-utils.ts +19 -0
  153. package/src/components/DefaultDocument.tsx +8 -2
  154. package/src/context-var.ts +84 -2
  155. package/src/debug.ts +2 -2
  156. package/src/decode-loader-results.ts +52 -0
  157. package/src/defer.ts +196 -0
  158. package/src/deps/ssr.ts +0 -1
  159. package/src/encode-kv.ts +49 -0
  160. package/src/errors.ts +30 -4
  161. package/src/escape-script.ts +52 -0
  162. package/src/handle.ts +70 -22
  163. package/src/handles/MetaTags.tsx +56 -19
  164. package/src/handles/Scripts.tsx +183 -0
  165. package/src/handles/breadcrumbs.ts +37 -8
  166. package/src/handles/is-thenable.ts +19 -0
  167. package/src/handles/meta.ts +51 -40
  168. package/src/handles/script.ts +244 -0
  169. package/src/host/cookie-handler.ts +9 -60
  170. package/src/host/errors.ts +0 -24
  171. package/src/host/index.ts +8 -2
  172. package/src/host/pattern-matcher.ts +23 -52
  173. package/src/host/router.ts +107 -99
  174. package/src/host/testing.ts +40 -27
  175. package/src/host/types.ts +37 -4
  176. package/src/host/utils.ts +1 -1
  177. package/src/href-client.ts +137 -22
  178. package/src/index.rsc.ts +93 -12
  179. package/src/index.ts +133 -15
  180. package/src/internal-debug.ts +11 -10
  181. package/src/loader-store.ts +500 -0
  182. package/src/loader.rsc.ts +20 -13
  183. package/src/loader.ts +12 -11
  184. package/src/missing-id-error.ts +68 -0
  185. package/src/outlet-context.ts +1 -1
  186. package/src/outlet-provider.tsx +1 -5
  187. package/src/prerender/param-hash.ts +16 -16
  188. package/src/prerender/store.ts +37 -41
  189. package/src/prerender.ts +198 -82
  190. package/src/redirect-origin.ts +100 -0
  191. package/src/regex-escape.ts +8 -0
  192. package/src/render-error-thrower.tsx +20 -0
  193. package/src/response-utils.ts +62 -0
  194. package/src/reverse.ts +65 -15
  195. package/src/root-error-boundary.tsx +1 -19
  196. package/src/route-content-wrapper.tsx +7 -72
  197. package/src/route-definition/dsl-helpers.ts +469 -276
  198. package/src/route-definition/helper-factories.ts +29 -139
  199. package/src/route-definition/helpers-types.ts +113 -37
  200. package/src/route-definition/index.ts +3 -0
  201. package/src/route-definition/redirect.ts +53 -12
  202. package/src/route-definition/resolve-handler-use.ts +161 -0
  203. package/src/route-definition/use-item-types.ts +32 -0
  204. package/src/route-map-builder.ts +7 -17
  205. package/src/route-types.ts +37 -41
  206. package/src/router/basename.ts +14 -0
  207. package/src/router/content-negotiation.ts +164 -17
  208. package/src/router/error-handling.ts +45 -18
  209. package/src/router/find-match.ts +45 -22
  210. package/src/router/handler-context.ts +83 -39
  211. package/src/router/instrument.ts +350 -0
  212. package/src/router/intercept-resolution.ts +50 -24
  213. package/src/router/lazy-includes.ts +19 -53
  214. package/src/router/loader-resolution.ts +274 -56
  215. package/src/router/logging.ts +5 -8
  216. package/src/router/manifest.ts +49 -45
  217. package/src/router/match-api.ts +120 -204
  218. package/src/router/match-context.ts +0 -22
  219. package/src/router/match-handlers.ts +58 -58
  220. package/src/router/match-middleware/background-revalidation.ts +33 -6
  221. package/src/router/match-middleware/cache-lookup.ts +214 -263
  222. package/src/router/match-middleware/cache-store.ts +73 -33
  223. package/src/router/match-middleware/intercept-resolution.ts +8 -28
  224. package/src/router/match-middleware/segment-resolution.ts +52 -18
  225. package/src/router/match-pipelines.ts +1 -42
  226. package/src/router/match-result.ts +104 -40
  227. package/src/router/metrics.ts +5 -34
  228. package/src/router/middleware-types.ts +13 -142
  229. package/src/router/middleware.ts +270 -172
  230. package/src/router/navigation-snapshot.ts +131 -0
  231. package/src/router/params-util.ts +23 -0
  232. package/src/router/pattern-matching.ts +132 -90
  233. package/src/router/prefetch-cache-ttl.ts +51 -0
  234. package/src/router/prerender-match.ts +195 -56
  235. package/src/router/preview-match.ts +32 -102
  236. package/src/router/request-classification.ts +276 -0
  237. package/src/router/revalidation.ts +123 -73
  238. package/src/router/route-snapshot.ts +244 -0
  239. package/src/router/router-context.ts +8 -28
  240. package/src/router/router-interfaces.ts +115 -35
  241. package/src/router/router-options.ts +172 -15
  242. package/src/router/router-registry.ts +2 -5
  243. package/src/router/segment-resolution/fresh.ts +264 -77
  244. package/src/router/segment-resolution/helpers.ts +115 -30
  245. package/src/router/segment-resolution/loader-cache.ts +63 -37
  246. package/src/router/segment-resolution/revalidation.ts +474 -385
  247. package/src/router/segment-resolution/static-store.ts +19 -5
  248. package/src/router/segment-resolution/streamed-handler-telemetry.ts +52 -0
  249. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  250. package/src/router/segment-resolution.ts +5 -1
  251. package/src/router/segment-wrappers.ts +8 -5
  252. package/src/router/state-cookie-name.ts +33 -0
  253. package/src/router/substitute-pattern-params.ts +56 -0
  254. package/src/router/telemetry-otel.ts +161 -199
  255. package/src/router/telemetry.ts +96 -19
  256. package/src/router/timeout.ts +0 -20
  257. package/src/router/tracing.ts +206 -0
  258. package/src/router/trie-matching.ts +163 -59
  259. package/src/router/types.ts +10 -63
  260. package/src/router/url-params.ts +44 -0
  261. package/src/router.ts +163 -55
  262. package/src/rsc/handler-context.ts +3 -2
  263. package/src/rsc/handler.ts +658 -511
  264. package/src/rsc/helpers.ts +168 -46
  265. package/src/rsc/index.ts +2 -5
  266. package/src/rsc/json-route-result.ts +38 -0
  267. package/src/rsc/loader-fetch.ts +127 -31
  268. package/src/rsc/manifest-init.ts +33 -42
  269. package/src/rsc/origin-guard.ts +39 -25
  270. package/src/rsc/progressive-enhancement.ts +77 -11
  271. package/src/rsc/redirect-guard.ts +99 -0
  272. package/src/rsc/response-cache-serve.ts +238 -0
  273. package/src/rsc/response-error.ts +79 -12
  274. package/src/rsc/response-route-handler.ts +99 -189
  275. package/src/rsc/rsc-rendering.ts +105 -72
  276. package/src/rsc/runtime-warnings.ts +23 -10
  277. package/src/rsc/server-action.ts +263 -112
  278. package/src/rsc/ssr-setup.ts +18 -2
  279. package/src/rsc/types.ts +32 -6
  280. package/src/runtime-env.ts +18 -0
  281. package/src/search-params.ts +35 -30
  282. package/src/segment-content-promise.ts +67 -0
  283. package/src/segment-loader-promise.ts +149 -0
  284. package/src/segment-system.tsx +281 -129
  285. package/src/serialize.ts +243 -0
  286. package/src/server/context.ts +309 -61
  287. package/src/server/cookie-parse.ts +32 -0
  288. package/src/server/cookie-store.ts +80 -5
  289. package/src/server/handle-store.ts +40 -38
  290. package/src/server/loader-registry.ts +26 -46
  291. package/src/server/request-context.ts +398 -172
  292. package/src/ssr/index.tsx +25 -16
  293. package/src/static-handler.ts +27 -18
  294. package/src/testing/cache-status.ts +162 -0
  295. package/src/testing/collect-handle.ts +40 -0
  296. package/src/testing/dispatch.ts +701 -0
  297. package/src/testing/dom.entry.ts +22 -0
  298. package/src/testing/e2e/fixture.ts +188 -0
  299. package/src/testing/e2e/index.ts +128 -0
  300. package/src/testing/e2e/matchers.ts +35 -0
  301. package/src/testing/e2e/page-helpers.ts +272 -0
  302. package/src/testing/e2e/parity.ts +387 -0
  303. package/src/testing/e2e/server.ts +195 -0
  304. package/src/testing/flight-matchers.ts +97 -0
  305. package/src/testing/flight-normalize.ts +11 -0
  306. package/src/testing/flight-runtime.d.ts +57 -0
  307. package/src/testing/flight-tree.ts +682 -0
  308. package/src/testing/flight.entry.ts +52 -0
  309. package/src/testing/flight.ts +257 -0
  310. package/src/testing/generated-routes.ts +183 -0
  311. package/src/testing/index.ts +99 -0
  312. package/src/testing/internal/context.ts +371 -0
  313. package/src/testing/internal/flight-client-globals.ts +30 -0
  314. package/src/testing/internal/seed-vars.ts +54 -0
  315. package/src/testing/render-handler.ts +343 -0
  316. package/src/testing/render-route.tsx +581 -0
  317. package/src/testing/run-loader.ts +385 -0
  318. package/src/testing/run-middleware.ts +205 -0
  319. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  320. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  321. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  322. package/src/testing/vitest-stubs/version.ts +5 -0
  323. package/src/testing/vitest.ts +305 -0
  324. package/src/theme/ThemeProvider.tsx +20 -58
  325. package/src/theme/ThemeScript.tsx +7 -9
  326. package/src/theme/constants.ts +52 -13
  327. package/src/theme/index.ts +0 -7
  328. package/src/theme/theme-context.ts +1 -5
  329. package/src/theme/theme-script.ts +22 -21
  330. package/src/theme/use-theme.ts +0 -3
  331. package/src/types/boundaries.ts +0 -35
  332. package/src/types/cache-types.ts +17 -8
  333. package/src/types/error-types.ts +30 -90
  334. package/src/types/global-namespace.ts +54 -41
  335. package/src/types/handler-context.ts +233 -81
  336. package/src/types/index.ts +1 -10
  337. package/src/types/loader-types.ts +44 -15
  338. package/src/types/request-scope.ts +112 -0
  339. package/src/types/route-config.ts +6 -50
  340. package/src/types/route-entry.ts +19 -7
  341. package/src/types/segments.ts +37 -14
  342. package/src/urls/include-helper.ts +33 -70
  343. package/src/urls/index.ts +1 -11
  344. package/src/urls/path-helper-types.ts +58 -11
  345. package/src/urls/path-helper.ts +57 -111
  346. package/src/urls/pattern-types.ts +48 -19
  347. package/src/urls/response-types.ts +25 -22
  348. package/src/urls/type-extraction.ts +58 -139
  349. package/src/urls/urls-function.ts +1 -18
  350. package/src/use-loader.tsx +346 -89
  351. package/src/vite/debug.ts +185 -0
  352. package/src/vite/discovery/bundle-postprocess.ts +36 -38
  353. package/src/vite/discovery/discover-routers.ts +130 -85
  354. package/src/vite/discovery/discovery-errors.ts +194 -0
  355. package/src/vite/discovery/gate-state.ts +171 -0
  356. package/src/vite/discovery/prerender-collection.ts +214 -132
  357. package/src/vite/discovery/route-types-writer.ts +40 -84
  358. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  359. package/src/vite/discovery/state.ts +57 -6
  360. package/src/vite/discovery/virtual-module-codegen.ts +14 -34
  361. package/src/vite/index.ts +6 -0
  362. package/src/vite/inject-client-debug.ts +36 -0
  363. package/src/vite/plugin-types.ts +155 -65
  364. package/src/vite/plugins/cjs-to-esm.ts +16 -19
  365. package/src/vite/plugins/client-ref-dedup.ts +16 -11
  366. package/src/vite/plugins/client-ref-hashing.ts +28 -15
  367. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  368. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  369. package/src/vite/plugins/cloudflare-protocol-stub.ts +194 -0
  370. package/src/vite/plugins/expose-action-id.ts +49 -98
  371. package/src/vite/plugins/expose-id-utils.ts +96 -51
  372. package/src/vite/plugins/expose-ids/export-analysis.ts +101 -34
  373. package/src/vite/plugins/expose-ids/handler-transform.ts +15 -64
  374. package/src/vite/plugins/expose-ids/loader-transform.ts +14 -24
  375. package/src/vite/plugins/expose-ids/router-transform.ts +118 -29
  376. package/src/vite/plugins/expose-internal-ids.ts +553 -317
  377. package/src/vite/plugins/performance-tracks.ts +89 -0
  378. package/src/vite/plugins/refresh-cmd.ts +89 -27
  379. package/src/vite/plugins/use-cache-transform.ts +73 -83
  380. package/src/vite/plugins/version-injector.ts +21 -25
  381. package/src/vite/plugins/version-plugin.ts +46 -37
  382. package/src/vite/plugins/virtual-entries.ts +13 -18
  383. package/src/vite/rango.ts +238 -295
  384. package/src/vite/router-discovery.ts +940 -149
  385. package/src/vite/utils/ast-handler-extract.ts +26 -35
  386. package/src/vite/utils/banner.ts +4 -4
  387. package/src/vite/utils/bundle-analysis.ts +10 -15
  388. package/src/vite/utils/client-chunks.ts +184 -0
  389. package/src/vite/utils/directive-prologue.ts +40 -0
  390. package/src/vite/utils/forward-user-plugins.ts +171 -0
  391. package/src/vite/utils/manifest-utils.ts +4 -59
  392. package/src/vite/utils/package-resolution.ts +20 -52
  393. package/src/vite/utils/prerender-utils.ts +81 -34
  394. package/src/vite/utils/shared-utils.ts +92 -42
  395. package/src/browser/action-response-classifier.ts +0 -99
  396. package/src/browser/react/use-client-cache.ts +0 -58
  397. package/src/browser/shallow.ts +0 -40
  398. package/src/handles/index.ts +0 -7
  399. package/src/network-error-thrower.tsx +0 -23
  400. package/src/router/middleware-cookies.ts +0 -55
@@ -3,22 +3,87 @@
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.
12
+ *
13
+ * In-flight promises are tracked in the cache so that navigation can reuse
14
+ * a prefetch that is still downloading/decoding instead of starting a
15
+ * duplicate request.
9
16
  */
10
17
 
11
18
  import {
12
19
  buildPrefetchKey,
20
+ buildSourceKey,
13
21
  hasPrefetch,
14
22
  markPrefetchInflight,
23
+ setInflightPromiseWithAliases,
15
24
  storePrefetch,
25
+ removePrefetch,
16
26
  clearPrefetchInflight,
17
27
  currentGeneration,
28
+ type DecodedPrefetch,
18
29
  } from "./cache.js";
19
30
  import { getRangoState } from "../rango-state.js";
31
+ import { isActionFenceActive } from "../action-fence.js";
20
32
  import { enqueuePrefetch } from "./queue.js";
21
33
  import { shouldPrefetch } from "./policy.js";
34
+ import { debugLog } from "../logging.js";
35
+ import { teeWithCompletion, isForeignRouterId } from "../response-adapter.js";
36
+ import type { RscPayload } from "../types.js";
37
+
38
+ /**
39
+ * Decoder injected at app startup (see setPrefetchDecoder). This is
40
+ * `deps.createFromFetch` — decoupled from the RSC runtime exactly like the
41
+ * navigation client. Prefetch decodes through it so the route's client chunks
42
+ * are pulled during the prefetch, not on click.
43
+ */
44
+ type PrefetchDecoder = (response: Promise<Response>) => Promise<RscPayload>;
45
+
46
+ let decoder: PrefetchDecoder | null = null;
47
+
48
+ /**
49
+ * Hard ceiling for ANY prefetch fetch (hover/direct AND queue-driven). A server
50
+ * that stalls leaves the fetch pending forever — its `.finally()` never runs,
51
+ * `clearPrefetchInflight` never fires, and `hasPrefetch(key)` stays true,
52
+ * permanently deduping every future prefetch of that URL. The hover path passes
53
+ * no signal; the queue passes its own AbortController signal that only aborts on
54
+ * navigation, never on a stall — so the timeout is layered on BOTH (combined
55
+ * with any caller signal via AbortSignal.any) and aborts the stalled fetch so it
56
+ * settles (rejects) and the inflight key is always released. Generous so a
57
+ * slow-but-live response is never cut short.
58
+ */
59
+ const PREFETCH_FETCH_TIMEOUT_MS = 30_000;
60
+
61
+ /**
62
+ * Wire the RSC decoder used to eagerly decode prefetched responses. Called
63
+ * once from initBrowserApp with the same createFromFetch the navigation client
64
+ * uses. Until set, prefetch warming is inert (prefetches are skipped) — the
65
+ * browser app always sets it before any Link can fire.
66
+ */
67
+ export function setPrefetchDecoder(fn: PrefetchDecoder): void {
68
+ decoder = fn;
69
+ }
70
+
71
+ /**
72
+ * Check if a URL resolves to the current page (same pathname + search).
73
+ * Used to prevent same-page prefetching, which produces a trivial diff
74
+ * that would corrupt the (default wildcard) prefetch cache entry.
75
+ */
76
+ function isSamePage(url: string): boolean {
77
+ try {
78
+ const target = new URL(url, window.location.origin);
79
+ return (
80
+ target.pathname + target.search ===
81
+ window.location.pathname + window.location.search
82
+ );
83
+ } catch {
84
+ return false;
85
+ }
86
+ }
22
87
 
23
88
  /**
24
89
  * Build an RSC partial URL for prefetching.
@@ -30,6 +95,7 @@ function buildPrefetchUrl(
30
95
  url: string,
31
96
  segmentIds: string[],
32
97
  version?: string,
98
+ routerId?: string,
33
99
  ): URL | null {
34
100
  let targetUrl: URL;
35
101
  try {
@@ -47,89 +113,322 @@ function buildPrefetchUrl(
47
113
  if (version) {
48
114
  targetUrl.searchParams.set("_rsc_v", version);
49
115
  }
116
+ if (routerId) {
117
+ targetUrl.searchParams.set("_rsc_rid", routerId);
118
+ }
50
119
  return targetUrl;
51
120
  }
52
121
 
53
122
  /**
54
- * Core prefetch fetch logic. Fetches the response, fully buffers the body,
55
- * and stores it in the in-memory cache. Returns a Promise and accepts an
56
- * optional AbortSignal for cancellation by the prefetch queue.
123
+ * Core prefetch fetch logic. Fetches the response, eagerly decodes it, and
124
+ * stores the decoded payload in the in-memory cache. The returned Promise
125
+ * resolves to the decoded entry (or null on failure / control header) so
126
+ * navigation can safely reuse an in-flight prefetch via
127
+ * consumeInflightPrefetch().
128
+ *
129
+ * Eager decode is the warming step: createFromFetch parses the Flight stream,
130
+ * which resolves the route's client references and imports its JS chunks. The
131
+ * stored payload is reused as-is by navigation, so the click loads no new code.
132
+ *
133
+ * Control headers are NOT acted on here. A speculative prefetch must never
134
+ * reload the page or throw a redirect — if the response carries X-RSC-Reload
135
+ * or X-RSC-Redirect, we drop it (resolve null) and let the real navigation
136
+ * re-fetch and honor it.
137
+ *
138
+ * Inflight + storage key selection:
139
+ *
140
+ * - `forceSourceScope` (Link opted in with `prefetchKey=":source"`): single
141
+ * inflight registration under `sourceKey`; entry stored under `sourceKey`.
142
+ * No wildcard leak is possible.
143
+ *
144
+ * - Otherwise: dual inflight registration under both `wildcardKey` and
145
+ * `sourceKey` so same-source navigations adopt directly via their own
146
+ * source key. Storage key is chosen at response time from the
147
+ * `X-RSC-Prefetch-Scope` header — `"source"` → `sourceKey` (intercept
148
+ * modals etc.), anything else → `wildcardKey`. The entry records its scope
149
+ * so cross-source navigations that adopted via `wildcardKey` can bail out
150
+ * in `navigation-client.ts` when the adopted entry turns out source-scoped.
57
151
  */
58
152
  function executePrefetchFetch(
59
- key: string,
153
+ wildcardKey: string,
154
+ sourceKey: string,
60
155
  fetchUrl: string,
156
+ forceSourceScope: boolean,
157
+ expectedRouterId?: string,
61
158
  signal?: AbortSignal,
62
- ): Promise<void> {
159
+ ): Promise<DecodedPrefetch | null> {
63
160
  const gen = currentGeneration();
64
- markPrefetchInflight(key);
161
+ const inflightKeys = forceSourceScope
162
+ ? [sourceKey]
163
+ : [wildcardKey, sourceKey];
164
+ for (const k of inflightKeys) markPrefetchInflight(k);
65
165
 
66
- return fetch(fetchUrl, {
166
+ // Always layer a stall timeout. It covers BOTH "no response ever arrives"
167
+ // (strands the inflight key) AND "the body stalls after headers" (leaves a
168
+ // published entry whose payload/streamComplete never settle, that future
169
+ // prefetches dedupe against and navigation awaits forever). Applies to the
170
+ // hover/direct path (no caller signal) and the queue-driven path (whose caller
171
+ // signal only aborts on navigation, never on a stall). On fire it aborts the
172
+ // fetch/stream and evicts the published entry if one exists; it is NOT cleared
173
+ // when headers arrive (see below) — it is cleared when the stream completes, or
174
+ // in `.finally()` for paths that publish no streaming entry.
175
+ let publishedKey: string | undefined;
176
+ let publishedEntry: DecodedPrefetch | undefined;
177
+ const timeoutController = new AbortController();
178
+ const timeoutId: ReturnType<typeof setTimeout> = setTimeout(() => {
179
+ timeoutController.abort();
180
+ // Body stalled after headers: evict the published-but-never-settling entry
181
+ // so future prefetches/navigation refetch. Identity-guarded (pass the exact
182
+ // entry) so a fresh entry republished under the same key — after this one was
183
+ // consumed — is NOT dropped. The abort cancels the tee (its finally resolves
184
+ // streamComplete) and rejects the eager decode.
185
+ if (publishedKey !== undefined && publishedEntry !== undefined) {
186
+ removePrefetch(publishedKey, publishedEntry);
187
+ }
188
+ }, PREFETCH_FETCH_TIMEOUT_MS);
189
+ let effectiveSignal: AbortSignal;
190
+ if (!signal) {
191
+ effectiveSignal = timeoutController.signal;
192
+ } else if (typeof AbortSignal.any === "function") {
193
+ // Combine the caller's signal (navigation-abort) with the timeout so either
194
+ // can settle the fetch.
195
+ effectiveSignal = AbortSignal.any([signal, timeoutController.signal]);
196
+ } else {
197
+ // Legacy runtime without AbortSignal.any: forward the caller's abort onto the
198
+ // timeout controller so a single signal carries both reasons.
199
+ effectiveSignal = timeoutController.signal;
200
+ if (signal.aborted) timeoutController.abort();
201
+ else
202
+ signal.addEventListener("abort", () => timeoutController.abort(), {
203
+ once: true,
204
+ });
205
+ }
206
+
207
+ const promise: Promise<DecodedPrefetch | null> = fetch(fetchUrl, {
67
208
  priority: "low" as RequestPriority,
68
- signal,
209
+ // During an action's flight the state is not rotated, so the old
210
+ // X-Rango-State still matches the Vary-keyed HTTP-cache entry; bypass it so
211
+ // a prefetch fetches fresh rather than warming the map with stale bytes (the
212
+ // fence's HTTP-cache-bypass requirement applies to prefetch as well as
213
+ // navigation fetches).
214
+ ...(isActionFenceActive() && { cache: "no-store" as RequestCache }),
215
+ signal: effectiveSignal,
69
216
  headers: {
70
217
  "X-Rango-State": getRangoState(),
71
218
  "X-RSC-Router-Client-Path": window.location.href,
72
219
  "X-Rango-Prefetch": "1",
73
220
  },
74
221
  })
75
- .then(async (response) => {
76
- if (!response.ok) return;
77
- // Fully buffer the response body so the cached Response is
78
- // self-contained and doesn't depend on the network connection.
79
- // This eliminates the race condition where the user clicks before
80
- // the response body has been fully downloaded.
81
- const buffer = await response.arrayBuffer();
82
- const cachedResponse = new Response(buffer, {
83
- headers: response.headers,
84
- status: response.status,
85
- statusText: response.statusText,
222
+ .then((response) => {
223
+ if (!response.ok || !decoder) return null;
224
+ // Control headers mean this response is stale (reload) or redirecting.
225
+ // Don't warm it — drop so navigation re-fetches and acts on the header.
226
+ if (
227
+ response.headers.has("X-RSC-Reload") ||
228
+ response.headers.has("X-RSC-Redirect")
229
+ ) {
230
+ return null;
231
+ }
232
+ // Integrity check: never warm (or decode/import the chunks of) a foreign
233
+ // app's payload. A speculative prefetch must never reload — just drop it;
234
+ // navigation re-fetches and the server steers it.
235
+ if (isForeignRouterId(response, expectedRouterId)) {
236
+ return null;
237
+ }
238
+
239
+ const scope: "source" | "wildcard" =
240
+ forceSourceScope ||
241
+ response.headers.get("x-rsc-prefetch-scope") === "source"
242
+ ? "source"
243
+ : "wildcard";
244
+ const storageKey = scope === "source" ? sourceKey : wildcardKey;
245
+
246
+ // Track stream completion off a tee so navigation's scroll/revalidation
247
+ // gating matches the fresh-fetch path; decode the other branch.
248
+ let resolveStreamComplete!: () => void;
249
+ const streamComplete = new Promise<void>((resolve) => {
250
+ resolveStreamComplete = resolve;
86
251
  });
87
- storePrefetch(key, cachedResponse, gen);
88
- })
89
- .catch(() => {
90
- // Silently ignore prefetch failures (including abort)
252
+ const tracked = teeWithCompletion(
253
+ response,
254
+ () => resolveStreamComplete(),
255
+ effectiveSignal,
256
+ // Speculative prefetch: a never-consumed/aborted stream error is benign.
257
+ true,
258
+ );
259
+
260
+ // Eager decode: parsing the Flight stream imports the route's client
261
+ // chunks now, not on click.
262
+ const payload = decoder(Promise.resolve(tracked));
263
+ // Mark handled so an unconsumed prefetch decode error stays quiet; the
264
+ // error is still surfaced to navigation if it consumes the entry.
265
+ payload.catch(() => {});
266
+
267
+ const entry: DecodedPrefetch = {
268
+ payload,
269
+ streamComplete,
270
+ scope,
271
+ complete: false,
272
+ };
273
+ storePrefetch(storageKey, entry, gen);
274
+ // The stall timeout now owns the body stream: arm eviction (publishedKey)
275
+ // and clear the timer once the stream completes. The tee's finally resolves
276
+ // streamComplete on normal completion AND on abort, so a healthy body pays
277
+ // no lingering timer while a stalled one is evicted when the timer fires.
278
+ publishedKey = storageKey;
279
+ publishedEntry = entry;
280
+ streamComplete.then(() => {
281
+ // Synchronous marker for navigation's fully-prefetched check (see
282
+ // DecodedPrefetch.complete). Set before clearing the stall timer.
283
+ entry.complete = true;
284
+ clearTimeout(timeoutId);
285
+ });
286
+ return entry;
91
287
  })
288
+ .catch(() => null)
92
289
  .finally(() => {
93
- clearPrefetchInflight(key);
290
+ clearPrefetchInflight(inflightKeys[0]!);
291
+ // Clear the stall timer here ONLY for paths that published no streaming
292
+ // entry (null return / fetch error / abort): the operation is fully done.
293
+ // When an entry WAS published, the timer stays armed to bound the body
294
+ // stream and is cleared on streamComplete (above) or on fire (eviction).
295
+ if (publishedKey === undefined) clearTimeout(timeoutId);
94
296
  });
297
+
298
+ setInflightPromiseWithAliases(inflightKeys, promise);
299
+ return promise;
300
+ }
301
+
302
+ /**
303
+ * Dedup check for prefetch entry presence.
304
+ *
305
+ * Forced `:source` must NOT dedupe against a pre-existing wildcard entry —
306
+ * otherwise the source slot would stay unpopulated and navigation from
307
+ * this source would fall through to the (potentially wrong) wildcard
308
+ * response, defeating the opt-out.
309
+ */
310
+ function hasPrefetchHit(
311
+ forceSourceScope: boolean,
312
+ wildcardKey: string,
313
+ sourceKey: string,
314
+ ): boolean {
315
+ return forceSourceScope
316
+ ? hasPrefetch(sourceKey)
317
+ : hasPrefetch(wildcardKey) || hasPrefetch(sourceKey);
95
318
  }
96
319
 
97
320
  /**
98
321
  * Prefetch (direct): fetch with low priority and store in in-memory cache.
99
322
  * Used by hover strategy -- fires immediately without queueing.
323
+ *
324
+ * By default the wildcard key (Rango-state-keyed) is used for inflight
325
+ * dedup and for responses that are not source-sensitive; source-scoped
326
+ * storage is automatic when the server emits `X-RSC-Prefetch-Scope: source`.
327
+ *
328
+ * Pass `prefetchKey=":source"` to force source-scoped inflight + storage
329
+ * (e.g. when the target uses a custom `revalidate()` that reads
330
+ * `currentUrl` and the wildcard slot would serve the wrong diff).
100
331
  */
101
332
  export function prefetchDirect(
102
333
  url: string,
103
334
  segmentIds: string[],
104
335
  version?: string,
336
+ routerId?: string,
337
+ prefetchKey?: ":source",
105
338
  ): void {
106
339
  if (!shouldPrefetch()) return;
107
340
 
108
- const targetUrl = buildPrefetchUrl(url, segmentIds, version);
341
+ const targetUrl = buildPrefetchUrl(url, segmentIds, version, routerId);
109
342
  if (!targetUrl) return;
110
- const key = buildPrefetchKey(window.location.href, targetUrl);
111
- if (hasPrefetch(key)) return;
112
- executePrefetchFetch(key, targetUrl.toString());
343
+ const forceSourceScope = prefetchKey === ":source";
344
+ // Skip same-page prefetch — a same-page diff is trivial and would corrupt
345
+ // the wildcard cache entry used for cross-page navigation.
346
+ // When `:source` is forced the entry is source-scoped (single-aliased to
347
+ // itself), so it cannot poison any shared slot — allow it.
348
+ if (!forceSourceScope && isSamePage(url)) {
349
+ return;
350
+ }
351
+ const sourceHref = window.location.href;
352
+ const rangoState = getRangoState();
353
+ const wildcardKey = buildPrefetchKey(rangoState, targetUrl);
354
+ const sourceKey = buildSourceKey(rangoState, sourceHref, targetUrl);
355
+ if (hasPrefetchHit(forceSourceScope, wildcardKey, sourceKey)) {
356
+ debugLog("[prefetch] direct dedup (key already exists)", {
357
+ url,
358
+ wildcardKey,
359
+ sourceKey,
360
+ forceSourceScope,
361
+ });
362
+ return;
363
+ }
364
+ debugLog("[prefetch] direct fetch", {
365
+ url,
366
+ wildcardKey,
367
+ sourceKey,
368
+ source: sourceHref,
369
+ forceSourceScope,
370
+ });
371
+ executePrefetchFetch(
372
+ wildcardKey,
373
+ sourceKey,
374
+ targetUrl.toString(),
375
+ forceSourceScope,
376
+ routerId,
377
+ );
113
378
  }
114
379
 
115
380
  /**
116
381
  * Prefetch (queued): goes through the concurrency-limited queue.
117
382
  * Used by viewport/render strategies to avoid flooding the server.
118
- * Returns the cache key for use in cleanup.
383
+ * Returns the inflight key (wildcard by default, source-scoped when
384
+ * `prefetchKey=":source"` is passed).
119
385
  */
120
386
  export function prefetchQueued(
121
387
  url: string,
122
388
  segmentIds: string[],
123
389
  version?: string,
390
+ routerId?: string,
391
+ prefetchKey?: ":source",
124
392
  ): string {
125
393
  if (!shouldPrefetch()) return "";
126
- const targetUrl = buildPrefetchUrl(url, segmentIds, version);
394
+ const targetUrl = buildPrefetchUrl(url, segmentIds, version, routerId);
127
395
  if (!targetUrl) return "";
128
- const key = buildPrefetchKey(window.location.href, targetUrl);
129
- if (hasPrefetch(key)) return key;
396
+ const forceSourceScope = prefetchKey === ":source";
397
+ if (!forceSourceScope && isSamePage(url)) {
398
+ return "";
399
+ }
400
+ const sourceHref = window.location.href;
401
+ const rangoState = getRangoState();
402
+ const wildcardKey = buildPrefetchKey(rangoState, targetUrl);
403
+ const sourceKey = buildSourceKey(rangoState, sourceHref, targetUrl);
404
+ const queueKey = forceSourceScope ? sourceKey : wildcardKey;
405
+ if (hasPrefetchHit(forceSourceScope, wildcardKey, sourceKey)) {
406
+ debugLog("[prefetch] queued dedup (key already exists)", {
407
+ url,
408
+ wildcardKey,
409
+ sourceKey,
410
+ forceSourceScope,
411
+ });
412
+ return queueKey;
413
+ }
130
414
  const fetchUrlStr = targetUrl.toString();
131
- enqueuePrefetch(key, (signal) =>
132
- executePrefetchFetch(key, fetchUrlStr, signal),
133
- );
134
- return key;
415
+ enqueuePrefetch(queueKey, (signal) => {
416
+ // Re-check at execution time: a hover-triggered prefetchDirect may
417
+ // have started or completed this key while the item sat in the queue.
418
+ if (hasPrefetchHit(forceSourceScope, wildcardKey, sourceKey)) {
419
+ return Promise.resolve();
420
+ }
421
+ if (!forceSourceScope && isSamePage(url)) {
422
+ return Promise.resolve();
423
+ }
424
+ return executePrefetchFetch(
425
+ wildcardKey,
426
+ sourceKey,
427
+ fetchUrlStr,
428
+ forceSourceScope,
429
+ routerId,
430
+ signal,
431
+ ).then(() => {});
432
+ });
433
+ return queueKey;
135
434
  }
@@ -5,11 +5,19 @@
5
5
  * Hover prefetches bypass this queue — they fire directly for immediate response
6
6
  * to user intent.
7
7
  *
8
- * All queued/executing prefetches share a single AbortController so they can
9
- * be cancelled in bulk when a navigation starts.
8
+ * Draining waits for an idle main-thread moment and for viewport images to
9
+ * finish loading, so prefetch fetch() calls never compete with critical
10
+ * resources for the browser's connection pool.
11
+ *
12
+ * When a navigation starts, queued prefetches are cancelled but executing ones
13
+ * are left running. Navigation can reuse their in-flight responses via the
14
+ * prefetch cache's inflight promise map, avoiding duplicate requests.
10
15
  */
11
16
 
17
+ import { wait, waitForIdle, waitForViewportImages } from "./resource-ready.js";
18
+
12
19
  const MAX_CONCURRENT = 2;
20
+ const IMAGE_WAIT_TIMEOUT = 2000;
13
21
 
14
22
  let active = 0;
15
23
  const queue: Array<{
@@ -18,7 +26,9 @@ const queue: Array<{
18
26
  }> = [];
19
27
  const queued = new Set<string>();
20
28
  const executing = new Set<string>();
21
- let abortController: AbortController | null = null;
29
+ const abortControllers = new Map<string, AbortController>();
30
+ let drainScheduled = false;
31
+ let drainGeneration = 0;
22
32
 
23
33
  function startExecution(
24
34
  key: string,
@@ -26,18 +36,52 @@ function startExecution(
26
36
  ): void {
27
37
  active++;
28
38
  executing.add(key);
29
- abortController ??= new AbortController();
30
- execute(abortController.signal).finally(() => {
39
+ const ac = new AbortController();
40
+ abortControllers.set(key, ac);
41
+ execute(ac.signal).finally(() => {
42
+ abortControllers.delete(key);
31
43
  // Only decrement if this key wasn't already cleared by cancelAllPrefetches.
32
44
  // Without this guard, cancelled tasks' .finally() would underflow active
33
45
  // below zero, breaking the MAX_CONCURRENT guarantee.
34
46
  if (executing.delete(key)) {
35
47
  active--;
36
48
  }
37
- drain();
49
+ scheduleDrain();
38
50
  });
39
51
  }
40
52
 
53
+ /**
54
+ * Schedule a drain after the browser is idle and viewport images are loaded.
55
+ * Coalesces multiple drain requests into a single deferred callback so
56
+ * batch completion doesn't schedule redundant waits.
57
+ *
58
+ * The two-step wait ensures prefetch fetch() calls don't compete with
59
+ * images for the browser's connection pool:
60
+ * 1. waitForIdle — yield until the main thread has a quiet moment
61
+ * 2. waitForViewportImages OR 2s timeout — yield until visible images
62
+ * finish loading, but don't let slow/broken images block indefinitely
63
+ */
64
+ function scheduleDrain(): void {
65
+ if (drainScheduled) return;
66
+ if (active >= MAX_CONCURRENT || queue.length === 0) return;
67
+ drainScheduled = true;
68
+ const gen = drainGeneration;
69
+ waitForIdle()
70
+ .then(() =>
71
+ Promise.race([waitForViewportImages(), wait(IMAGE_WAIT_TIMEOUT)]),
72
+ )
73
+ .then(() => {
74
+ // Stale drain: a cancel/abort happened while we were waiting, and a fresh
75
+ // scheduleDrain may already own drainScheduled for the new generation.
76
+ // Bail WITHOUT clearing the flag so we don't clobber the live wait's
77
+ // single-in-flight-drain coalescing (clearing it here would let the next
78
+ // enqueue start a third overlapping wait).
79
+ if (gen !== drainGeneration) return;
80
+ drainScheduled = false;
81
+ if (queue.length > 0) drain();
82
+ });
83
+ }
84
+
41
85
  function drain(): void {
42
86
  while (active < MAX_CONCURRENT && queue.length > 0) {
43
87
  const item = queue.shift()!;
@@ -48,9 +92,10 @@ function drain(): void {
48
92
 
49
93
  /**
50
94
  * Enqueue a prefetch for concurrency-limited execution.
51
- * If below the concurrency limit, executes immediately.
52
- * Otherwise queues for later execution.
53
- * Deduplicates by key — items already queued or executing are skipped.
95
+ * Execution is deferred until the browser is idle and viewport images
96
+ * have finished loading, so prefetches never compete with critical
97
+ * resources. Deduplicates by key — items already queued or executing
98
+ * are skipped.
54
99
  *
55
100
  * The executor receives an AbortSignal that is aborted when
56
101
  * cancelAllPrefetches() is called (e.g. on navigation start).
@@ -61,22 +106,81 @@ export function enqueuePrefetch(
61
106
  ): void {
62
107
  if (queued.has(key) || executing.has(key)) return;
63
108
 
64
- if (active < MAX_CONCURRENT) {
65
- startExecution(key, execute);
66
- } else {
67
- queued.add(key);
68
- queue.push({ key, execute });
109
+ queued.add(key);
110
+ queue.push({ key, execute });
111
+ scheduleDrain();
112
+ }
113
+
114
+ /**
115
+ * Normalize a URL-like string for keep-alive matching: parse against a
116
+ * placeholder origin and strip internal `_rsc_*` query params. Returns
117
+ * `pathname + search` so comparisons ignore hash and the internal params
118
+ * that prefetch appends to targets (`_rsc_partial`, `_rsc_segments`,
119
+ * `_rsc_v`, `_rsc_rid`, `_rsc_stale`).
120
+ */
121
+ function normalizeForMatch(urlish: string): string {
122
+ try {
123
+ const u = new URL(urlish, "http://placeholder");
124
+ for (const k of [...u.searchParams.keys()]) {
125
+ if (k.startsWith("_rsc_")) u.searchParams.delete(k);
126
+ }
127
+ return u.pathname + u.search;
128
+ } catch {
129
+ return urlish;
69
130
  }
70
131
  }
71
132
 
72
133
  /**
73
- * Cancel all in-flight and queued prefetches.
74
- * Called when a navigation starts speculative prefetches should not
75
- * compete with navigation fetches for connection slots.
134
+ * Cancel queued prefetches and abort in-flight ones that don't match
135
+ * the current navigation target. If `keepUrl` is provided, the
136
+ * executing prefetch whose key targets that URL is kept alive so
137
+ * navigation can reuse its response via consumeInflightPrefetch.
138
+ *
139
+ * Called when a navigation starts via the NavigationProvider's
140
+ * event controller subscription.
76
141
  */
77
- export function cancelAllPrefetches(): void {
78
- abortController?.abort();
79
- abortController = null;
142
+ export function cancelAllPrefetches(keepUrl?: string | null): void {
143
+ queue.length = 0;
144
+ queued.clear();
145
+ drainScheduled = false;
146
+ drainGeneration++;
147
+
148
+ // Abort in-flight prefetches that aren't for the navigation target.
149
+ // Key shapes (see prefetch/cache.ts buildPrefetchKey):
150
+ // wildcard: "rangoState\0/target?..."
151
+ // source-scoped: "rangoState\0sourceHref\0/target?..."
152
+ // The target portion is always the final \0-delimited segment and
153
+ // includes internal `_rsc_*` params (from buildPrefetchUrl); keepUrl
154
+ // comes from NavigationProvider's pendingUrl which is the bare
155
+ // navigation target. Normalize both sides before comparing.
156
+ const normalizedKeep = keepUrl ? normalizeForMatch(keepUrl) : null;
157
+ for (const [key, ac] of abortControllers) {
158
+ const lastNul = key.lastIndexOf("\0");
159
+ const target = lastNul >= 0 ? key.substring(lastNul + 1) : "";
160
+ if (
161
+ normalizedKeep &&
162
+ target &&
163
+ normalizeForMatch(target) === normalizedKeep
164
+ )
165
+ continue;
166
+ ac.abort();
167
+ abortControllers.delete(key);
168
+ if (executing.delete(key)) {
169
+ active--;
170
+ }
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Hard-cancel everything including in-flight prefetches.
176
+ * Used by clearPrefetchCache (server action invalidation) where
177
+ * in-flight responses would be stale.
178
+ */
179
+ export function abortAllPrefetches(): void {
180
+ for (const ac of abortControllers.values()) {
181
+ ac.abort();
182
+ }
183
+ abortControllers.clear();
80
184
 
81
185
  queue.length = 0;
82
186
  queued.clear();
@@ -85,4 +189,6 @@ export function cancelAllPrefetches(): void {
85
189
  // so active settles at 0 without underflow.
86
190
  executing.clear();
87
191
  active = 0;
192
+ drainScheduled = false;
193
+ drainGeneration++;
88
194
  }