@rangojs/router 0.0.0-experimental.19 → 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 (406) hide show
  1. package/AGENTS.md +17 -0
  2. package/README.md +291 -61
  3. package/dist/bin/rango.js +544 -143
  4. package/dist/testing/vitest.js +82 -0
  5. package/dist/vite/index.js +3744 -1329
  6. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  7. package/package.json +67 -13
  8. package/skills/api-client/SKILL.md +211 -0
  9. package/skills/breadcrumbs/SKILL.md +312 -0
  10. package/skills/bundle-analysis/SKILL.md +159 -0
  11. package/skills/cache-guide/SKILL.md +247 -23
  12. package/skills/caching/SKILL.md +322 -19
  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 +282 -60
  19. package/skills/host-router/SKILL.md +278 -0
  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 +297 -31
  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 +155 -111
  32. package/skills/rango/SKILL.md +251 -23
  33. package/skills/react-compiler/SKILL.md +168 -0
  34. package/skills/response-routes/SKILL.md +123 -48
  35. package/skills/route/SKILL.md +104 -9
  36. package/skills/router-setup/SKILL.md +124 -11
  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 +125 -222
  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 +357 -52
  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 +67 -40
  62. package/src/bin/rango.ts +18 -0
  63. package/src/browser/action-coordinator.ts +53 -36
  64. package/src/browser/action-fence.ts +47 -0
  65. package/src/browser/app-shell.ts +39 -0
  66. package/src/browser/app-version.ts +14 -0
  67. package/src/browser/connection-warmup.ts +134 -0
  68. package/src/browser/cookie-name.ts +140 -0
  69. package/src/browser/event-controller.ts +197 -150
  70. package/src/browser/history-state.ts +21 -0
  71. package/src/browser/index.ts +3 -3
  72. package/src/browser/invalidate-client-cache.ts +52 -0
  73. package/src/browser/link-interceptor.ts +4 -0
  74. package/src/browser/navigation-bridge.ts +200 -30
  75. package/src/browser/navigation-client.ts +217 -58
  76. package/src/browser/navigation-store-handle.ts +38 -0
  77. package/src/browser/navigation-store.ts +76 -67
  78. package/src/browser/navigation-transaction.ts +18 -66
  79. package/src/browser/network-error-handler.ts +34 -7
  80. package/src/browser/partial-update.ts +187 -112
  81. package/src/browser/prefetch/cache.ts +312 -30
  82. package/src/browser/prefetch/fetch.ts +344 -47
  83. package/src/browser/prefetch/policy.ts +6 -0
  84. package/src/browser/prefetch/queue.ts +126 -20
  85. package/src/browser/prefetch/resource-ready.ts +77 -0
  86. package/src/browser/rango-state.ts +158 -76
  87. package/src/browser/react/Link.tsx +125 -18
  88. package/src/browser/react/NavigationProvider.tsx +135 -120
  89. package/src/browser/react/ScrollRestoration.tsx +10 -6
  90. package/src/browser/react/context.ts +7 -2
  91. package/src/browser/react/filter-segment-order.ts +66 -7
  92. package/src/browser/react/index.ts +0 -48
  93. package/src/browser/react/location-state-shared.ts +178 -8
  94. package/src/browser/react/location-state.ts +39 -14
  95. package/src/browser/react/use-action.ts +6 -15
  96. package/src/browser/react/use-handle.ts +23 -69
  97. package/src/browser/react/use-href.tsx +8 -1
  98. package/src/browser/react/use-link-status.ts +33 -8
  99. package/src/browser/react/use-navigation.ts +32 -7
  100. package/src/browser/react/use-params.ts +20 -10
  101. package/src/browser/react/use-reverse.ts +106 -0
  102. package/src/browser/react/use-router.ts +46 -11
  103. package/src/browser/react/use-search-params.ts +0 -5
  104. package/src/browser/react/use-segments.ts +11 -21
  105. package/src/browser/response-adapter.ts +80 -5
  106. package/src/browser/rsc-router.tsx +226 -75
  107. package/src/browser/scroll-restoration.ts +54 -42
  108. package/src/browser/segment-reconciler.ts +36 -9
  109. package/src/browser/segment-structure-assert.ts +2 -2
  110. package/src/browser/server-action-bridge.ts +619 -442
  111. package/src/browser/types.ts +115 -11
  112. package/src/browser/validate-redirect-origin.ts +43 -16
  113. package/src/build/collect-fallback-refs.ts +107 -0
  114. package/src/build/generate-manifest.ts +65 -40
  115. package/src/build/generate-route-types.ts +7 -1
  116. package/src/build/index.ts +8 -2
  117. package/src/build/prefix-tree-utils.ts +123 -0
  118. package/src/build/route-trie.ts +182 -37
  119. package/src/build/route-types/ast-route-extraction.ts +15 -8
  120. package/src/build/route-types/codegen.ts +16 -5
  121. package/src/build/route-types/include-resolution.ts +125 -24
  122. package/src/build/route-types/param-extraction.ts +6 -3
  123. package/src/build/route-types/per-module-writer.ts +22 -6
  124. package/src/build/route-types/router-processing.ts +392 -106
  125. package/src/build/route-types/scan-filter.ts +9 -2
  126. package/src/build/route-types/source-scan.ts +216 -0
  127. package/src/build/runtime-discovery.ts +9 -20
  128. package/src/cache/cache-error.ts +104 -0
  129. package/src/cache/cache-key-utils.ts +29 -13
  130. package/src/cache/cache-policy.ts +108 -34
  131. package/src/cache/cache-runtime.ts +214 -48
  132. package/src/cache/cache-scope.ts +236 -89
  133. package/src/cache/cache-tag.ts +103 -0
  134. package/src/cache/cf/cf-base64.ts +33 -0
  135. package/src/cache/cf/cf-cache-constants.ts +127 -0
  136. package/src/cache/cf/cf-cache-store.ts +2224 -171
  137. package/src/cache/cf/cf-cache-types.ts +349 -0
  138. package/src/cache/cf/cf-kv-utils.ts +46 -0
  139. package/src/cache/cf/cf-tag-marker-memo.ts +105 -0
  140. package/src/cache/cf/index.ts +11 -17
  141. package/src/cache/document-cache.ts +89 -27
  142. package/src/cache/handle-snapshot.ts +70 -0
  143. package/src/cache/index.ts +11 -20
  144. package/src/cache/memory-segment-store.ts +136 -37
  145. package/src/cache/profile-registry.ts +31 -31
  146. package/src/cache/read-through-swr.ts +41 -11
  147. package/src/cache/segment-codec.ts +9 -17
  148. package/src/cache/tag-invalidation.ts +230 -0
  149. package/src/cache/taint.ts +55 -0
  150. package/src/cache/types.ts +37 -100
  151. package/src/client.rsc.tsx +45 -21
  152. package/src/client.tsx +120 -336
  153. package/src/cloudflare/index.ts +11 -0
  154. package/src/cloudflare/tracing.ts +109 -0
  155. package/src/component-utils.ts +19 -0
  156. package/src/components/DefaultDocument.tsx +8 -2
  157. package/src/context-var.ts +84 -2
  158. package/src/debug.ts +2 -2
  159. package/src/decode-loader-results.ts +52 -0
  160. package/src/defer.ts +196 -0
  161. package/src/deps/ssr.ts +0 -1
  162. package/src/encode-kv.ts +49 -0
  163. package/src/errors.ts +30 -4
  164. package/src/escape-script.ts +52 -0
  165. package/src/handle.ts +70 -22
  166. package/src/handles/MetaTags.tsx +56 -19
  167. package/src/handles/Scripts.tsx +183 -0
  168. package/src/handles/breadcrumbs.ts +95 -0
  169. package/src/handles/is-thenable.ts +19 -0
  170. package/src/handles/meta.ts +51 -40
  171. package/src/handles/script.ts +244 -0
  172. package/src/host/cookie-handler.ts +9 -60
  173. package/src/host/errors.ts +0 -24
  174. package/src/host/index.ts +8 -5
  175. package/src/host/pattern-matcher.ts +23 -52
  176. package/src/host/router.ts +107 -99
  177. package/src/host/testing.ts +40 -27
  178. package/src/host/types.ts +37 -4
  179. package/src/host/utils.ts +1 -1
  180. package/src/href-client.ts +137 -22
  181. package/src/index.rsc.ts +79 -29
  182. package/src/index.ts +149 -65
  183. package/src/internal-debug.ts +11 -10
  184. package/src/loader-store.ts +500 -0
  185. package/src/loader.rsc.ts +20 -13
  186. package/src/loader.ts +12 -11
  187. package/src/missing-id-error.ts +68 -0
  188. package/src/outlet-context.ts +1 -1
  189. package/src/outlet-provider.tsx +1 -5
  190. package/src/prerender/param-hash.ts +16 -16
  191. package/src/prerender/store.ts +63 -26
  192. package/src/prerender.ts +198 -82
  193. package/src/redirect-origin.ts +100 -0
  194. package/src/regex-escape.ts +8 -0
  195. package/src/render-error-thrower.tsx +20 -0
  196. package/src/response-utils.ts +62 -0
  197. package/src/reverse.ts +65 -15
  198. package/src/root-error-boundary.tsx +1 -19
  199. package/src/route-content-wrapper.tsx +7 -72
  200. package/src/route-definition/dsl-helpers.ts +469 -276
  201. package/src/route-definition/helper-factories.ts +29 -139
  202. package/src/route-definition/helpers-types.ts +113 -37
  203. package/src/route-definition/index.ts +3 -3
  204. package/src/route-definition/redirect.ts +53 -12
  205. package/src/route-definition/resolve-handler-use.ts +161 -0
  206. package/src/route-definition/use-item-types.ts +32 -0
  207. package/src/route-map-builder.ts +7 -17
  208. package/src/route-types.ts +37 -41
  209. package/src/router/basename.ts +14 -0
  210. package/src/router/content-negotiation.ts +164 -17
  211. package/src/router/error-handling.ts +45 -18
  212. package/src/router/find-match.ts +45 -22
  213. package/src/router/handler-context.ts +110 -39
  214. package/src/router/instrument.ts +350 -0
  215. package/src/router/intercept-resolution.ts +50 -24
  216. package/src/router/lazy-includes.ts +19 -53
  217. package/src/router/loader-resolution.ts +274 -56
  218. package/src/router/logging.ts +5 -8
  219. package/src/router/manifest.ts +49 -45
  220. package/src/router/match-api.ts +121 -205
  221. package/src/router/match-context.ts +0 -22
  222. package/src/router/match-handlers.ts +58 -58
  223. package/src/router/match-middleware/background-revalidation.ts +33 -6
  224. package/src/router/match-middleware/cache-lookup.ts +214 -263
  225. package/src/router/match-middleware/cache-store.ts +73 -33
  226. package/src/router/match-middleware/intercept-resolution.ts +8 -28
  227. package/src/router/match-middleware/segment-resolution.ts +52 -18
  228. package/src/router/match-pipelines.ts +1 -42
  229. package/src/router/match-result.ts +104 -49
  230. package/src/router/metrics.ts +217 -26
  231. package/src/router/middleware-types.ts +24 -110
  232. package/src/router/middleware.ts +384 -197
  233. package/src/router/navigation-snapshot.ts +131 -0
  234. package/src/router/params-util.ts +23 -0
  235. package/src/router/pattern-matching.ts +148 -91
  236. package/src/router/prefetch-cache-ttl.ts +51 -0
  237. package/src/router/prerender-match.ts +199 -56
  238. package/src/router/preview-match.ts +32 -102
  239. package/src/router/request-classification.ts +276 -0
  240. package/src/router/revalidation.ts +144 -74
  241. package/src/router/route-snapshot.ts +244 -0
  242. package/src/router/router-context.ts +8 -28
  243. package/src/router/router-interfaces.ts +129 -36
  244. package/src/router/router-options.ts +185 -23
  245. package/src/router/router-registry.ts +2 -5
  246. package/src/router/segment-resolution/fresh.ts +281 -76
  247. package/src/router/segment-resolution/helpers.ts +116 -31
  248. package/src/router/segment-resolution/loader-cache.ts +63 -37
  249. package/src/router/segment-resolution/revalidation.ts +493 -391
  250. package/src/router/segment-resolution/static-store.ts +19 -5
  251. package/src/router/segment-resolution/streamed-handler-telemetry.ts +52 -0
  252. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  253. package/src/router/segment-resolution.ts +5 -1
  254. package/src/router/segment-wrappers.ts +8 -5
  255. package/src/router/state-cookie-name.ts +33 -0
  256. package/src/router/substitute-pattern-params.ts +56 -0
  257. package/src/router/telemetry-otel.ts +161 -199
  258. package/src/router/telemetry.ts +96 -19
  259. package/src/router/timeout.ts +0 -20
  260. package/src/router/tracing.ts +206 -0
  261. package/src/router/trie-matching.ts +180 -58
  262. package/src/router/types.ts +10 -63
  263. package/src/router/url-params.ts +44 -0
  264. package/src/router.ts +182 -54
  265. package/src/rsc/handler-context.ts +3 -2
  266. package/src/rsc/handler.ts +702 -460
  267. package/src/rsc/helpers.ts +168 -46
  268. package/src/rsc/index.ts +2 -25
  269. package/src/rsc/json-route-result.ts +38 -0
  270. package/src/rsc/loader-fetch.ts +127 -31
  271. package/src/rsc/manifest-init.ts +33 -42
  272. package/src/rsc/origin-guard.ts +39 -25
  273. package/src/rsc/progressive-enhancement.ts +98 -19
  274. package/src/rsc/redirect-guard.ts +99 -0
  275. package/src/rsc/response-cache-serve.ts +238 -0
  276. package/src/rsc/response-error.ts +79 -12
  277. package/src/rsc/response-route-handler.ts +99 -189
  278. package/src/rsc/rsc-rendering.ts +126 -106
  279. package/src/rsc/runtime-warnings.ts +23 -10
  280. package/src/rsc/server-action.ts +269 -114
  281. package/src/rsc/ssr-setup.ts +144 -0
  282. package/src/rsc/types.ts +34 -6
  283. package/src/runtime-env.ts +18 -0
  284. package/src/search-params.ts +49 -41
  285. package/src/segment-content-promise.ts +67 -0
  286. package/src/segment-loader-promise.ts +149 -0
  287. package/src/segment-system.tsx +281 -129
  288. package/src/serialize.ts +243 -0
  289. package/src/server/context.ts +317 -63
  290. package/src/server/cookie-parse.ts +32 -0
  291. package/src/server/cookie-store.ts +80 -5
  292. package/src/server/handle-store.ts +40 -38
  293. package/src/server/loader-registry.ts +26 -46
  294. package/src/server/request-context.ts +425 -177
  295. package/src/server.ts +6 -0
  296. package/src/ssr/index.tsx +25 -16
  297. package/src/static-handler.ts +27 -18
  298. package/src/testing/cache-status.ts +162 -0
  299. package/src/testing/collect-handle.ts +40 -0
  300. package/src/testing/dispatch.ts +701 -0
  301. package/src/testing/dom.entry.ts +22 -0
  302. package/src/testing/e2e/fixture.ts +188 -0
  303. package/src/testing/e2e/index.ts +128 -0
  304. package/src/testing/e2e/matchers.ts +35 -0
  305. package/src/testing/e2e/page-helpers.ts +272 -0
  306. package/src/testing/e2e/parity.ts +387 -0
  307. package/src/testing/e2e/server.ts +195 -0
  308. package/src/testing/flight-matchers.ts +97 -0
  309. package/src/testing/flight-normalize.ts +11 -0
  310. package/src/testing/flight-runtime.d.ts +57 -0
  311. package/src/testing/flight-tree.ts +682 -0
  312. package/src/testing/flight.entry.ts +52 -0
  313. package/src/testing/flight.ts +257 -0
  314. package/src/testing/generated-routes.ts +183 -0
  315. package/src/testing/index.ts +99 -0
  316. package/src/testing/internal/context.ts +371 -0
  317. package/src/testing/internal/flight-client-globals.ts +30 -0
  318. package/src/testing/internal/seed-vars.ts +54 -0
  319. package/src/testing/render-handler.ts +343 -0
  320. package/src/testing/render-route.tsx +581 -0
  321. package/src/testing/run-loader.ts +385 -0
  322. package/src/testing/run-middleware.ts +205 -0
  323. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  324. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  325. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  326. package/src/testing/vitest-stubs/version.ts +5 -0
  327. package/src/testing/vitest.ts +305 -0
  328. package/src/theme/ThemeProvider.tsx +20 -58
  329. package/src/theme/ThemeScript.tsx +7 -9
  330. package/src/theme/constants.ts +52 -13
  331. package/src/theme/index.ts +3 -19
  332. package/src/theme/theme-context.ts +1 -5
  333. package/src/theme/theme-script.ts +22 -21
  334. package/src/theme/use-theme.ts +0 -3
  335. package/src/types/boundaries.ts +0 -35
  336. package/src/types/cache-types.ts +17 -8
  337. package/src/types/error-types.ts +30 -90
  338. package/src/types/global-namespace.ts +54 -41
  339. package/src/types/handler-context.ts +236 -88
  340. package/src/types/index.ts +1 -10
  341. package/src/types/loader-types.ts +44 -15
  342. package/src/types/request-scope.ts +112 -0
  343. package/src/types/route-config.ts +10 -45
  344. package/src/types/route-entry.ts +19 -7
  345. package/src/types/segments.ts +37 -19
  346. package/src/urls/include-helper.ts +33 -70
  347. package/src/urls/index.ts +1 -11
  348. package/src/urls/path-helper-types.ts +58 -11
  349. package/src/urls/path-helper.ts +57 -111
  350. package/src/urls/pattern-types.ts +48 -19
  351. package/src/urls/response-types.ts +25 -22
  352. package/src/urls/type-extraction.ts +58 -139
  353. package/src/urls/urls-function.ts +1 -18
  354. package/src/use-loader.tsx +346 -89
  355. package/src/vite/debug.ts +185 -0
  356. package/src/vite/discovery/bundle-postprocess.ts +64 -91
  357. package/src/vite/discovery/discover-routers.ts +147 -88
  358. package/src/vite/discovery/discovery-errors.ts +194 -0
  359. package/src/vite/discovery/gate-state.ts +171 -0
  360. package/src/vite/discovery/prerender-collection.ts +247 -145
  361. package/src/vite/discovery/route-types-writer.ts +40 -84
  362. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  363. package/src/vite/discovery/state.ts +61 -13
  364. package/src/vite/discovery/virtual-module-codegen.ts +14 -34
  365. package/src/vite/index.ts +10 -3
  366. package/src/vite/inject-client-debug.ts +36 -0
  367. package/src/vite/plugin-types.ts +155 -65
  368. package/src/vite/plugins/cjs-to-esm.ts +16 -19
  369. package/src/vite/plugins/client-ref-dedup.ts +120 -0
  370. package/src/vite/plugins/client-ref-hashing.ts +28 -15
  371. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  372. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  373. package/src/vite/plugins/cloudflare-protocol-stub.ts +194 -0
  374. package/src/vite/plugins/expose-action-id.ts +49 -98
  375. package/src/vite/plugins/expose-id-utils.ts +96 -51
  376. package/src/vite/plugins/expose-ids/export-analysis.ts +101 -34
  377. package/src/vite/plugins/expose-ids/handler-transform.ts +15 -64
  378. package/src/vite/plugins/expose-ids/loader-transform.ts +14 -24
  379. package/src/vite/plugins/expose-ids/router-transform.ts +118 -29
  380. package/src/vite/plugins/expose-internal-ids.ts +553 -317
  381. package/src/vite/plugins/performance-tracks.ts +89 -0
  382. package/src/vite/plugins/refresh-cmd.ts +127 -0
  383. package/src/vite/plugins/use-cache-transform.ts +73 -83
  384. package/src/vite/plugins/version-injector.ts +21 -25
  385. package/src/vite/plugins/version-plugin.ts +46 -37
  386. package/src/vite/plugins/virtual-entries.ts +13 -18
  387. package/src/vite/rango.ts +241 -287
  388. package/src/vite/router-discovery.ts +956 -149
  389. package/src/vite/utils/ast-handler-extract.ts +26 -35
  390. package/src/vite/utils/banner.ts +4 -4
  391. package/src/vite/utils/bundle-analysis.ts +10 -15
  392. package/src/vite/utils/client-chunks.ts +184 -0
  393. package/src/vite/utils/directive-prologue.ts +40 -0
  394. package/src/vite/utils/forward-user-plugins.ts +171 -0
  395. package/src/vite/utils/manifest-utils.ts +4 -59
  396. package/src/vite/utils/package-resolution.ts +20 -52
  397. package/src/vite/utils/prerender-utils.ts +141 -34
  398. package/src/vite/utils/shared-utils.ts +92 -42
  399. package/CLAUDE.md +0 -5
  400. package/src/browser/action-response-classifier.ts +0 -99
  401. package/src/browser/react/use-client-cache.ts +0 -58
  402. package/src/browser/shallow.ts +0 -40
  403. package/src/handles/index.ts +0 -6
  404. package/src/network-error-thrower.tsx +0 -23
  405. package/src/route-definition/route-function.ts +0 -119
  406. package/src/router/middleware-cookies.ts +0 -55
