@rangojs/router 0.0.0-experimental.eb0645d3 → 0.0.0-experimental.f1468e3c

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 (392) hide show
  1. package/AGENTS.md +8 -0
  2. package/README.md +126 -16
  3. package/dist/bin/rango.js +319 -95
  4. package/dist/testing/vitest.js +82 -0
  5. package/dist/vite/index.js +2724 -1053
  6. package/package.json +68 -14
  7. package/skills/api-client/SKILL.md +211 -0
  8. package/skills/breadcrumbs/SKILL.md +64 -2
  9. package/skills/bundle-analysis/SKILL.md +159 -0
  10. package/skills/cache-guide/SKILL.md +224 -32
  11. package/skills/caching/SKILL.md +279 -17
  12. package/skills/composability/SKILL.md +27 -3
  13. package/skills/css/SKILL.md +76 -0
  14. package/skills/debug-manifest/SKILL.md +4 -2
  15. package/skills/document-cache/SKILL.md +78 -55
  16. package/skills/handler-use/SKILL.md +11 -9
  17. package/skills/hooks/SKILL.md +243 -29
  18. package/skills/host-router/SKILL.md +83 -23
  19. package/skills/i18n/SKILL.md +276 -0
  20. package/skills/intercept/SKILL.md +68 -19
  21. package/skills/layout/SKILL.md +13 -9
  22. package/skills/links/SKILL.md +190 -23
  23. package/skills/loader/SKILL.md +235 -9
  24. package/skills/middleware/SKILL.md +18 -10
  25. package/skills/migrate-nextjs/SKILL.md +43 -19
  26. package/skills/migrate-react-router/SKILL.md +8 -2
  27. package/skills/mime-routes/SKILL.md +28 -1
  28. package/skills/observability/SKILL.md +172 -0
  29. package/skills/parallel/SKILL.md +18 -7
  30. package/skills/prerender/SKILL.md +65 -60
  31. package/skills/rango/SKILL.md +251 -24
  32. package/skills/react-compiler/SKILL.md +168 -0
  33. package/skills/response-routes/SKILL.md +115 -48
  34. package/skills/route/SKILL.md +46 -5
  35. package/skills/router-setup/SKILL.md +30 -8
  36. package/skills/scripts/SKILL.md +179 -0
  37. package/skills/server-actions/SKILL.md +775 -0
  38. package/skills/tailwind/SKILL.md +27 -3
  39. package/skills/testing/SKILL.md +130 -0
  40. package/skills/testing/bindings.md +103 -0
  41. package/skills/testing/cache-prerender.md +127 -0
  42. package/skills/testing/client-components.md +124 -0
  43. package/skills/testing/e2e-parity.md +125 -0
  44. package/skills/testing/flight.md +91 -0
  45. package/skills/testing/handles.md +129 -0
  46. package/skills/testing/loader.md +128 -0
  47. package/skills/testing/middleware.md +99 -0
  48. package/skills/testing/render-handler.md +122 -0
  49. package/skills/testing/response-routes.md +95 -0
  50. package/skills/testing/reverse-and-types.md +84 -0
  51. package/skills/testing/server-actions.md +107 -0
  52. package/skills/testing/server-tree.md +128 -0
  53. package/skills/testing/setup.md +123 -0
  54. package/skills/typesafety/SKILL.md +322 -29
  55. package/skills/use-cache/SKILL.md +57 -14
  56. package/skills/view-transitions/SKILL.md +337 -0
  57. package/src/__augment-tests__/augment.ts +81 -0
  58. package/src/__augment-tests__/augmented.check.ts +116 -0
  59. package/src/__internal.ts +0 -65
  60. package/src/browser/action-coordinator.ts +53 -36
  61. package/src/browser/action-fence.ts +47 -0
  62. package/src/browser/app-shell.ts +39 -0
  63. package/src/browser/connection-warmup.ts +134 -0
  64. package/src/browser/cookie-name.ts +140 -0
  65. package/src/browser/event-controller.ts +192 -150
  66. package/src/browser/history-state.ts +21 -0
  67. package/src/browser/index.ts +3 -3
  68. package/src/browser/invalidate-client-cache.ts +52 -0
  69. package/src/browser/navigation-bridge.ts +94 -25
  70. package/src/browser/navigation-client.ts +121 -84
  71. package/src/browser/navigation-store-handle.ts +38 -0
  72. package/src/browser/navigation-store.ts +115 -67
  73. package/src/browser/navigation-transaction.ts +9 -59
  74. package/src/browser/network-error-handler.ts +34 -7
  75. package/src/browser/partial-update.ts +147 -128
  76. package/src/browser/prefetch/cache.ts +107 -56
  77. package/src/browser/prefetch/fetch.ts +204 -34
  78. package/src/browser/prefetch/queue.ts +6 -3
  79. package/src/browser/rango-state.ts +158 -76
  80. package/src/browser/react/Link.tsx +30 -7
  81. package/src/browser/react/NavigationProvider.tsx +283 -118
  82. package/src/browser/react/ScrollRestoration.tsx +10 -6
  83. package/src/browser/react/deferred-handle-resolution.ts +75 -0
  84. package/src/browser/react/filter-segment-order.ts +66 -7
  85. package/src/browser/react/index.ts +0 -48
  86. package/src/browser/react/location-state-shared.ts +178 -8
  87. package/src/browser/react/location-state.ts +39 -14
  88. package/src/browser/react/use-action.ts +6 -15
  89. package/src/browser/react/use-handle.ts +17 -14
  90. package/src/browser/react/use-href.tsx +8 -1
  91. package/src/browser/react/use-link-status.ts +33 -8
  92. package/src/browser/react/use-navigation.ts +10 -5
  93. package/src/browser/react/use-params.ts +11 -11
  94. package/src/browser/react/use-reverse.ts +106 -0
  95. package/src/browser/react/use-router.ts +25 -3
  96. package/src/browser/react/use-search-params.ts +0 -5
  97. package/src/browser/react/use-segments.ts +11 -21
  98. package/src/browser/response-adapter.ts +99 -8
  99. package/src/browser/rsc-router.tsx +91 -24
  100. package/src/browser/scroll-restoration.ts +30 -17
  101. package/src/browser/segment-structure-assert.ts +2 -2
  102. package/src/browser/server-action-bridge.ts +214 -55
  103. package/src/browser/types.ts +80 -9
  104. package/src/browser/validate-redirect-origin.ts +43 -16
  105. package/src/build/collect-fallback-refs.ts +107 -0
  106. package/src/build/generate-manifest.ts +60 -35
  107. package/src/build/generate-route-types.ts +2 -1
  108. package/src/build/index.ts +8 -2
  109. package/src/build/prefix-tree-utils.ts +123 -0
  110. package/src/build/route-trie.ts +117 -14
  111. package/src/build/route-types/ast-route-extraction.ts +15 -8
  112. package/src/build/route-types/codegen.ts +16 -5
  113. package/src/build/route-types/include-resolution.ts +117 -23
  114. package/src/build/route-types/param-extraction.ts +6 -3
  115. package/src/build/route-types/per-module-writer.ts +22 -6
  116. package/src/build/route-types/router-processing.ts +55 -28
  117. package/src/build/route-types/scan-filter.ts +1 -1
  118. package/src/build/route-types/source-scan.ts +216 -0
  119. package/src/build/runtime-discovery.ts +9 -20
  120. package/src/cache/cache-error.ts +104 -0
  121. package/src/cache/cache-key-utils.ts +29 -13
  122. package/src/cache/cache-policy.ts +108 -34
  123. package/src/cache/cache-runtime.ts +224 -41
  124. package/src/cache/cache-scope.ts +188 -82
  125. package/src/cache/cache-tag.ts +103 -0
  126. package/src/cache/cf/cf-base64.ts +33 -0
  127. package/src/cache/cf/cf-cache-constants.ts +127 -0
  128. package/src/cache/cf/cf-cache-store.ts +1989 -378
  129. package/src/cache/cf/cf-cache-types.ts +349 -0
  130. package/src/cache/cf/cf-kv-utils.ts +46 -0
  131. package/src/cache/cf/cf-tag-marker-memo.ts +105 -0
  132. package/src/cache/cf/index.ts +6 -16
  133. package/src/cache/document-cache.ts +89 -21
  134. package/src/cache/handle-snapshot.ts +70 -0
  135. package/src/cache/index.ts +10 -20
  136. package/src/cache/memory-segment-store.ts +136 -37
  137. package/src/cache/profile-registry.ts +46 -31
  138. package/src/cache/read-through-swr.ts +56 -12
  139. package/src/cache/segment-codec.ts +9 -17
  140. package/src/cache/tag-invalidation.ts +230 -0
  141. package/src/cache/types.ts +37 -100
  142. package/src/client.rsc.tsx +44 -21
  143. package/src/client.tsx +36 -61
  144. package/src/cloudflare/index.ts +11 -0
  145. package/src/cloudflare/tracing.ts +109 -0
  146. package/src/component-utils.ts +19 -0
  147. package/src/components/DefaultDocument.tsx +8 -2
  148. package/src/context-var.ts +18 -6
  149. package/src/decode-loader-results.ts +52 -0
  150. package/src/defer.ts +196 -0
  151. package/src/deps/ssr.ts +0 -1
  152. package/src/encode-kv.ts +49 -0
  153. package/src/errors.ts +30 -4
  154. package/src/escape-script.ts +52 -0
  155. package/src/handle.ts +31 -23
  156. package/src/handles/MetaTags.tsx +62 -19
  157. package/src/handles/Scripts.tsx +183 -0
  158. package/src/handles/breadcrumbs.ts +37 -8
  159. package/src/handles/is-thenable.ts +19 -0
  160. package/src/handles/meta.ts +51 -40
  161. package/src/handles/script.ts +244 -0
  162. package/src/host/cookie-handler.ts +9 -60
  163. package/src/host/errors.ts +0 -24
  164. package/src/host/index.ts +8 -2
  165. package/src/host/pattern-matcher.ts +23 -52
  166. package/src/host/router.ts +107 -99
  167. package/src/host/testing.ts +40 -27
  168. package/src/host/types.ts +37 -4
  169. package/src/host/utils.ts +1 -1
  170. package/src/href-client.ts +137 -22
  171. package/src/index.rsc.ts +96 -12
  172. package/src/index.ts +94 -14
  173. package/src/internal-debug.ts +11 -10
  174. package/src/loader-store.ts +500 -0
  175. package/src/loader.rsc.ts +20 -13
  176. package/src/loader.ts +12 -11
  177. package/src/missing-id-error.ts +68 -0
  178. package/src/outlet-context.ts +1 -1
  179. package/src/outlet-provider.tsx +1 -5
  180. package/src/prerender/param-hash.ts +16 -16
  181. package/src/prerender/store.ts +32 -37
  182. package/src/prerender.ts +61 -6
  183. package/src/redirect-origin.ts +100 -0
  184. package/src/regex-escape.ts +8 -0
  185. package/src/render-error-thrower.tsx +20 -0
  186. package/src/response-utils.ts +34 -0
  187. package/src/reverse.ts +65 -40
  188. package/src/root-error-boundary.tsx +1 -19
  189. package/src/route-content-wrapper.tsx +19 -77
  190. package/src/route-definition/dsl-helpers.ts +304 -309
  191. package/src/route-definition/helper-factories.ts +28 -140
  192. package/src/route-definition/helpers-types.ts +82 -55
  193. package/src/route-definition/index.ts +1 -2
  194. package/src/route-definition/redirect.ts +44 -11
  195. package/src/route-definition/resolve-handler-use.ts +12 -1
  196. package/src/route-definition/use-item-types.ts +29 -0
  197. package/src/route-map-builder.ts +0 -16
  198. package/src/route-types.ts +19 -46
  199. package/src/router/basename.ts +14 -0
  200. package/src/router/content-negotiation.ts +73 -25
  201. package/src/router/error-handling.ts +45 -18
  202. package/src/router/find-match.ts +44 -23
  203. package/src/router/handler-context.ts +27 -43
  204. package/src/router/instrument.ts +350 -0
  205. package/src/router/intercept-resolution.ts +39 -20
  206. package/src/router/lazy-includes.ts +10 -47
  207. package/src/router/loader-resolution.ts +155 -72
  208. package/src/router/logging.ts +0 -6
  209. package/src/router/manifest.ts +18 -29
  210. package/src/router/match-api.ts +9 -24
  211. package/src/router/match-context.ts +0 -22
  212. package/src/router/match-handlers.ts +58 -58
  213. package/src/router/match-middleware/background-revalidation.ts +40 -24
  214. package/src/router/match-middleware/cache-lookup.ts +159 -285
  215. package/src/router/match-middleware/cache-store.ts +64 -52
  216. package/src/router/match-middleware/intercept-resolution.ts +0 -22
  217. package/src/router/match-middleware/segment-resolution.ts +0 -22
  218. package/src/router/match-pipelines.ts +1 -42
  219. package/src/router/match-result.ts +44 -74
  220. package/src/router/metrics.ts +0 -34
  221. package/src/router/middleware-types.ts +7 -134
  222. package/src/router/middleware.ts +247 -166
  223. package/src/router/navigation-snapshot.ts +0 -51
  224. package/src/router/params-util.ts +23 -0
  225. package/src/router/pattern-matching.ts +85 -94
  226. package/src/router/prefetch-cache-ttl.ts +51 -0
  227. package/src/router/prerender-match.ts +104 -65
  228. package/src/router/preview-match.ts +3 -1
  229. package/src/router/request-classification.ts +28 -62
  230. package/src/router/revalidation.ts +123 -73
  231. package/src/router/route-snapshot.ts +0 -1
  232. package/src/router/router-context.ts +3 -28
  233. package/src/router/router-interfaces.ts +83 -35
  234. package/src/router/router-options.ts +136 -5
  235. package/src/router/router-registry.ts +2 -5
  236. package/src/router/segment-resolution/fresh.ts +97 -84
  237. package/src/router/segment-resolution/helpers.ts +86 -6
  238. package/src/router/segment-resolution/loader-cache.ts +76 -39
  239. package/src/router/segment-resolution/revalidation.ts +272 -320
  240. package/src/router/segment-resolution/static-store.ts +19 -5
  241. package/src/router/segment-resolution/streamed-handler-telemetry.ts +52 -0
  242. package/src/router/segment-resolution/view-transition-default.ts +56 -0
  243. package/src/router/segment-resolution.ts +5 -1
  244. package/src/router/segment-wrappers.ts +6 -5
  245. package/src/router/state-cookie-name.ts +33 -0
  246. package/src/router/substitute-pattern-params.ts +56 -0
  247. package/src/router/telemetry-otel.ts +161 -199
  248. package/src/router/telemetry.ts +96 -19
  249. package/src/router/timeout.ts +0 -20
  250. package/src/router/tracing.ts +206 -0
  251. package/src/router/trie-matching.ts +162 -64
  252. package/src/router/types.ts +9 -63
  253. package/src/router/url-params.ts +0 -5
  254. package/src/router.ts +110 -55
  255. package/src/rsc/handler-context.ts +3 -2
  256. package/src/rsc/handler.ts +264 -220
  257. package/src/rsc/helpers.ts +100 -6
  258. package/src/rsc/index.ts +2 -5
  259. package/src/rsc/json-route-result.ts +38 -0
  260. package/src/rsc/loader-fetch.ts +114 -38
  261. package/src/rsc/manifest-init.ts +28 -41
  262. package/src/rsc/origin-guard.ts +39 -25
  263. package/src/rsc/progressive-enhancement.ts +117 -11
  264. package/src/rsc/redirect-guard.ts +99 -0
  265. package/src/rsc/response-cache-serve.ts +238 -0
  266. package/src/rsc/response-error.ts +79 -12
  267. package/src/rsc/response-route-handler.ts +88 -188
  268. package/src/rsc/rsc-rendering.ts +98 -76
  269. package/src/rsc/runtime-warnings.ts +23 -10
  270. package/src/rsc/server-action.ts +281 -117
  271. package/src/rsc/ssr-setup.ts +16 -0
  272. package/src/rsc/transition-gate.ts +89 -0
  273. package/src/rsc/types.ts +23 -5
  274. package/src/runtime-env.ts +18 -0
  275. package/src/search-params.ts +35 -30
  276. package/src/segment-loader-promise.ts +31 -4
  277. package/src/segment-system.tsx +254 -143
  278. package/src/serialize.ts +243 -0
  279. package/src/server/context.ts +163 -51
  280. package/src/server/cookie-parse.ts +32 -0
  281. package/src/server/cookie-store.ts +80 -5
  282. package/src/server/handle-store.ts +21 -38
  283. package/src/server/loader-registry.ts +33 -42
  284. package/src/server/request-context.ts +287 -178
  285. package/src/ssr/index.tsx +21 -16
  286. package/src/static-handler.ts +10 -13
  287. package/src/testing/cache-status.ts +162 -0
  288. package/src/testing/collect-handle.ts +40 -0
  289. package/src/testing/dispatch.ts +701 -0
  290. package/src/testing/dom.entry.ts +22 -0
  291. package/src/testing/e2e/fixture.ts +188 -0
  292. package/src/testing/e2e/index.ts +128 -0
  293. package/src/testing/e2e/matchers.ts +35 -0
  294. package/src/testing/e2e/page-helpers.ts +272 -0
  295. package/src/testing/e2e/parity.ts +387 -0
  296. package/src/testing/e2e/server.ts +195 -0
  297. package/src/testing/flight-matchers.ts +97 -0
  298. package/src/testing/flight-normalize.ts +11 -0
  299. package/src/testing/flight-runtime.d.ts +57 -0
  300. package/src/testing/flight-tree.ts +682 -0
  301. package/src/testing/flight.entry.ts +52 -0
  302. package/src/testing/flight.ts +257 -0
  303. package/src/testing/generated-routes.ts +183 -0
  304. package/src/testing/index.ts +105 -0
  305. package/src/testing/internal/context.ts +371 -0
  306. package/src/testing/internal/flight-client-globals.ts +30 -0
  307. package/src/testing/internal/seed-vars.ts +54 -0
  308. package/src/testing/render-handler.ts +357 -0
  309. package/src/testing/render-route.tsx +581 -0
  310. package/src/testing/run-loader.ts +385 -0
  311. package/src/testing/run-middleware.ts +205 -0
  312. package/src/testing/run-transition-when.ts +164 -0
  313. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  314. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  315. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  316. package/src/testing/vitest-stubs/version.ts +5 -0
  317. package/src/testing/vitest.ts +305 -0
  318. package/src/theme/ThemeProvider.tsx +20 -58
  319. package/src/theme/ThemeScript.tsx +7 -9
  320. package/src/theme/constants.ts +52 -13
  321. package/src/theme/index.ts +0 -7
  322. package/src/theme/theme-context.ts +1 -5
  323. package/src/theme/theme-script.ts +22 -21
  324. package/src/theme/use-theme.ts +0 -3
  325. package/src/types/boundaries.ts +0 -35
  326. package/src/types/cache-types.ts +13 -4
  327. package/src/types/error-types.ts +30 -90
  328. package/src/types/global-namespace.ts +54 -41
  329. package/src/types/handler-context.ts +110 -62
  330. package/src/types/index.ts +3 -10
  331. package/src/types/loader-types.ts +11 -9
  332. package/src/types/request-scope.ts +112 -0
  333. package/src/types/route-config.ts +6 -50
  334. package/src/types/route-entry.ts +0 -6
  335. package/src/types/segments.ts +135 -14
  336. package/src/urls/include-helper.ts +9 -56
  337. package/src/urls/index.ts +1 -11
  338. package/src/urls/path-helper-types.ts +29 -12
  339. package/src/urls/path-helper.ts +17 -106
  340. package/src/urls/pattern-types.ts +36 -19
  341. package/src/urls/response-types.ts +22 -29
  342. package/src/urls/type-extraction.ts +58 -139
  343. package/src/urls/urls-function.ts +1 -19
  344. package/src/use-loader.tsx +292 -107
  345. package/src/vite/debug.ts +185 -0
  346. package/src/vite/discovery/bundle-postprocess.ts +8 -7
  347. package/src/vite/discovery/discover-routers.ts +126 -85
  348. package/src/vite/discovery/discovery-errors.ts +194 -0
  349. package/src/vite/discovery/gate-state.ts +171 -0
  350. package/src/vite/discovery/prerender-collection.ts +96 -68
  351. package/src/vite/discovery/route-types-writer.ts +40 -84
  352. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  353. package/src/vite/discovery/state.ts +44 -0
  354. package/src/vite/discovery/virtual-module-codegen.ts +14 -34
  355. package/src/vite/index.ts +2 -0
  356. package/src/vite/inject-client-debug.ts +36 -0
  357. package/src/vite/plugin-types.ts +126 -8
  358. package/src/vite/plugins/cjs-to-esm.ts +16 -19
  359. package/src/vite/plugins/client-ref-dedup.ts +16 -11
  360. package/src/vite/plugins/client-ref-hashing.ts +28 -15
  361. package/src/vite/plugins/cloudflare-protocol-stub.ts +1 -21
  362. package/src/vite/plugins/expose-action-id.ts +48 -95
  363. package/src/vite/plugins/expose-id-utils.ts +88 -55
  364. package/src/vite/plugins/expose-ids/export-analysis.ts +101 -34
  365. package/src/vite/plugins/expose-ids/handler-transform.ts +11 -90
  366. package/src/vite/plugins/expose-ids/loader-transform.ts +14 -24
  367. package/src/vite/plugins/expose-ids/router-transform.ts +118 -29
  368. package/src/vite/plugins/expose-internal-ids.ts +505 -486
  369. package/src/vite/plugins/performance-tracks.ts +26 -25
  370. package/src/vite/plugins/refresh-cmd.ts +1 -1
  371. package/src/vite/plugins/use-cache-transform.ts +73 -83
  372. package/src/vite/plugins/version-injector.ts +40 -29
  373. package/src/vite/plugins/version-plugin.ts +37 -40
  374. package/src/vite/plugins/virtual-entries.ts +39 -25
  375. package/src/vite/rango.ts +109 -118
  376. package/src/vite/router-discovery.ts +718 -119
  377. package/src/vite/utils/ast-handler-extract.ts +26 -35
  378. package/src/vite/utils/banner.ts +1 -1
  379. package/src/vite/utils/bundle-analysis.ts +10 -15
  380. package/src/vite/utils/client-chunks.ts +184 -0
  381. package/src/vite/utils/directive-prologue.ts +40 -0
  382. package/src/vite/utils/forward-user-plugins.ts +171 -0
  383. package/src/vite/utils/manifest-utils.ts +4 -59
  384. package/src/vite/utils/package-resolution.ts +20 -52
  385. package/src/vite/utils/prerender-utils.ts +54 -39
  386. package/src/vite/utils/shared-utils.ts +90 -41
  387. package/src/browser/action-response-classifier.ts +0 -99
  388. package/src/browser/react/use-client-cache.ts +0 -58
  389. package/src/browser/shallow.ts +0 -40
  390. package/src/handles/index.ts +0 -7
  391. package/src/network-error-thrower.tsx +0 -23
  392. package/src/router/middleware-cookies.ts +0 -55
