@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,95 @@
1
+ /**
2
+ * Built-in Breadcrumbs handle for accumulating breadcrumb items across route segments.
3
+ *
4
+ * Each layout/route pushes breadcrumb items via `ctx.use(Breadcrumbs)`.
5
+ * Items are collected in parent-to-child order with automatic deduplication
6
+ * by `href`: each href keeps its FIRST position but takes the LAST value, so a
7
+ * child re-pushing a parent href refreshes the label without reordering the trail.
8
+ *
9
+ * @example
10
+ * ```tsx
11
+ * // In route handler
12
+ * route("/blog/:slug", (ctx) => {
13
+ * const breadcrumb = ctx.use(Breadcrumbs);
14
+ * breadcrumb({ label: "Blog", href: "/blog" });
15
+ * breadcrumb({ label: post.title, href: `/blog/${ctx.params.slug}` });
16
+ * });
17
+ *
18
+ * // In client component (consume with useHandle)
19
+ * const crumbs = useHandle(Breadcrumbs);
20
+ * crumbs.map((c) => <a href={c.href}>{c.label}</a>);
21
+ * ```
22
+ */
23
+
24
+ import type { ReactNode } from "react";
25
+ import { createHandle, type Handle } from "../handle.js";
26
+ import { isThenable } from "./is-thenable.js";
27
+
28
+ /**
29
+ * A single breadcrumb item.
30
+ *
31
+ * @property label - Display text for the breadcrumb
32
+ * @property href - URL the breadcrumb links to
33
+ * @property content - Optional extra content (sync or async) rendered alongside the label
34
+ */
35
+ export interface BreadcrumbItem {
36
+ label: string;
37
+ href: string;
38
+ content?: ReactNode | Promise<ReactNode>;
39
+ }
40
+
41
+ /**
42
+ * Collect function for Breadcrumbs handle.
43
+ * Flattens segments in parent-to-child order with deduplication by href: each
44
+ * href keeps its FIRST position but takes the LAST value (re-pushing a parent
45
+ * href refreshes the label in place without reordering the trail).
46
+ * Deferred slots (`ctx.use(Breadcrumbs).defer()`)
47
+ * arrive as pending Promise entries with no href yet; they are passed through by
48
+ * identity and excluded from the href dedup so concurrent deferred crumbs do not
49
+ * all collapse under a single `undefined` href.
50
+ */
51
+ function collectBreadcrumbs(segments: BreadcrumbItem[][]): BreadcrumbItem[] {
52
+ const all = segments.flat();
53
+
54
+ const isResolvedItem = (item: unknown): item is BreadcrumbItem =>
55
+ item != null &&
56
+ typeof item === "object" &&
57
+ !isThenable(item) &&
58
+ typeof (item as { href?: unknown }).href === "string";
59
+
60
+ // Dedup resolved crumbs by href: keep the FIRST position (preserving
61
+ // parent->child order) but the LAST value (a child re-pushing a parent's href
62
+ // can refresh its label). Deferred items bypass dedup entirely (they have no
63
+ // href yet) and are passed through by identity at their original position.
64
+ const valueByHref = new Map<string, BreadcrumbItem>();
65
+ for (const item of all) {
66
+ if (isResolvedItem(item)) valueByHref.set(item.href, item);
67
+ }
68
+
69
+ const result: BreadcrumbItem[] = [];
70
+ const emitted = new Set<string>();
71
+ for (const item of all) {
72
+ if (!isResolvedItem(item)) {
73
+ result.push(item);
74
+ continue;
75
+ }
76
+ // Emit each href once, at its first occurrence, with the final value.
77
+ if (!emitted.has(item.href)) {
78
+ emitted.add(item.href);
79
+ result.push(valueByHref.get(item.href)!);
80
+ }
81
+ }
82
+ return result;
83
+ }
84
+
85
+ /**
86
+ * Built-in handle for accumulating breadcrumb navigation items.
87
+ *
88
+ * Use `ctx.use(Breadcrumbs)` in route handlers to push breadcrumb items.
89
+ * Use `useHandle(Breadcrumbs)` in client components to consume them.
90
+ */
91
+ export const Breadcrumbs: Handle<BreadcrumbItem, BreadcrumbItem[]> =
92
+ createHandle<BreadcrumbItem, BreadcrumbItem[]>(
93
+ collectBreadcrumbs,
94
+ "__rsc_router_breadcrumbs__",
95
+ );
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Single thenable predicate shared by the built-in handles that distinguish a
3
+ * synchronous descriptor/item from a deferred `Promise` one (Meta collect, the
4
+ * MetaTags render side, and Breadcrumbs).
5
+ *
6
+ * Requires a CALLABLE `then` (`typeof obj.then === "function"`), not merely a
7
+ * `"then" in obj` membership check. The two had drifted: a descriptor carrying a
8
+ * non-callable `then` (e.g. a serialized shape `{ then: 5 }`) was classified as
9
+ * synchronous by collect but as a Promise by render — so render would call
10
+ * React's `use()` on a non-thenable and throw. One owner keeps the collect and
11
+ * render sides from ever disagreeing.
12
+ */
13
+ export function isThenable(value: unknown): value is PromiseLike<unknown> {
14
+ return (
15
+ value !== null &&
16
+ typeof value === "object" &&
17
+ typeof (value as { then?: unknown }).then === "function"
18
+ );
19
+ }
@@ -29,15 +29,20 @@
29
29
  */
