@rangojs/router 0.0.0-experimental.bd6e11bc → 0.0.0-experimental.bdaf10aa

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 (411) hide show
  1. package/AGENTS.md +8 -4
  2. package/README.md +296 -887
  3. package/dist/bin/rango.js +459 -91
  4. package/dist/testing/vitest.js +36 -2
  5. package/dist/vite/index.js +1708 -414
  6. package/package.json +35 -10
  7. package/skills/api-client/SKILL.md +211 -0
  8. package/skills/breadcrumbs/SKILL.md +82 -5
  9. package/skills/bundle-analysis/SKILL.md +2 -2
  10. package/skills/cache-guide/SKILL.md +14 -9
  11. package/skills/caching/SKILL.md +221 -12
  12. package/skills/catalog.json +271 -0
  13. package/skills/comparison/SKILL.md +50 -0
  14. package/skills/comparison/agents/openai.yaml +4 -0
  15. package/skills/comparison/references/framework-comparison.md +837 -0
  16. package/skills/composability/SKILL.md +83 -2
  17. package/skills/css/SKILL.md +76 -0
  18. package/skills/debug-manifest/SKILL.md +5 -3
  19. package/skills/defer-hydration/SKILL.md +235 -0
  20. package/skills/document-cache/SKILL.md +11 -3
  21. package/skills/fonts/SKILL.md +1 -1
  22. package/skills/handler-use/SKILL.md +9 -9
  23. package/skills/hooks/SKILL.md +73 -900
  24. package/skills/hooks/data.md +273 -0
  25. package/skills/hooks/handle-and-actions.md +103 -0
  26. package/skills/hooks/navigation.md +110 -0
  27. package/skills/hooks/outlets.md +41 -0
  28. package/skills/hooks/state.md +228 -0
  29. package/skills/hooks/urls.md +135 -0
  30. package/skills/host-router/SKILL.md +84 -7
  31. package/skills/i18n/SKILL.md +1 -1
  32. package/skills/intercept/SKILL.md +51 -17
  33. package/skills/layout/SKILL.md +38 -16
  34. package/skills/links/SKILL.md +1 -1
  35. package/skills/loader/SKILL.md +48 -20
  36. package/skills/middleware/SKILL.md +11 -5
  37. package/skills/migrate-nextjs/SKILL.md +203 -20
  38. package/skills/migrate-react-router/SKILL.md +59 -675
  39. package/skills/migrate-react-router/cloudflare-workers.md +129 -0
  40. package/skills/migrate-react-router/component-migration.md +196 -0
  41. package/skills/migrate-react-router/data-and-actions.md +225 -0
  42. package/skills/migrate-react-router/route-mapping.md +271 -0
  43. package/skills/mime-routes/SKILL.md +3 -3
  44. package/skills/observability/SKILL.md +70 -5
  45. package/skills/parallel/SKILL.md +32 -8
  46. package/skills/ppr/SKILL.md +622 -0
  47. package/skills/prerender/SKILL.md +59 -28
  48. package/skills/rango/SKILL.md +124 -50
  49. package/skills/response-routes/SKILL.md +78 -46
  50. package/skills/route/SKILL.md +85 -6
  51. package/skills/router-setup/SKILL.md +41 -6
  52. package/skills/scripts/SKILL.md +179 -0
  53. package/skills/server-actions/SKILL.md +28 -3
  54. package/skills/shell-manifest/SKILL.md +185 -0
  55. package/skills/streams-and-websockets/SKILL.md +1 -1
  56. package/skills/tailwind/SKILL.md +28 -4
  57. package/skills/testing/SKILL.md +68 -654
  58. package/skills/testing/bindings.md +103 -0
  59. package/skills/testing/cache-prerender.md +127 -0
  60. package/skills/testing/client-components.md +124 -0
  61. package/skills/testing/e2e-parity.md +125 -0
  62. package/skills/testing/flight.md +91 -0
  63. package/skills/testing/handles.md +131 -0
  64. package/skills/testing/loader.md +128 -0
  65. package/skills/testing/middleware.md +99 -0
  66. package/skills/testing/render-handler.md +122 -0
  67. package/skills/testing/response-routes.md +95 -0
  68. package/skills/testing/reverse-and-types.md +85 -0
  69. package/skills/testing/server-actions.md +107 -0
  70. package/skills/testing/server-tree.md +128 -0
  71. package/skills/testing/setup.md +123 -0
  72. package/skills/theme/SKILL.md +1 -1
  73. package/skills/typesafety/SKILL.md +45 -918
  74. package/skills/typesafety/env-and-bindings.md +254 -0
  75. package/skills/typesafety/generated-files-and-cli.md +335 -0
  76. package/skills/typesafety/params-and-search.md +153 -0
  77. package/skills/typesafety/route-types.md +209 -0
  78. package/skills/use-cache/SKILL.md +47 -17
  79. package/skills/vercel/SKILL.md +128 -0
  80. package/skills/view-transitions/SKILL.md +44 -1
  81. package/src/__augment-tests__/augmented.check.ts +2 -3
  82. package/src/__internal.ts +0 -65
  83. package/src/browser/action-coordinator.ts +1 -1
  84. package/src/browser/action-fence.ts +47 -0
  85. package/src/browser/app-shell.ts +14 -27
  86. package/src/browser/connection-warmup.ts +134 -0
  87. package/src/browser/cookie-name.ts +140 -0
  88. package/src/browser/event-controller.ts +178 -100
  89. package/src/browser/invalidate-client-cache.ts +52 -0
  90. package/src/browser/logging.ts +28 -0
  91. package/src/browser/merge-segment-loaders.ts +6 -4
  92. package/src/browser/navigation-bridge.ts +81 -68
  93. package/src/browser/navigation-client.ts +115 -70
  94. package/src/browser/navigation-store-handle.ts +38 -0
  95. package/src/browser/navigation-store.ts +153 -88
  96. package/src/browser/navigation-transaction.ts +0 -32
  97. package/src/browser/network-error-handler.ts +34 -7
  98. package/src/browser/partial-update.ts +157 -144
  99. package/src/browser/prefetch/cache.ts +148 -81
  100. package/src/browser/prefetch/fetch.ts +231 -51
  101. package/src/browser/prefetch/queue.ts +25 -7
  102. package/src/browser/rango-state.ts +157 -115
  103. package/src/browser/react/Link.tsx +40 -7
  104. package/src/browser/react/NavigationProvider.tsx +140 -99
  105. package/src/browser/react/ScrollRestoration.tsx +10 -6
  106. package/src/browser/react/filter-segment-order.ts +17 -2
  107. package/src/browser/react/index.ts +0 -51
  108. package/src/browser/react/location-state-shared.ts +14 -15
  109. package/src/browser/react/location-state.ts +0 -1
  110. package/src/browser/react/use-action.ts +6 -15
  111. package/src/browser/react/use-handle.ts +0 -5
  112. package/src/browser/react/use-href.tsx +8 -1
  113. package/src/browser/react/use-link-status.ts +33 -8
  114. package/src/browser/react/use-navigation.ts +10 -5
  115. package/src/browser/react/use-params.ts +0 -2
  116. package/src/browser/react/use-router.ts +6 -4
  117. package/src/browser/react/use-search-params.ts +0 -5
  118. package/src/browser/react/use-segments.ts +0 -13
  119. package/src/browser/response-adapter.ts +74 -8
  120. package/src/browser/rsc-router.tsx +97 -22
  121. package/src/browser/scroll-restoration.ts +15 -8
  122. package/src/browser/segment-reconciler.ts +31 -21
  123. package/src/browser/server-action-bridge.ts +216 -38
  124. package/src/browser/types.ts +94 -22
  125. package/src/browser/validate-redirect-origin.ts +43 -16
  126. package/src/build/generate-manifest.ts +155 -131
  127. package/src/build/generate-route-types.ts +1 -1
  128. package/src/build/index.ts +11 -5
  129. package/src/build/prefix-tree-utils.ts +123 -0
  130. package/src/build/route-trie.ts +152 -22
  131. package/src/build/route-types/ast-route-extraction.ts +15 -8
  132. package/src/build/route-types/codegen.ts +12 -1
  133. package/src/build/route-types/include-resolution.ts +455 -61
  134. package/src/build/route-types/param-extraction.ts +6 -3
  135. package/src/build/route-types/per-module-writer.ts +15 -2
  136. package/src/build/route-types/router-processing.ts +77 -41
  137. package/src/build/route-types/source-scan.ts +105 -7
  138. package/src/build/runtime-discovery.ts +4 -1
  139. package/src/cache/cache-error.ts +104 -0
  140. package/src/cache/cache-key-utils.ts +58 -13
  141. package/src/cache/cache-policy.ts +108 -34
  142. package/src/cache/cache-runtime.ts +454 -101
  143. package/src/cache/cache-scope.ts +159 -54
  144. package/src/cache/cache-tag.ts +149 -0
  145. package/src/cache/cf/cf-base64.ts +33 -0
  146. package/src/cache/cf/cf-cache-constants.ts +127 -0
  147. package/src/cache/cf/cf-cache-store.ts +2170 -377
  148. package/src/cache/cf/cf-cache-types.ts +349 -0
  149. package/src/cache/cf/cf-kv-utils.ts +46 -0
  150. package/src/cache/cf/cf-tag-marker-memo.ts +105 -0
  151. package/src/cache/cf/index.ts +6 -16
  152. package/src/cache/document-cache.ts +126 -41
  153. package/src/cache/handle-snapshot.ts +70 -0
  154. package/src/cache/index.ts +23 -20
  155. package/src/cache/memory-segment-store.ts +243 -37
  156. package/src/cache/profile-registry.ts +46 -31
  157. package/src/cache/read-through-swr.ts +56 -12
  158. package/src/cache/segment-codec.ts +13 -21
  159. package/src/cache/shell-snapshot.ts +417 -0
  160. package/src/cache/tag-invalidation.ts +230 -0
  161. package/src/cache/types.ts +194 -99
  162. package/src/cache/vercel/index.ts +11 -0
  163. package/src/cache/vercel/vercel-cache-store.ts +1132 -0
  164. package/src/client.rsc.tsx +39 -22
  165. package/src/client.tsx +28 -58
  166. package/src/cloudflare/index.ts +11 -0
  167. package/src/cloudflare/tracing.ts +108 -0
  168. package/src/component-utils.ts +19 -0
  169. package/src/components/DefaultDocument.tsx +8 -2
  170. package/src/context-var.ts +13 -1
  171. package/src/decode-loader-results.ts +18 -2
  172. package/src/defer.ts +185 -0
  173. package/src/deps/ssr.ts +0 -1
  174. package/src/encode-kv.ts +49 -0
  175. package/src/errors.ts +0 -3
  176. package/src/escape-script.ts +52 -0
  177. package/src/handle.ts +57 -40
  178. package/src/handles/MetaTags.tsx +24 -53
  179. package/src/handles/Scripts.tsx +183 -0
  180. package/src/handles/breadcrumbs.ts +35 -8
  181. package/src/handles/deferred-resolution.ts +127 -0
  182. package/src/handles/is-thenable.ts +18 -0
  183. package/src/handles/meta.ts +14 -40
  184. package/src/handles/script.ts +244 -0
  185. package/src/host/cookie-handler.ts +9 -60
  186. package/src/host/errors.ts +13 -22
  187. package/src/host/index.ts +7 -0
  188. package/src/host/pattern-matcher.ts +23 -52
  189. package/src/host/router.ts +1 -65
  190. package/src/host/testing.ts +40 -27
  191. package/src/host/types.ts +6 -2
  192. package/src/href-client.ts +7 -12
  193. package/src/index.rsc.ts +88 -8
  194. package/src/index.ts +90 -16
  195. package/src/internal-debug.ts +11 -10
  196. package/src/loader.rsc.ts +19 -9
  197. package/src/loader.ts +12 -4
  198. package/src/outlet-provider.tsx +1 -5
  199. package/src/prerender/param-hash.ts +16 -16
  200. package/src/prerender/store.ts +32 -37
  201. package/src/prerender.ts +75 -7
  202. package/src/redirect-origin.ts +114 -0
  203. package/src/regex-escape.ts +8 -0
  204. package/src/render-error-thrower.tsx +20 -0
  205. package/src/response-utils.ts +25 -0
  206. package/src/root-error-boundary.tsx +1 -19
  207. package/src/route-content-wrapper.tsx +13 -49
  208. package/src/route-definition/dsl-helpers.ts +60 -53
  209. package/src/route-definition/helper-factories.ts +0 -2
  210. package/src/route-definition/helpers-types.ts +46 -46
  211. package/src/route-definition/index.ts +1 -2
  212. package/src/route-definition/redirect.ts +44 -11
  213. package/src/route-definition/resolve-handler-use.ts +6 -1
  214. package/src/route-definition/use-item-types.ts +3 -6
  215. package/src/route-map-builder.ts +41 -20
  216. package/src/route-types.ts +0 -5
  217. package/src/router/content-negotiation.ts +58 -23
  218. package/src/router/error-handling.ts +44 -17
  219. package/src/router/find-match.ts +129 -30
  220. package/src/router/handler-context.ts +6 -1
  221. package/src/router/instrument.ts +355 -0
  222. package/src/router/intercept-resolution.ts +35 -2
  223. package/src/router/lazy-includes.ts +79 -56
  224. package/src/router/loader-resolution.ts +151 -73
  225. package/src/router/logging.ts +0 -6
  226. package/src/router/manifest.ts +74 -40
  227. package/src/router/match-api.ts +76 -52
  228. package/src/router/match-context.ts +0 -22
  229. package/src/router/match-handlers.ts +181 -178
  230. package/src/router/match-middleware/background-revalidation.ts +40 -24
  231. package/src/router/match-middleware/cache-lookup.ts +115 -194
  232. package/src/router/match-middleware/cache-store.ts +61 -50
  233. package/src/router/match-middleware/intercept-resolution.ts +0 -22
  234. package/src/router/match-middleware/segment-resolution.ts +0 -22
  235. package/src/router/match-pipelines.ts +1 -42
  236. package/src/router/match-result.ts +36 -67
  237. package/src/router/metrics.ts +0 -34
  238. package/src/router/middleware-types.ts +0 -116
  239. package/src/router/middleware.ts +231 -120
  240. package/src/router/navigation-snapshot.ts +7 -56
  241. package/src/router/params-util.ts +23 -0
  242. package/src/router/parse-pattern.ts +115 -0
  243. package/src/router/pattern-matching.ts +99 -152
  244. package/src/router/prefetch-cache-ttl.ts +51 -0
  245. package/src/router/prefetch-limits.ts +37 -0
  246. package/src/router/prerender-match.ts +111 -66
  247. package/src/router/preview-match.ts +3 -1
  248. package/src/router/request-classification.ts +47 -42
  249. package/src/router/revalidation.ts +75 -81
  250. package/src/router/route-snapshot.ts +14 -3
  251. package/src/router/router-context.ts +6 -29
  252. package/src/router/router-interfaces.ts +70 -8
  253. package/src/router/router-options.ts +126 -4
  254. package/src/router/segment-resolution/fresh.ts +104 -80
  255. package/src/router/segment-resolution/helpers.ts +86 -6
  256. package/src/router/segment-resolution/loader-cache.ts +155 -39
  257. package/src/router/segment-resolution/loader-mask.ts +60 -0
  258. package/src/router/segment-resolution/loader-snapshot.ts +259 -0
  259. package/src/router/segment-resolution/mask-nested.ts +83 -0
  260. package/src/router/segment-resolution/revalidation.ts +215 -304
  261. package/src/router/segment-resolution/static-store.ts +19 -5
  262. package/src/router/segment-resolution/streamed-handler-telemetry.ts +52 -0
  263. package/src/router/segment-resolution/view-transition-default.ts +35 -15
  264. package/src/router/segment-resolution.ts +5 -1
  265. package/src/router/segment-wrappers.ts +6 -5
  266. package/src/router/state-cookie-name.ts +33 -0
  267. package/src/router/substitute-pattern-params.ts +54 -35
  268. package/src/router/telemetry-otel.ts +160 -200
  269. package/src/router/telemetry.ts +9 -23
  270. package/src/router/timeout.ts +0 -20
  271. package/src/router/tracing.ts +215 -0
  272. package/src/router/trie-matching.ts +171 -64
  273. package/src/router/types.ts +1 -63
  274. package/src/router/url-params.ts +13 -5
  275. package/src/router.ts +119 -48
  276. package/src/rsc/full-payload.ts +70 -0
  277. package/src/rsc/handler-context.ts +1 -0
  278. package/src/rsc/handler.ts +267 -152
  279. package/src/rsc/helpers.ts +78 -4
  280. package/src/rsc/index.ts +1 -4
  281. package/src/rsc/json-route-result.ts +38 -0
  282. package/src/rsc/loader-fetch.ts +114 -38
  283. package/src/rsc/manifest-init.ts +29 -42
  284. package/src/rsc/nonce.ts +10 -1
  285. package/src/rsc/origin-guard.ts +11 -15
  286. package/src/rsc/progressive-enhancement.ts +120 -13
  287. package/src/rsc/redirect-guard.ts +100 -0
  288. package/src/rsc/response-cache-serve.ts +238 -0
  289. package/src/rsc/response-error.ts +79 -12
  290. package/src/rsc/response-route-handler.ts +58 -141
  291. package/src/rsc/rsc-rendering.ts +492 -49
  292. package/src/rsc/runtime-warnings.ts +14 -0
  293. package/src/rsc/server-action.ts +268 -82
  294. package/src/rsc/shell-capture.ts +1190 -0
  295. package/src/rsc/shell-serve.ts +181 -0
  296. package/src/rsc/transition-gate.ts +89 -0
  297. package/src/rsc/types.ts +45 -3
  298. package/src/runtime-env.ts +18 -0
  299. package/src/search-params.ts +31 -26
  300. package/src/segment-loader-promise.ts +49 -4
  301. package/src/segment-system.tsx +260 -95
  302. package/src/server/context.ts +99 -9
  303. package/src/server/cookie-parse.ts +32 -0
  304. package/src/server/cookie-store.ts +125 -2
  305. package/src/server/handle-store.ts +21 -38
  306. package/src/server/loader-registry.ts +33 -42
  307. package/src/server/request-context.ts +379 -138
  308. package/src/ssr/index.tsx +491 -182
  309. package/src/ssr/inject-rsc-eager.ts +167 -0
  310. package/src/ssr/ssr-root.tsx +228 -0
  311. package/src/static-handler.ts +10 -13
  312. package/src/testing/cache-status.ts +44 -48
  313. package/src/testing/collect-handle.ts +14 -31
  314. package/src/testing/dispatch.ts +533 -160
  315. package/src/testing/e2e/fixture.ts +45 -11
  316. package/src/testing/e2e/index.ts +1 -22
  317. package/src/testing/e2e/matchers.ts +0 -16
  318. package/src/testing/e2e/parity.ts +85 -4
  319. package/src/testing/e2e/server.ts +12 -0
  320. package/src/testing/flight-matchers.ts +7 -14
  321. package/src/testing/flight-normalize.ts +11 -0
  322. package/src/testing/flight-runtime.d.ts +36 -0
  323. package/src/testing/flight-tree.ts +682 -0
  324. package/src/testing/flight.entry.ts +30 -0
  325. package/src/testing/flight.ts +145 -70
  326. package/src/testing/generated-routes.ts +26 -50
  327. package/src/testing/index.ts +18 -19
  328. package/src/testing/internal/context.ts +184 -68
  329. package/src/testing/internal/flight-client-globals.ts +30 -0
  330. package/src/testing/internal/seed-vars.ts +54 -0
  331. package/src/testing/render-handler.ts +357 -0
  332. package/src/testing/render-route.tsx +134 -115
  333. package/src/testing/run-loader.ts +140 -51
  334. package/src/testing/run-middleware.ts +59 -33
  335. package/src/testing/run-transition-when.ts +164 -0
  336. package/src/testing/vitest-stubs/cloudflare-email.ts +1 -1
  337. package/src/testing/vitest-stubs/cloudflare-workers.ts +1 -1
  338. package/src/testing/vitest.ts +138 -16
  339. package/src/theme/ThemeProvider.tsx +56 -84
  340. package/src/theme/ThemeScript.tsx +7 -9
  341. package/src/theme/constants.ts +52 -13
  342. package/src/theme/index.ts +0 -7
  343. package/src/theme/theme-context.ts +1 -5
  344. package/src/theme/theme-script.ts +22 -21
  345. package/src/theme/use-theme.ts +0 -3
  346. package/src/types/boundaries.ts +0 -35
  347. package/src/types/cache-types.ts +13 -4
  348. package/src/types/error-types.ts +30 -90
  349. package/src/types/global-namespace.ts +15 -15
  350. package/src/types/handler-context.ts +45 -15
  351. package/src/types/index.ts +2 -10
  352. package/src/types/loader-types.ts +6 -3
  353. package/src/types/request-scope.ts +8 -22
  354. package/src/types/route-config.ts +20 -52
  355. package/src/types/route-entry.ts +0 -6
  356. package/src/types/segments.ts +100 -13
  357. package/src/urls/include-helper.ts +10 -12
  358. package/src/urls/include-provider.ts +71 -0
  359. package/src/urls/index.ts +2 -8
  360. package/src/urls/path-helper-types.ts +52 -14
  361. package/src/urls/path-helper.ts +5 -54
  362. package/src/urls/pattern-types.ts +36 -0
  363. package/src/urls/type-extraction.ts +76 -42
  364. package/src/urls/urls-function.ts +0 -14
  365. package/src/use-loader.tsx +0 -186
  366. package/src/vercel/index.ts +11 -0
  367. package/src/vercel/tracing.ts +88 -0
  368. package/src/vite/discovery/bundle-postprocess.ts +2 -1
  369. package/src/vite/discovery/dev-prerender-cache.ts +117 -0
  370. package/src/vite/discovery/discover-routers.ts +34 -43
  371. package/src/vite/discovery/discovery-errors.ts +61 -0
  372. package/src/vite/discovery/prerender-collection.ts +33 -46
  373. package/src/vite/discovery/state.ts +12 -1
  374. package/src/vite/discovery/virtual-module-codegen.ts +1 -11
  375. package/src/vite/index.ts +9 -0
  376. package/src/vite/inject-client-debug.ts +88 -0
  377. package/src/vite/plugin-types.ts +143 -10
  378. package/src/vite/plugins/cjs-to-esm.ts +8 -12
  379. package/src/vite/plugins/client-ref-dedup.ts +0 -11
  380. package/src/vite/plugins/client-ref-hashing.ts +0 -10
  381. package/src/vite/plugins/cloudflare-protocol-stub.ts +0 -20
  382. package/src/vite/plugins/expose-action-id.ts +2 -73
  383. package/src/vite/plugins/expose-id-utils.ts +85 -56
  384. package/src/vite/plugins/expose-ids/export-analysis.ts +30 -43
  385. package/src/vite/plugins/expose-ids/handler-transform.ts +5 -31
  386. package/src/vite/plugins/expose-ids/loader-transform.ts +12 -20
  387. package/src/vite/plugins/expose-ids/router-transform.ts +98 -26
  388. package/src/vite/plugins/expose-internal-ids.ts +10 -1
  389. package/src/vite/plugins/performance-tracks.ts +0 -3
  390. package/src/vite/plugins/refresh-cmd.ts +1 -1
  391. package/src/vite/plugins/use-cache-transform.ts +21 -46
  392. package/src/vite/plugins/vercel-output.ts +384 -0
  393. package/src/vite/plugins/version-injector.ts +22 -27
  394. package/src/vite/plugins/version-plugin.ts +6 -66
  395. package/src/vite/plugins/virtual-entries.ts +137 -26
  396. package/src/vite/rango.ts +146 -135
  397. package/src/vite/router-discovery.ts +189 -48
  398. package/src/vite/utils/ast-handler-extract.ts +11 -20
  399. package/src/vite/utils/bundle-analysis.ts +6 -13
  400. package/src/vite/utils/client-chunks.ts +0 -6
  401. package/src/vite/utils/directive-prologue.ts +40 -0
  402. package/src/vite/utils/forward-user-plugins.ts +0 -22
  403. package/src/vite/utils/manifest-utils.ts +4 -75
  404. package/src/vite/utils/package-resolution.ts +1 -73
  405. package/src/vite/utils/prerender-utils.ts +71 -44
  406. package/src/vite/utils/shared-utils.ts +55 -37
  407. package/src/browser/react/use-client-cache.ts +0 -58
  408. package/src/browser/shallow.ts +0 -40
  409. package/src/handles/index.ts +0 -7
  410. package/src/network-error-thrower.tsx +0 -23
  411. package/src/router/middleware-cookies.ts +0 -55