@@ -1,8 +1,13 @@
1
1
  /**
2
2
  * Prefetch Cache
3
3
  *
4
- * In-memory cache storing prefetched Response objects for instant cache hits
5
- * on subsequent navigation. Two key scopes are in play:
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:
6
11
  * - Wildcard (default): built by `buildPrefetchKey(rangoState, target)` —
7
12
  * shape `rangoState\0/target?...`. Shared across all source pages and
8
13
  * invalidated automatically when Rango state bumps (deploy or
@@ -17,8 +22,8 @@
17
22
  * from other pages.
18
23
  *
19
24
  * Also tracks in-flight prefetch promises. Each promise resolves to the
20
- * navigation branch of a tee'd Response, allowing navigation to adopt a
21
- * still-downloading prefetch without reparsing or buffering the body. A
25
+ * decoded prefetch entry (or null), letting navigation adopt a
26
+ * still-downloading prefetch without issuing a duplicate request. A
22
27
  * single promise can be registered under multiple alias keys (see
23
28
  * `setInflightPromiseWithAliases`) so same-source navigations adopt via
24
29
  * their source key while cross-source ones fall through to the wildcard
@@ -26,14 +31,50 @@
26
31
  *
27
32
  * Replaces the previous browser HTTP cache approach which was unreliable
28
33
  * due to response draining race conditions and browser inconsistencies.
34
+ *
35
+ * State here lives in module-level singletons (cache, inflight, generation,
36
+ * cacheTTL, etc.) rather than a per-instance factory. This is correct because
37
+ * exactly one router is live per document — an SPA navigation crossing a
38
+ * host-router boundary forces a full document reload — so the singletons are
39
+ * effectively per-document. Unit tests reset them via clearPrefetchCache().
29
40
  */
