@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
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { ComponentType } from "react";
8
+ import { isUnderTestRunner } from "./runtime-env.js";
8
9
 
9
10
  /**
10
11
  * Symbol used by React to mark client component references.
@@ -48,11 +49,21 @@ export function isClientComponent(
48
49
  *
49
50
  * @param component - The component to check
50
51
  * @param name - Name to use in error message (e.g., "document")
52
+ * @param opts.allowServerInTest - When true AND running under a test runner
53
+ * (`isUnderTestRunner()`), relax ONLY the "use client" requirement: a server
54
+ * component is accepted. The plugin's "use client" transform does not run in a
55
+ * bare unit test, so a real exported `document` (almost every app sets one) has
56
+ * no client marker and would otherwise throw at `createRouter`, blocking
57
+ * `dispatch`/`assertGeneratedRoutesMatch` against the real router. The document
58
+ * reference is irrelevant to those (no Flight render). The "not a JSX element"
59
+ * guard still fires, and a real dev/build still throws (mirrors the runtime
60
+ * fallback-id gating in handle.ts/loader.ts).
51
61
  * @throws Error if the component is not a client component
52
62
  */
53
63
  export function assertClientComponent(
54
64
  component: ComponentType<unknown> | unknown,
55
65
  name: string,
66
+ opts?: { allowServerInTest?: boolean },
56
67
  ): asserts component is ComponentType<unknown> {
57
68
  if (typeof component !== "function") {
58
69
  throw new Error(
@@ -62,6 +73,14 @@ export function assertClientComponent(
62
73
  );
63
74
  }
64
75
 
76
+ // Under a test runner the "use client" transform did not run, so a real
77
+ // server-rendered `document` has no client marker; accept it (the reference is
78
+ // never serialized in dispatch/route-map checks). Outside a test runner this
79
+ // still throws — the build-time safety net is preserved.
80
+ if (opts?.allowServerInTest && isUnderTestRunner()) {
81
+ return;
82
+ }
83
+
65
84
  if (!isClientComponent(component)) {
66
85
  throw new Error(
67
86
  `${name} must be a client component with "use client" directive at the top of the file. ` +
@@ -2,11 +2,13 @@
2
2
 
3
3
  import type { ReactNode, ReactElement } from "react";
4
4
  import { MetaTags } from "../handles/MetaTags.js";
5
+ import { Scripts } from "../handles/Scripts.js";
5
6
 
6
7
  /**
7
8
  * Default document component that provides a basic HTML structure.
8
9
  * Used when no custom document is provided to createRouter.
9
- * Includes MetaTags for automatic charset, viewport, and route meta support.
10
+ * Includes MetaTags for automatic charset, viewport, and route meta support,
11
+ * and Scripts (head + body sites) so the Script handle works out of the box.
10
12
  *
11
13
  * Uses suppressHydrationWarning on <html> because the theme script
12
14
  * may modify class/style attributes before React hydrates.
@@ -20,8 +22,12 @@ export function DefaultDocument({
20
22
  <html lang="en" suppressHydrationWarning>
21
23
  <head>
22
24
  <MetaTags />
25
+ <Scripts />
23
26
  </head>
24
- <body>{children}</body>
27
+ <body>
28
+ <Scripts position="body" />
29
+ {children}
30
+ </body>
25
31
  </html>
26
32
  );
27
33
  }
@@ -12,7 +12,7 @@
12
12
  * interface PaginationData { current: number; total: number }
13
13
  * export const Pagination = createVar<PaginationData>();
14
14
  *
15
- * // Non-cacheable var — throws if set/get inside cache() or "use cache"
15
+ * // Non-cacheable var — ctx.get(User) throws inside a cache() boundary
16
16
  * export const User = createVar<UserData>({ cache: false });
17
17
  *
18
18
  * // handler
@@ -26,7 +26,7 @@
26
26
  export interface ContextVar<T> {
27
27
  readonly __brand: "context-var";
28
28
  readonly key: symbol;
29
- /** When false, the var is non-cacheable — throws inside cache() / "use cache" */
29
+ /** When false, ctx.get(var) throws inside a cache() boundary. */
30
30
  readonly cache: boolean;
31
31
  /** Phantom field to carry the type parameter. Never set at runtime. */
32
32
  readonly __type?: T;
@@ -35,9 +35,9 @@ export interface ContextVar<T> {
35
35
  export interface ContextVarOptions {
36
36
  /**
37
37
  * When false, marks this variable as non-cacheable.
38
- * Setting or getting this var inside a cache() boundary or "use cache"
39
- * function will throw. Use for inherently request-specific data (user
40
- * sessions, auth tokens, etc.) that must never be baked into cached segments.
38
+ * Reading this var with ctx.get() inside a cache() boundary throws. Use for
39
+ * inherently request-specific data (user sessions, auth tokens, etc.) that
40
+ * must never be baked into cached segments.
41
41
  *
42
42
  * @default true
43
43
  */
@@ -70,13 +70,25 @@ export function isContextVar(value: unknown): value is ContextVar<unknown> {
70
70
  );
71
71
  }
72
72
 
73
+ /**
74
+ * Does a variables object hold any entries? Counts both string keys and the
75
+ * symbol-keyed entries (context vars are stored under symbols), so an object
76
+ * carrying only symbol-keyed vars is still reported as non-empty.
77
+ */
78
+ export function hasContextVars(variables: object): boolean {
79
+ return (
80
+ Object.keys(variables).length > 0 ||
81
+ Object.getOwnPropertySymbols(variables).length > 0
82
+ );
83
+ }
84
+
73
85
  /**
74
86
  * Symbol used as a Set stored on the variables object to track
75
87
  * which keys hold non-cacheable values (from write-level { cache: false }).
76
88
  */
77
89
  const NON_CACHEABLE_KEYS: unique symbol = Symbol.for(
78
90
  "rango:non-cacheable-keys",
79
- ) as any;
91
+ );
80
92
 
81
93
  function getNonCacheableKeys(variables: any): Set<string | symbol> {
82
94
  if (!variables[NON_CACHEABLE_KEYS]) {
@@ -0,0 +1,52 @@
1
+ import type { ReactNode } from "react";
2
+ import { isLoaderDataResult } from "./types.js";
3
+
4
+ // Shared by segment-system (server) and LoaderResolver (client) so the
5
+ // legacy/ok/error-fallback/throw decode of resolved loader values lives once.
6
+ // Last failing loader wins errorFallback; an error without a fallback throws.
7
+ export function decodeLoaderResults(
8
+ resolvedData: any[],
9
+ loaderIds: string[],
10
+ ): { loaderData: Record<string, any>; errorFallback: ReactNode } {
11
+ const loaderData: Record<string, any> = {};
12
+ let errorFallback: ReactNode = null;
13
+
14
+ for (let i = 0; i < loaderIds.length; i++) {
15
+ const id = loaderIds[i];
16
+ const result = resolvedData[i];
17
+
18
+ if (!isLoaderDataResult(result)) {
19
+ loaderData[id] = result;
20
+ continue;
21
+ }
22
+
23
+ if (result.ok) {
24
+ loaderData[id] = result.data;
25
+ continue;
26
+ }
27
+
28
+ // null/undefined is the producer's ONLY "no boundary found" sentinel
29
+ // (loader-resolution.ts sets fallback: null for the no-boundary and the
30
+ // fallback-render-threw cases). A matched boundary's rendered ReactNode can
31
+ // legitimately be falsy (0, "", false), so test for null explicitly rather
32
+ // than truthiness, otherwise a valid falsy fallback is discarded and the
33
+ // original loader error is rethrown.
34
+ if (result.fallback != null) {
35
+ errorFallback = result.fallback;
36
+ } else {
37
+ // No boundary: rethrow preserving the ErrorInfo identity (name/stack/
38
+ // code/cause) instead of a stripped generic Error.
39
+ const info = result.error;
40
+ const err = new Error(
41
+ info.message,
42
+ info.cause !== undefined ? { cause: info.cause } : undefined,
43
+ );
44
+ if (info.name) err.name = info.name;
45
+ if (info.stack) err.stack = info.stack;
46
+ if (info.code !== undefined) (err as { code?: string }).code = info.code;
47
+ throw err;
48
+ }
49
+ }
50
+
51
+ return { loaderData, errorFallback };
52
+ }
package/src/defer.ts ADDED
@@ -0,0 +1,196 @@
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 the renderer `use()`s; RSC Flight
18
+ * streams it as a late row, so a deferred-aware consumer reading the handle
19
+ * (`useHandle`) sees that entry as a `Promise` until it resolves (see
20
+ * {@link DeferredHandleEntry}). The hazard that guards against bugs: a deferred
21
+ * slot whose resolver is never called would keep the Flight stream — and the HTTP
22
+ * response — open forever. So a deferred auto-resolves to `else` after `timeoutMs`
23
+ * (default {@link DEFAULT_DEFER_TIMEOUT_MS}) if the resolver is never called,
24
+ * degrading gracefully (and warning in dev) instead of hanging the request.
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 appears in the accumulated handle data as a pending
78
+ * `Promise` until it resolves (see {@link DeferredHandleEntry}); a
79
+ * deferred-aware consumer narrows thenable entries (`use()`/`await` + null
80
+ * check) before dereferencing.
81
+ */
82
+ defer(options?: DeferOptions<TData>): HandlePushFn<TData>;
83
+ };
84
+
85
+ /**
86
+ * A handle entry a deferred-aware consumer may read from `useHandle`: either a
87
+ * resolved value, or a pending `Promise` that resolves to the value, to `else`,
88
+ * or (when no `else` was given) `undefined` on timeout. Reading code should treat
89
+ * thenable entries as such and narrow before dereferencing.
90
+ */
91
+ export type DeferredHandleEntry<TData> =
92
+ | TData
93
+ | Promise<TData | null | undefined>;
94
+
95
+ // Internal: a timeout-bounded { promise, resolve }. Not part of the public API
96
+ // (the public surface is `ctx.use(Handle).defer()`); exported for `withDefer`
97
+ // and unit tests only. Resolves to `T`, the `else` fallback, or `undefined`.
98
+ export function createDeferred<T>(options?: {
99
+ timeoutMs?: number;
100
+ fallback?: T | null;
101
+ }): {
102
+ promise: Promise<T | null | undefined>;
103
+ resolve: (value: T | null | undefined) => void;
104
+ } {
105
+ let resolveInner!: (value: T | null | undefined) => void;
106
+ let settled = false;
107
+ let timer: ReturnType<typeof setTimeout> | undefined;
108
+
109
+ const promise = new Promise<T | null | undefined>((resolve) => {
110
+ resolveInner = resolve;
111
+ });
112
+
113
+ const finish = (value: T | null | undefined): void => {
114
+ if (settled) return;
115
+ settled = true;
116
+ if (timer !== undefined) {
117
+ clearTimeout(timer);
118
+ timer = undefined;
119
+ }
120
+ resolveInner(value);
121
+ };
122
+
123
+ // 0 and Infinity are documented intentional disables. Any other non-finite or
124
+ // negative value (NaN, -1, a bad parsed config/env) is a mistake — fall back to
125
+ // the default rather than SILENTLY disabling the safety net, which would let a
126
+ // forgotten resolve hang the Flight stream and the response forever.
127
+ const requested = options?.timeoutMs ?? DEFAULT_DEFER_TIMEOUT_MS;
128
+ let ms: number;
129
+ if (requested === 0 || requested === Infinity) {
130
+ ms = requested;
131
+ } else if (Number.isFinite(requested) && requested > 0) {
132
+ ms = requested;
133
+ } else {
134
+ if (process.env.NODE_ENV !== "production") {
135
+ console.warn(
136
+ `[rango] defer(): invalid timeout ${String(requested)}; using the ` +
137
+ `${DEFAULT_DEFER_TIMEOUT_MS}ms default so the safety net stays on. ` +
138
+ `Use 0 or Infinity to disable the timeout intentionally.`,
139
+ );
140
+ }
141
+ ms = DEFAULT_DEFER_TIMEOUT_MS;
142
+ }
143
+
144
+ if (ms > 0 && ms !== Infinity) {
145
+ timer = setTimeout(() => {
146
+ if (process.env.NODE_ENV !== "production") {
147
+ console.warn(
148
+ `[rango] A deferred handle value was not resolved within ${ms}ms; ` +
149
+ `resolving to the fallback so the response can flush. Call the ` +
150
+ `resolver from the component that produces the value, or raise timeoutMs.`,
151
+ );
152
+ }
153
+ finish(options?.fallback);
154
+ }, ms);
155
+ // Don't let a pending timer alone keep a Node process alive (no-op on workerd).
156
+ (timer as { unref?: () => void }).unref?.();
157
+ }
158
+
159
+ return { promise, resolve: finish };
160
+ }
161
+
162
+ /**
163
+ * Attach `.defer()` to a handle push function. The deferred slot is reserved by
164
+ * pushing the deferred promise through the same push (so ordering, sealing, and
165
+ * Flight streaming all reuse the existing path); the returned resolver settles it.
166
+ */
167
+ export function withDefer<TData>(push: HandlePushFn<TData>): HandlePush<TData> {
168
+ const handlePush = push as HandlePush<TData>;
169
+ // Safe to mutate push in place: each ctx.use(Handle) call (request-context.ts,
170
+ // loader-resolution.ts) builds a fresh closure, so .defer never leaks across
171
+ // handles or requests.
172
+ handlePush.defer = (options) => {
173
+ const deferred = createDeferred<TData>({
174
+ timeoutMs: options?.timeoutMs,
175
+ fallback: options?.else,
176
+ });
177
+ // Reserve the slot now by pushing the pending promise (the renderer use()s it).
178
+ push(deferred.promise as Promise<TData>);
179
+ // The resolver is push-equal: a thunk is invoked immediately (as push does)
180
+ // and a Promise is adopted by the reserved slot. Calling it settles the slot
181
+ // and cancels the timeout — the timeout only fires if it is never called.
182
+ const resolveSlot = deferred.resolve as (
183
+ value: TData | Promise<TData>,
184
+ ) => void;
185
+ return (data) => {
186
+ // The thunk runs without re-entering the push-callback scope a direct push
187
+ // thunk gets on the action/revalidation path (loader-resolution.ts): a
188
+ // deferred resolver fires from a deep component after the handler phase has
189
+ // closed, so there is no live deadlock-guard window to exempt.
190
+ resolveSlot(
191
+ typeof data === "function" ? (data as () => Promise<TData>)() : data,
192
+ );
193
+ };
194
+ };
195
+ return handlePush;
196
+ }
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
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Custom error classes for RSC Router
2
+ * Custom error classes for Rango
3
3
  *
4
4
  * All errors include:
5
5
  * - Descriptive names for easy identification
@@ -27,6 +27,17 @@ export class RouteNotFoundError extends Error {
27
27
  }
28
28
  }
29
29
 
30
+ // name fallback covers cross-realm errors (Vite dev dupes, RSC serialization)
31
+ // where instanceof fails.
32
+ export function isRouteNotFoundError(
33
+ error: unknown,
34
+ ): error is RouteNotFoundError {
35
+ return (
36
+ error instanceof RouteNotFoundError ||
37
+ (error instanceof Error && error.name === "RouteNotFoundError")
38
+ );
39
+ }
40
+
30
41
  /**
31
42
  * Thrown when data is not found (e.g., product with ID doesn't exist)
32
43
  * Use this in handlers/loaders to trigger the nearest notFoundBoundary
@@ -109,6 +120,24 @@ export class BuildError extends Error {
109
120
  }
110
121
  }
111
122
 
123
+ /**
124
+ * Thrown when a route-definition DSL helper (route/layout/loader/cache/…) is
125
+ * called outside an active urls()/map() builder, so there is no
126
+ * AsyncLocalStorage build context to attach to. The message names the specific
127
+ * helper and how to fix it; the `cause` records the mechanical reason so the
128
+ * failure mode is identifiable (not conflated with an unrelated throw).
129
+ */
130
+ export class DslContextError extends Error {
131
+ name = "DslContextError" as const;
132
+ cause?: unknown;
133
+
134
+ constructor(message: string, options?: ErrorOptions) {
135
+ super(message);
136
+ Object.setPrototypeOf(this, DslContextError.prototype);
137
+ this.cause = options?.cause;
138
+ }
139
+ }
140
+
112
141
  /**
113
142
  * Thrown when a network request fails (server unreachable, no internet, etc.)
114
143
  * This error triggers the root error boundary with retry capability.
@@ -196,7 +225,6 @@ export function isNetworkError(error: unknown): boolean {
196
225
  export class RouterError extends Error {
197
226
  name = "RouterError" as const;
198
227
  code: string;
199
- type?: string;
200
228
  status: number;
201
229
  cause?: unknown;
202
230
 
@@ -205,7 +233,6 @@ export class RouterError extends Error {
205
233
  message: string,
206
234
  options?: {
207
235
  status?: number;
208
- type?: string;
209
236
  cause?: unknown;
210
237
  },
211
238
  ) {
@@ -213,7 +240,6 @@ export class RouterError extends Error {
213
240
  Object.setPrototypeOf(this, RouterError.prototype);
214
241
  this.code = code;
215
242
  this.status = options?.status ?? 500;
216
- this.type = options?.type;
217
243
  this.cause = options?.cause;
218
244
  }
219
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,3 +1,6 @@
1
+ import { missingInjectedIdError } from "./missing-id-error.js";
2
+ import { isUnderTestRunner } from "./runtime-env.js";
3
+
1
4
  /**
2
5
  * Handle definition for accumulating data across route segments.
3
6
  *
@@ -43,10 +46,10 @@ function defaultCollect<T>(segments: T[][]): T[] {
43
46
  // Used by useHandle() to recover collect when handle is deserialized from RSC prop.
44
47
  const collectRegistry = new Map<string, (segments: unknown[][]) => unknown>();
45
48
 
46
- /**
47
- * Look up a collect function from the registry by handle $$id.
48
- * Returns undefined if not registered (falls back to defaultCollect in useHandle).
49
- */
49
+ // Monotonic counter for runtime fallback ids (see createHandle). Only used
50
+ // when no build id was injected (a bare unit test).
51
+ let runtimeHandleIdCounter = 0;
52
+
50
53
  export function getCollectFn(
51
54
  id: string,
52
55
  ): ((segments: unknown[][]) => unknown) | undefined {
@@ -93,28 +96,36 @@ export function createHandle<TData, TAccumulated = TData[]>(
93
96
  collect?: (segments: TData[][]) => TAccumulated,
94
97
  __injectedId?: string,
95
98
  ): Handle<TData, TAccumulated> {
96
- const handleId = __injectedId ?? "";
99
+ let handleId = __injectedId ?? "";
97
100
 
98
- if (!handleId && process.env.NODE_ENV === "development") {
99
- throw new Error(
100
- "[rsc-router] Handle is missing $$id. " +
101
- "Make sure the exposeInternalIds Vite plugin is enabled and " +
102
- "the handle is exported with: export const MyHandle = createHandle(...)",
103
- );
101
+ // No build-injected id. Under a test runner: fall back to a synthetic id so the
102
+ // collect registers below and the handle is exercisable in tests (useHandle,
103
+ // collectHandle, renderRoute's `handles` run the REAL collect). Otherwise (dev
104
+ // or a real build) it means an UNSUPPORTED handler shape the plugin skipped —
105
+ // fail loud. The rich, stack-parsing diagnostic stays behind the NODE_ENV check
106
+ // so a production build folds it away and tree-shakes missing-id-error.ts out,
107
+ // shipping the small throw instead. isUnderTestRunner() is runtime-safe.
108
+ if (!handleId) {
109
+ if (isUnderTestRunner()) {
110
+ handleId = `__rango_runtime_handle_${runtimeHandleIdCounter++}`;
111
+ } else if (process.env.NODE_ENV !== "production") {
112
+ throw missingInjectedIdError("Handle", "createHandle");
113
+ } else {
114
+ throw new Error(
115
+ "[rango] Handle is missing $$id — the build plugin did not inject one. " +
116
+ "Export it as `export const X = createHandle(...)`.",
117
+ );
118
+ }
104
119
  }
105
120
 
106
121
  const collectFn =
107
122
  collect ??
108
123
  (defaultCollect as unknown as (segments: TData[][]) => TAccumulated);
109
124
 
110
- // Register collect in module-level registry so useHandle() can recover it
111
- // when the handle is deserialized from RSC props (toJSON strips collect).
112
- if (handleId) {
113
- collectRegistry.set(
114
- handleId,
115
- collectFn as (segments: unknown[][]) => unknown,
116
- );
117
- }
125
+ collectRegistry.set(
126
+ handleId,
127
+ collectFn as (segments: unknown[][]) => unknown,
128
+ );
118
129
 
119
130
  return {
120
131
  __brand: "handle" as const,
@@ -122,9 +133,6 @@ export function createHandle<TData, TAccumulated = TData[]>(
122
133
  };
123
134
  }
124
135
 
125
- /**
126
- * Type guard to check if a value is a Handle.
127
- */
128
136
  export function isHandle(value: unknown): value is Handle<unknown, unknown> {
129
137
  return (
130
138
  typeof value === "object" &&
@@ -151,7 +159,7 @@ export function collectHandleData<TData, TAccumulated>(
151
159
  const collectFn = getCollectFn(handle.$$id);
152
160
  if (!collectFn && process.env.NODE_ENV !== "production") {
153
161
  console.warn(
154
- `[rsc-router] Handle "${handle.$$id}" has no registered collect function. ` +
162
+ `[rango] Handle "${handle.$$id}" has no registered collect function. ` +
155
163
  `Falling back to flat array. Ensure the handle module is imported so ` +
156
164
  `createHandle() runs and registers the collect function.`,
157
165
  );