@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
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Resource Readiness
3
+ *
4
+ * Utilities to defer speculative prefetches until critical resources
5
+ * (viewport images) have finished loading. Prevents prefetch fetch()
6
+ * calls from competing with images for the browser's connection pool.
7
+ */
8
+
9
+ /**
10
+ * Resolve when all in-viewport images have finished loading.
11
+ * Returns immediately if no images are pending.
12
+ *
13
+ * Only checks images that exist at call time — does not observe
14
+ * dynamically added images. For SPA navigations where new images
15
+ * appear after render, call this after the navigation settles.
16
+ */
17
+ export function waitForViewportImages(): Promise<void> {
18
+ if (typeof document === "undefined") return Promise.resolve();
19
+
20
+ const pending = Array.from(document.querySelectorAll("img")).filter((img) => {
21
+ if (img.complete) return false;
22
+ const rect = img.getBoundingClientRect();
23
+ return (
24
+ rect.bottom > 0 &&
25
+ rect.right > 0 &&
26
+ rect.top < window.innerHeight &&
27
+ rect.left < window.innerWidth
28
+ );
29
+ });
30
+
31
+ if (pending.length === 0) return Promise.resolve();
32
+
33
+ return new Promise((resolve) => {
34
+ const settled = new Set<HTMLImageElement>();
35
+
36
+ const settle = (img: HTMLImageElement) => {
37
+ if (settled.has(img)) return;
38
+ settled.add(img);
39
+ if (settled.size >= pending.length) resolve();
40
+ };
41
+
42
+ for (const img of pending) {
43
+ img.addEventListener("load", () => settle(img), { once: true });
44
+ img.addEventListener("error", () => settle(img), { once: true });
45
+ // Re-check: image may have completed between the initial filter
46
+ // and listener attachment. settle() is idempotent per image, so
47
+ // a queued load event firing afterward is harmless.
48
+ if (img.complete) settle(img);
49
+ }
50
+ });
51
+ }
52
+
53
+ /**
54
+ * Resolve after the given number of milliseconds.
55
+ */
56
+ export function wait(ms: number): Promise<void> {
57
+ return new Promise((resolve) => setTimeout(resolve, ms));
58
+ }
59
+
60
+ /**
61
+ * Resolve when the browser has an idle main-thread moment.
62
+ * Uses requestIdleCallback where available, falls back to setTimeout.
63
+ *
64
+ * This is a scheduling hint, not an asset-loaded detector — combine
65
+ * with waitForViewportImages() for full resource readiness.
66
+ */
67
+ export function waitForIdle(timeout = 200): Promise<void> {
68
+ if (typeof window !== "undefined" && "requestIdleCallback" in window) {
69
+ return new Promise((resolve) => {
70
+ window.requestIdleCallback(() => resolve(), { timeout });
71
+ });
72
+ }
73
+
74
+ return new Promise((resolve) => {
75
+ setTimeout(resolve, 0);
76
+ });
77
+ }
@@ -1,112 +1,194 @@
1
1
  /**
2
2
  * Rango State
3
3
  *
4
- * Manages a localStorage-based state key for HTTP cache invalidation.
5
- * The key is sent as the `X-Rango-State` header on both prefetch and
6
- * navigation requests. The server responds with `Vary: X-Rango-State`,
7
- * so the browser HTTP cache keys responses by (URL, X-Rango-State value).
4
+ * Manages a session-cookie-based state value for HTTP cache invalidation. The
5
+ * value is sent as the `X-Rango-State` header on prefetch and navigation
6
+ * requests; the server responds with `Vary: X-Rango-State`, so the browser HTTP
7
+ * cache keys responses by (URL, X-Rango-State value).
8
8
  *
9
- * Format: `{buildVersion}:{invalidationTimestamp}`
10
- * - Build version changes on deploy, busting all cached prefetches.
11
- * - Timestamp changes on server action invalidation.
9
+ * Value format: `{buildVersion}:{invalidationTimestamp}`
10
+ * - Build version changes on deploy, busting all cached prefetches at boot.
11
+ * - Timestamp rotates on invalidation (server action, invalidateClientCache).
12
12
  *
13
- * localStorage is cross-tab and survives page refresh, so:
14
- * - One tab's prefetch warms the cache for all tabs.
15
- * - Invalidation in one tab is picked up by other tabs on next fetch.
13
+ * Storage is a session cookie named by the server-resolved name passed to
14
+ * initRangoState (`{prefix}_{routerId}`, default prefix `rango-state`). The
15
+ * cookie jar is shared across tabs, so a per-request read IS the cross-tab
16
+ * value sync — no `storage` event is needed. An in-memory mirror is a
17
+ * write-through copy that is authoritative only when the cookie is unreadable
18
+ * (e.g. a sandboxed frame, or site data blocked wholesale): the failure
19
+ * direction is always toward freshness.
20
+ *
21
+ * Precedence is load-bearing: when `document.cookie` is readable, the
22
+ * per-request read wins; the mirror is a fallback, never a cache of the read.
23
+ * Caching the read across requests would reintroduce the staleness this
24
+ * mechanism removes.
16
25
  */
