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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (392) hide show
  1. package/AGENTS.md +8 -0
  2. package/README.md +126 -16
  3. package/dist/bin/rango.js +319 -95
  4. package/dist/testing/vitest.js +82 -0
  5. package/dist/vite/index.js +2724 -1053
  6. package/package.json +68 -14
  7. package/skills/api-client/SKILL.md +211 -0
  8. package/skills/breadcrumbs/SKILL.md +64 -2
  9. package/skills/bundle-analysis/SKILL.md +159 -0
  10. package/skills/cache-guide/SKILL.md +224 -32
  11. package/skills/caching/SKILL.md +279 -17
  12. package/skills/composability/SKILL.md +27 -3
  13. package/skills/css/SKILL.md +76 -0
  14. package/skills/debug-manifest/SKILL.md +4 -2
  15. package/skills/document-cache/SKILL.md +78 -55
  16. package/skills/handler-use/SKILL.md +11 -9
  17. package/skills/hooks/SKILL.md +243 -29
  18. package/skills/host-router/SKILL.md +83 -23
  19. package/skills/i18n/SKILL.md +276 -0
  20. package/skills/intercept/SKILL.md +68 -19
  21. package/skills/layout/SKILL.md +13 -9
  22. package/skills/links/SKILL.md +190 -23
  23. package/skills/loader/SKILL.md +235 -9
  24. package/skills/middleware/SKILL.md +18 -10
  25. package/skills/migrate-nextjs/SKILL.md +43 -19
  26. package/skills/migrate-react-router/SKILL.md +8 -2
  27. package/skills/mime-routes/SKILL.md +28 -1
  28. package/skills/observability/SKILL.md +172 -0
  29. package/skills/parallel/SKILL.md +18 -7
  30. package/skills/prerender/SKILL.md +65 -60
  31. package/skills/rango/SKILL.md +251 -24
  32. package/skills/react-compiler/SKILL.md +168 -0
  33. package/skills/response-routes/SKILL.md +115 -48
  34. package/skills/route/SKILL.md +46 -5
  35. package/skills/router-setup/SKILL.md +30 -8
  36. package/skills/scripts/SKILL.md +179 -0
  37. package/skills/server-actions/SKILL.md +775 -0
  38. package/skills/tailwind/SKILL.md +27 -3
  39. package/skills/testing/SKILL.md +130 -0
  40. package/skills/testing/bindings.md +103 -0
  41. package/skills/testing/cache-prerender.md +127 -0
  42. package/skills/testing/client-components.md +124 -0
  43. package/skills/testing/e2e-parity.md +125 -0
  44. package/skills/testing/flight.md +91 -0
  45. package/skills/testing/handles.md +129 -0
  46. package/skills/testing/loader.md +128 -0
  47. package/skills/testing/middleware.md +99 -0
  48. package/skills/testing/render-handler.md +122 -0
  49. package/skills/testing/response-routes.md +95 -0
  50. package/skills/testing/reverse-and-types.md +84 -0
  51. package/skills/testing/server-actions.md +107 -0
  52. package/skills/testing/server-tree.md +128 -0
  53. package/skills/testing/setup.md +123 -0
  54. package/skills/typesafety/SKILL.md +322 -29
  55. package/skills/use-cache/SKILL.md +57 -14
  56. package/skills/view-transitions/SKILL.md +337 -0
  57. package/src/__augment-tests__/augment.ts +81 -0
  58. package/src/__augment-tests__/augmented.check.ts +116 -0
  59. package/src/__internal.ts +0 -65
  60. package/src/browser/action-coordinator.ts +53 -36
  61. package/src/browser/action-fence.ts +47 -0
  62. package/src/browser/app-shell.ts +39 -0
  63. package/src/browser/connection-warmup.ts +134 -0
  64. package/src/browser/cookie-name.ts +140 -0
  65. package/src/browser/event-controller.ts +192 -150
  66. package/src/browser/history-state.ts +21 -0
  67. package/src/browser/index.ts +3 -3
  68. package/src/browser/invalidate-client-cache.ts +52 -0
  69. package/src/browser/navigation-bridge.ts +94 -25
  70. package/src/browser/navigation-client.ts +121 -84
  71. package/src/browser/navigation-store-handle.ts +38 -0
  72. package/src/browser/navigation-store.ts +115 -67
  73. package/src/browser/navigation-transaction.ts +9 -59
  74. package/src/browser/network-error-handler.ts +34 -7
  75. package/src/browser/partial-update.ts +147 -128
  76. package/src/browser/prefetch/cache.ts +107 -56
  77. package/src/browser/prefetch/fetch.ts +204 -34
  78. package/src/browser/prefetch/queue.ts +6 -3
  79. package/src/browser/rango-state.ts +158 -76
  80. package/src/browser/react/Link.tsx +30 -7
  81. package/src/browser/react/NavigationProvider.tsx +283 -118
  82. package/src/browser/react/ScrollRestoration.tsx +10 -6
  83. package/src/browser/react/deferred-handle-resolution.ts +75 -0
  84. package/src/browser/react/filter-segment-order.ts +66 -7
  85. package/src/browser/react/index.ts +0 -48
  86. package/src/browser/react/location-state-shared.ts +178 -8
  87. package/src/browser/react/location-state.ts +39 -14
  88. package/src/browser/react/use-action.ts +6 -15
  89. package/src/browser/react/use-handle.ts +17 -14
  90. package/src/browser/react/use-href.tsx +8 -1
  91. package/src/browser/react/use-link-status.ts +33 -8
  92. package/src/browser/react/use-navigation.ts +10 -5
  93. package/src/browser/react/use-params.ts +11 -11
  94. package/src/browser/react/use-reverse.ts +106 -0
  95. package/src/browser/react/use-router.ts +25 -3
  96. package/src/browser/react/use-search-params.ts +0 -5
  97. package/src/browser/react/use-segments.ts +11 -21
  98. package/src/browser/response-adapter.ts +99 -8
  99. package/src/browser/rsc-router.tsx +91 -24
  100. package/src/browser/scroll-restoration.ts +30 -17
  101. package/src/browser/segment-structure-assert.ts +2 -2
  102. package/src/browser/server-action-bridge.ts +214 -55
  103. package/src/browser/types.ts +80 -9
  104. package/src/browser/validate-redirect-origin.ts +43 -16
  105. package/src/build/collect-fallback-refs.ts +107 -0
  106. package/src/build/generate-manifest.ts +60 -35
  107. package/src/build/generate-route-types.ts +2 -1
  108. package/src/build/index.ts +8 -2
  109. package/src/build/prefix-tree-utils.ts +123 -0
  110. package/src/build/route-trie.ts +117 -14
  111. package/src/build/route-types/ast-route-extraction.ts +15 -8
  112. package/src/build/route-types/codegen.ts +16 -5
  113. package/src/build/route-types/include-resolution.ts +117 -23
  114. package/src/build/route-types/param-extraction.ts +6 -3
  115. package/src/build/route-types/per-module-writer.ts +22 -6
  116. package/src/build/route-types/router-processing.ts +55 -28
  117. package/src/build/route-types/scan-filter.ts +1 -1
  118. package/src/build/route-types/source-scan.ts +216 -0
  119. package/src/build/runtime-discovery.ts +9 -20
  120. package/src/cache/cache-error.ts +104 -0
  121. package/src/cache/cache-key-utils.ts +29 -13
  122. package/src/cache/cache-policy.ts +108 -34
  123. package/src/cache/cache-runtime.ts +224 -41
  124. package/src/cache/cache-scope.ts +188 -82
  125. package/src/cache/cache-tag.ts +103 -0
  126. package/src/cache/cf/cf-base64.ts +33 -0
  127. package/src/cache/cf/cf-cache-constants.ts +127 -0
  128. package/src/cache/cf/cf-cache-store.ts +1989 -378
  129. package/src/cache/cf/cf-cache-types.ts +349 -0
  130. package/src/cache/cf/cf-kv-utils.ts +46 -0
  131. package/src/cache/cf/cf-tag-marker-memo.ts +105 -0
  132. package/src/cache/cf/index.ts +6 -16
  133. package/src/cache/document-cache.ts +89 -21
  134. package/src/cache/handle-snapshot.ts +70 -0
  135. package/src/cache/index.ts +10 -20
  136. package/src/cache/memory-segment-store.ts +136 -37
  137. package/src/cache/profile-registry.ts +46 -31
  138. package/src/cache/read-through-swr.ts +56 -12
  139. package/src/cache/segment-codec.ts +9 -17
  140. package/src/cache/tag-invalidation.ts +230 -0
  141. package/src/cache/types.ts +37 -100
  142. package/src/client.rsc.tsx +44 -21
  143. package/src/client.tsx +36 -61
  144. package/src/cloudflare/index.ts +11 -0
  145. package/src/cloudflare/tracing.ts +109 -0
  146. package/src/component-utils.ts +19 -0
  147. package/src/components/DefaultDocument.tsx +8 -2
  148. package/src/context-var.ts +18 -6
  149. package/src/decode-loader-results.ts +52 -0
  150. package/src/defer.ts +196 -0
  151. package/src/deps/ssr.ts +0 -1
  152. package/src/encode-kv.ts +49 -0
  153. package/src/errors.ts +30 -4
  154. package/src/escape-script.ts +52 -0
  155. package/src/handle.ts +31 -23
  156. package/src/handles/MetaTags.tsx +62 -19
  157. package/src/handles/Scripts.tsx +183 -0
  158. package/src/handles/breadcrumbs.ts +37 -8
  159. package/src/handles/is-thenable.ts +19 -0
  160. package/src/handles/meta.ts +51 -40
  161. package/src/handles/script.ts +244 -0
  162. package/src/host/cookie-handler.ts +9 -60
  163. package/src/host/errors.ts +0 -24
  164. package/src/host/index.ts +8 -2
  165. package/src/host/pattern-matcher.ts +23 -52
  166. package/src/host/router.ts +107 -99
  167. package/src/host/testing.ts +40 -27
  168. package/src/host/types.ts +37 -4
  169. package/src/host/utils.ts +1 -1
  170. package/src/href-client.ts +137 -22
  171. package/src/index.rsc.ts +96 -12
  172. package/src/index.ts +94 -14
  173. package/src/internal-debug.ts +11 -10
  174. package/src/loader-store.ts +500 -0
  175. package/src/loader.rsc.ts +20 -13
  176. package/src/loader.ts +12 -11
  177. package/src/missing-id-error.ts +68 -0
  178. package/src/outlet-context.ts +1 -1
  179. package/src/outlet-provider.tsx +1 -5
  180. package/src/prerender/param-hash.ts +16 -16
  181. package/src/prerender/store.ts +32 -37
  182. package/src/prerender.ts +61 -6
  183. package/src/redirect-origin.ts +100 -0
  184. package/src/regex-escape.ts +8 -0
  185. package/src/render-error-thrower.tsx +20 -0
  186. package/src/response-utils.ts +34 -0
  187. package/src/reverse.ts +65 -40
  188. package/src/root-error-boundary.tsx +1 -19
  189. package/src/route-content-wrapper.tsx +19 -77
  190. package/src/route-definition/dsl-helpers.ts +304 -309
  191. package/src/route-definition/helper-factories.ts +28 -140
  192. package/src/route-definition/helpers-types.ts +82 -55
  193. package/src/route-definition/index.ts +1 -2
  194. package/src/route-definition/redirect.ts +44 -11
  195. package/src/route-definition/resolve-handler-use.ts +12 -1
  196. package/src/route-definition/use-item-types.ts +29 -0
  197. package/src/route-map-builder.ts +0 -16
  198. package/src/route-types.ts +19 -46
  199. package/src/router/basename.ts +14 -0
  200. package/src/router/content-negotiation.ts +73 -25
  201. package/src/router/error-handling.ts +45 -18
  202. package/src/router/find-match.ts +44 -23
  203. package/src/router/handler-context.ts +27 -43
  204. package/src/router/instrument.ts +350 -0
  205. package/src/router/intercept-resolution.ts +39 -20
  206. package/src/router/lazy-includes.ts +10 -47
  207. package/src/router/loader-resolution.ts +155 -72
  208. package/src/router/logging.ts +0 -6
  209. package/src/router/manifest.ts +18 -29
  210. package/src/router/match-api.ts +9 -24
  211. package/src/router/match-context.ts +0 -22
  212. package/src/router/match-handlers.ts +58 -58
  213. package/src/router/match-middleware/background-revalidation.ts +40 -24
  214. package/src/router/match-middleware/cache-lookup.ts +159 -285
  215. package/src/router/match-middleware/cache-store.ts +64 -52
  216. package/src/router/match-middleware/intercept-resolution.ts +0 -22
  217. package/src/router/match-middleware/segment-resolution.ts +0 -22
  218. package/src/router/match-pipelines.ts +1 -42
  219. package/src/router/match-result.ts +44 -74
  220. package/src/router/metrics.ts +0 -34
  221. package/src/router/middleware-types.ts +7 -134
  222. package/src/router/middleware.ts +247 -166
  223. package/src/router/navigation-snapshot.ts +0 -51
  224. package/src/router/params-util.ts +23 -0
  225. package/src/router/pattern-matching.ts +85 -94
  226. package/src/router/prefetch-cache-ttl.ts +51 -0
  227. package/src/router/prerender-match.ts +104 -65
  228. package/src/router/preview-match.ts +3 -1
  229. package/src/router/request-classification.ts +28 -62
  230. package/src/router/revalidation.ts +123 -73
  231. package/src/router/route-snapshot.ts +0 -1
  232. package/src/router/router-context.ts +3 -28
  233. package/src/router/router-interfaces.ts +83 -35
  234. package/src/router/router-options.ts +136 -5
  235. package/src/router/router-registry.ts +2 -5
  236. package/src/router/segment-resolution/fresh.ts +97 -84
  237. package/src/router/segment-resolution/helpers.ts +86 -6
  238. package/src/router/segment-resolution/loader-cache.ts +76 -39
  239. package/src/router/segment-resolution/revalidation.ts +272 -320
  240. package/src/router/segment-resolution/static-store.ts +19 -5
  241. package/src/router/segment-resolution/streamed-handler-telemetry.ts +52 -0
  242. package/src/router/segment-resolution/view-transition-default.ts +56 -0
  243. package/src/router/segment-resolution.ts +5 -1
  244. package/src/router/segment-wrappers.ts +6 -5
  245. package/src/router/state-cookie-name.ts +33 -0
  246. package/src/router/substitute-pattern-params.ts +56 -0
  247. package/src/router/telemetry-otel.ts +161 -199
  248. package/src/router/telemetry.ts +96 -19
  249. package/src/router/timeout.ts +0 -20
  250. package/src/router/tracing.ts +206 -0
  251. package/src/router/trie-matching.ts +162 -64
  252. package/src/router/types.ts +9 -63
  253. package/src/router/url-params.ts +0 -5
  254. package/src/router.ts +110 -55
  255. package/src/rsc/handler-context.ts +3 -2
  256. package/src/rsc/handler.ts +264 -220
  257. package/src/rsc/helpers.ts +100 -6
  258. package/src/rsc/index.ts +2 -5
  259. package/src/rsc/json-route-result.ts +38 -0
  260. package/src/rsc/loader-fetch.ts +114 -38
  261. package/src/rsc/manifest-init.ts +28 -41
  262. package/src/rsc/origin-guard.ts +39 -25
  263. package/src/rsc/progressive-enhancement.ts +117 -11
  264. package/src/rsc/redirect-guard.ts +99 -0
  265. package/src/rsc/response-cache-serve.ts +238 -0
  266. package/src/rsc/response-error.ts +79 -12
  267. package/src/rsc/response-route-handler.ts +88 -188
  268. package/src/rsc/rsc-rendering.ts +98 -76
  269. package/src/rsc/runtime-warnings.ts +23 -10
  270. package/src/rsc/server-action.ts +281 -117
  271. package/src/rsc/ssr-setup.ts +16 -0
  272. package/src/rsc/transition-gate.ts +89 -0
  273. package/src/rsc/types.ts +23 -5
  274. package/src/runtime-env.ts +18 -0
  275. package/src/search-params.ts +35 -30
  276. package/src/segment-loader-promise.ts +31 -4
  277. package/src/segment-system.tsx +254 -143
  278. package/src/serialize.ts +243 -0
  279. package/src/server/context.ts +163 -51
  280. package/src/server/cookie-parse.ts +32 -0
  281. package/src/server/cookie-store.ts +80 -5
  282. package/src/server/handle-store.ts +21 -38
  283. package/src/server/loader-registry.ts +33 -42
  284. package/src/server/request-context.ts +287 -178
  285. package/src/ssr/index.tsx +21 -16
  286. package/src/static-handler.ts +10 -13
  287. package/src/testing/cache-status.ts +162 -0
  288. package/src/testing/collect-handle.ts +40 -0
  289. package/src/testing/dispatch.ts +701 -0
  290. package/src/testing/dom.entry.ts +22 -0
  291. package/src/testing/e2e/fixture.ts +188 -0
  292. package/src/testing/e2e/index.ts +128 -0
  293. package/src/testing/e2e/matchers.ts +35 -0
  294. package/src/testing/e2e/page-helpers.ts +272 -0
  295. package/src/testing/e2e/parity.ts +387 -0
  296. package/src/testing/e2e/server.ts +195 -0
  297. package/src/testing/flight-matchers.ts +97 -0
  298. package/src/testing/flight-normalize.ts +11 -0
  299. package/src/testing/flight-runtime.d.ts +57 -0
  300. package/src/testing/flight-tree.ts +682 -0
  301. package/src/testing/flight.entry.ts +52 -0
  302. package/src/testing/flight.ts +257 -0
  303. package/src/testing/generated-routes.ts +183 -0
  304. package/src/testing/index.ts +105 -0
  305. package/src/testing/internal/context.ts +371 -0
  306. package/src/testing/internal/flight-client-globals.ts +30 -0
  307. package/src/testing/internal/seed-vars.ts +54 -0
  308. package/src/testing/render-handler.ts +357 -0
  309. package/src/testing/render-route.tsx +581 -0
  310. package/src/testing/run-loader.ts +385 -0
  311. package/src/testing/run-middleware.ts +205 -0
  312. package/src/testing/run-transition-when.ts +164 -0
  313. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  314. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  315. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  316. package/src/testing/vitest-stubs/version.ts +5 -0
  317. package/src/testing/vitest.ts +305 -0
  318. package/src/theme/ThemeProvider.tsx +20 -58
  319. package/src/theme/ThemeScript.tsx +7 -9
  320. package/src/theme/constants.ts +52 -13
  321. package/src/theme/index.ts +0 -7
  322. package/src/theme/theme-context.ts +1 -5
  323. package/src/theme/theme-script.ts +22 -21
  324. package/src/theme/use-theme.ts +0 -3
  325. package/src/types/boundaries.ts +0 -35
  326. package/src/types/cache-types.ts +13 -4
  327. package/src/types/error-types.ts +30 -90
  328. package/src/types/global-namespace.ts +54 -41
  329. package/src/types/handler-context.ts +110 -62
  330. package/src/types/index.ts +3 -10
  331. package/src/types/loader-types.ts +11 -9
  332. package/src/types/request-scope.ts +112 -0
  333. package/src/types/route-config.ts +6 -50
  334. package/src/types/route-entry.ts +0 -6
  335. package/src/types/segments.ts +135 -14
  336. package/src/urls/include-helper.ts +9 -56
  337. package/src/urls/index.ts +1 -11
  338. package/src/urls/path-helper-types.ts +29 -12
  339. package/src/urls/path-helper.ts +17 -106
  340. package/src/urls/pattern-types.ts +36 -19
  341. package/src/urls/response-types.ts +22 -29
  342. package/src/urls/type-extraction.ts +58 -139
  343. package/src/urls/urls-function.ts +1 -19
  344. package/src/use-loader.tsx +292 -107
  345. package/src/vite/debug.ts +185 -0
  346. package/src/vite/discovery/bundle-postprocess.ts +8 -7
  347. package/src/vite/discovery/discover-routers.ts +126 -85
  348. package/src/vite/discovery/discovery-errors.ts +194 -0
  349. package/src/vite/discovery/gate-state.ts +171 -0
  350. package/src/vite/discovery/prerender-collection.ts +96 -68
  351. package/src/vite/discovery/route-types-writer.ts +40 -84
  352. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  353. package/src/vite/discovery/state.ts +44 -0
  354. package/src/vite/discovery/virtual-module-codegen.ts +14 -34
  355. package/src/vite/index.ts +2 -0
  356. package/src/vite/inject-client-debug.ts +36 -0
  357. package/src/vite/plugin-types.ts +126 -8
  358. package/src/vite/plugins/cjs-to-esm.ts +16 -19
  359. package/src/vite/plugins/client-ref-dedup.ts +16 -11
  360. package/src/vite/plugins/client-ref-hashing.ts +28 -15
  361. package/src/vite/plugins/cloudflare-protocol-stub.ts +1 -21
  362. package/src/vite/plugins/expose-action-id.ts +48 -95
  363. package/src/vite/plugins/expose-id-utils.ts +88 -55
  364. package/src/vite/plugins/expose-ids/export-analysis.ts +101 -34
  365. package/src/vite/plugins/expose-ids/handler-transform.ts +11 -90
  366. package/src/vite/plugins/expose-ids/loader-transform.ts +14 -24
  367. package/src/vite/plugins/expose-ids/router-transform.ts +118 -29
  368. package/src/vite/plugins/expose-internal-ids.ts +505 -486
  369. package/src/vite/plugins/performance-tracks.ts +26 -25
  370. package/src/vite/plugins/refresh-cmd.ts +1 -1
  371. package/src/vite/plugins/use-cache-transform.ts +73 -83
  372. package/src/vite/plugins/version-injector.ts +40 -29
  373. package/src/vite/plugins/version-plugin.ts +37 -40
  374. package/src/vite/plugins/virtual-entries.ts +39 -25
  375. package/src/vite/rango.ts +109 -118
  376. package/src/vite/router-discovery.ts +718 -119
  377. package/src/vite/utils/ast-handler-extract.ts +26 -35
  378. package/src/vite/utils/banner.ts +1 -1
  379. package/src/vite/utils/bundle-analysis.ts +10 -15
  380. package/src/vite/utils/client-chunks.ts +184 -0
  381. package/src/vite/utils/directive-prologue.ts +40 -0
  382. package/src/vite/utils/forward-user-plugins.ts +171 -0
  383. package/src/vite/utils/manifest-utils.ts +4 -59
  384. package/src/vite/utils/package-resolution.ts +20 -52
  385. package/src/vite/utils/prerender-utils.ts +54 -39
  386. package/src/vite/utils/shared-utils.ts +90 -41
  387. package/src/browser/action-response-classifier.ts +0 -99
  388. package/src/browser/react/use-client-cache.ts +0 -58
  389. package/src/browser/shallow.ts +0 -40
  390. package/src/handles/index.ts +0 -7
  391. package/src/network-error-thrower.tsx +0 -23
  392. package/src/router/middleware-cookies.ts +0 -55