30
30
 
31
31
  import { createHandle, type Handle } from "../handle.js";
32
+ import { isThenable } from "./is-thenable.js";
32
33
  import type {
33
34
  MetaDescriptor,
35
+ MetaDescriptorBase,
34
36
  TitleDescriptor,
35
37
  UnsetDescriptor,
36
38
  } from "../router/types.js";
37
39
 
38
- /**
39
- * Type guard for unset descriptor
40
- */
40
+ function isPromiseDescriptor(
41
+ descriptor: MetaDescriptor,
42
+ ): descriptor is Promise<MetaDescriptorBase> {
43
+ return isThenable(descriptor);
44
+ }
45
+
41
46
  function isUnsetDescriptor(
42
47
  descriptor: MetaDescriptor,
43
48
  ): descriptor is UnsetDescriptor {
@@ -49,9 +54,6 @@ function isUnsetDescriptor(
49
54
  );
50
55
  }
51
56
 
52
- /**
53
- * Type guard for title descriptor (any form)
54
- */
55
57
  function isTitleDescriptor(
56
58
  descriptor: MetaDescriptor,
57
59
  ): descriptor is { title: TitleDescriptor } {
@@ -62,9 +64,6 @@ function isTitleDescriptor(
62
64
  );
63
65
  }
64
66
 
65
- /**
66
- * Type guard for title template descriptor
67
- */
68
67
  function isTitleTemplate(
69
68
  title: TitleDescriptor,
70
69
  ): title is { template: string; default: string } {
@@ -76,21 +75,13 @@ function isTitleTemplate(
76
75
  );
77
76
  }
78
77
 
79
- /**
80
- * Type guard for absolute title descriptor
81
- */
82
78
  function isAbsoluteTitle(
83
79
  title: TitleDescriptor,
84
80
  ): title is { absolute: string } {
85
81
  return typeof title === "object" && title !== null && "absolute" in title;
86
82
  }
87
83
 