30
41
 
31
42
  import { abortAllPrefetches } from "./queue.js";
32
43
  import { invalidateRangoState } from "../rango-state.js";
44
+ import type { RscPayload } from "../types.js";
45
+
46
+ /**
47
+ * A prefetch that has been fetched AND eagerly decoded. Storing the decoded
48
+ * payload (not the raw Response) is what makes a prefetched navigation "warm":
49
+ * decoding the Flight stream during prefetch pulls the route's client chunks,
50
+ * so the click reuses ready elements and loads no new JS.
51
+ */
52
+ export interface DecodedPrefetch {
53
+ /** The eagerly-decoded RSC payload. Reused verbatim by navigation. */
54
+ payload: Promise<RscPayload>;
55
+ /**
56
+ * Resolves when the underlying RSC stream finishes draining. Navigation
57
+ * forwards this as its streamComplete so scroll/revalidation gating is
58
+ * unchanged from the fresh-fetch path.
59
+ */
60
+ streamComplete: Promise<void>;
61
+ /**
62
+ * Prefetch scope as tagged by the server via `X-RSC-Prefetch-Scope`.
63
+ * `"source"` means the response is source-page-sensitive and must not be
64
+ * reused by a navigation from a different page — navigation enforces this
65
+ * when it adopted an inflight entry through the wildcard key.
66
+ */
67
+ scope: "source" | "wildcard";
68
+ /**
69
+ * Synchronously-readable flag, flipped to true when `streamComplete` resolves
70
+ * (the entire RSC stream has drained). Navigation reads this at click time to
71
+ * tell a FULLY warmed prefetch (payload commits without suspending → safe to
72
+ * commit in a startTransition, no fallback flash) from a partially-warmed one
73
+ * (still streaming → stream its fallbacks like a cold load). Starts false.
74
+ */
75
+ complete: boolean;
76
+ }
33
77
 