@@ -8,6 +8,10 @@
8
8
  *
9
9
  * When theme is enabled in the router config, MetaTags also renders
10
10
  * the theme initialization script to prevent FOUC (flash of unstyled content).
11
+ * This makes MetaTags the sole FOUC-script injector for apps that render it;
12
+ * the standalone `<ThemeScript />` is only needed when MetaTags is not used.
13
+ * Rendering both is safe (the inline script guards listener registration) but
14
+ * redundant.
11
15
  *
12
16
  * @example
13
17
  * ```tsx
@@ -27,10 +31,12 @@
27
31
  import { use } from "react";
28
32
  import { useHandle } from "../browser/react/use-handle.js";
29
33
  import { Meta } from "./meta.js";
34
+ import { isThenable } from "./is-thenable.js";
30
35
  import type { MetaDescriptor, MetaDescriptorBase } from "../router/types.js";
31
36
  import { useThemeContext } from "../theme/theme-context.js";
32
37
  import { generateThemeScript } from "../theme/theme-script.js";
33
38
  import { useNonce } from "../browser/react/nonce-context.js";
39
+ import { escapeJsonForScript } from "../escape-script.js";
34
40
 
35
41
  // Type guards for MetaDescriptorBase variants
36
42
  function hasCharSet(d: MetaDescriptorBase): d is { charSet: "utf-8" } {
@@ -91,30 +97,27 @@ function hasTagName(
91
97
  }
92
98
 
93
99
  /**
94
- * Check if a value is a Promise.
100
+ * Check if a value is a Promise. Uses the shared thenable predicate (callable
101
+ * `then`) so collect (meta.ts) and render never disagree: an object carrying a
102
+ * non-callable `then` (e.g. `{ then: 5 }`) is a SYNC descriptor on both sides,
103
+ * not a Promise that would crash React's `use()`.
95
104
  */
96
105
  function isPromise(value: unknown): value is Promise<unknown> {
97
- return value !== null && typeof value === "object" && "then" in value;
106
+ return isThenable(value);
98
107
  }
99
108
 
100
- /**
101
- * Render a single meta descriptor as a React element.
102
- */
103
109
  function renderMetaDescriptor(
104
110
  descriptor: MetaDescriptorBase,
105
111
  index: number,
106
112
  ): React.ReactNode {
107
- // charset
108
113
  if (hasCharSet(descriptor)) {
109
114
  return <meta key="charSet" charSet={descriptor.charSet} />;
110
115
  }
111
116
 
112
- // title
113
117
  if (hasTitle(descriptor)) {
114
118
  return <title key="title">{descriptor.title}</title>;
115
119
  }
116
120
 
117
- // name + content (description, viewport, etc.)
118
121
  if (hasNameContent(descriptor)) {
119
122
  return (
120
123
  <meta
@@ -125,7 +128,6 @@ function renderMetaDescriptor(
125
128
  );
126
129
  }
127
130
 
128
- // property + content (Open Graph, etc.)
129
131
  if (hasPropertyContent(descriptor)) {
130
132
  return (
131
133
  <meta
@@ -136,7 +138,6 @@ function renderMetaDescriptor(
136
138
  );
137
139
  }
138
140
 
139
- // http-equiv + content
140
141
  if (hasHttpEquivContent(descriptor)) {
141
142
  return (
142
143
  <meta
@@ -147,9 +148,10 @@ function renderMetaDescriptor(
147
148
  );
148
149
  }
149
150
 
150
- // JSON-LD structured data
151
151
  if (hasScriptLdJson(descriptor)) {
152
- const json = JSON.stringify(descriptor["script:ld+json"]);
152
+ const json = escapeJsonForScript(
153
+ JSON.stringify(descriptor["script:ld+json"]),
154
+ );
153
155
  return (
154
156
  <script
155
157
  key={`ld-json-${index}`}
@@ -159,7 +161,6 @@ function renderMetaDescriptor(
159
161
  );
160
162
  }
161
163
 
162
- // Custom tagName (meta or link with arbitrary attributes)
163
164
  if (hasTagName(descriptor)) {
164
165
  const { tagName, ...rest } = descriptor;
165
166
  if (tagName === "link") {
@@ -180,7 +181,6 @@ function renderMetaDescriptor(
180
181
  }
181
182
  }
182
183
 
183
- // Fallback: treat as meta attributes
184
184
  return (
185
185
  <meta
186
186
  key={`meta-fallback-${index}`}
@@ -189,17 +189,54 @@ function renderMetaDescriptor(
189
189
  );
190
190
  }
191
191
 
192
- /**
193
- * Wrapper component to resolve a Promise<MetaDescriptorBase> using use().
194
- */
195
- function AsyncMetaTag({
192
+ // Sentinel a rejected async descriptor resolves to: renderMetaDescriptor sees
193
+ // no recognized fields and returns nothing renderable (see renderRejected).
194
+ const REJECTED_META: unique symbol = Symbol("rango.rejectedMeta");
195
+
196
+ // Cache the rejection-swallowing wrapper per source promise so use() gets a
197
+ // stable reference across re-renders (a fresh .then() each render would make
198
+ // React treat it as a new pending promise and never settle). WeakMap keys on
199
+ // the original promise so entries are collected with it.
200
+ const safeMetaPromises = new WeakMap<
201
+ Promise<MetaDescriptorBase>,
202
+ Promise<MetaDescriptorBase | typeof REJECTED_META>
203
+ >();
204
+
205
+ function toSafeMetaPromise(
206
+ promise: Promise<MetaDescriptorBase>,
207
+ ): Promise<MetaDescriptorBase | typeof REJECTED_META> {
208
+ let safe = safeMetaPromises.get(promise);
209
+ if (!safe) {
210
+ // Swallow the rejection at the promise boundary, not via an error boundary:
211
+ // an error boundary above a suspended use() makes React abandon the whole
212
+ // Suspense subtree (and on the server switch it to client rendering). A
213
+ // settled-to-sentinel promise degrades the single bad descriptor to nothing
214
+ // while every sibling descriptor still renders.
215
+ //
216
+ // Normalize via Promise.resolve first: a collected async descriptor may be a
217
+ // non-native thenable (a React wakeable in SSR/RSC) whose .then() returns
218
+ // void rather than a Promise. Calling .then directly would leave `safe`
219
+ // undefined and use(undefined) would throw ("unsupported type passed to
220
+ // use()"), 500-ing the page. Promise.resolve adopts the thenable into a
221
+ // native Promise whose .then always returns one.
222
+ safe = Promise.resolve(promise).then(
223
+ (value) => value,
224
+ () => REJECTED_META,
225
+ );
226
+ safeMetaPromises.set(promise, safe);
227
+ }
228
+ return safe;
229
+ }
230
+
231
+ export function AsyncMetaTag({
196
232
  promise,
197
233
  index,
198
234
  }: {
199
235
  promise: Promise<MetaDescriptorBase>;
200
236
  index: number;
201
237
  }): React.ReactNode {
202
- const resolved = use(promise);
238
+ const resolved = use(toSafeMetaPromise(promise));
239
+ if (resolved === REJECTED_META) return null;
203
240
  return renderMetaDescriptor(resolved, index);
204
241
  }
205
242
 
@@ -230,6 +267,12 @@ export function MetaTags(): React.ReactNode {
230
267
  />
231
268
  )}
232
269
  {descriptors.map((descriptor, index) => {
270
+ // A descriptor is only a Promise on the SSR/hydration path, where it is
271
+ // use()d to stream the tag into the document. On client navigation the
272
+ // store resolves deferred handle values before applying them (see
273
+ // processHandles), so MetaTags only ever receives resolved descriptors
274
+ // there and never suspends — which would otherwise revert the committed
275
+ // route (MetaTags lives in <head>, above the route's <Suspense>).
233
276
  if (isPromise(descriptor)) {
234
277
  return (
235
278
  <AsyncMetaTag
@@ -0,0 +1,183 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Renders the scripts collected by the Script handle into the document.
5
+ *
6
+ * Place `<Scripts />` inside `<head>` (default) and, if you push body scripts,
7
+ * `<Scripts position="body" />` at the top of `<body>`. Each site renders the
8
+ * configs whose `position` matches; the request CSP nonce is applied
9
+ * automatically to every DOCUMENT-RENDERED <script> (consumers never pass it). An
10
+ * async script first encountered on a soft navigation is injected client-side
11
+ * where the nonce is unavailable, so it carries no nonce and relies on
12
+ * 'strict-dynamic' (or a host allowance) — see the nonce caveat in the /scripts
13
+ * skill.
14
+ *
15
+ * EXECUTION CONTRACT — see the Script handle's docs. Inline + ordered (defer)
16
+ * scripts are document-load: they execute only when present in the initial HTML,
17
+ * so this component FREEZES that set after hydration (the initializer below runs
18
+ * once) — a later soft navigation never inserts an inert <script> (React creates
19
+ * client-mounted scripts via innerHTML, which the HTML spec makes non-executing).
20
+ * Async external scripts are React resources and stay reactive: React loads them
21
+ * on first encounter, including after navigation, deduped by src.
22
+ *
23
+ * @example
24
+ * ```tsx
25
+ * <html>
26
+ * <head>
27
+ * <MetaTags />
28
+ * <Scripts />
29
+ * </head>
30
+ * <body>
31
+ * <Scripts position="body" />
32
+ * {children}
33
+ * </body>
34
+ * </html>
35
+ * ```
36
+ */
37
+
38
+ import { useState, type ReactNode } from "react";
39
+ import { useHandle } from "../browser/react/use-handle.js";
40
+ import { useNonce } from "../browser/react/nonce-context.js";
41
+ import { escapeScriptBody } from "../escape-script.js";
42
+ import { Script, type ScriptAttributes, type ScriptConfig } from "./script.js";
43
+
44
+ /** An external async script is a React-managed resource (reactive on nav). */
45
+ function isAsyncResource(config: ScriptConfig): boolean {
46
+ return config.src != null && config.async === true;
47
+ }
48
+
49
+ // Fields the Script handle owns (set via the ScriptConfig fields, applied as
50
+ // explicit props by renderScript) plus the inline-content props. Dropped from the
51
+ // attributes bag so untyped/serialized input cannot smuggle them in — e.g.
52
+ // `children`/`dangerouslySetInnerHTML` alongside an inline body makes React throw,
53
+ // or `src` on an inline script. The discriminated type already excludes these;
54
+ // this is the runtime guard.
55
+ const MANAGED_ATTRS = new Set([
56
+ "id",
57
+ "src",
58
+ "async",
59
+ "defer",
60
+ "type",
61
+ "children",
62
+ "nonce",
63
+ "dangerouslySetInnerHTML",
64
+ ]);
65
+
66
+ // Drop managed fields + any `on*` event handlers (a config serializes across the
67
+ // server -> client boundary, so a function cannot survive it) from the passthrough
68
+ // attributes, warning in dev.
69
+ function passthroughAttributes(
70
+ attributes: ScriptAttributes | undefined,
71
+ ): Record<string, unknown> {
72
+ if (!attributes) return {};
73
+ const out: Record<string, unknown> = {};
74
+ const dev = process.env.NODE_ENV !== "production";
75
+ for (const [key, value] of Object.entries(
76
+ attributes as Record<string, unknown>,
77
+ )) {
78
+ const isHandler = key.startsWith("on");
79
+ if (isHandler || MANAGED_ATTRS.has(key)) {
80
+ if (dev) {
81
+ console.warn(
82
+ isHandler
83
+ ? `[Scripts] event handler "${key}" in a script's attributes is ` +
84
+ `dropped; callbacks cannot cross the server -> client handle ` +
85
+ `boundary. Use a "use client" component for load/error handling.`
86
+ : `[Scripts] managed field "${key}" in a script's attributes is ` +
87
+ `dropped; set it via the ScriptConfig fields (the request nonce ` +
88
+ `is applied automatically).`,
89
+ );
90
+ }
91
+ continue;
92
+ }
93
+ out[key] = value;
94
+ }
95
+ return out;
96
+ }
97
+
98
+ function renderScript(
99
+ config: ScriptConfig,
100
+ nonce: string | undefined,
101
+ index: number,
102
+ ): ReactNode {
103
+ const { id, src, children, async, defer, type, attributes } = config;
104
+ const key = id ?? src ?? `rango-script-${index}`;
105
+ const attrs = passthroughAttributes(attributes);
106
+
107
+ // Inline: rendered in place (never hoisted), escaped against </script> breakout.
108
+ // The server-only nonce makes the attribute differ from the (undefined) client
109
+ // value, so suppressHydrationWarning is required — the same sanctioned pattern
110
+ // as the theme/Meta inline scripts.
111
+ if (src == null) {
112
+ if (children == null) return null;
113
+ return (
114
+ <script
115
+ key={key}
116
+ {...attrs}
117
+ id={id}
118
+ type={type}
119
+ nonce={nonce}
120
+ suppressHydrationWarning
121
+ dangerouslySetInnerHTML={{ __html: escapeScriptBody(children) }}
122
+ />
123
+ );
124
+ }
125
+
126
+ if (
127
+ process.env.NODE_ENV !== "production" &&
128
+ config.position === "body" &&
129
+ async
130
+ ) {
131
+ console.warn(
132
+ `[Scripts] An async external script (src="${src}") is hoisted into ` +
133
+ `<head> by React; position: "body" is ignored for it.`,
134
+ );
135
+ }
136
+
137
+ // External: async => React-hoisted, src-deduped resource; otherwise in place
138
+ // (defer or plain), preserving authoring order.
139
+ return (
140
+ <script
141
+ key={key}
142
+ {...attrs}
143
+ id={id}
144
+ type={type}
145
+ src={src}
146
+ async={async}
147
+ defer={defer}
148
+ nonce={nonce}
149
+ suppressHydrationWarning
150
+ />
151
+ );
152
+ }
153
+
154
+ export function Scripts({
155
+ position = "head",
156
+ }: { position?: "head" | "body" } = {}): ReactNode {
157
+ const all = useHandle(Script) as ScriptConfig[];
158
+ const nonce = useNonce();
159
+
160
+ const forPosition = all.filter(
161
+ (config) => (config.position ?? "head") === position,
162
+ );
163
+
164
+ // Document-load scripts (inline + ordered external) execute only from the
165
+ // initial HTML, so freeze them to the first-render set. The initializer runs
166
+ // during SSR and again at hydration with the same handle data, so the output
167
+ // matches; afterwards a navigation cannot add an inert <script>.
168
+ const [documentLoad] = useState(() =>
169
+ forPosition.filter((config) => !isAsyncResource(config)),
170
+ );
171
+ // Async external scripts are resources React loads on first encounter; keep
172
+ // them reactive so a script first reached via navigation still loads.
173
+ const asyncResources = forPosition.filter(isAsyncResource);
174
+
175
+ return (
176
+ <>
177
+ {documentLoad.map((config, index) => renderScript(config, nonce, index))}
178
+ {asyncResources.map((config, index) =>
179
+ renderScript(config, nonce, index),
180
+ )}
181
+ </>
182
+ );
183
+ }
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * Each layout/route pushes breadcrumb items via `ctx.use(Breadcrumbs)`.
5
5
  * Items are collected in parent-to-child order with automatic deduplication
6
- * by `href` (last item for each href wins).
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.
7
8
  *
8
9
  * @example
9
10
  * ```tsx
@@ -22,6 +23,7 @@
22
23
 
23
24
  import type { ReactNode } from "react";
24
25
  import { createHandle, type Handle } from "../handle.js";
26
+ import { isThenable } from "./is-thenable.js";
25
27
 
26
28
  /**
27
29
  * A single breadcrumb item.
@@ -38,19 +40,46 @@ export interface BreadcrumbItem {
38
40
 
39
41
  /**
40
42
  * Collect function for Breadcrumbs handle.
41
- * Flattens segments in parent-to-child order with deduplication by href
42
- * (last item for each href wins).
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.
43
50
  */
44
51
  function collectBreadcrumbs(segments: BreadcrumbItem[][]): BreadcrumbItem[] {
45
52
  const all = segments.flat();
46
- const seen = new Map<string, number>();
47
53
 
48
- for (let i = 0; i < all.length; i++) {
49
- seen.set(all[i].href, i);
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);
50
67
  }
51
68
 
52
- // Return items in order, keeping only the last occurrence per href
53
- return all.filter((item, index) => seen.get(item.href) === index);
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;
54
83
  }
55
84
 
56
85
  /**
@@ -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,