17
26
 
18
- const STORAGE_KEY = "rango-state";
27
+ import {
28
+ DEFAULT_STATE_COOKIE_PREFIX,
29
+ decodeStateValue,
30
+ getRawCookieValue,
31
+ mintStateValue,
32
+ serializeStateCookie,
33
+ } from "./cookie-name.js";
19
34
 
20
- // Module-level cache avoids hitting localStorage on every getRangoState() call.
21
- // Initialized from localStorage on first access or by initRangoState().
22
- let cachedState: string | null = null;
35
+ let cookieName: string = DEFAULT_STATE_COOKIE_PREFIX;
23
36
 
24
- // Cross-tab sync: the `storage` event fires in OTHER tabs when one tab writes
25
- // to localStorage, keeping cachedState fresh without polling.
26
- let storageListenerAttached = false;
37
+ let currentVersion = "0";
27
38
 
28
- function attachStorageListener(): void {
29
- if (storageListenerAttached || typeof window === "undefined") return;
30
- window.addEventListener("storage", (e) => {
31
- if (e.key !== STORAGE_KEY) return;
32
- cachedState = e.newValue;
33
- });
34
- storageListenerAttached = true;
35
- }
39
+ let mirror: string | null = null;
40
+ let cookieBacked = false;
41
+
42
+ let externalRotationObserver: ((value: string) => void) | null = null;
36
43
 
37
44
  /**
38
- * Initialize the Rango state key in localStorage.
39
- * Called once at app startup with the build version from the server.
40
- * If localStorage already has a key with matching version prefix, keeps it
41
- * (preserves invalidation state across refresh). Otherwise writes a new key.
45
+ * Register the observer invoked when a read detects an EXTERNAL rotation (a
46
+ * sibling tab, a server `Set-Cookie`, or a cookie clear). Self-rotations
47
+ * (invalidateRangoState) update the mirror synchronously and never fire it.
42
48
  */