88
- /**
89
- * Get a unique key for a meta descriptor for deduplication.
90
- * Returns undefined for descriptors that shouldn't be deduplicated.
91
- */
92
84
  function getMetaKey(descriptor: MetaDescriptor): string | undefined {
93
- // Skip unset descriptors - they are processed separately
94
85
  if (isUnsetDescriptor(descriptor)) {
95
86
  return undefined;
96
87
  }
@@ -110,13 +101,10 @@ function getMetaKey(descriptor: MetaDescriptor): string | undefined {
110
101
  return `httpEquiv:${descriptor.httpEquiv}`;
111
102
  }
112
103
  if ("script:ld+json" in descriptor) {
113
- // JSON-LD scripts can have multiple, don't dedupe by default
114
104
  return undefined;
115
105
  }
116
106
  if ("tagName" in descriptor) {
117
- // For link tags, dedupe by rel if present
118
107
  if (descriptor.tagName === "link" && "rel" in descriptor) {
119
- // Some link rels should be unique (canonical), others not (stylesheet)
120
108
  const uniqueRels = ["canonical", "icon", "apple-touch-icon"];
121
109
  if (uniqueRels.includes(descriptor.rel as string)) {
122
110
  return `link:${descriptor.rel}`;
@@ -136,9 +124,6 @@ const defaultMetaDescriptors: MetaDescriptor[] = [
136
124
  { name: "viewport", content: "width=device-width, initial-scale=1" },
137
125
  ];
138
126
 
139
- /**
140
- * Helper to add or replace a descriptor in the result array
141
- */
142
127
  function addOrReplace(
143
128
  result: MetaDescriptor[],
144
129
  keyToIndex: Map<string, number>,
@@ -155,9 +140,6 @@ function addOrReplace(
155
140
  }
156
141
  }
157
142
 
158
- /**
159
- * Helper to update indices after removing an element
160
- */
161
143
  function updateIndicesAfterRemoval(
162
144
  keyToIndex: Map<string, number>,
163
145
  removedIndex: number,
@@ -169,17 +151,11 @@ function updateIndicesAfterRemoval(
169
151
  }
170
152
  }
171
153
 
172
- /**
173
- * Collect function for Meta handle.
174
- * Includes default meta descriptors, then deduplicates by key with later routes overriding earlier ones.
175
- * Supports title templates, absolute titles, and unset descriptors.
176
- */
177
154
  function collectMeta(segments: MetaDescriptor[][]): MetaDescriptor[] {
178
155
  const result: MetaDescriptor[] = [];
179
156
  const keyToIndex = new Map<string, number>();
180
157
  let titleTemplate: string | undefined;
181
158
 
182
- // Add defaults first so they can be overridden
183
159
  for (const descriptor of defaultMetaDescriptors) {
184
160
  const key = getMetaKey(descriptor);
185
161
  if (key !== undefined) {
@@ -190,7 +166,37 @@ function collectMeta(segments: MetaDescriptor[][]): MetaDescriptor[] {
190
166
 
191
167
  for (const descriptors of segments) {
192
168
  for (const descriptor of descriptors) {
193
- // Handle unset descriptors
169
+ // Promise descriptors cannot be inspected synchronously (their content is
170
+ // unknown until resolved in <MetaTags> via React's use()), so they bypass
171
+ // key-based dedup and title-templating: they are appended verbatim. Warn in
172
+ // dev when a title template is active so the author knows an async
173
+ // descriptor will NOT participate in the template/dedup.
174
+ //
175
+ // The warning is deliberately a GENERAL note, not a duplicate-<title>
176
+ // prediction: collectMeta cannot tell whether this Promise resolves to a
177
+ // title (which would indeed yield a 2nd <title>) or to an ordinary
178
+ // descriptor like an async og:image (which would not). Asserting a
179
+ // duplicate <title> here is a false positive for the common og:image case,
180
+ // so the message states only that async descriptors bypass templating —
181
+ // not that a duplicate <title> WILL occur.
182
+ if (isPromiseDescriptor(descriptor)) {
183
+ if (
184
+ titleTemplate !== undefined &&
185
+ process.env.NODE_ENV !== "production"
186
+ ) {
187
+ console.warn(
188
+ `[Meta] A Promise meta descriptor was pushed while a title template is active. ` +
189
+ `Async descriptors bypass deduplication and title-templating: the template is ` +
190
+ `not applied to them. If this Promise resolves to a title, resolve the value ` +
191
+ `before pushing (or push a synchronous descriptor) so it participates in the ` +
192
+ `template; if it resolves to a non-title descriptor (e.g. og:image), this ` +
193
+ `note does not apply.`,
194
+ );
195
+ }
196
+ result.push(descriptor);
197
+ continue;
198
+ }
199
+
194
200
  if (isUnsetDescriptor(descriptor)) {
195
201
  const keyToRemove = descriptor.unset;
196
202
  if (keyToIndex.has(keyToRemove)) {
@@ -202,14 +208,11 @@ function collectMeta(segments: MetaDescriptor[][]): MetaDescriptor[] {
202
208
  continue;
203
209
  }
204
210
 
205
- // Handle title descriptors with template/absolute support
206
211
  if (isTitleDescriptor(descriptor)) {
207
212
  const titleValue = descriptor.title;
208
213
 
209
214
  if (isTitleTemplate(titleValue)) {
210
- // Store template for subsequent title descriptors in child segments
211
215
  titleTemplate = titleValue.template;
212
- // Set the default title
213
216
  addOrReplace(
214
217
  result,
215
218
  keyToIndex,
@@ -220,7 +223,6 @@ function collectMeta(segments: MetaDescriptor[][]): MetaDescriptor[] {
220
223
  }
221
224
 
222
225
  if (isAbsoluteTitle(titleValue)) {
223
- // Absolute title bypasses any template
224
226
  addOrReplace(
225
227
  result,
226
228
  keyToIndex,
@@ -230,9 +232,12 @@ function collectMeta(segments: MetaDescriptor[][]): MetaDescriptor[] {
230
232
  continue;
231
233
  }
232
234
 
233
- // String title - apply template if one exists
235
+ // Insert the title literally. String.prototype.replace treats the
236
+ // replacement string specially ($&, $`, $', $$, $n), so a title like
237
+ // "Save $5" or one containing "$&" would be mangled. split/join inserts
238
+ // the raw value with no special-character interpretation.
234
239
  const finalTitle = titleTemplate
235
- ? titleTemplate.replace("%s", titleValue as string)
240
+ ? titleTemplate.split("%s").join(titleValue as string)
236
241
  : titleValue;
237
242
  addOrReplace(
238
243
  result,
@@ -243,7 +248,6 @@ function collectMeta(segments: MetaDescriptor[][]): MetaDescriptor[] {
243
248
  continue;
244
249
  }
245
250
 
246
- // Handle all other descriptors
247
251
  const key = getMetaKey(descriptor);
248
252
  addOrReplace(result, keyToIndex, descriptor, key);
249
253
  }
@@ -257,6 +261,13 @@ function collectMeta(segments: MetaDescriptor[][]): MetaDescriptor[] {
257
261
  *
258
262
  * Use `ctx.use(Meta)` in route handlers to push meta descriptors.
259
263
  * Use `<MetaTags />` component to render them in the document head.
264
+ *
265
+ * Deduplication and title-templating apply only to SYNCHRONOUS descriptors.
266
+ * A Promise descriptor (`Promise<MetaDescriptorBase>`) is appended verbatim —
267
+ * its content is not known until it resolves in `<MetaTags>`, so it cannot be
268
+ * keyed for dedup nor receive a parent title template. If you need a child title
269
+ * to participate in a layout's `%s` template, push the resolved string title
270
+ * synchronously rather than a `Promise<{ title }>`.
260
271
  */
261
272
  export const Meta: Handle<MetaDescriptor, MetaDescriptor[]> = createHandle<
262
273
  MetaDescriptor,
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Built-in Script handle for injecting <script> tags into the document from
3
+ * route/layout handlers.
4
+ *
5
+ * Push from a SERVER handler with `ctx.use(Script)(config)`; render with the
6
+ * `<Scripts />` component (from `@rangojs/router/client`) placed in the Document
7
+ * `<head>` (and optionally a second `<Scripts position="body" />` at the top of
8
+ * `<body>`). This mirrors the Meta / <MetaTags> pair.
9
+ *
10
+ * The request CSP nonce is applied AUTOMATICALLY by <Scripts> to document-rendered
11
+ * scripts; consumers never pass a nonce. (An async script first loaded on a soft
12
+ * navigation is injected client-side without a nonce — it relies on
13
+ * 'strict-dynamic' or a host allowance; see the EXECUTION CONTRACT below and the
14
+ * /scripts skill.) A ScriptConfig is fully serializable (it crosses the
15
+ * server -> client handle-collection boundary), so callbacks like onLoad are NOT
16
+ * supported — a consumer needing them renders their own "use client" script.
17
+ *
18
+ * EXECUTION CONTRACT (see the /scripts skill for the full story):
19
+ * - Inline (`children`) and ordered external (`src`, optional `defer`) scripts
20
+ * are DOCUMENT-LOAD scripts: they execute only when present in the initial HTML
21
+ * response. <Scripts> freezes them after hydration, so a later client (soft)
22
+ * navigation never inserts an inert copy — React creates client-mounted
23
+ * <script> elements via innerHTML, which the HTML spec makes non-executing.
24
+ * - Async external scripts (`src` + `async: true`) are React RESOURCES: they load
25
+ * once when first encountered, including after a soft navigation, deduped by
26
+ * `src`. Use this for a vendor that should load on first visit to a route.
27
+ * - Reusing an `id` shapes the INITIAL document output (last-push-wins); it does
28
+ * not re-run a script during navigation. Per-navigation behavior belongs in a
29
+ * "use client" component or hook (see the GtmPageViews pattern in the demo).
30
+ *
31
+ * @example
32
+ * ```ts
33
+ * // External async loader (React resource — loads on first visit, even soft nav):
34
+ * ctx.use(Script)({ id: "stripe", src: "https://js.stripe.com/v3", async: true });
35
+ *
36
+ * // Inline bootstrap that self-injects its loader (GTM/GA4) — keep it inline so
37
+ * // React cannot hoist a declarative loader above the bootstrap:
38
+ * ctx.use(Script)({ id: "gtm", children: gtmBootstrap(containerId) });
39
+ *
40
+ * // External ordered (defer) with vendor attributes (document-load):
41
+ * ctx.use(Script)({
42
+ * id: "plausible",
43
+ * src: "https://plausible.io/js/script.js",
44
+ * defer: true,
45
+ * attributes: { "data-domain": "example.com" },
46
+ * });
47
+ * ```
48
+ */
49
+
50
+ import type { ScriptHTMLAttributes } from "react";
51
+ import { createHandle, type Handle } from "../handle.js";
52
+
53
+ /**
54
+ * Extra attributes forwarded onto the emitted <script>. Typed by React, so the
55
+ * casing is React's (`crossOrigin`, not `crossorigin`) and value shapes are
56
+ * checked at compile time. `data-*` attributes are allowed. Two groups are
57
+ * excluded: the fields the Script handle manages itself (`id`, `src`, `async`,
58
+ * `defer`, `type`, `children`, `nonce`, `dangerouslySetInnerHTML` — set those via
59
+ * the ScriptConfig fields), and ALL `on*` event handlers (`onLoad`, `onError`,
60
+ * …): a ScriptConfig is serialized across the server -> client handle boundary, so
61
+ * a function cannot survive it — render your own "use client" script for callbacks.
62
+ */
63
+ export type ScriptAttributes = Omit<
64
+ ScriptHTMLAttributes<HTMLScriptElement>,
65
+ | "id"
66
+ | "src"
67
+ | "async"
68
+ | "defer"
69
+ | "type"
70
+ | "children"
71
+ | "nonce"
72
+ | "dangerouslySetInnerHTML"
73
+ | `on${string}`
74
+ > & {
75
+ [dataAttr: `data-${string}`]: string | number | boolean | undefined;
76
+ };
77
+
78
+ /** Fields shared by every script shape. */
79
+ interface ScriptConfigBase {
80
+ /**
81
+ * Where <Scripts> renders this script.
82
+ * - "head" (default): the `<head>` <Scripts> site.
83
+ * - "body": the `<Scripts position="body" />` site at the top of <body>.
84
+ * Note: an external `async` script is hoisted into <head> by React regardless.
85
+ */
86
+ position?: "head" | "body";
87
+ /**
88
+ * The `type` attribute, as a free string: "module", "application/ld+json",
89
+ * "text/partytown", etc. Omitted means a classic script.
90
+ */
91
+ type?: string;
92
+ /** Extra React-cased attributes (`data-*`, `crossOrigin`, `integrity`, ...). */
93
+ attributes?: ScriptAttributes;
94
+ }
95
+
96
+ /**
97
+ * Inline script: a raw JS body rendered in place, escaped against `</script>`
98
+ * breakout. DOCUMENT-LOAD only (executes when present in the initial HTML;
99
+ * <Scripts> freezes it after hydration so navigation never inserts an inert
100
+ * copy). `id` is REQUIRED — inline scripts are never deduped by React, so a
101
+ * layout and a child pushing the same bootstrap would inject it twice. It is also
102
+ * rendered as the script's DOM `id`. Forbids `src`/`async`/`defer`. For analytics
103
+ * vendors (GTM/GA4/Segment) the body should
104
+ * create+append its own loader, so the loader is never a separate declarative tag
105
+ * React could hoist out of order.
106
+ */
107
+ export interface InlineScriptConfig extends ScriptConfigBase {
108
+ id: string;
109
+ children: string;
110
+ src?: never;
111
+ async?: never;
112
+ defer?: never;
113
+ }
114
+
115
+ /**
116
+ * External async script: a React-hoisted, `src`-deduped RESOURCE (the
117
+ * fire-and-forget loader case). Loads once when first encountered, including
118
+ * after a soft navigation. Deduped by `src` (matching React); `id` is optional
119
+ * and, when set, is rendered as the DOM `id` (not used as the dedup key here).
120
+ * Forbids `children`/`defer`.
121
+ */
122
+ export interface AsyncScriptConfig extends ScriptConfigBase {
123
+ src: string;
124
+ async: true;
125
+ id?: string;
126
+ children?: never;
127
+ defer?: never;
128
+ }
129
+
130
+ /**
131
+ * External ordered script: in-place, optionally `defer`. DOCUMENT-LOAD only
132
+ * (executes when present in the initial HTML; not re-run on navigation). `id` is
133
+ * optional (the dedup key falls back to `src`) and, when set, is rendered as the
134
+ * DOM `id`. Forbids `children`/`async`.
135
+ */
136
+ export interface OrderedScriptConfig extends ScriptConfigBase {
137
+ src: string;
138
+ defer?: boolean;
139
+ id?: string;
140
+ children?: never;
141
+ async?: never;
142
+ }
143
+
144
+ /**
145
+ * A single script to inject, as a discriminated union — exactly one of:
146
+ * inline (`id` + `children`), external async (`src` + `async: true`), or external
147
+ * ordered (`src`, optional `defer`). Invalid combinations (both `src`+`children`,
148
+ * `async`+`defer`, inline without `id`) are compile errors. The CSP nonce is
149
+ * applied by <Scripts>, never here.
150
+ */
151
+ export type ScriptConfig =
152
+ | InlineScriptConfig
153
+ | AsyncScriptConfig
154
+ | OrderedScriptConfig;
155
+
156
+ /** A config's runtime view, for validating untyped/serialized input. */
157
+ type LooseScriptConfig = {
158
+ id?: string;
159
+ src?: string;
160
+ children?: string;
161
+ async?: boolean;
162
+ defer?: boolean;
163
+ };
164
+
165
+ /**
166
+ * Dev-only validation. The discriminated union makes these states unrepresentable
167
+ * in TypeScript; the runtime checks exist only for untyped JavaScript callers and
168
+ * malformed serialized input, not as the primary contract.
169
+ */
170
+ function validateConfigDev(config: ScriptConfig): void {
171
+ if (process.env.NODE_ENV === "production") return;
172
+ const c = config as LooseScriptConfig;
173
+ if (c.src != null && c.children != null) {
174
+ console.warn(
175
+ `[Script] A config has both "src" and "children"; they are mutually ` +
176
+ `exclusive — "src" wins and the inline body is ignored.`,
177
+ );
178
+ } else if (c.src == null && c.children == null) {
179
+ console.warn(
180
+ `[Script] A config has neither "src" nor "children"; it injects nothing.`,
181
+ );
182
+ } else if (c.src == null && c.id == null) {
183
+ console.warn(
184
+ `[Script] An inline script was pushed without an "id" and cannot be ` +
185
+ `deduplicated. Pass an "id" so a layout + child pushing the same script ` +
186
+ `inject it only once.`,
187
+ );
188
+ }
189
+ if (c.async && c.defer) {
190
+ console.warn(
191
+ `[Script] A config has both "async" and "defer"; they are mutually ` +
192
+ `exclusive.`,
193
+ );
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Accumulate scripts across matched segments, parent -> child, preserving push
199
+ * order, last-push-wins per dedup key (mirroring the Meta handle).
200
+ *
201
+ * Dedup key:
202
+ * - async resources key by `src` ONLY — React itself dedups async scripts by src,
203
+ * so two async configs with different ids but the same src must collapse to one
204
+ * here (last wins) for a single, deterministic winner; otherwise React would
205
+ * silently pick one with undefined attribute precedence.
206
+ * - everything else keys by `id ?? src`.
207
+ *
208
+ * An (untyped) inline script with neither `id` nor `src` cannot be deduplicated;
209
+ * it is kept and validateConfigDev warns.
210
+ */
211
+ function collectScripts(segments: ScriptConfig[][]): ScriptConfig[] {
212
+ const result: ScriptConfig[] = [];
213
+ const keyToIndex = new Map<string, number>();
214
+
215
+ for (const configs of segments) {
216
+ for (const config of configs) {
217
+ validateConfigDev(config);
218
+ const isAsyncResource = config.src != null && config.async === true;
219
+ const key = isAsyncResource ? config.src : (config.id ?? config.src);
220
+ if (key === undefined) {
221
+ result.push(config);
222
+ continue;
223
+ }
224
+ const existing = keyToIndex.get(key);
225
+ if (existing !== undefined) {
226
+ result[existing] = config;
227
+ } else {
228
+ keyToIndex.set(key, result.length);
229
+ result.push(config);
230
+ }
231
+ }
232
+ }
233
+
234
+ return result;
235
+ }
236
+
237
+ /**
238
+ * Built-in handle for injecting scripts. Uses an explicit stable id (built-ins
239
+ * do not rely on the Vite id-injection plugin, which only covers consumer code).
240
+ */
241
+ export const Script: Handle<ScriptConfig, ScriptConfig[]> = createHandle<
242
+ ScriptConfig,
243
+ ScriptConfig[]
244
+ >(collectScripts, "__rsc_router_script__");