@@ -0,0 +1,131 @@
1
+ import type { RouteMatchResult } from "./pattern-matching.js";
2
+
3
+ export interface NavigationSnapshot {
4
+ prevUrl: URL;
5
+ prevParams: Record<string, string>;
6
+ prevMatch: RouteMatchResult | null;
7
+
8
+ interceptContextUrl: URL;
9
+ interceptContextMatch: RouteMatchResult | null;
10
+
11
+ clientSegmentIds: string[];
12
+ clientSegmentSet: Set<string>;
13
+ filteredSegmentIds: string[];
14
+
15
+ stale: boolean;
16
+
17
+ isSameRouteNavigation: boolean;
18
+
19
+ effectiveFromUrl: URL;
20
+ effectiveFromMatch: RouteMatchResult | null;
21
+
22
+ hasInterceptSource: boolean;
23
+
24
+ isHmr: boolean;
25
+ }
26
+
27
+ export interface ResolveNavigationDeps {
28
+ findMatch: (pathname: string) => RouteMatchResult | null;
29
+ }
30
+
31
+ export function resolveNavigation(
32
+ request: Request,
33
+ url: URL,
34
+ currentRouteKey: string,
35
+ deps: ResolveNavigationDeps,
36
+ ): NavigationSnapshot | null {
37
+ const clientSegmentIds =
38
+ url.searchParams.get("_rsc_segments")?.split(",").filter(Boolean) || [];
39
+ const stale = url.searchParams.get("_rsc_stale") === "true";
40
+ const previousUrl =
41
+ request.headers.get("X-RSC-Router-Client-Path") ||
42
+ request.headers.get("Referer");
43
+ const interceptSourceUrl = request.headers.get(
44
+ "X-RSC-Router-Intercept-Source",
45
+ );
46
+ const isHmr = !!request.headers.get("X-RSC-HMR");
47
+
48
+ if (!previousUrl) {
49
+ return null;
50
+ }
51
+
52
+ let prevUrl: URL;
53
+ try {
54
+ prevUrl = new URL(previousUrl, url.origin);
55
+ } catch {
56
+ return null;
57
+ }
58
+
59
+ let interceptContextUrl: URL;
60
+ try {
61
+ interceptContextUrl = interceptSourceUrl
62
+ ? new URL(interceptSourceUrl, url.origin)
63
+ : prevUrl;
64
+ } catch {
65
+ interceptContextUrl = prevUrl;
66
+ }
67
+
68
+ const prevMatch = deps.findMatch(prevUrl.pathname);
69
+ const prevParams = prevMatch?.params || {};
70
+ const interceptContextMatch = interceptSourceUrl
71
+ ? deps.findMatch(interceptContextUrl.pathname)
72
+ : prevMatch;
73
+
74
+ const isSameRouteNavigation = !!(
75
+ interceptContextMatch && interceptContextMatch.routeKey === currentRouteKey
76
+ );
77
+
78
+ const hasInterceptSource = !!interceptSourceUrl;
79
+ const effectiveFromUrl = hasInterceptSource ? interceptContextUrl : prevUrl;
80
+ const effectiveFromMatch = hasInterceptSource
81
+ ? interceptContextMatch
82
+ : prevMatch;
83
+
84
+ const filteredSegmentIds = clientSegmentIds.filter((id) => {
85
+ if (id.includes(".@")) return false;
86
+ if (/D\d+\./.test(id)) return false;
87
+ return true;
88
+ });
89
+
90
+ const clientSegmentSet = new Set(clientSegmentIds);
91
+
92
+ return {
93
+ prevUrl,
94
+ prevParams,
95
+ prevMatch,
96
+ interceptContextUrl,
97
+ interceptContextMatch,
98
+ clientSegmentIds,
99
+ clientSegmentSet,
100
+ filteredSegmentIds,
101
+ stale,
102
+ isSameRouteNavigation,
103
+ effectiveFromUrl,
104
+ effectiveFromMatch,
105
+ hasInterceptSource,
106
+ isHmr,
107
+ };
108
+ }
109
+
110
+ export function createNavigationSnapshot(
111
+ overrides?: Partial<NavigationSnapshot>,
112
+ ): NavigationSnapshot {
113
+ const defaultUrl = new URL("http://localhost/");
114
+ return {
115
+ prevUrl: defaultUrl,
116
+ prevParams: {},
117
+ prevMatch: null,
118
+ interceptContextUrl: defaultUrl,
119
+ interceptContextMatch: null,
120
+ clientSegmentIds: [],
121
+ clientSegmentSet: new Set(),
122
+ filteredSegmentIds: [],
123
+ stale: false,
124
+ isSameRouteNavigation: false,
125
+ effectiveFromUrl: defaultUrl,
126
+ effectiveFromMatch: null,
127
+ hasInterceptSource: false,
128
+ isHmr: false,
129
+ ...overrides,
130
+ };
131
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Shared route-param comparison helpers.
3
+ */
4
+
5
+ /**
6
+ * Shallow equality for two route-param records. Same-reference is a fast path;
7
+ * otherwise compares key count then each value.
8
+ */
9
+ export function paramsEqual(
10
+ a: Record<string, string>,
11
+ b: Record<string, string>,
12
+ ): boolean {
13
+ if (a === b) return true;
14
+
15
+ const keysA = Object.keys(a);
16
+ if (keysA.length !== Object.keys(b).length) return false;
17
+
18
+ for (const key of keysA) {
19
+ if (a[key] !== b[key]) return false;
20
+ }
21
+
22
+ return true;
23
+ }
@@ -7,6 +7,8 @@
7
7
  import type { RouteEntry, TrailingSlashMode } from "../types";
8
8
  import type { EntryData } from "../server/context";
9
9
  import { debugLog, isRouterDebugEnabled } from "./logging.js";
10
+ import { escapeRegExp } from "../regex-escape.js";
11
+ import { safeDecodeURIComponent } from "./url-params.js";
10
12
 
11
13
  /**
12
14
  * Parsed segment info
@@ -16,6 +18,7 @@ export interface ParsedSegment {
16
18
  value: string; // static text, param name, or "*"
17
19
  optional: boolean;
18
20
  constraint?: string[]; // enum values like ["en", "gb"]
21
+ suffix?: string; // literal text after param in same segment (e.g., ".html")
19
22
  }
20
23
 
21
24
  /**
@@ -31,19 +34,22 @@ export interface ParsedSegment {
31
34
  */
32
35
  export function parsePattern(pattern: string): ParsedSegment[] {
33
36
  const segments: ParsedSegment[] = [];
34
- // Match: /segment where segment can be:
35
- // - static text
36
- // - :param
37
- // - :param?
38
- // - :param(a|b)
39
- // - :param(a|b)?
40
- // - *
41
37
  const segmentRegex =
42
- /\/(:([a-zA-Z_][a-zA-Z0-9_]*)(\(([^)]+)\))?(\?)?|(\*)|([^/]+))/g;
38
+ /\/(:([a-zA-Z_][a-zA-Z0-9_]*)(\(([^)]+)\))?(\?)?([^/]*)|(\*)|([^/]+))/g;
43
39
 