package/src/defer.ts ADDED
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Deferred handle values — "decide synchronously, resolve late".
3
+ *
4
+ * A handle is pushed from code that holds `ctx` (a route/layout handler), so the
5
+ * decision to push lands before the handles stream seals. But the value often
6
+ * isn't known there — it may come from a deep async component far from the
7
+ * handler. `ctx.use(Handle).defer()` reserves the handle's slot now (synchronous,
8
+ * so ordering and the pre-seal timing hold) and returns a resolver with the SAME
9
+ * signature as the push: you call it later, anywhere in the render, with the same
10
+ * value you would have passed to the push.
11
+ *
12
+ * const breadcrumb = ctx.use(Breadcrumbs); // (item) => void & .defer()
13
+ * const resolve = breadcrumb.defer({ timeoutMs: 5000, else: null });
14
+ * // deep async component, far from ctx:
15
+ * resolve({ label, href, content }); // identical call, just deferred
16
+ *
17
+ * Under the hood the reserved slot is a Promise. Handle values are resolved
18
+ * before any consumer sees them (resolve-by-default: the full render resolves
19
+ * server-side, navigation resolves client-side before apply), so `useHandle`
20
+ * receives the resolved value, never the Promise. The hazard that guards against
21
+ * bugs: a deferred slot whose resolver is never called would keep the render —
22
+ * and the HTTP response — waiting forever. So a deferred auto-resolves to `else`
23
+ * after `timeoutMs` (default {@link DEFAULT_DEFER_TIMEOUT_MS}) if the resolver is
24
+ * never called, degrading gracefully (and warning in dev) instead of hanging.
25
+ */
26
+
27
+ /** Default auto-resolve window. Long enough for genuine deep async work, short
28
+ * enough that a forgotten resolve does not hang the response indefinitely. */
29
+ export const DEFAULT_DEFER_TIMEOUT_MS = 10_000;
30
+
31
+ /** Options for `ctx.use(Handle).defer()`. */
32
+ export interface DeferOptions<TData> {
33
+ /**
34
+ * Auto-resolve to `else` after this many ms if the resolver is never called,
35
+ * so a forgotten resolve cannot hold the Flight stream — and thus the HTTP
36
+ * response — open. Defaults to {@link DEFAULT_DEFER_TIMEOUT_MS}. `0` or
37
+ * `Infinity` disable the timeout intentionally (not recommended on a request
38
+ * path). Any other non-finite or negative value is treated as a mistake and
39
+ * falls back to the default rather than silently disabling the safety net.
40
+ * Named `timeoutMs` to match the router's `*Ms` duration convention.
41
+ */
42
+ timeoutMs?: number;
43
+ /**
44
+ * Value the slot resolves to if the timeout fires before the resolver is
45
+ * called. Defaults to `undefined` (the deferred item is skipped/empty). For
46
+ * renderable handle content, `null` is the usual graceful fallback, so the
47
+ * type admits `null` even when `TData` does not.
48
+ */
49
+ else?: TData | null;
50
+ }
51
+
52
+ /**
53
+ * The call signature shared by a handle push and the resolver returned by
54
+ * `.defer()`: a concrete value, a `Promise` of the value (Flight streams it as a
55
+ * late row), or a thunk returning a `Promise` (called immediately).
56
+ */
57
+ export type HandlePushFn<TData> = (
58
+ data: TData | Promise<TData> | (() => Promise<TData>),
59
+ ) => void;
60
+
61
+ /**
62
+ * The push function returned by `ctx.use(Handle)`. Call it to push a value now,
63
+ * or call `.defer()` to reserve the slot now and resolve the value later (e.g.
64
+ * from a deep async component) with a timeout safety net.
65
+ */
66
+ export type HandlePush<TData> = HandlePushFn<TData> & {
67
+ /**
68
+ * Reserve this handle's slot synchronously and return a resolver that is
69
+ * push-equal: it takes the same argument shapes as the push (value, Promise, or
70
+ * thunk) and behaves identically. Two things the resolver adds over a direct
71
+ * push: a timeout (if the resolver is never called, the slot auto-resolves to
72
+ * `options.else` after `options.timeoutMs`; calling the resolver cancels it),
73
+ * and — on the action/revalidation path only — a thunk it runs does NOT
74
+ * re-enter the deadlock-guard push-callback scope a direct push thunk gets,
75
+ * because a deferred resolver fires after the handler phase has closed.
76
+ *
77
+ * The reserved slot is resolved before any consumer reads it
78
+ * (resolve-by-default), so `useHandle` receives the resolved value (or the
79
+ * `else` fallback on timeout), never a Promise.
80
+ */
81
+ defer(options?: DeferOptions<TData>): HandlePushFn<TData>;
82
+ };
83
+
84
+ // Internal: a timeout-bounded { promise, resolve }. Not part of the public API
85
+ // (the public surface is `ctx.use(Handle).defer()`); exported for `withDefer`
86
+ // and unit tests only. Resolves to `T`, the `else` fallback, or `undefined`.
87
+ export function createDeferred<T>(options?: {
88
+ timeoutMs?: number;
89
+ fallback?: T | null;
90
+ }): {
91
+ promise: Promise<T | null | undefined>;
92
+ resolve: (value: T | null | undefined) => void;
93
+ } {
94
+ let resolveInner!: (value: T | null | undefined) => void;
95
+ let settled = false;
96
+ let timer: ReturnType<typeof setTimeout> | undefined;
97
+
98
+ const promise = new Promise<T | null | undefined>((resolve) => {
99
+ resolveInner = resolve;
100
+ });
101
+
102
+ const finish = (value: T | null | undefined): void => {
103
+ if (settled) return;
104
+ settled = true;
105
+ if (timer !== undefined) {
106
+ clearTimeout(timer);
107
+ timer = undefined;
108
+ }
109
+ resolveInner(value);
110
+ };
111
+
112
+ // 0 and Infinity are documented intentional disables. Any other non-finite or
113
+ // negative value (NaN, -1, a bad parsed config/env) is a mistake — fall back to
114
+ // the default rather than SILENTLY disabling the safety net, which would let a
115
+ // forgotten resolve hang the Flight stream and the response forever.
116
+ const requested = options?.timeoutMs ?? DEFAULT_DEFER_TIMEOUT_MS;
117
+ let ms: number;
118
+ if (requested === 0 || requested === Infinity) {
119
+ ms = requested;
120
+ } else if (Number.isFinite(requested) && requested > 0) {
121
+ ms = requested;
122
+ } else {
123
+ if (process.env.NODE_ENV !== "production") {
124
+ console.warn(
125
+ `[rango] defer(): invalid timeout ${String(requested)}; using the ` +
126
+ `${DEFAULT_DEFER_TIMEOUT_MS}ms default so the safety net stays on. ` +
127
+ `Use 0 or Infinity to disable the timeout intentionally.`,
128
+ );
129
+ }
130
+ ms = DEFAULT_DEFER_TIMEOUT_MS;
131
+ }
132
+
133
+ if (ms > 0 && ms !== Infinity) {
134
+ timer = setTimeout(() => {
135
+ if (process.env.NODE_ENV !== "production") {
136
+ console.warn(
137
+ `[rango] A deferred handle value was not resolved within ${ms}ms; ` +
138
+ `resolving to the fallback so the response can flush. Call the ` +
139
+ `resolver from the component that produces the value, or raise timeoutMs.`,
140
+ );
141
+ }
142
+ finish(options?.fallback);
143
+ }, ms);
144
+ // Don't let a pending timer alone keep a Node process alive (no-op on workerd).
145
+ (timer as { unref?: () => void }).unref?.();
146
+ }
147
+
148
+ return { promise, resolve: finish };
149
+ }
150
+
151
+ /**
152
+ * Attach `.defer()` to a handle push function. The deferred slot is reserved by
153
+ * pushing the deferred promise through the same push (so ordering, sealing, and
154
+ * Flight streaming all reuse the existing path); the returned resolver settles it.
155
+ */
156
+ export function withDefer<TData>(push: HandlePushFn<TData>): HandlePush<TData> {
157
+ const handlePush = push as HandlePush<TData>;
158
+ // Safe to mutate push in place: each ctx.use(Handle) call (request-context.ts,
159
+ // loader-resolution.ts) builds a fresh closure, so .defer never leaks across
160
+ // handles or requests.
161
+ handlePush.defer = (options) => {
162
+ const deferred = createDeferred<TData>({
163
+ timeoutMs: options?.timeoutMs,
164
+ fallback: options?.else,
165
+ });
166
+ // Reserve the slot now by pushing the pending promise (the renderer use()s it).
167
+ push(deferred.promise as Promise<TData>);
168
+ // The resolver is push-equal: a thunk is invoked immediately (as push does)
169
+ // and a Promise is adopted by the reserved slot. Calling it settles the slot
170
+ // and cancels the timeout — the timeout only fires if it is never called.
171
+ const resolveSlot = deferred.resolve as (
172
+ value: TData | Promise<TData>,
173
+ ) => void;
174
+ return (data) => {
175
+ // The thunk runs without re-entering the push-callback scope a direct push
176
+ // thunk gets on the action/revalidation path (loader-resolution.ts): a
177
+ // deferred resolver fires from a deep component after the handler phase has
178
+ // closed, so there is no live deadlock-guard window to exempt.
179
+ resolveSlot(
180
+ typeof data === "function" ? (data as () => Promise<TData>)() : data,
181
+ );
182
+ };
183
+ };
184
+ return handlePush;
185
+ }
package/src/deps/ssr.ts CHANGED
@@ -1,2 +1 @@
1
- // Re-export @vitejs/plugin-rsc/ssr for internal use by virtual entries
2
1
  export { createFromReadableStream } from "@vitejs/plugin-rsc/ssr";
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Shared key=value serialization core.
3
+ *
4
+ * One encode+join rule shared by every deterministic query-string serializer
5
+ * in the codebase (cache keys, route-param strings, search-param strings,
6
+ * prerender param hashes). Each call site differs only in how it pre-filters
7
+ * and orders the pairs it hands in, plus whether it sorts; the encoding itself
8
+ * (`encodeURIComponent(k)=encodeURIComponent(v)` joined by `&`) is identical.
9
+ *
10
+ * Dependency-free leaf module so any layer (build-time prerender hashing,
11
+ * runtime cache keys, client search-param serialization) can import it without
12
+ * pulling in cache/router internals.
13
+ */
14
+
15
+ export interface EncodeKVOptions {
16
+ /**
17
+ * Sort pairs by key in codepoint (byte) order before joining. Byte order via
18
+ * the `<` operator, NOT localeCompare, so the result is identical across
19
+ * Node, Workers, and browsers regardless of runtime locale.
20
+ *
21
+ * Defaults to false (insertion order preserved).
22
+ */
23
+ sort?: boolean;
24
+ }
25
+
26
+ /**
27
+ * Serialize an array of [key, value] pairs to a deterministic query string
28
+ * (no leading `?`). Both key and value are passed through encodeURIComponent
29
+ * so a key/value containing `&` or `=` cannot collide with a structurally
30
+ * different pair set.
31
+ *
32
+ * Callers are responsible for any filtering (e.g. dropping reserved params) and
33
+ * any value coercion (e.g. String(v), skipping null/undefined) BEFORE calling.
34
+ * This function never inspects or skips values; it encodes exactly what it is
35
+ * given so each call site keeps its own existing output.
36
+ */
37
+ export function encodeKV(
38
+ pairs: Iterable<readonly [string, string]>,
39
+ options: EncodeKVOptions = {},
40
+ ): string {
41
+ const list = [...pairs];
42
+ if (list.length === 0) return "";
43
+ if (options.sort) {
44
+ list.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
45
+ }
46
+ return list
47
+ .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
48
+ .join("&");
49
+ }
package/src/errors.ts CHANGED
@@ -225,7 +225,6 @@ export function isNetworkError(error: unknown): boolean {
225
225
  export class RouterError extends Error {
226
226
  name = "RouterError" as const;
227
227
  code: string;
228
- type?: string;
229
228
  status: number;
230
229
  cause?: unknown;
231
230
 
@@ -234,7 +233,6 @@ export class RouterError extends Error {
234
233
  message: string,
235
234
  options?: {
236
235
  status?: number;
237
- type?: string;
238
236
  cause?: unknown;
239
237
  },
240
238
  ) {
@@ -242,7 +240,6 @@ export class RouterError extends Error {
242
240
  Object.setPrototypeOf(this, RouterError.prototype);
243
241
  this.code = code;
244
242
  this.status = options?.status ?? 500;
245
- this.type = options?.type;
246
243
  this.cause = options?.cause;
247
244
  }
248
245
  }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Escape a JSON (or JSON-derived) string for safe embedding inside an HTML
3
+ * <script> element via dangerouslySetInnerHTML. Without this a value containing
4
+ * "</script>" closes the tag early — the rest of the page leaks as raw HTML, and
5
+ * in an executable script the trailing content runs. Escaping "<" defeats the
6
+ * early close; ">" and "&" are escaped for completeness so the serialized payload
7
+ * can never form HTML syntax. The result is still valid JSON and a valid JS
8
+ * string literal (\uXXXX escapes are legal in both) and re-parses identically.
9
+ *
10
+ * Used by every site that interpolates JSON.stringify(...) into inline <script>
11
+ * content: the JSON-LD meta descriptors (handles/MetaTags) and the FOUC theme
12
+ * init script (theme/theme-script).
13
+ */
14
+ export function escapeJsonForScript(json: string): string {
15
+ return json
16
+ .replace(/</g, "\\u003c")
17
+ .replace(/>/g, "\\u003e")
18
+ .replace(/&/g, "\\u0026");
19
+ }
20
+
21
+ /**
22
+ * Escape an inline <script> body so it cannot terminate or corrupt the document.
23
+ * Two sequences are rewritten, each via a JS escape that is valid in string,
24
+ * template, regex (including the `u`/`v` flags), and JSON contexts — so the body
25
+ * still parses identically as code AND as JSON (application/json, ld+json):
26
+ * - "</script" -> "<\/script": stops a literal close tag inside the body from
27
+ * ending the element early. `\/` is a valid JSON escape and a valid regex escape.
28
+ * - "<!--": the "!" (U+0021) is emitted as a unicode escape (see the replacement
29
+ * string below), so the literal "<!--" token never reaches the HTML parser. A
30
+ * literal "<!--" puts the parser into the "script data escaped" state and a
31
+ * following "<script" into "script data DOUBLE escaped", where the real
32
+ * "</script>" no longer closes the element — `var x = "<!--<script>"` would
33
+ * swallow the rest of the document. The unicode-escape form decodes back to "!"
34
+ * in string/template/JSON/regex contexts, unlike "\!" (invalid JSON, invalid
35
+ * /u-regex escape).
36
+ * Real operators such as `a < b` and `a && b` are untouched (unlike
37
+ * escapeJsonForScript, which \u-escapes every "<", "&", ">").
38
+ *
39
+ * GUARANTEE / LIMITATION: value-preserving for the contexts where these sequences
40
+ * legitimately appear — string/template literals, regexes (incl. `u`/`v`), and
41
+ * JSON. It is NOT source-text-preserving (e.g. String.raw`</script>` sees the
42
+ * extra backslash), and it cannot rewrite "</script"/"<!--" that appear as bare
43
+ * code (a legacy `<!--` line comment, or `</script` outside any literal) — neither
44
+ * occurs in valid script payloads. Not a general sanitizer for arbitrary UNTRUSTED
45
+ * source; for untrusted dynamic data, JSON-encode it and read it back, rather than
46
+ * inlining it as code.
47
+ *
48
+ * Used by the Script handle's <Scripts> renderer for inline `children`.
49
+ */
50
+ export function escapeScriptBody(js: string): string {
51
+ return js.replace(/<!--/g, "<\\u0021--").replace(/<\/(script)/gi, "<\\/$1");
52
+ }
package/src/handle.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { missingInjectedIdError } from "./missing-id-error.js";
2
+ import { isUnderTestRunner } from "./runtime-env.js";
2
3
 
3
4
  /**
4
5
  * Handle definition for accumulating data across route segments.
@@ -9,8 +10,11 @@ import { missingInjectedIdError } from "./missing-id-error.js";
9
10
  *
10
11
  * @example
11
12
  * ```ts
12
- * // Define a handle (name auto-generated from file + export)
13
- * export const Breadcrumbs = createHandle<BreadcrumbItem>();
13
+ * // Define a handle (name auto-generated from file + export).
14
+ * // Default collect is the identity: one array per segment that pushed.
15
+ * export const Breadcrumbs = createHandle<BreadcrumbItem, BreadcrumbItem[]>(
16
+ * (segments) => segments.flat(), // opt into a single flat list
17
+ * );
14
18
  *
15
19
  * // Use in handler
16
20
  * const push = ctx.use(Breadcrumbs);
@@ -20,7 +24,7 @@ import { missingInjectedIdError } from "./missing-id-error.js";
20
24
  * const crumbs = useHandle(Breadcrumbs);
21
25
  * ```
22
26
  */
23
- export interface Handle<TData, TAccumulated = TData[]> {
27
+ export interface Handle<TData, TAccumulated = TData[][]> {
24
28
  /**
25
29
  * Brand to distinguish handles from loaders in ctx.use()
26
30
  */
@@ -34,10 +38,13 @@ export interface Handle<TData, TAccumulated = TData[]> {
34
38
  }
35
39
 
36
40
  /**
37
- * Default collect function that flattens segment arrays into a single array.
41
+ * Default collect: pass the per-segment data through as-is one array per segment
42
+ * that pushed, in segment order. Lossless, so a consumer can tell which/how-many
43
+ * segments contributed. Callers that want a single flat list opt in with
44
+ * `createHandle((segments) => segments.flat())`.
38
45
  */
39
- function defaultCollect<T>(segments: T[][]): T[] {
40
- return segments.flat();
46
+ function defaultCollect<T>(segments: T[][]): T[][] {
47
+ return segments;
41
48
  }
42
49
 
43
50
  // Module-level registry mapping $$id to collect functions.
@@ -45,15 +52,10 @@ function defaultCollect<T>(segments: T[][]): T[] {
45
52
  // Used by useHandle() to recover collect when handle is deserialized from RSC prop.
46
53
  const collectRegistry = new Map<string, (segments: unknown[][]) => unknown>();
47
54
 
48
- // Monotonic counter for runtime fallback ids (see createHandle). Module-scoped
49
- // and deterministic, so each createHandle() call gets a stable, unique id within
50
- // the process. Only used when no build id was injected (a bare unit test).
55
+ // Monotonic counter for runtime fallback ids (see createHandle). Only used
56
+ // when no build id was injected (a bare unit test).
51
57
  let runtimeHandleIdCounter = 0;
52
58
 
53
- /**
54
- * Look up a collect function from the registry by handle $$id.
55
- * Returns undefined if not registered (falls back to defaultCollect in useHandle).
56
- */
57
59
  export function getCollectFn(
58
60
  id: string,
59
61
  ): ((segments: unknown[][]) => unknown) | undefined {
@@ -66,13 +68,22 @@ export function getCollectFn(
66
68
  * The $$id is auto-generated by the Vite exposeInternalIds plugin based on
67
69
  * file path and export name. No manual naming required.
68
70
  *
69
- * @param collect - Optional collect function (default: flatten into array)
71
+ * @param collect - Optional collect function. Default: pass the per-segment data
72
+ * through as-is (one array per segment that pushed, in segment order). Lossless,
73
+ * so a consumer can tell which/how-many segments contributed. Opt into a single
74
+ * flat list with `(segments) => segments.flat()`.
70
75
  * @param __injectedId - Auto-injected by Vite plugin, do not provide manually
71
76
  *
72
77
  * @example
73
78
  * ```ts
74
- * // Default: flatten into array
75
- * export const Breadcrumbs = createHandle<BreadcrumbItem>();
79
+ * // Default: per-segment grouping, as-is
80
+ * export const Pushed = createHandle<string>();
81
+ * // Result type: string[][] (e.g. [["a"], ["b", "c"]])
82
+ *
83
+ * // Opt into a single flat list
84
+ * export const Breadcrumbs = createHandle<BreadcrumbItem, BreadcrumbItem[]>(
85
+ * (segments) => segments.flat()
86
+ * );
76
87
  * // Result type: BreadcrumbItem[]
77
88
  *
78
89
  * // Custom: last value wins
@@ -87,8 +98,9 @@ export function getCollectFn(
87
98
  * );
88
99
  * // Result type: MetaTags
89
100
  *
90
- * // Custom: dedupe by href
91
- * export const Breadcrumbs = createHandle<BreadcrumbItem>(
101
+ * // Custom: dedupe by href (TAccumulated must be given — a custom collect that
102
+ * // returns a flat array no longer matches the default TData[][])
103
+ * export const Breadcrumbs = createHandle<BreadcrumbItem, BreadcrumbItem[]>(
92
104
  * (segments) => {
93
105
  * const all = segments.flat();
94
106
  * return all.filter((item, i) => all.findIndex(x => x.href === item.href) === i);
@@ -96,34 +108,36 @@ export function getCollectFn(
96
108
  * );
97
109
  * ```
98
110
  */
99
- export function createHandle<TData, TAccumulated = TData[]>(
111
+ export function createHandle<TData, TAccumulated = TData[][]>(
100
112
  collect?: (segments: TData[][]) => TAccumulated,
101
113
  __injectedId?: string,
102
114
  ): Handle<TData, TAccumulated> {
103
115
  let handleId = __injectedId ?? "";
104
116
 
105
- if (!handleId && process.env.NODE_ENV === "development") {
106
- throw missingInjectedIdError("Handle", "createHandle");
107
- }
108
-
109
- // No build-injected id. This only happens in a bare unit test — every real
110
- // build runs the rango Vite plugin, which always injects a stable id (and the
111
- // line above throws for a genuinely non-exported handle in dev). Assign a
112
- // process-stable runtime id so the collect registers below and the handle is
113
- // fully exercisable in tests (useHandle, collectHandle, renderRoute's `handles`
114
- // seeding run the REAL collect). Provably inert in production: the fallback
115
- // never triggers when the plugin injects the id, so server/client id
116
- // consistency (required for RSC recovery) is unaffected.
117
+ // No build-injected id. Under a test runner: fall back to a synthetic id so the
118
+ // collect registers below and the handle is exercisable in tests (useHandle,
119
+ // collectHandle, renderRoute's `handles` run the REAL collect). Otherwise (dev
120
+ // or a real build) it means an UNSUPPORTED handler shape the plugin skipped —
121
+ // fail loud. The rich, stack-parsing diagnostic stays behind the NODE_ENV check
122
+ // so a production build folds it away and tree-shakes missing-id-error.ts out,
123
+ // shipping the small throw instead. isUnderTestRunner() is runtime-safe.
117
124
  if (!handleId) {
118
- handleId = `__rango_runtime_handle_${runtimeHandleIdCounter++}`;
125
+ if (isUnderTestRunner()) {
126
+ handleId = `__rango_runtime_handle_${runtimeHandleIdCounter++}`;
127
+ } else if (process.env.NODE_ENV !== "production") {
128
+ throw missingInjectedIdError("Handle", "createHandle");
129
+ } else {
130
+ throw new Error(
131
+ "[rango] Handle is missing $$id — the build plugin did not inject one. " +
132
+ "Export it as `export const X = createHandle(...)`.",
133
+ );
134
+ }
119
135
  }
120
136
 
121
137
  const collectFn =
122
138
  collect ??
123
139
  (defaultCollect as unknown as (segments: TData[][]) => TAccumulated);
124
140
 
125
- // Register collect in module-level registry so useHandle() can recover it
126
- // when the handle is deserialized from RSC props (toJSON strips collect).
127
141
  collectRegistry.set(
128
142
  handleId,
129
143
  collectFn as (segments: unknown[][]) => unknown,
@@ -135,9 +149,6 @@ export function createHandle<TData, TAccumulated = TData[]>(
135
149
  };
136
150
  }
137
151
 
138
- /**
139
- * Type guard to check if a value is a Handle.
140
- */
141
152
  export function isHandle(value: unknown): value is Handle<unknown, unknown> {
142
153
  return (
143
154
  typeof value === "object" &&
@@ -161,12 +172,18 @@ export function collectHandleData<TData, TAccumulated>(
161
172
  data: Record<string, Record<string, unknown[]>>,
162
173
  segmentOrder: string[],
163
174
  ): TAccumulated {
175
+ // Fall back to the default (identity, pass-through) collect when none is
176
+ // registered — e.g. the handle's module was not imported so createHandle() never
177
+ // ran. This is harmless for a handle that wanted the default, but a handle with a
178
+ // CUSTOM collect that failed to register silently gets the wrong shape (identity
179
+ // TData[][]) cast as its declared TAccumulated. The runtime can't tell the two
180
+ // apart (a Handle only carries $$id), so warn in dev (folded out of production).
164
181
  const collectFn = getCollectFn(handle.$$id);
165
182
  if (!collectFn && process.env.NODE_ENV !== "production") {
166
183
  console.warn(
167
- `[rango] Handle "${handle.$$id}" has no registered collect function. ` +
168
- `Falling back to flat array. Ensure the handle module is imported so ` +
169
- `createHandle() runs and registers the collect function.`,
184
+ `[rango] Handle "${handle.$$id}" has no registered collect falling back ` +
185
+ `to the identity (per-segment data as-is). If this handle declares a ` +
186
+ `CUSTOM collect, import its module so createHandle() runs where it is read.`,
170
187
  );
171
188
  }
172
189
  const collect = (collectFn ??
@@ -3,11 +3,16 @@
3
3
  /**
4
4
  * Component to render collected meta descriptors in the document head.
5
5
  *
6
- * Supports both sync and async meta descriptors. Async descriptors
7
- * (Promise<MetaDescriptorBase>) are resolved using React's use() hook.
6
+ * Deferred (Promise) meta descriptors are resolved before MetaTags renders
7
+ * (server-side on the full render, client-side before apply on navigation), so
8
+ * it only ever receives resolved descriptors and never suspends.
8
9
  *
9
10
  * When theme is enabled in the router config, MetaTags also renders
10
11
  * the theme initialization script to prevent FOUC (flash of unstyled content).
12
+ * This makes MetaTags the sole FOUC-script injector for apps that render it;
13
+ * the standalone `<ThemeScript />` is only needed when MetaTags is not used.
14
+ * Rendering both is safe (the inline script guards listener registration) but
15
+ * redundant.
11
16
  *
12
17
  * @example
13
18
  * ```tsx
@@ -24,13 +29,13 @@
24
29
  * ```
25
30
  */
26
31
 
27
- import { use } from "react";
28
32
  import { useHandle } from "../browser/react/use-handle.js";
29
33
  import { Meta } from "./meta.js";
30
- import type { MetaDescriptor, MetaDescriptorBase } from "../router/types.js";
34
+ import type { MetaDescriptorBase } from "../router/types.js";
31
35
  import { useThemeContext } from "../theme/theme-context.js";
32
36
  import { generateThemeScript } from "../theme/theme-script.js";
33
37
  import { useNonce } from "../browser/react/nonce-context.js";
38
+ import { escapeJsonForScript } from "../escape-script.js";
34
39
 
35
40
  // Type guards for MetaDescriptorBase variants
36
41
  function hasCharSet(d: MetaDescriptorBase): d is { charSet: "utf-8" } {
@@ -90,31 +95,18 @@ function hasTagName(
90
95
  );
91
96
  }
92
97
 
93
- /**
94
- * Check if a value is a Promise.
95
- */
96
- function isPromise(value: unknown): value is Promise<unknown> {
97
- return value !== null && typeof value === "object" && "then" in value;
98
- }
99
-
100
- /**
101
- * Render a single meta descriptor as a React element.
102
- */
103
- function renderMetaDescriptor(
98
+ export function renderMetaDescriptor(
104
99
  descriptor: MetaDescriptorBase,
105
100
  index: number,
106
101
  ): React.ReactNode {
107
- // charset
108
102
  if (hasCharSet(descriptor)) {
109
103
  return <meta key="charSet" charSet={descriptor.charSet} />;
110
104
  }
111
105
 
112
- // title
113
106
  if (hasTitle(descriptor)) {
114
107
  return <title key="title">{descriptor.title}</title>;
115
108
  }
116
109
 
117
- // name + content (description, viewport, etc.)
118
110
  if (hasNameContent(descriptor)) {
119
111
  return (
120
112
  <meta
@@ -125,7 +117,6 @@ function renderMetaDescriptor(
125
117
  );
126
118
  }
127
119
 
128
- // property + content (Open Graph, etc.)
129
120
  if (hasPropertyContent(descriptor)) {
130
121
  return (
131
122
  <meta
@@ -136,7 +127,6 @@ function renderMetaDescriptor(
136
127
  );
137
128
  }
138
129
 
139
- // http-equiv + content
140
130
  if (hasHttpEquivContent(descriptor)) {
141
131
  return (
142
132
  <meta
@@ -147,9 +137,10 @@ function renderMetaDescriptor(
147
137
  );
148
138
  }
149
139
 
150
- // JSON-LD structured data
151
140
  if (hasScriptLdJson(descriptor)) {
152
- const json = JSON.stringify(descriptor["script:ld+json"]);
141
+ const json = escapeJsonForScript(
142
+ JSON.stringify(descriptor["script:ld+json"]),
143
+ );
153
144
  return (
154
145
  <script
155
146
  key={`ld-json-${index}`}
@@ -159,7 +150,6 @@ function renderMetaDescriptor(
159
150
  );
160
151
  }
161
152
 
162
- // Custom tagName (meta or link with arbitrary attributes)
163
153
  if (hasTagName(descriptor)) {
164
154
  const { tagName, ...rest } = descriptor;
165
155
  if (tagName === "link") {
@@ -180,7 +170,6 @@ function renderMetaDescriptor(
180
170
  }
181
171
  }
182
172
 
183
- // Fallback: treat as meta attributes
184
173
  return (
185
174
  <meta
186
175
  key={`meta-fallback-${index}`}
@@ -189,20 +178,6 @@ function renderMetaDescriptor(
189
178
  );
190
179
  }
191
180
 
192
- /**
193
- * Wrapper component to resolve a Promise<MetaDescriptorBase> using use().
194
- */
195
- function AsyncMetaTag({
196
- promise,
197
- index,
198
- }: {
199
- promise: Promise<MetaDescriptorBase>;
200
- index: number;
201
- }): React.ReactNode {
202
- const resolved = use(promise);
203
- return renderMetaDescriptor(resolved, index);
204
- }
205
-
206
181
  /**
207
182
  * Renders all collected meta descriptors from route handlers.
208
183
  *
@@ -212,11 +187,16 @@ function AsyncMetaTag({
212
187
  * When theme is enabled in router config, also renders the theme initialization
213
188
  * script to prevent FOUC (flash of unstyled content).
214
189
  *
215
- * Async meta descriptors (Promise<MetaDescriptorBase>) are resolved using
216
- * React's use() hook. RSC streaming handles the Promise resolution.
190
+ * Deferred (Promise) meta descriptors are resolved BEFORE MetaTags renders —
191
+ * server-side on the full/SSR render, client-side before apply on navigation
192
+ * (resolve-by-default) — so MetaTags only ever receives resolved descriptors and
193
+ * never suspends.
217
194
  */
218
195
  export function MetaTags(): React.ReactNode {
219
- const descriptors = useHandle(Meta) as MetaDescriptor[];
196
+ // Deferred descriptors are resolved BEFORE collect runs (resolve-by-default),
197
+ // and collectMeta strips unset markers, so the collected output is always
198
+ // resolved base descriptors (never a Promise).
199
+ const descriptors = useHandle(Meta) as MetaDescriptorBase[];
220
200
  const themeConfig = useThemeContext()?.config ?? null;
221
201
  const nonce = useNonce();
222
202
 
@@ -229,18 +209,9 @@ export function MetaTags(): React.ReactNode {
229
209
  dangerouslySetInnerHTML={{ __html: generateThemeScript(themeConfig) }}
230
210
  />
231
211
  )}
232
- {descriptors.map((descriptor, index) => {
233
- if (isPromise(descriptor)) {
234
- return (
235
- <AsyncMetaTag
236
- key={`async-${index}`}
237
- promise={descriptor}
238
- index={index}
239
- />
240
- );
241
- }
242
- return renderMetaDescriptor(descriptor, index);
243
- })}
212
+ {descriptors.map((descriptor, index) =>
213
+ renderMetaDescriptor(descriptor, index),
214
+ )}
244
215
  </>
245
216
  );
246
217
  }