43
- export function initRangoState(version: string): void {
44
- if (typeof window === "undefined") return;
49
+ export function setRangoStateObserver(
50
+ observer: ((value: string) => void) | null,
51
+ ): void {
52
+ externalRotationObserver = observer;
53
+ }
45
54
 
46
- attachStorageListener();
55
+ function notifyExternalRotation(value: string): void {
56
+ externalRotationObserver?.(value);
57
+ }
58
+
59
+ interface CookieRead {
60
+ /** False when there is no document or the read threw (sandboxed frame). */
61
+ readable: boolean;
62
+ /** The cookie value, or null when readable but absent. */
63
+ value: string | null;
64
+ }
47
65
 
66
+ function readCookie(name: string): CookieRead {
67
+ if (typeof document === "undefined") return { readable: false, value: null };
68
+ let raw: string;
48
69
  try {
49
- const existing = localStorage.getItem(STORAGE_KEY);
50
- if (existing) {
51
- const colonIdx = existing.indexOf(":");
52
- if (colonIdx > 0) {
53
- const existingVersion = existing.slice(0, colonIdx);
54
- if (existingVersion === version) {
55
- cachedState = existing;
56
- return;
57
- }
58
- }
59
- }
60
- // New version or first load
61
- const newState = `${version}:${Date.now()}`;
62
- localStorage.setItem(STORAGE_KEY, newState);
63
- cachedState = newState;
70
+ raw = document.cookie;
64
71
  } catch {
65
- // localStorage may be unavailable (private browsing in some browsers)
66
- cachedState = `${version}:${Date.now()}`;
72
+ return { readable: false, value: null };
73
+ }
74
+ return { readable: true, value: getRawCookieValue(raw, name) };
75
+ }
76
+
77
+ function writeCookie(name: string, value: string): void {
78
+ if (typeof document === "undefined") return;
79
+ const secure =
80
+ typeof location !== "undefined" && location.protocol === "https:";
81
+ try {
82
+ document.cookie = serializeStateCookie(name, value, secure);
83
+ } catch {}
84
+ }
85
+
86
+ function mintValue(): string {
87
+ return mintStateValue(currentVersion, mirror);
88
+ }
89
+
90
+ /**
91
+ * Initialize the Rango state cookie at app startup. `version` is the build
92
+ * version; `stateCookieName` is the server-resolved cookie name from payload
93
+ * metadata (falls back to the bare default prefix when a payload arrives
94
+ * without it). Keeps an existing matching-version cookie (preserves the cache
95
+ * key across reloads); mints fresh on a version change or a missing cookie.
96
+ */
97
+ export function initRangoState(
98
+ version: string,
99
+ stateCookieName?: string,
100
+ ): void {
101
+ currentVersion = version;
102
+ cookieName = stateCookieName || DEFAULT_STATE_COOKIE_PREFIX;
103
+ cleanupLegacyStorage();
104
+
105
+ const read = readCookie(cookieName);
106
+ if (!read.readable) {
107
+ // Cookies unreadable: the mirror is the source of truth for this session.
108
+ mirror = mintValue();
109
+ cookieBacked = false;
110
+ return;
67
111
  }
112
+ if (read.value !== null) {
113
+ const decoded = decodeStateValue(read.value);
114
+ if (decoded && decoded.version === version) {
115
+ // Keep: a matching-version cookie survives the reload warm.
116
+ mirror = read.value;
117
+ cookieBacked = true;
118
+ return;
119
+ }
120
+ }
121
+ // Absent, malformed, or a version change (deploy): mint fresh and write.
122
+ mirror = mintValue();
123
+ cookieBacked = false;
124
+ writeCookie(cookieName, mirror);
68
125
  }
69
126
 
70
127
  /**
71
- * Get the current Rango state key value.
72
- * Used as the `X-Rango-State` header value for prefetch and navigation requests.
128
+ * Get the current Rango state value, used as the `X-Rango-State` header on
129
+ * prefetch and navigation requests. Reads the cookie every call (the read is
130
+ * the cross-tab sync channel) and reconciles the mirror.
73
131
  */
74
132
  export function getRangoState(): string {
75
- if (cachedState) return cachedState;
133
+ const read = readCookie(cookieName);
76
134
 
77
- if (typeof window === "undefined") return "0:0";
135
+ if (!read.readable) {
136
+ // Mirror authoritative when the jar is unreadable.
137
+ return mirror ?? "0:0";
138
+ }
78
139
 
79
- try {
80
- const stored = localStorage.getItem(STORAGE_KEY);
81
- if (stored) {
82
- cachedState = stored;
83
- return stored;
140
+ if (read.value !== null) {
141
+ if (read.value !== mirror) {
142
+ // External rotation (sibling tab / server Set-Cookie): adopt it. The
143
+ // mirror update makes this idempotent across a burst of reads.
144
+ mirror = read.value;
145
+ cookieBacked = true;
146
+ notifyExternalRotation(read.value);
147
+ } else {
148
+ cookieBacked = true;
84
149
  }
85
- } catch {
86
- // Fallback for unavailable localStorage
150
+ return read.value;
87
151
  }
88
152
 
89
- return "0:0";
153
+ // Readable but absent.
154
+ if (cookieBacked) {
155
+ // present -> absent: an external clear. Mint fresh, write back, and notify
156
+ // once (cookieBacked flips to false so we don't re-fire on the next read).
157
+ mirror = mintValue();
158
+ cookieBacked = false;
159
+ writeCookie(cookieName, mirror);
160
+ notifyExternalRotation(mirror);
161
+ } else if (mirror === null) {
162
+ // First access with no cookie yet (pre-boot): mint silently — there is
163
+ // nothing to invalidate.
164
+ mirror = mintValue();
165
+ writeCookie(cookieName, mirror);
166
+ }
167
+ return mirror;
90
168
  }
91
169
 
92
170
  /**
93
- * Invalidate the Rango state key. Called when server actions mutate data.
94
- * Updates the timestamp portion while keeping the version prefix.
95
- * The new value takes effect immediately for all subsequent fetches,
96
- * causing Vary mismatches with previously cached responses.
171
+ * Invalidate the Rango state (self-rotation). Called when the client clears its
172
+ * prefetch caches (e.g. via the server-action bridge). Rotates the timestamp,
173
+ * keeps the version, writes the cookie, and updates the mirror synchronously so
174
+ * the external-rotation observer is NOT triggered by our own write.
97
175
  */
98
176
  export function invalidateRangoState(): void {
99
- const current = getRangoState();
100
- const colonIdx = current.indexOf(":");
101
- const version = colonIdx > 0 ? current.slice(0, colonIdx) : "0";
102
- const newState = `${version}:${Date.now()}`;
103
- cachedState = newState;
104
-
105
- if (typeof window === "undefined") return;
177
+ mirror = mintValue();
178
+ cookieBacked = false;
179
+ writeCookie(cookieName, mirror);
180
+ }
106
181
 
182
+ function cleanupLegacyStorage(): void {
183
+ if (typeof localStorage === "undefined") return;
107
184
  try {
108
- localStorage.setItem(STORAGE_KEY, newState);
109
- } catch {
110
- // Silently handle localStorage errors
111
- }
185
+ const toRemove: string[] = [];
186
+ for (let i = 0; i < localStorage.length; i++) {
187
+ const key = localStorage.key(i);
188
+ if (key === "rango-state" || (key && key.startsWith("rango-state:"))) {
189
+ toRemove.push(key);
190
+ }
191
+ }
192
+ for (const key of toRemove) localStorage.removeItem(key);
193
+ } catch {}
112
194
  }
@@ -5,6 +5,7 @@ import React, {
5
5
  useCallback,
6
6
  useContext,
7
7
  useEffect,
8
+ useMemo,
8
9
  useRef,
9
10
  type ForwardRefExoticComponent,
10
11
  type RefAttributes,
@@ -32,15 +33,24 @@ export type LinkState =
32
33
  | StateOrGetter<Record<string, unknown>>;
33
34
 
34
35
  import { prefetchDirect, prefetchQueued } from "../prefetch/fetch.js";
36
+ import { getAppVersion } from "../app-version.js";
35
37
  import {
36
38
  observeForPrefetch,
37
39
  unobserveForPrefetch,
38
40
  } from "../prefetch/observer.js";
39
41
 
40
- // Touch device detection for adaptive strategy.
41
- // Checked once at module load (Link.tsx is "use client", runs only in browser).
42
- const isTouchDevice =
43
- typeof window !== "undefined" && window.matchMedia("(hover: none)").matches;
42
+ /**
43
+ * Read current touch/no-hover capability. Evaluated at the point of use (per
44
+ * render) rather than once at module load, so `prefetch="adaptive"` reacts to
45
+ * input-capability changes on hybrid devices (touch laptops, tablets gaining or
46
+ * losing a pointer) and after SSR -> hydrate. The SSR guard returns a stable
47
+ * `false` (pointer/hover default) so the resolved strategy doesn't drift on the
48
+ * server vs the first client render.
49
+ */
50
+ function isTouchDevice(): boolean {
51
+ if (typeof window === "undefined") return false;
52
+ return window.matchMedia("(hover: none)").matches;
53
+ }
44
54
 
45
55
  /**
46
56
  * Prefetch strategy for the Link component
@@ -57,6 +67,20 @@ export type PrefetchStrategy =
57
67
  | "adaptive"
58
68
  | "none";
59
69
 
70
+ /**
71
+ * Resolve a prefetch strategy, expanding "adaptive" to the concrete strategy
72
+ * for the CURRENT input capability: "viewport" on touch (no-hover) devices,
73
+ * "hover" on pointer devices. Non-adaptive strategies pass through unchanged.
74
+ * Reads touch capability live (not a module-load snapshot) so the result
75
+ * tracks input-capability changes.
76
+ */
77
+ export function resolveAdaptiveStrategy(
78
+ prefetch: PrefetchStrategy,
79
+ ): PrefetchStrategy {
80
+ if (prefetch !== "adaptive") return prefetch;
81
+ return isTouchDevice() ? "viewport" : "hover";
82
+ }
83
+
60
84
  /**
61
85
  * Link component props
62
86
  */
@@ -95,6 +119,31 @@ export interface LinkProps extends Omit<
95
119
  * @default "none"
96
120
  */
97
121
  prefetch?: PrefetchStrategy;
122
+ /**
123
+ * Opt-in override for the prefetch cache scope.
124
+ *
125
+ * The default cache is source-agnostic: one shared entry per target,
126
+ * keyed on Rango state + target URL. This is correct for routes whose
127
+ * response shape doesn't depend on where the user navigates from.
128
+ *
129
+ * Set `":source"` when this Link's response would legitimately differ
130
+ * based on the source page — typically when the target route (or one
131
+ * of its layouts) uses a custom `revalidate()` handler that reads
132
+ * `currentUrl` / `currentParams`, and the wildcard entry would
133
+ * therefore serve the wrong diff to a navigation from a different
134
+ * source.
135
+ *
136
+ * Intercept responses are auto-scoped to the source via a server-side
137
+ * tag, so `":source"` is only needed for custom revalidation logic.
138
+ *
139
+ * @example
140
+ * ```tsx
141
+ * // Route uses a `revalidate()` that branches on currentUrl — opt in
142
+ * // so prefetches don't bleed across source pages.
143
+ * <Link to="/dashboard" prefetch="hover" prefetchKey=":source" />
144
+ * ```
145
+ */
146
+ prefetchKey?: ":source";
98
147
  /**
99
148
  * State to pass to history.pushState/replaceState.
100
149
  * Accessible via useLocationState() hook.
@@ -182,6 +231,7 @@ export const Link: ForwardRefExoticComponent<
182
231
  reloadDocument = false,
183
232
  revalidate,
184
233
  prefetch = "none",
234
+ prefetchKey,
185
235
  state,
186
236
  children,
187
237
  onClick,
@@ -192,9 +242,20 @@ export const Link: ForwardRefExoticComponent<
192
242
  const ctx = useContext(NavigationStoreContext);
193
243
  const isExternal = isExternalUrl(to);
194
244
 
195
- // Resolve adaptive: viewport on touch devices, hover on pointer devices
196
- const resolvedStrategy =
197
- prefetch === "adaptive" ? (isTouchDevice ? "viewport" : "hover") : prefetch;
245
+ // Auto-prefix with basename for app-local paths.
246
+ // Skip if external, already prefixed, or not a root-relative path.
247
+ const resolvedTo = useMemo(() => {
248
+ if (isExternal) return to;
249
+ const bn = ctx?.basename;
250
+ if (!bn || !to.startsWith("/") || to.startsWith(bn + "/") || to === bn)
251
+ return to;
252
+ return to === "/" ? bn : bn + to;
253
+ }, [to, isExternal, ctx?.basename]);
254
+
255
+ // Resolve adaptive: viewport on touch devices, hover on pointer devices.
256
+ // isTouchDevice() is read here (per render), not from a module-load snapshot,
257
+ // so a device whose input capability changes resolves to the current value.
258
+ const resolvedStrategy = resolveAdaptiveStrategy(prefetch);
198
259
 
199
260
  // Internal ref for viewport observation; merge with forwarded ref
200
261
  const internalRef = useRef<HTMLAnchorElement | null>(null);
@@ -273,17 +334,45 @@ export const Link: ForwardRefExoticComponent<
273
334
  resolvedState = currentState;
274
335
  }
275
336
 
276
- ctx.navigate(to, { replace, scroll, state: resolvedState, revalidate });
337
+ ctx.navigate(resolvedTo, {
338
+ replace,
339
+ scroll,
340
+ state: resolvedState,
341
+ revalidate,
342
+ });
277
343
  },
278
- [to, isExternal, reloadDocument, replace, scroll, revalidate, ctx, onClick],
344
+ [
345
+ resolvedTo,
346
+ isExternal,
347
+ reloadDocument,
348
+ replace,
349
+ scroll,
350
+ revalidate,
351
+ ctx,
352
+ onClick,
353
+ ],
279
354
  );
280
355
 
281
356
  const handleMouseEnter = useCallback(() => {
282
- if (resolvedStrategy === "hover" && !isExternal && ctx?.store) {
357
+ if (
358
+ (resolvedStrategy === "hover" || resolvedStrategy === "viewport") &&
359
+ !isExternal &&
360
+ ctx?.store
361
+ ) {
362
+ // For "hover", this is the primary prefetch trigger.
363
+ // For "viewport", this upgrades/prioritizes a potentially queued
364
+ // prefetch — prefetchDirect bypasses the queue, and hasPrefetch
365
+ // deduplicates if the viewport prefetch already completed.
283
366
  const segmentState = ctx.store.getSegmentState();
284
- prefetchDirect(to, segmentState.currentSegmentIds, ctx.version);
367
+ prefetchDirect(
368
+ resolvedTo,
369
+ segmentState.currentSegmentIds,
370
+ getAppVersion(),
371
+ ctx.store.getRouterId?.(),
372
+ prefetchKey,
373
+ );
285
374
  }
286
- }, [resolvedStrategy, to, isExternal, ctx]);
375
+ }, [resolvedStrategy, resolvedTo, isExternal, ctx, prefetchKey]);
287
376
 
288
377
  // Viewport/render prefetch: waits for idle before starting,
289
378
  // uses concurrency-limited queue to avoid flooding.
@@ -300,7 +389,13 @@ export const Link: ForwardRefExoticComponent<
300
389
  const triggerPrefetch = () => {
301
390
  if (cancelled) return;
302
391
  const segmentState = ctx.store.getSegmentState();
303
- prefetchQueued(to, segmentState.currentSegmentIds, ctx.version);
392
+ prefetchQueued(
393
+ resolvedTo,
394
+ segmentState.currentSegmentIds,
395
+ getAppVersion(),
396
+ ctx.store.getRouterId?.(),
397
+ prefetchKey,
398
+ );
304
399
  };
305
400
 
306
401
  // Schedule prefetch only when the app is idle (no navigation/streaming).
@@ -339,12 +434,12 @@ export const Link: ForwardRefExoticComponent<
339
434
  unobserveForPrefetch(observedElement);
340
435
  }
341
436
  };
342
- }, [resolvedStrategy, to, isExternal, ctx]);
437
+ }, [resolvedStrategy, resolvedTo, isExternal, ctx, prefetchKey]);
343
438
 
344
439
  return (
345
440
  <a
346
441
  ref={setRef}
347
- href={to}
442
+ href={resolvedTo}
348
443
  onClick={handleClick}
349
444
  onMouseEnter={handleMouseEnter}
350
445
  data-link-component
@@ -354,7 +449,7 @@ export const Link: ForwardRefExoticComponent<
354
449
  data-revalidate={revalidate === false ? "false" : undefined}
355
450
  {...props}
356
451
  >
357
- <LinkContext.Provider value={to}>{children}</LinkContext.Provider>
452
+ <LinkContext.Provider value={resolvedTo}>{children}</LinkContext.Provider>
358
453
  </a>
359
454
  );
360
455
  });