44
40
  let match;
45
41
  while ((match = segmentRegex.exec(pattern)) !== null) {
46
- const [, , paramName, , constraint, optional, wildcard, staticText] = match;
42
+ const [
43
+ ,
44
+ ,
45
+ paramName,
46
+ ,
47
+ constraint,
48
+ optional,
49
+ suffix,
50
+ wildcard,
51
+ staticText,
52
+ ] = match;
47
53
 
48
54
  if (wildcard) {
49
55
  segments.push({ type: "wildcard", value: "*", optional: false });
@@ -53,6 +59,7 @@ export function parsePattern(pattern: string): ParsedSegment[] {
53
59
  value: paramName,
54
60
  optional: optional === "?",
55
61
  constraint: constraint ? constraint.split("|") : undefined,
62
+ suffix: suffix || undefined,
56
63
  });
57
64
  } else if (staticText) {
58
65
  segments.push({ type: "static", value: staticText, optional: false });
@@ -68,8 +75,14 @@ export function parsePattern(pattern: string): ParsedSegment[] {
68
75
  export interface CompiledPattern {
69
76
  regex: RegExp;
70
77
  paramNames: string[];
71
- optionalParams: Set<string>;
72
78
  hasTrailingSlash: boolean;
79
+ /**
80
+ * Param-name → allowed values for constrained params (e.g. `:lang(en|gb)`).
81
+ * Validated against the **decoded** param value after regex extraction so
82
+ * a URL like `/en%20GB` still matches `:lang(en GB)` — matching the trie
83
+ * path's behavior (trie-matching.ts:validateAndBuild).
84
+ */
85
+ constraints?: Record<string, string[]>;
73
86
  }
74
87
 
75
88
  // Module-level cache for compiled patterns. Route patterns are a finite set
@@ -129,7 +142,7 @@ export function compilePattern(pattern: string): CompiledPattern {
129
142
 
130
143
  const segments = parsePattern(normalizedPattern);
131
144
  const paramNames: string[] = [];
132
- const optionalParams = new Set<string>();
145
+ let constraints: Record<string, string[]> | undefined;
133
146
 
134
147
  let regexPattern = "";
135
148
 
@@ -139,28 +152,46 @@ export function compilePattern(pattern: string): CompiledPattern {
139
152
  regexPattern += "/(.*)";
140
153
  } else if (segment.type === "param") {
141
154
  paramNames.push(segment.value);
142
- const valuePattern = segment.constraint
143
- ? `(${segment.constraint.map(escapeRegex).join("|")})`
144
- : "([^/]+)";
155
+ const suffixPattern = segment.suffix ? escapeRegExp(segment.suffix) : "";
156
+ // Constrained params capture anything here; the allowed values are
157
+ // checked post-decode in findMatch so URL-encoded constraint values
158
+ // (e.g. `:lang(en GB)` via `/en%20GB`) still match.
159
+ const valuePattern = segment.suffix ? "([^/]+?)" : "([^/]+)";
160
+
161
+ if (segment.constraint) {
162
+ (constraints ??= {})[segment.value] = segment.constraint;
163
+ }
145
164
 
146
165
  if (segment.optional) {
147
- optionalParams.add(segment.value);
148
166
  // Optional: make the whole /segment optional
149
- regexPattern += `(?:/${valuePattern})?`;
167
+ regexPattern += `(?:/${valuePattern}${suffixPattern})?`;
150
168
  } else {
151
- regexPattern += `/${valuePattern}`;
169
+ regexPattern += `/${valuePattern}${suffixPattern}`;
152
170
  }
153
171
  } else {
154
172
  // Static segment
155
- regexPattern += `/${escapeRegex(segment.value)}`;
173
+ regexPattern += `/${escapeRegExp(segment.value)}`;
156
174
  }
157
175
  }
158
176
 
159
- // Handle root path
160
177
  if (regexPattern === "") {
161
178
  regexPattern = "/";
162
179
  }
163
180
 
181
+ // Patterns of only optional segments (e.g. `/:locale?`, `/:a?/:b?`) need
182
+ // an explicit `/` alternative so a bare `/` matches the absent form. The
183
+ // optional template `(?:/X)?` matches `/X` or empty string, but pathnames
184
+ // are never empty. Arises from `include("/:locale?", routes)` + inner
185
+ // `path("/")`. Skip when an explicit trailing slash already anchors the
186
+ // match.
187
+ const hasOnlyOptionalSegments =
188
+ !hasTrailingSlash &&
189
+ segments.length > 0 &&
190
+ segments.every((segment) => segment.type === "param" && segment.optional);
191
+ if (hasOnlyOptionalSegments) {
192
+ regexPattern = `(?:/|${regexPattern})`;
193
+ }
194
+
164
195
  // Add trailing slash to regex if pattern has one
165
196
  if (hasTrailingSlash) {
166
197
  regexPattern += "/";
@@ -169,16 +200,55 @@ export function compilePattern(pattern: string): CompiledPattern {
169
200
  return {
170
201
  regex: new RegExp(`^${regexPattern}$`),
171
202
  paramNames,
172
- optionalParams,
173
203
  hasTrailingSlash,
204
+ ...(constraints ? { constraints } : {}),
174
205
  };
175
206
  }
176
207
 
177
208
  /**
178
- * Escape special regex characters in a string
209
+ * Validate decoded params against a compiled pattern's constraints.
210
+ * Returns false if any constrained param has a non-empty value not in the
211
+ * allowed list. Absent optionals (key missing or `undefined`) are allowed;
212
+ * `""` is also tolerated as "absent" so user-provided params or fixtures
213
+ * that pass empty strings explicitly behave the same way.
179
214
  */
180
- function escapeRegex(str: string): string {
181
- return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
215
+ function satisfiesConstraints(
216
+ params: Record<string, string>,
217
+ constraints: Record<string, string[]> | undefined,
218
+ ): boolean {
219
+ if (!constraints) return true;
220
+ for (const name in constraints) {
221
+ const value = params[name];
222
+ if (
223
+ value !== undefined &&
224
+ value !== "" &&
225
+ !constraints[name].includes(value)
226
+ ) {
227
+ return false;
228
+ }
229
+ }
230
+ return true;
231
+ }
232
+
233
+ /**
234
+ * Build the named-params record from a regex match. Optional segments that
235
+ * didn't capture leave the corresponding group `undefined`; we skip those
236
+ * keys so `ctx.params.<name>` reads as `undefined` rather than `""`. This
237
+ * keeps the runtime aligned with the `ExtractParams` type and matches the
238
+ * trie matcher's contract (see `trie-matching.ts:validateAndBuild`).
239
+ */
240
+ function buildParamsFromMatch(
241
+ match: RegExpExecArray,
242
+ paramNames: string[],
243
+ ): Record<string, string> {
244
+ const params: Record<string, string> = {};
245
+ paramNames.forEach((name, index) => {
246
+ const captured = match[index + 1];
247
+ if (captured !== undefined) {
248
+ params[name] = safeDecodeURIComponent(captured);
249
+ }
250
+ });
251
+ return params;
182
252
  }
183
253
 
184
254
  /**
@@ -197,43 +267,58 @@ function escapeRegex(str: string): string {
197
267
  export function extractStaticPrefix(pattern: string): string {
198
268
  if (!pattern || pattern === "/") return "";
199
269
 
200
- // Find the first occurrence of : or *
201
- const paramIndex = pattern.indexOf(":");
202
- const wildcardIndex = pattern.indexOf("*");
203
-
204
- let cutIndex = -1;
205
- if (paramIndex !== -1 && wildcardIndex !== -1) {
206
- cutIndex = Math.min(paramIndex, wildcardIndex);
207
- } else if (paramIndex !== -1) {
208
- cutIndex = paramIndex;
209
- } else if (wildcardIndex !== -1) {
210
- cutIndex = wildcardIndex;
211
- }
212
-
213
- if (cutIndex === -1) {
214
- // No params or wildcards - entire pattern is static
215
- return pattern;
270
+ // Walk segments and stop at the first that is a real param (`:name`) or a
271
+ // wildcard (`*`). A literal `:` or `*` not at a segment boundary (e.g. the
272
+ // `a:b` in `/a:b/c/:id`, or `tel:+1`) is a STATIC segment and must NOT
273
+ // terminate the prefix — `pattern.indexOf(":")` misread it as a param marker,
274
+ // returning "" and dropping the findMatch fast-skip optimization for that
275
+ // entry on every request. Classification mirrors parsePattern: a leading `:`
276
+ // marks a param, a leading `*` marks a wildcard.
277
+ const hasLeadingSlash = pattern.startsWith("/");
278
+ const body = hasLeadingSlash ? pattern.slice(1) : pattern;
279
+ const segments = body.split("/");
280
+
281
+ const staticSegments: string[] = [];
282
+ for (const segment of segments) {
283
+ if (segment.startsWith(":") || segment.startsWith("*")) {
284
+ break;
285
+ }
286
+ staticSegments.push(segment);
216
287
  }
217
288
 
218
- if (cutIndex === 0) {
219
- // Pattern starts with : or * - no static prefix
220
- return "";
221
- }
289
+ // No leading static segment (first segment is a param/wildcard) -> no prefix.
290
+ if (staticSegments.length === 0) return "";
291
+ // Every segment was static (no param/wildcard) -> the whole pattern is the
292
+ // prefix. Preserve a trailing slash only when it existed in the input; a
293
+ // split of "/a/b" yields ["a","b"] (no empty tail) so a re-join is exact.
294
+ if (staticSegments.length === segments.length) return pattern;
222
295
 
223
- // Find the last / before the param
224
- const lastSlash = pattern.lastIndexOf("/", cutIndex - 1);
225
- if (lastSlash === -1 || lastSlash === 0) {
226
- return "";
227
- }
296
+ const prefix = staticSegments.join("/");
297
+ return hasLeadingSlash ? "/" + prefix : prefix;
298
+ }
228
299
 
229
- return pattern.slice(0, lastSlash);
300
+ /**
301
+ * Join a URL prefix to a sub-prefix, collapsing the duplicate slash when the
302
+ * base ends with "/" and the sub-prefix starts with "/". This mirrors the
303
+ * canonical join in `include()` (urls/include-helper.ts) and `runWithPrefixes`
304
+ * (server/context.ts) so a nested lazy include's runtime staticPrefix matches
305
+ * the build-time trie's `sp` (e.g. `include("/parent/", …)` containing
306
+ * `include("/child", …)` resolves to `/parent/child`, not `/parent//child`).
307
+ */
308
+ export function joinPrefix(base: string | undefined, prefix: string): string {
309
+ if (!base) return prefix;
310
+ return base.endsWith("/") && prefix.startsWith("/")
311
+ ? base + prefix.slice(1)
312
+ : base + prefix;
230
313
  }
231
314
 
232
315
  /**
233
316
  * Match a pathname against registered routes
234
317
  *
235
- * Note: Optional params that are absent in the path will have empty string value.
236
- * Use the pattern definition to determine if a param is optional.
318
+ * Note: Optional params that are absent in the path are omitted from the
319
+ * returned `params` (read as `undefined`), matching the trie matcher and
320
+ * the `ExtractParams<"/:locale?/...">` type. Use the pattern definition to
321
+ * determine which keys are optional.
237
322
  *
238
323
  * Trailing slash handling (priority order):
239
324
  * 1. Per-route `trailingSlash` config from route()
@@ -251,10 +336,7 @@ export interface RouteMatchResult<TEnv = any> {
251
336
  entry: RouteEntry<TEnv>;
252
337
  routeKey: string;
253
338
  params: Record<string, string>;
254
- optionalParams: Set<string>;
255
339
  redirectTo?: string;
256
- /** Ancestry shortCodes for layout pruning (from trie match) */
257
- ancestry?: string[];
258
340
  /** Route has pre-rendered data available (from trie) */
259
341
  pr?: true;
260
342
  /** Passthrough: handler kept for live fallback on unknown params (from trie) */
@@ -332,8 +414,6 @@ export function findMatch<TEnv>(
332
414
  : pathname + "/";
333
415
 
334
416
  for (const entry of routesEntries) {
335
- // Short-circuit: skip entry if pathname doesn't start with static prefix
336
- // staticPrefix is pre-computed at registration time, so this is O(1)
337
417
  if (entry.staticPrefix && !pathname.startsWith(entry.staticPrefix)) {
338
418
  if (effectiveDebug) {
339
419
  debugStats.entriesSkipped++;
@@ -345,8 +425,6 @@ export function findMatch<TEnv>(
345
425
  continue;
346
426
  }
347
427
 
348
- // Check if this is a lazy entry that needs evaluation
349
- // When staticPrefix matches but routes are not yet populated, signal caller to evaluate
350
428
  if (entry.lazy && !entry.lazyEvaluated) {
351
429
  if (effectiveDebug) {
352
430
  debugLog("findMatch", "lazy entry requires evaluation", {
@@ -367,7 +445,6 @@ export function findMatch<TEnv>(
367
445
  debugStats.routesChecked++;
368
446
  }
369
447
 
370
- // Join prefix and pattern, handling edge cases
371
448
  let fullPattern: string;
372
449
  if (entry.prefix === "" || entry.prefix === "/") {
373
450
  fullPattern = pattern;
@@ -377,14 +454,12 @@ export function findMatch<TEnv>(
377
454
  fullPattern = entry.prefix + pattern;
378
455
  }
379
456
 
380
- const { regex, paramNames, optionalParams, hasTrailingSlash } =
457
+ const { regex, paramNames, hasTrailingSlash, constraints } =
381
458
  getCompiledPattern(fullPattern);
382
459
 
383
- // Get trailing slash mode for this route (per-route config or pattern-based)
384
460
  const trailingSlashMode: TrailingSlashMode | undefined =
385
461
  entry.trailingSlash?.[routeKey];
386
462
 
387
- // Prerender flag from entry metadata (set by urls() for prerender handlers)
388
463
  const prFlag = entry.prerenderRouteKeys?.has(routeKey)
389
464
  ? { pr: true as const }
390
465
  : {};
@@ -392,13 +467,13 @@ export function findMatch<TEnv>(
392
467
  ? { pt: true as const }
393
468
  : {};
394
469
 
395
- // Try exact match first
396
470
  const match = regex.exec(pathname);
397
471
  if (match) {
398
- const params: Record<string, string> = {};
399
- paramNames.forEach((name, index) => {
400
- params[name] = match[index + 1] ?? "";
401
- });
472
+ const params = buildParamsFromMatch(match, paramNames);
473
+
474
+ if (!satisfiesConstraints(params, constraints)) {
475
+ continue;
476
+ }
402
477
 
403
478
  if (effectiveDebug) {
404
479
  debugLog("findMatch", "matched route", {
@@ -408,29 +483,24 @@ export function findMatch<TEnv>(
408
483
  });
409
484
  }
410
485
 
411
- // Check if trailing slash mode requires redirect even on exact match
412
486
  if (
413
487
  trailingSlashMode === "always" &&
414
488
  !pathnameHasTrailingSlash &&
415
489
  pathname !== "/"
416
490
  ) {
417
- // Mode says always have trailing slash, but pathname doesn't have it
418
491
  return {
419
492
  entry,
420
493
  routeKey,
421
494
  params,
422
- optionalParams,
423
495
  redirectTo: pathname + "/",
424
496
  ...prFlag,
425
497
  ...ptFlag,
426
498
  };
427
499
  } else if (trailingSlashMode === "never" && pathnameHasTrailingSlash) {
428
- // Mode says never have trailing slash, but pathname has it
429
500
  return {
430
501
  entry,
431
502
  routeKey,
432
503
  params,
433
- optionalParams,
434
504
  redirectTo: pathname.slice(0, -1),
435
505
  ...prFlag,
436
506
  ...ptFlag,
@@ -441,39 +511,33 @@ export function findMatch<TEnv>(
441
511
  entry,
442
512
  routeKey,
443
513
  params,
444
- optionalParams,
445
514
  ...prFlag,
446
515
  ...ptFlag,
447
516
  };
448
517
  }
449
518
 
450
- // Try alternate pathname (opposite trailing slash)
451
519
  const altMatch = regex.exec(alternatePathname);
452
520
  if (altMatch) {
453
- const params: Record<string, string> = {};
454
- paramNames.forEach((name, index) => {
455
- params[name] = altMatch[index + 1] ?? "";
456
- });
521
+ const params = buildParamsFromMatch(altMatch, paramNames);
522
+
523
+ if (!satisfiesConstraints(params, constraints)) {
524
+ continue;
525
+ }
457
526
 
458
- // Determine redirect behavior based on mode
459
527
  if (trailingSlashMode === "ignore") {
460
- // Match without redirect
461
528
  return {
462
529
  entry,
463
530
  routeKey,
464
531
  params,
465
- optionalParams,
466
532
  ...prFlag,
467
533
  ...ptFlag,
468
534
  };
469
535
  } else if (trailingSlashMode === "never") {
470
- // Redirect to no trailing slash
471
536
  if (pathnameHasTrailingSlash) {
472
537
  return {
473
538
  entry,
474
539
  routeKey,
475
540
  params,
476
- optionalParams,
477
541
  redirectTo: alternatePathname,
478
542
  ...prFlag,
479
543
  ...ptFlag,
@@ -483,18 +547,15 @@ export function findMatch<TEnv>(
483
547
  entry,
484
548
  routeKey,
485
549
  params,
486
- optionalParams,
487
550
  ...prFlag,
488
551
  ...ptFlag,
489
552
  };
490
553
  } else if (trailingSlashMode === "always") {
491
- // Redirect to with trailing slash
492
554
  if (!pathnameHasTrailingSlash) {
493
555
  return {
494
556
  entry,
495
557
  routeKey,
496
558
  params,
497
- optionalParams,
498
559
  redirectTo: alternatePathname,
499
560
  ...prFlag,
500
561
  ...ptFlag,
@@ -504,13 +565,10 @@ export function findMatch<TEnv>(
504
565
  entry,
505
566
  routeKey,
506
567
  params,
507
- optionalParams,
508
568
  ...prFlag,
509
569
  ...ptFlag,
510
570
  };
511
571
  } else {
512
- // No explicit mode - use pattern-based detection
513
- // Redirect to canonical form (what the pattern defines)
514
572
  const canonicalPath = hasTrailingSlash
515
573
  ? alternatePathname
516
574
  : pathname.slice(0, -1);
@@ -518,7 +576,6 @@ export function findMatch<TEnv>(
518
576
  entry,
519
577
  routeKey,
520
578
  params,
521
- optionalParams,
522
579
  redirectTo: canonicalPath,
523
580
  ...prFlag,
524
581
  ...ptFlag,
@@ -539,7 +596,7 @@ export function* traverseBack(entry: EntryData): Generator<EntryData> {
539
596
  let current: EntryData | null = entry;
540
597
  const items = [] as EntryData[];
541
598
  while (current !== null) {
542
- items.push(current); // Move up to next parent
599
+ items.push(current);
543
600
  current = current.parent;
544
601
  }
545
602
  for (let i = items.length - 1; i >= 0; i--) {
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Resolve the prefetch cache TTL once, at router init, into the three derived
3
+ * values the rest of the router consumes: seconds (for the Cache-Control
4
+ * max-age), milliseconds (for the client-side in-memory cache), and the
5
+ * Cache-Control header string (or false when caching is disabled).
6
+ *
7
+ * `false` disables prefetch caching (seconds 0). A non-finite input
8
+ * (NaN/Infinity, e.g. from an env-derived number that failed to parse) is
9
+ * treated as the default rather than producing a malformed
10
+ * `Cache-Control: max-age=NaN` header; CDNs/browsers ignore such a directive,
11
+ * which would silently disable caching on EVERY prefetch response. Negative
12
+ * finite values clamp to 0 (disabled).
13
+ *
14
+ * Policy note: this is the prefetch-TTL policy specifically. Other finite-number
15
+ * guards in the codebase deliberately differ — defer.ts treats Infinity as an
16
+ * intentional disable, and profile-registry.ts THROWS on non-finite/negative ttl
17
+ * at config time. They are NOT the same guard, so don't unify them.
18
+ */
19
+ export interface ResolvedPrefetchCacheTTL {
20
+ /** TTL in seconds for the Cache-Control max-age directive. */
21
+ seconds: number;
22
+ /** TTL in milliseconds for the client-side in-memory prefetch cache. */
23
+ ms: number;
24
+ /** Cache-Control header value, or false when caching is disabled. */
25
+ cacheControl: string | false;
26
+ }
27
+
28
+ const DEFAULT_PREFETCH_CACHE_TTL_SECONDS = 300;
29
+
30
+ export function resolvePrefetchCacheTTL(
31
+ rawTTL: number | false | undefined,
32
+ ): ResolvedPrefetchCacheTTL {
33
+ let seconds: number;
34
+ if (rawTTL === false) {
35
+ seconds = 0;
36
+ } else if (rawTTL === undefined) {
37
+ seconds = DEFAULT_PREFETCH_CACHE_TTL_SECONDS;
38
+ } else if (typeof rawTTL === "number" && Number.isFinite(rawTTL)) {
39
+ seconds = Math.max(0, Math.floor(rawTTL));
40
+ } else {
41
+ // Non-finite (NaN/Infinity): fall back to the default rather than emit a
42
+ // malformed max-age=NaN/Infinity that CDNs and browsers reject.
43
+ seconds = DEFAULT_PREFETCH_CACHE_TTL_SECONDS;
44
+ }
45
+
46
+ return {
47
+ seconds,
48
+ ms: seconds * 1000,
49
+ cacheControl: seconds === 0 ? false : `private, max-age=${seconds}`,
50
+ };
51
+ }