34
- // Default TTL: 5 minutes. Overridden by initPrefetchCache() with
35
- // the server-configured prefetchCacheTTL from router options.
36
- // 0 disables the in-memory cache entirely.
37
78
  let cacheTTL = 300_000;
38
79
 
39
80
  /**
@@ -55,31 +96,19 @@ export function isPrefetchCacheDisabled(): boolean {
55
96
  const MAX_PREFETCH_CACHE_SIZE = 50;
56
97
 
57
98
  interface PrefetchCacheEntry {
58
- response: Response;
99
+ entry: DecodedPrefetch;
59
100
  timestamp: number;
60
101
  }
61
102
 
62
103
  const cache = new Map<string, PrefetchCacheEntry>();
63
104
  const inflight = new Set<string>();
64
105
 
65
- /**
66
- * In-flight promise map. When a prefetch fetch is in progress, its
67
- * Promise<Response | null> is stored here so navigation can await
68
- * it instead of starting a duplicate request.
69
- */
70
- const inflightPromises = new Map<string, Promise<Response | null>>();
106
+ const inflightPromises = new Map<string, Promise<DecodedPrefetch | null>>();
71
107
 
72
- /**
73
- * Alias map for in-flight promises registered under multiple keys (see
74
- * dual inflight in prefetch/fetch.ts). Records each key's sibling set so
75
- * that consuming or clearing any one key atomically removes every alias —
76
- * guaranteeing a single consumer for the shared Response stream.
77
- */
78
108
  const inflightAliases = new Map<string, string[]>();
79
109
 
80
- // Generation counter incremented on each clearPrefetchCache(). Fetches that
81
- // started before a clear carry a stale generation and must not store their
82
- // response (the data may be stale due to a server action invalidation).
110
+ const adoptedKeys = new Set<string>();
111
+
83
112
  let generation = 0;
84
113
 
85
114
  /**
@@ -152,14 +181,14 @@ export function hasPrefetch(key: string): boolean {
152
181
  }
153
182
 
154
183
  /**
155
- * Consume a cached prefetch response. Returns null if not found or expired.
156
- * One-time consumption: the entry is deleted after retrieval.
184
+ * Consume a cached, eagerly-decoded prefetch. Returns null if not found or
185
+ * expired. One-time consumption: the entry is deleted after retrieval.
157
186
  * Returns null when caching is disabled (TTL <= 0).
158
187
  *
159
188
  * Does NOT check in-flight prefetches — use consumeInflightPrefetch()
160
- * for that (returns a Promise instead of a Response).
189
+ * for that (returns a Promise instead of a resolved entry).
161
190
  */
162
- export function consumePrefetch(key: string): Response | null {
191
+ export function consumePrefetch(key: string): DecodedPrefetch | null {
163
192
  if (cacheTTL <= 0) return null;
164
193
  const entry = cache.get(key);
165
194
  if (!entry) return null;
@@ -168,13 +197,14 @@ export function consumePrefetch(key: string): Response | null {
168
197
  return null;
169
198
  }
170
199
  cache.delete(key);
171
- return entry.response;
200
+ return entry.entry;
172
201
  }
173
202
 
174
203
  /**
175
204
  * Consume an in-flight prefetch promise. Returns null if no prefetch is
176
- * in-flight for this key. The returned Promise resolves to the buffered
177
- * Response (or null if the fetch failed/was aborted).
205
+ * in-flight for this key. The returned Promise resolves to the decoded
206
+ * prefetch entry (or null if the fetch failed/was aborted, or carried a
207
+ * control header the navigation must re-fetch to honor).
178
208
  *
179
209
  * One-time consumption: the promise entry is removed (along with any
180
210
  * sibling aliases registered via `setInflightPromiseWithAliases`) so a
@@ -188,38 +218,51 @@ export function consumePrefetch(key: string): Response | null {
188
218
  */
189
219
  export function consumeInflightPrefetch(
190
220
  key: string,
191
- ): Promise<Response | null> | null {
221
+ ): Promise<DecodedPrefetch | null> | null {
192
222
  const promise = inflightPromises.get(key);
193
223
  if (!promise) return null;
194
224
  // Remove the promise under every alias so a second consumer cannot
195
- // adopt the same stream and race on the body. `inflightAliases` is
196
- // intentionally preserved `clearPrefetchInflight()` in the fetch's
197
- // `.finally()` still needs it to clear every inflight flag; deleting
198
- // here would strand the sibling's flag forever.
199
- forEachAlias(key, (k) => inflightPromises.delete(k));
225
+ // adopt the same stream and race on the body, and mark every alias as
226
+ // adopted so the pending `storePrefetch` (which resolves later, after this
227
+ // adoption) does not leave the now-owned, single-use entry in the cache map.
228
+ // `inflightAliases` is intentionally preserved `clearPrefetchInflight()` in
229
+ // the fetch's `.finally()` still needs it to clear every inflight flag and
230
+ // adopted marker; deleting here would strand the sibling's flag forever.
231
+ forEachAlias(key, (k) => {
232
+ inflightPromises.delete(k);
233
+ adoptedKeys.add(k);
234
+ });
200
235
  return promise;
201
236
  }
202
237
 
203
238
  /**
204
- * Store a prefetch response in the in-memory cache.
205
- * The response should be a clone() of the original so the caller can
206
- * still consume the body. The clone's body streams independently.
239
+ * Store an eagerly-decoded prefetch in the in-memory cache.
207
240
  *
208
241
  * Skips storage if the generation has changed since the fetch started
209
242
  * (a server action invalidated the cache mid-flight).
210
243
  */
211
244
  export function storePrefetch(
212
245
  key: string,
213
- response: Response,
246
+ entry: DecodedPrefetch,
214
247
  fetchGeneration: number,
215
248
  ): void {
216
249
  if (cacheTTL <= 0) return;
217
250
  if (fetchGeneration !== generation) return;
218
251
 
252
+ // If a navigation already adopted this prefetch's in-flight promise, it owns
253
+ // the single-use entry (and has drained its handle generator). Do NOT also
254
+ // publish it to the cache map, or a later navigation would be served the
255
+ // exhausted entry and lose that route's handles. Clear the marker (under all
256
+ // aliases) now that the decision is made.
257
+ if (adoptedKeys.has(key)) {
258
+ forEachAlias(key, (k) => adoptedKeys.delete(k));
259
+ return;
260
+ }
261
+
219
262
  // Evict expired entries
220
263
  const now = Date.now();
221
- for (const [k, entry] of cache) {
222
- if (now - entry.timestamp > cacheTTL) {
264
+ for (const [k, cached] of cache) {
265
+ if (now - cached.timestamp > cacheTTL) {
223
266
  cache.delete(k);
224
267
  }
225
268
  }
@@ -230,7 +273,24 @@ export function storePrefetch(
230
273
  if (oldest) cache.delete(oldest);
231
274
  }
232
275
 
233
- cache.set(key, { response, timestamp: now });
276
+ cache.set(key, { entry, timestamp: now });
277
+ }
278
+
279
+ /**
280
+ * Remove a single stored prefetch entry. Used to evict an entry whose body
281
+ * stream stalled after headers arrived (its payload / streamComplete never
282
+ * settle), so future prefetches and navigation refetch instead of dedupe-ing
283
+ * against — and awaiting — a stuck entry.
284
+ *
285
+ * Identity-guarded: only evicts when the entry CURRENTLY stored under `key` is
286
+ * the exact `entry` we published. A generation check alone is insufficient —
287
+ * after this entry is consumed (consumePrefetch) and a fresh prefetch
288
+ * republishes under the SAME key in the SAME generation, a gen-only guard would
289
+ * delete that valid newer entry. Reference identity drops only our own stalled
290
+ * entry.
291
+ */
292
+ export function removePrefetch(key: string, entry: DecodedPrefetch): void {
293
+ if (cache.get(key)?.entry === entry) cache.delete(key);
234
294
  }
235
295
 
236
296
  /**
@@ -245,12 +305,9 @@ export function markPrefetchInflight(key: string): void {
245
305
  inflight.add(key);
246
306
  }
247
307
 
248
- /**
249
- * Store the in-flight Promise for a prefetch so navigation can reuse it.
250
- */
251
308
  export function setInflightPromise(
252
309
  key: string,
253
- promise: Promise<Response | null>,
310
+ promise: Promise<DecodedPrefetch | null>,
254
311
  ): void {
255
312
  inflightPromises.set(key, promise);
256
313
  }
@@ -263,7 +320,7 @@ export function setInflightPromise(
263
320
  */
264
321
  export function setInflightPromiseWithAliases(
265
322
  keys: string[],
266
- promise: Promise<Response | null>,
323
+ promise: Promise<DecodedPrefetch | null>,
267
324
  ): void {
268
325
  for (const k of keys) {
269
326
  inflightPromises.set(k, promise);
@@ -276,22 +333,16 @@ export function clearPrefetchInflight(key: string): void {
276
333
  inflight.delete(k);
277
334
  inflightPromises.delete(k);
278
335
  inflightAliases.delete(k);
336
+ adoptedKeys.delete(k);
279
337
  });
280
338
  }
281
339
 
282
- /**
283
- * Invalidate all prefetch state. Called when server actions mutate data.
284
- * Clears the in-memory cache, cancels in-flight prefetches, and rotates
285
- * the Rango state key so CDN-cached responses are also invalidated.
286
- *
287
- * Uses abortAllPrefetches (hard cancel) because in-flight responses
288
- * may contain stale data after a mutation.
289
- */
290
340
  export function clearPrefetchCache(): void {
291
341
  generation++;
292
342
  inflight.clear();
293
343
  inflightPromises.clear();
294
344
  inflightAliases.clear();
345
+ adoptedKeys.clear();
295
346
  cache.clear();
296
347
  abortAllPrefetches();
297
348
  invalidateRangoState();
@@ -3,12 +3,16 @@
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 {
@@ -18,13 +22,51 @@ import {
18
22
  markPrefetchInflight,
19
23
  setInflightPromiseWithAliases,
20
24
  storePrefetch,
25
+ removePrefetch,
21
26
  clearPrefetchInflight,
22
27
  currentGeneration,
28
+ type DecodedPrefetch,
23
29
  } from "./cache.js";
24
30
  import { getRangoState } from "../rango-state.js";
31
+ import { isActionFenceActive } from "../action-fence.js";
25
32
  import { enqueuePrefetch } from "./queue.js";
26
33
  import { shouldPrefetch } from "./policy.js";
27
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
+ }
28
70
 
29
71
  /**
30
72
  * Check if a URL resolves to the current page (same pathname + search).
@@ -78,41 +120,99 @@ function buildPrefetchUrl(
78
120
  }
79
121
 
80
122
  /**
81
- * Core prefetch fetch logic. Fetches the response, tees the body, and stores
82
- * one branch in the in-memory cache. The returned Promise resolves to the
83
- * sibling navigation branch (or null on failure) so navigation can safely
84
- * reuse an in-flight prefetch via consumeInflightPrefetch().
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.
85
137
  *
86
138
  * Inflight + storage key selection:
87
139
  *
88
140
  * - `forceSourceScope` (Link opted in with `prefetchKey=":source"`): single
89
- * inflight registration under `sourceKey`; response stored under
90
- * `sourceKey`. No wildcard leak is possible.
141
+ * inflight registration under `sourceKey`; entry stored under `sourceKey`.
142
+ * No wildcard leak is possible.
91
143
  *
92
144
  * - Otherwise: dual inflight registration under both `wildcardKey` and
93
145
  * `sourceKey` so same-source navigations adopt directly via their own
94
146
  * source key. Storage key is chosen at response time from the
95
147
  * `X-RSC-Prefetch-Scope` header — `"source"` → `sourceKey` (intercept
96
- * modals etc.), anything else → `wildcardKey`. Cross-source navigations
97
- * that adopted via `wildcardKey` must bail out in `navigation-client.ts`
98
- * if the adopted response turns out to be source-scoped.
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.
99
151
  */
100
152
  function executePrefetchFetch(
101
153
  wildcardKey: string,
102
154
  sourceKey: string,
103
155
  fetchUrl: string,
104
156
  forceSourceScope: boolean,
157
+ expectedRouterId?: string,
105
158
  signal?: AbortSignal,
106
- ): Promise<Response | null> {
159
+ ): Promise<DecodedPrefetch | null> {
107
160
  const gen = currentGeneration();
108
161
  const inflightKeys = forceSourceScope
109
162
  ? [sourceKey]
110
163
  : [wildcardKey, sourceKey];
111
164
  for (const k of inflightKeys) markPrefetchInflight(k);
112
165
 
113
- const promise: Promise<Response | null> = 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, {
114
208
  priority: "low" as RequestPriority,
115
- 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,
116
216
  headers: {
117
217
  "X-Rango-State": getRangoState(),
118
218
  "X-RSC-Router-Client-Path": window.location.href,
@@ -120,29 +220,97 @@ function executePrefetchFetch(
120
220
  },
121
221
  })
122
222
  .then((response) => {
123
- if (!response.ok) return null;
124
- // Don't buffer with arrayBuffer() that blocks until the entire
125
- // body downloads, defeating streaming for slow loaders.
126
- // Tee the body: one branch for navigation, one for cache storage.
127
- const [navStream, cacheStream] = response.body!.tee();
128
- const responseInit = {
129
- headers: response.headers,
130
- status: response.status,
131
- statusText: response.statusText,
132
- };
133
- let storageKey: string;
134
- if (forceSourceScope) {
135
- storageKey = sourceKey;
136
- } else {
137
- const scope = response.headers.get("x-rsc-prefetch-scope");
138
- storageKey = scope === "source" ? sourceKey : wildcardKey;
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;
139
237
  }
140
- storePrefetch(storageKey, new Response(cacheStream, responseInit), gen);
141
- return new Response(navStream, responseInit);
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. The
248
+ // completion callback reports whether the stream ended on a clean EOF
249
+ // (true) or was aborted/errored (false) — only a clean end can mark the
250
+ // entry complete (see below).
251
+ let resolveStreamComplete!: () => void;
252
+ let endedCleanly = false;
253
+ const streamComplete = new Promise<void>((resolve) => {
254
+ resolveStreamComplete = resolve;
255
+ });
256
+ const tracked = teeWithCompletion(
257
+ response,
258
+ (clean) => {
259
+ endedCleanly = clean;
260
+ resolveStreamComplete();
261
+ },
262
+ effectiveSignal,
263
+ // Speculative prefetch: a never-consumed/aborted stream error is benign.
264
+ true,
265
+ );
266
+
267
+ // Eager decode: parsing the Flight stream imports the route's client
268
+ // chunks now, not on click.
269
+ const payload = decoder(Promise.resolve(tracked));
270
+ // Mark handled so an unconsumed prefetch decode error stays quiet; the
271
+ // error is still surfaced to navigation if it consumes the entry.
272
+ payload.catch(() => {});
273
+
274
+ const entry: DecodedPrefetch = {
275
+ payload,
276
+ streamComplete,
277
+ scope,
278
+ complete: false,
279
+ };
280
+ storePrefetch(storageKey, entry, gen);
281
+ // The stall timeout now owns the body stream: arm eviction (publishedKey)
282
+ // and clear the timer once the stream completes. The tee's finally resolves
283
+ // streamComplete on normal completion AND on abort, so a healthy body pays
284
+ // no lingering timer while a stalled one is evicted when the timer fires.
285
+ publishedKey = storageKey;
286
+ publishedEntry = entry;
287
+ // Evict a broken prefetch IMMEDIATELY on the earliest failure signal — do not
288
+ // wait for both branches to settle. A decode that rejects while the tracking
289
+ // stream is still draining (or hung) would otherwise leave the rejected payload
290
+ // consumable (navigation reads entry.payload regardless of `complete`) until EOF
291
+ // or the stall timeout. removePrefetch is identity-guarded, so a fresh entry
292
+ // republished under the same key is never dropped, and a double call is a no-op.
293
+ payload.catch(() => removePrefetch(storageKey, entry));
294
+ streamComplete.then(() => {
295
+ if (!endedCleanly) removePrefetch(storageKey, entry);
296
+ });
297
+ // Mark complete ONLY on a fully-healthy prefetch (decode resolved AND clean EOF).
298
+ Promise.allSettled([payload, streamComplete]).then(([decode]) => {
299
+ if (decode.status === "fulfilled" && endedCleanly) {
300
+ entry.complete = true;
301
+ }
302
+ clearTimeout(timeoutId);
303
+ });
304
+ return entry;
142
305
  })
143
306
  .catch(() => null)
144
307
  .finally(() => {
145
308
  clearPrefetchInflight(inflightKeys[0]!);
309
+ // Clear the stall timer here ONLY for paths that published no streaming
310
+ // entry (null return / fetch error / abort): the operation is fully done.
311
+ // When an entry WAS published, the timer stays armed to bound the body
312
+ // stream and is cleared on streamComplete (above) or on fire (eviction).
313
+ if (publishedKey === undefined) clearTimeout(timeoutId);
146
314
  });
147
315
 
148
316
  setInflightPromiseWithAliases(inflightKeys, promise);
@@ -223,6 +391,7 @@ export function prefetchDirect(
223
391
  sourceKey,
224
392
  targetUrl.toString(),
225
393
  forceSourceScope,
394
+ routerId,
226
395
  );
227
396
  }
228
397
 
@@ -275,6 +444,7 @@ export function prefetchQueued(
275
444
  sourceKey,
276
445
  fetchUrlStr,
277
446
  forceSourceScope,
447
+ routerId,
278
448
  signal,
279
449
  ).then(() => {});
280
450
  });
@@ -71,10 +71,13 @@ function scheduleDrain(): void {
71
71
  Promise.race([waitForViewportImages(), wait(IMAGE_WAIT_TIMEOUT)]),
72
72
  )
73
73
  .then(() => {
74
- drainScheduled = false;
75
- // Stale drain: a cancel/abort happened while we were waiting.
76
- // A fresh scheduleDrain will be called by whatever enqueues next.
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).
77
79
  if (gen !== drainGeneration) return;
80
+ drainScheduled = false;
78
81
  if (queue.length > 0) drain();
79
82
  });
80
83
  }