@rangojs/router 0.0.0-experimental.97 → 0.0.0-experimental.98914650

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 (356) hide show
  1. package/README.md +24 -9
  2. package/dist/bin/rango.js +157 -63
  3. package/dist/testing/vitest.js +82 -0
  4. package/dist/vite/index.js +1584 -639
  5. package/package.json +71 -21
  6. package/skills/api-client/SKILL.md +211 -0
  7. package/skills/breadcrumbs/SKILL.md +60 -0
  8. package/skills/bundle-analysis/SKILL.md +159 -0
  9. package/skills/cache-guide/SKILL.md +222 -30
  10. package/skills/caching/SKILL.md +263 -8
  11. package/skills/composability/SKILL.md +27 -2
  12. package/skills/css/SKILL.md +76 -0
  13. package/skills/document-cache/SKILL.md +78 -55
  14. package/skills/handler-use/SKILL.md +3 -1
  15. package/skills/hooks/SKILL.md +235 -28
  16. package/skills/host-router/SKILL.md +122 -22
  17. package/skills/i18n/SKILL.md +276 -0
  18. package/skills/intercept/SKILL.md +29 -5
  19. package/skills/layout/SKILL.md +13 -9
  20. package/skills/links/SKILL.md +173 -17
  21. package/skills/loader/SKILL.md +170 -23
  22. package/skills/middleware/SKILL.md +16 -10
  23. package/skills/migrate-nextjs/SKILL.md +38 -16
  24. package/skills/mime-routes/SKILL.md +27 -0
  25. package/skills/observability/SKILL.md +137 -0
  26. package/skills/parallel/SKILL.md +11 -7
  27. package/skills/prerender/SKILL.md +14 -33
  28. package/skills/rango/SKILL.md +250 -25
  29. package/skills/react-compiler/SKILL.md +168 -0
  30. package/skills/response-routes/SKILL.md +114 -47
  31. package/skills/route/SKILL.md +42 -5
  32. package/skills/router-setup/SKILL.md +3 -3
  33. package/skills/server-actions/SKILL.md +78 -42
  34. package/skills/tailwind/SKILL.md +27 -3
  35. package/skills/testing/SKILL.md +129 -0
  36. package/skills/testing/bindings.md +89 -0
  37. package/skills/testing/cache-prerender.md +124 -0
  38. package/skills/testing/client-components.md +122 -0
  39. package/skills/testing/e2e-parity.md +125 -0
  40. package/skills/testing/flight.md +92 -0
  41. package/skills/testing/handles.md +129 -0
  42. package/skills/testing/loader.md +128 -0
  43. package/skills/testing/middleware.md +99 -0
  44. package/skills/testing/render-handler.md +121 -0
  45. package/skills/testing/response-routes.md +95 -0
  46. package/skills/testing/reverse-and-types.md +84 -0
  47. package/skills/testing/server-actions.md +107 -0
  48. package/skills/testing/server-tree.md +128 -0
  49. package/skills/testing/setup.md +120 -0
  50. package/skills/typesafety/SKILL.md +316 -26
  51. package/skills/use-cache/SKILL.md +36 -5
  52. package/skills/vercel/SKILL.md +107 -0
  53. package/skills/view-transitions/SKILL.md +294 -0
  54. package/src/__augment-tests__/augment.ts +81 -0
  55. package/src/__augment-tests__/augmented.check.ts +116 -0
  56. package/src/__internal.ts +0 -65
  57. package/src/browser/action-coordinator.ts +53 -36
  58. package/src/browser/action-fence.ts +47 -0
  59. package/src/browser/app-shell.ts +14 -27
  60. package/src/browser/cookie-name.ts +140 -0
  61. package/src/browser/event-controller.ts +37 -143
  62. package/src/browser/history-state.ts +21 -0
  63. package/src/browser/index.ts +3 -3
  64. package/src/browser/invalidate-client-cache.ts +52 -0
  65. package/src/browser/navigation-bridge.ts +30 -59
  66. package/src/browser/navigation-client.ts +96 -84
  67. package/src/browser/navigation-store-handle.ts +38 -0
  68. package/src/browser/navigation-store.ts +32 -82
  69. package/src/browser/navigation-transaction.ts +9 -59
  70. package/src/browser/partial-update.ts +60 -127
  71. package/src/browser/prefetch/cache.ts +82 -72
  72. package/src/browser/prefetch/fetch.ts +108 -33
  73. package/src/browser/prefetch/queue.ts +6 -3
  74. package/src/browser/rango-state.ts +157 -115
  75. package/src/browser/react/Link.tsx +0 -2
  76. package/src/browser/react/NavigationProvider.tsx +41 -48
  77. package/src/browser/react/ScrollRestoration.tsx +10 -6
  78. package/src/browser/react/filter-segment-order.ts +0 -2
  79. package/src/browser/react/index.ts +0 -48
  80. package/src/browser/react/location-state-shared.ts +166 -8
  81. package/src/browser/react/location-state.ts +39 -14
  82. package/src/browser/react/use-action.ts +6 -15
  83. package/src/browser/react/use-handle.ts +17 -14
  84. package/src/browser/react/use-link-status.ts +0 -4
  85. package/src/browser/react/use-navigation.ts +0 -3
  86. package/src/browser/react/use-params.ts +11 -11
  87. package/src/browser/react/use-reverse.ts +106 -0
  88. package/src/browser/react/use-router.ts +20 -5
  89. package/src/browser/react/use-search-params.ts +0 -5
  90. package/src/browser/react/use-segments.ts +0 -13
  91. package/src/browser/response-adapter.ts +52 -1
  92. package/src/browser/rsc-router.tsx +70 -34
  93. package/src/browser/scroll-restoration.ts +22 -14
  94. package/src/browser/segment-structure-assert.ts +2 -2
  95. package/src/browser/server-action-bridge.ts +168 -44
  96. package/src/browser/types.ts +36 -21
  97. package/src/browser/validate-redirect-origin.ts +43 -16
  98. package/src/build/collect-fallback-refs.ts +107 -0
  99. package/src/build/generate-manifest.ts +60 -35
  100. package/src/build/generate-route-types.ts +3 -0
  101. package/src/build/index.ts +8 -2
  102. package/src/build/prefix-tree-utils.ts +123 -0
  103. package/src/build/route-trie.ts +89 -10
  104. package/src/build/route-types/codegen.ts +4 -4
  105. package/src/build/route-types/include-resolution.ts +1 -1
  106. package/src/build/route-types/param-extraction.ts +6 -3
  107. package/src/build/route-types/per-module-writer.ts +7 -4
  108. package/src/build/route-types/router-processing.ts +122 -22
  109. package/src/build/route-types/scan-filter.ts +1 -1
  110. package/src/build/route-types/source-scan.ts +118 -0
  111. package/src/build/runtime-discovery.ts +9 -20
  112. package/src/cache/cache-error.ts +104 -0
  113. package/src/cache/cache-policy.ts +68 -28
  114. package/src/cache/cache-runtime.ts +134 -32
  115. package/src/cache/cache-scope.ts +100 -74
  116. package/src/cache/cache-tag.ts +98 -0
  117. package/src/cache/cf/cf-cache-store.ts +2255 -238
  118. package/src/cache/cf/index.ts +6 -16
  119. package/src/cache/document-cache.ts +61 -20
  120. package/src/cache/handle-snapshot.ts +63 -0
  121. package/src/cache/index.ts +22 -20
  122. package/src/cache/memory-segment-store.ts +136 -37
  123. package/src/cache/profile-registry.ts +6 -30
  124. package/src/cache/read-through-swr.ts +41 -11
  125. package/src/cache/segment-codec.ts +0 -16
  126. package/src/cache/tag-invalidation.ts +230 -0
  127. package/src/cache/types.ts +33 -100
  128. package/src/cache/vercel/index.ts +11 -0
  129. package/src/cache/vercel/vercel-cache-store.ts +799 -0
  130. package/src/client.rsc.tsx +6 -21
  131. package/src/client.tsx +25 -61
  132. package/src/component-utils.ts +19 -0
  133. package/src/context-var.ts +17 -5
  134. package/src/decode-loader-results.ts +36 -0
  135. package/src/defer.ts +196 -0
  136. package/src/deps/ssr.ts +0 -1
  137. package/src/errors.ts +30 -4
  138. package/src/handle.ts +31 -23
  139. package/src/handles/MetaTags.tsx +0 -14
  140. package/src/handles/breadcrumbs.ts +16 -5
  141. package/src/handles/meta.ts +0 -39
  142. package/src/host/cookie-handler.ts +0 -36
  143. package/src/host/errors.ts +0 -24
  144. package/src/host/index.ts +8 -2
  145. package/src/host/pattern-matcher.ts +7 -50
  146. package/src/host/router.ts +107 -99
  147. package/src/host/testing.ts +40 -27
  148. package/src/host/types.ts +37 -4
  149. package/src/host/utils.ts +1 -1
  150. package/src/href-client.ts +137 -22
  151. package/src/index.rsc.ts +63 -9
  152. package/src/index.ts +64 -9
  153. package/src/internal-debug.ts +2 -4
  154. package/src/loader-store.ts +500 -0
  155. package/src/loader.rsc.ts +20 -13
  156. package/src/loader.ts +12 -11
  157. package/src/missing-id-error.ts +68 -0
  158. package/src/network-error-thrower.tsx +1 -6
  159. package/src/outlet-provider.tsx +1 -5
  160. package/src/prerender/param-hash.ts +10 -11
  161. package/src/prerender/store.ts +32 -37
  162. package/src/prerender.ts +61 -6
  163. package/src/redirect-origin.ts +100 -0
  164. package/src/response-utils.ts +9 -0
  165. package/src/reverse.ts +65 -40
  166. package/src/root-error-boundary.tsx +1 -19
  167. package/src/route-content-wrapper.tsx +7 -72
  168. package/src/route-definition/dsl-helpers.ts +244 -281
  169. package/src/route-definition/helper-factories.ts +29 -139
  170. package/src/route-definition/helpers-types.ts +40 -17
  171. package/src/route-definition/redirect.ts +43 -9
  172. package/src/route-definition/resolve-handler-use.ts +6 -0
  173. package/src/route-definition/use-item-types.ts +32 -0
  174. package/src/route-map-builder.ts +0 -16
  175. package/src/route-types.ts +19 -41
  176. package/src/router/basename.ts +14 -0
  177. package/src/router/content-negotiation.ts +15 -15
  178. package/src/router/error-handling.ts +13 -17
  179. package/src/router/find-match.ts +44 -23
  180. package/src/router/handler-context.ts +4 -41
  181. package/src/router/intercept-resolution.ts +14 -19
  182. package/src/router/lazy-includes.ts +9 -46
  183. package/src/router/loader-resolution.ts +91 -46
  184. package/src/router/logging.ts +0 -6
  185. package/src/router/manifest.ts +18 -29
  186. package/src/router/match-api.ts +0 -20
  187. package/src/router/match-context.ts +0 -22
  188. package/src/router/match-handlers.ts +57 -58
  189. package/src/router/match-middleware/background-revalidation.ts +0 -7
  190. package/src/router/match-middleware/cache-lookup.ts +150 -271
  191. package/src/router/match-middleware/cache-store.ts +3 -33
  192. package/src/router/match-middleware/intercept-resolution.ts +0 -22
  193. package/src/router/match-middleware/segment-resolution.ts +0 -22
  194. package/src/router/match-pipelines.ts +1 -42
  195. package/src/router/match-result.ts +31 -80
  196. package/src/router/metrics.ts +0 -34
  197. package/src/router/middleware-types.ts +5 -112
  198. package/src/router/middleware.ts +118 -133
  199. package/src/router/navigation-snapshot.ts +0 -51
  200. package/src/router/params-util.ts +23 -0
  201. package/src/router/pattern-matching.ts +62 -67
  202. package/src/router/prerender-match.ts +99 -63
  203. package/src/router/preview-match.ts +3 -1
  204. package/src/router/request-classification.ts +28 -62
  205. package/src/router/revalidation.ts +50 -56
  206. package/src/router/route-snapshot.ts +0 -1
  207. package/src/router/router-context.ts +0 -27
  208. package/src/router/router-interfaces.ts +68 -35
  209. package/src/router/router-options.ts +55 -1
  210. package/src/router/router-registry.ts +2 -5
  211. package/src/router/segment-resolution/fresh.ts +44 -63
  212. package/src/router/segment-resolution/helpers.ts +34 -0
  213. package/src/router/segment-resolution/loader-cache.ts +40 -37
  214. package/src/router/segment-resolution/revalidation.ts +203 -285
  215. package/src/router/segment-resolution/static-store.ts +19 -5
  216. package/src/router/segment-resolution/streamed-handler-telemetry.ts +52 -0
  217. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  218. package/src/router/segment-resolution.ts +4 -1
  219. package/src/router/segment-wrappers.ts +0 -3
  220. package/src/router/state-cookie-name.ts +33 -0
  221. package/src/router/substitute-pattern-params.ts +56 -0
  222. package/src/router/telemetry-otel.ts +0 -20
  223. package/src/router/telemetry.ts +96 -19
  224. package/src/router/timeout.ts +0 -20
  225. package/src/router/trie-matching.ts +87 -48
  226. package/src/router/types.ts +9 -63
  227. package/src/router/url-params.ts +0 -5
  228. package/src/router.ts +80 -41
  229. package/src/rsc/handler-context.ts +3 -2
  230. package/src/rsc/handler.ts +83 -78
  231. package/src/rsc/helpers.ts +93 -5
  232. package/src/rsc/index.ts +1 -1
  233. package/src/rsc/json-route-result.ts +38 -0
  234. package/src/rsc/manifest-init.ts +28 -41
  235. package/src/rsc/origin-guard.ts +39 -25
  236. package/src/rsc/progressive-enhancement.ts +12 -1
  237. package/src/rsc/redirect-guard.ts +99 -0
  238. package/src/rsc/response-error.ts +79 -12
  239. package/src/rsc/response-route-handler.ts +76 -62
  240. package/src/rsc/rsc-rendering.ts +41 -60
  241. package/src/rsc/runtime-warnings.ts +23 -10
  242. package/src/rsc/server-action.ts +62 -67
  243. package/src/rsc/ssr-setup.ts +16 -0
  244. package/src/rsc/types.ts +10 -5
  245. package/src/runtime-env.ts +18 -0
  246. package/src/search-params.ts +4 -20
  247. package/src/segment-loader-promise.ts +14 -2
  248. package/src/segment-system.tsx +199 -142
  249. package/src/serialize.ts +243 -0
  250. package/src/server/context.ts +150 -51
  251. package/src/server/cookie-store.ts +80 -5
  252. package/src/server/handle-store.ts +7 -24
  253. package/src/server/loader-registry.ts +5 -24
  254. package/src/server/request-context.ts +165 -87
  255. package/src/ssr/index.tsx +14 -14
  256. package/src/static-handler.ts +10 -13
  257. package/src/testing/cache-status.ts +162 -0
  258. package/src/testing/collect-handle.ts +40 -0
  259. package/src/testing/dispatch.ts +618 -0
  260. package/src/testing/dom.entry.ts +22 -0
  261. package/src/testing/e2e/fixture.ts +188 -0
  262. package/src/testing/e2e/index.ts +128 -0
  263. package/src/testing/e2e/matchers.ts +35 -0
  264. package/src/testing/e2e/page-helpers.ts +272 -0
  265. package/src/testing/e2e/parity.ts +387 -0
  266. package/src/testing/e2e/server.ts +195 -0
  267. package/src/testing/flight-matchers.ts +97 -0
  268. package/src/testing/flight-normalize.ts +11 -0
  269. package/src/testing/flight-runtime.d.ts +57 -0
  270. package/src/testing/flight-tree.ts +682 -0
  271. package/src/testing/flight.entry.ts +52 -0
  272. package/src/testing/flight.ts +232 -0
  273. package/src/testing/generated-routes.ts +183 -0
  274. package/src/testing/index.ts +99 -0
  275. package/src/testing/internal/context.ts +348 -0
  276. package/src/testing/internal/flight-client-globals.ts +30 -0
  277. package/src/testing/internal/seed-vars.ts +54 -0
  278. package/src/testing/render-handler.ts +330 -0
  279. package/src/testing/render-route.tsx +566 -0
  280. package/src/testing/run-loader.ts +378 -0
  281. package/src/testing/run-middleware.ts +205 -0
  282. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  283. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  284. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  285. package/src/testing/vitest-stubs/version.ts +5 -0
  286. package/src/testing/vitest.ts +305 -0
  287. package/src/theme/ThemeProvider.tsx +0 -52
  288. package/src/theme/ThemeScript.tsx +0 -6
  289. package/src/theme/constants.ts +0 -12
  290. package/src/theme/index.ts +0 -7
  291. package/src/theme/theme-context.ts +1 -5
  292. package/src/theme/theme-script.ts +0 -14
  293. package/src/theme/use-theme.ts +0 -3
  294. package/src/types/boundaries.ts +0 -35
  295. package/src/types/cache-types.ts +13 -4
  296. package/src/types/error-types.ts +30 -90
  297. package/src/types/global-namespace.ts +54 -41
  298. package/src/types/handler-context.ts +97 -22
  299. package/src/types/index.ts +1 -10
  300. package/src/types/loader-types.ts +6 -3
  301. package/src/types/request-scope.ts +0 -19
  302. package/src/types/route-config.ts +6 -50
  303. package/src/types/route-entry.ts +0 -6
  304. package/src/types/segments.ts +18 -14
  305. package/src/urls/include-helper.ts +9 -56
  306. package/src/urls/index.ts +1 -11
  307. package/src/urls/path-helper-types.ts +19 -5
  308. package/src/urls/path-helper.ts +17 -106
  309. package/src/urls/pattern-types.ts +36 -19
  310. package/src/urls/response-types.ts +20 -19
  311. package/src/urls/type-extraction.ts +58 -139
  312. package/src/urls/urls-function.ts +1 -18
  313. package/src/use-loader.tsx +292 -107
  314. package/src/vite/debug.ts +1 -0
  315. package/src/vite/discovery/bundle-postprocess.ts +8 -7
  316. package/src/vite/discovery/discover-routers.ts +95 -82
  317. package/src/vite/discovery/discovery-errors.ts +194 -0
  318. package/src/vite/discovery/prerender-collection.ts +26 -34
  319. package/src/vite/discovery/route-types-writer.ts +40 -84
  320. package/src/vite/discovery/state.ts +39 -1
  321. package/src/vite/discovery/virtual-module-codegen.ts +14 -34
  322. package/src/vite/index.ts +4 -0
  323. package/src/vite/plugin-types.ts +185 -10
  324. package/src/vite/plugins/cjs-to-esm.ts +3 -18
  325. package/src/vite/plugins/client-ref-dedup.ts +0 -11
  326. package/src/vite/plugins/client-ref-hashing.ts +12 -11
  327. package/src/vite/plugins/cloudflare-protocol-stub.ts +1 -21
  328. package/src/vite/plugins/expose-action-id.ts +4 -75
  329. package/src/vite/plugins/expose-id-utils.ts +3 -54
  330. package/src/vite/plugins/expose-ids/export-analysis.ts +76 -34
  331. package/src/vite/plugins/expose-ids/handler-transform.ts +6 -74
  332. package/src/vite/plugins/expose-ids/loader-transform.ts +3 -20
  333. package/src/vite/plugins/expose-ids/router-transform.ts +0 -13
  334. package/src/vite/plugins/expose-internal-ids.ts +57 -67
  335. package/src/vite/plugins/performance-tracks.ts +9 -16
  336. package/src/vite/plugins/refresh-cmd.ts +1 -1
  337. package/src/vite/plugins/use-cache-transform.ts +26 -49
  338. package/src/vite/plugins/vercel-output.ts +258 -0
  339. package/src/vite/plugins/version-injector.ts +2 -32
  340. package/src/vite/plugins/version-plugin.ts +32 -23
  341. package/src/vite/plugins/virtual-entries.ts +35 -17
  342. package/src/vite/rango.ts +148 -115
  343. package/src/vite/router-discovery.ts +220 -68
  344. package/src/vite/utils/ast-handler-extract.ts +15 -31
  345. package/src/vite/utils/bundle-analysis.ts +10 -15
  346. package/src/vite/utils/client-chunks.ts +184 -0
  347. package/src/vite/utils/forward-user-plugins.ts +171 -0
  348. package/src/vite/utils/manifest-utils.ts +4 -59
  349. package/src/vite/utils/package-resolution.ts +1 -73
  350. package/src/vite/utils/prerender-utils.ts +0 -34
  351. package/src/vite/utils/shared-utils.ts +95 -43
  352. package/src/browser/action-response-classifier.ts +0 -99
  353. package/src/browser/react/use-client-cache.ts +0 -58
  354. package/src/browser/shallow.ts +0 -40
  355. package/src/handles/index.ts +0 -7
  356. package/src/router/middleware-cookies.ts +0 -55
@@ -0,0 +1,566 @@
1
+ /**
2
+ * renderRoute — a React-Testing-Library-style helper for unit-testing CLIENT
3
+ * components that read @rangojs/router client context (useParams, useReverse,
4
+ * Outlet, useNavigation, useLoader).
5
+ *
6
+ * Peer of React Router's createRoutesStub and Expo Router's renderRouter. It
7
+ * mounts the router's NavigationProvider plus a synthetic segment tree so that
8
+ * a component under test sees real router context, without spinning up a
9
+ * server, a Vite build, or a real Flight round-trip.
10
+ *
11
+ * FIDELITY CONTRACT — read before relying on this helper:
12
+ * This renders the CLIENT tree ONLY. The segment tree is built synthetically
13
+ * from the `routes` you pass; there is no server render and no Flight
14
+ * (de)serialization. Consequences:
15
+ * - It will NOT catch server/client boundary reference-identity remount bugs
16
+ * (a server-serialized component reference differing from the client
17
+ * reference). Use renderServerTree / e2e for those.
18
+ * - It will NOT catch real Flight serialization errors (non-serializable
19
+ * props crossing the RSC boundary), loader execution on the server,
20
+ * middleware, or handler ordering. Those are renderServerTree / renderHandler
21
+ * / e2e territory.
22
+ * - Loader data, location state, and handle output are SEEDED directly into
23
+ * client context (see the `loaders` / `locationState` / `handles` options) —
24
+ * nothing is executed on the server. This exercises the read path
25
+ * (useLoader / useLocationState / useHandle from context), not the run path.
26
+ * - navigate() commits synchronously, so it does NOT drive the navigation
27
+ * lifecycle: useNavigation().state, useLinkStatus().pending, and
28
+ * useAction().state stay "idle". Assert pending/loading/submitting transition
29
+ * states with renderServerTree / e2e instead (navigate() warns once if used).
30
+ * What it DOES cover: client hooks that read NavigationProvider /
31
+ * OutletContext — useParams, useReverse, useHref, useMount, useNavigation,
32
+ * useRouter, usePathname, useSearchParams, Outlet nesting, useLoader /
33
+ * useFetchLoader (seeded data), useLocationState (seeded), and useHandle (seeded).
34
+ * Basename-mounted apps: pass the `basename` option so useRouter().basename,
35
+ * <Link> prefixing, and useMount/useHref resolve against the mount prefix
36
+ * (without it they resolve at the root "/"). For an include("/shop", ...)
37
+ * subtree, pass the `mount` option so useMount() returns the mounted prefix
38
+ * (the segment chain is wrapped in a MountContext exactly as in production).
39
+ */
40
+
41
+ import type { ReactNode, ComponentType } from "react";
42
+ import type { RenderResult } from "@testing-library/react";
43
+ import { renderSegments } from "../segment-system.js";
44
+ import {
45
+ createNavigationStore,
46
+ generateHistoryKey,
47
+ } from "../browser/navigation-store.js";
48
+ import { createEventController } from "../browser/event-controller.js";
49
+ import type { NavigationStore, NavigationBridge } from "../browser/types.js";
50
+ import type { EventController } from "../browser/event-controller.js";
51
+ import type { ResolvedSegment, RscMetadata } from "../browser/types.js";
52
+ import { NavigationProvider } from "../browser/react/NavigationProvider.js";
53
+ import { compilePattern } from "../router/pattern-matching.js";
54
+ import { normalizeBasename } from "../router/basename.js";
55
+ import type { LoaderDefinition } from "../types.js";
56
+ import type { LocationStateDefinition } from "../browser/react/location-state-shared.js";
57
+ import type { Handle } from "../handle.js";
58
+ import type { ThemeConfig } from "../theme/types.js";
59
+ import { resolveThemeConfig } from "../theme/constants.js";
60
+ import { isUnderTestRunner } from "../runtime-env.js";
61
+
62
+ const TEST_ORIGIN = "http://localhost";
63
+
64
+ /**
65
+ * Seed shape for `options.handle`, matching the handle wire format:
66
+ * `{ [handleName]: { [segmentId]: pushedValues[] } }` (each segment accumulates
67
+ * an array of values pushed for that handle).
68
+ */
69
+ export type HandleDataSeed = Record<string, Record<string, unknown[]>>;
70
+
71
+ const syntheticIds = new WeakMap<object, string>();
72
+ let syntheticIdCounter = 0;
73
+
74
+ function ensureSyntheticId(
75
+ handle: object,
76
+ field: "$$id" | "__rsc_ls_key",
77
+ ): string {
78
+ const existing = (handle as Record<string, string>)[field];
79
+ if (existing) return existing;
80
+ let id = syntheticIds.get(handle);
81
+ if (!id) {
82
+ id = `__rango_test_id_${syntheticIdCounter++}`;
83
+ syntheticIds.set(handle, id);
84
+ }
85
+ (handle as Record<string, string>)[field] = id;
86
+ return id;
87
+ }
88
+
89
+ function cloneHandleSeed(seed?: HandleDataSeed): HandleDataSeed {
90
+ const out: HandleDataSeed = {};
91
+ for (const [name, segMap] of Object.entries(seed ?? {})) {
92
+ out[name] = { ...segMap };
93
+ }
94
+ return out;
95
+ }
96
+
97
+ export interface RenderRouteSpec {
98
+ /**
99
+ * The route pattern this node matches, e.g. "/products/:productId". The LAST
100
+ * spec in the array is treated as the leaf route; earlier specs are layouts
101
+ * wrapping it. Only the leaf pattern is matched against the `request` URL to
102
+ * extract params; layout patterns are informational.
103
+ */
104
+ path: string;
105
+ /** The component rendered for this node (the leaf route or a layout body). */
106
+ Component: ComponentType;
107
+ /**
108
+ * Optional layout component. When set on the LEAF spec it wraps the route in
109
+ * its own layout segment (useful for a route that owns a layout). Prefer
110
+ * expressing layouts as their own array entries instead.
111
+ */
112
+ layout?: ComponentType;
113
+ /**
114
+ * Loader ids ($$id) whose seeded data (from `options.loaderData`) should be
115
+ * attached to THIS node's segment so useLoader/useFetchLoader resolve it from
116
+ * context. When omitted, every key in `options.loaderData` is attached to the
117
+ * leaf route segment.
118
+ */
119
+ loaderIds?: string[];
120
+ /** Optional route name (informational; not used for matching). */
121
+ name?: string;
122
+ }
123
+
124
+ /**
125
+ * Options for renderRoute.
126
+ */
127
+ export interface RenderRouteOptions {
128
+ /**
129
+ * The initial location to render at: a `Request`, or a URL string (absolute or
130
+ * path). Only the URL is read (this is a client render — headers/method are
131
+ * ignored); named `request` for parity with the other primitives. Defaults to
132
+ * the leaf spec's static prefix or "/".
133
+ */
134
+ request?: Request | string;
135
+ /**
136
+ * Loader data to seed into client context, keyed by loader id ($$id). A
137
+ * component calling useLoader(SomeLoader) reads `loaderData[SomeLoader.$$id]`.
138
+ * Seeded values are placed in the route segment's OutletProvider context, so
139
+ * the read path is exercised without executing any loader.
140
+ */
141
+ loaderData?: Record<string, unknown>;
142
+ /**
143
+ * Loaders to seed by REFERENCE — the robust way to test a component that calls
144
+ * `useLoader(loader)`. A real `createLoader()` handle has an empty `$$id` in a
145
+ * bare test (the id is injected by the Vite plugin at build time), so keying
146
+ * `loaderData` by `$$id` collides under `""` and `useLoader` resolves nothing.
147
+ * Passing `[loader, data]` pairs lets renderRoute assign a synthetic stable id
148
+ * and wire `useLoader` to it. Prefer this over `loaderData` for real handles.
149
+ *
150
+ * NOTE: when a real handle has no `$$id`, renderRoute MUTATES it to assign a
151
+ * synthetic stable id (so repeat renders key consistently). This is a side
152
+ * effect on your input object; a handle reused across tests keeps that id.
153
+ *
154
+ * @example
155
+ * // useLoader returns an ENVELOPE — destructure `data`, it is not the bare value.
156
+ * function CartBadge() {
157
+ * const { data } = useLoader(CartLoader); // NOT `useLoader(CartLoader).itemCount`
158
+ * return <span>{data.itemCount}</span>;
159
+ * }
160
+ * renderRoute([{ path: "/cart", Component: CartBadge }], {
161
+ * loaders: [[CartLoader, { itemCount: 3, total: 89.97 }]],
162
+ * });
163
+ */
164
+ loaders?: ReadonlyArray<readonly [LoaderDefinition<any>, unknown]>;
165
+ /**
166
+ * Explicit params. Merged over (and overriding) params extracted from the
167
+ * `request` URL. Use this when the URL alone cannot express the params, or to
168
+ * avoid relying on URL parsing. Supplying params also OPTS OUT of the
169
+ * request/leaf match check: a `request` whose pathname does not resolve the
170
+ * leaf is normally rejected under the test runner, but passing params here
171
+ * tells renderRoute the request is intentionally not the param source.
172
+ */
173
+ params?: Record<string, string>;
174
+ /**
175
+ * Location-state values to seed by REFERENCE, for components that call
176
+ * `useLocationState(StateDef)`. Like loaders, a real `createLocationState()`
177
+ * handle has an empty injected key in a bare test, so pass `[def, value]`
178
+ * pairs; renderRoute assigns a synthetic key and writes it to `history.state`.
179
+ *
180
+ * @example
181
+ * renderRoute([{ path: "/", Component: FlashBanner }], {
182
+ * locationState: [[FlashMessage, { text: "Saved" }]],
183
+ * });
184
+ */
185
+ locationState?: ReadonlyArray<
186
+ readonly [LocationStateDefinition<any, any>, unknown]
187
+ >;
188
+ /**
189
+ * Handles to seed by REFERENCE, for components that read handle output via
190
+ * `useHandle(SomeHandle)` (e.g. a client `Breadcrumbs` trail). Each entry is
191
+ * `[handle, pushedValues[]]` — the values a route's handlers would have pushed;
192
+ * renderRoute attaches them to the leaf route segment under the handle's id.
193
+ * Built-in handles (Breadcrumbs/Meta) have stable ids and work directly.
194
+ *
195
+ * Handle data is accumulated GLOBALLY on the event controller, not scoped per
196
+ * segment like loaders — so ANY component in the chain reads the seeded values,
197
+ * a LAYOUT (e.g. a DetailLayout/ActionToolbar reading a handle) just as much as
198
+ * the leaf route. Most handle usage is server-side (`ctx.use(...)`) and is
199
+ * better covered by `renderToFlightString`/e2e; this seeds the client read path
200
+ * only.
201
+ *
202
+ * @example
203
+ * renderRoute([{ path: "/p", Component: BreadcrumbTrail }], {
204
+ * handles: [[Breadcrumbs, [{ label: "Home", href: "/" }, { label: "P", href: "/p" }]]],
205
+ * });
206
+ */
207
+ handles?: ReadonlyArray<readonly [Handle<any, any>, unknown[]]>;
208
+ /**
209
+ * Advanced: raw handle data in wire format
210
+ * `{ [handleId]: { [segmentId]: pushedValues[] } }`. Prefer `handles` (which
211
+ * computes the segment id for you). Merged with `handles`.
212
+ */
213
+ handle?: HandleDataSeed;
214
+ /**
215
+ * Route name -> pattern map. Informational for parity with the server test
216
+ * context; client useReverse takes its map directly as an argument, so this
217
+ * is not consumed by the client hooks.
218
+ */
219
+ routeMap?: Record<string, string>;
220
+ /**
221
+ * Router basename (the `createRouter({ basename })` value). Wired into
222
+ * NavigationProvider so `useRouter().basename`, `<Link>` href prefixing, and
223
+ * `useMount`/`useHref` resolve against the mounted prefix instead of the root.
224
+ * Normalized exactly like createRouter (leading slash forced, trailing
225
+ * stripped, bare "/" -> undefined). Defaults to undefined (root mount).
226
+ */
227
+ basename?: string;
228
+ /**
229
+ * include() mount prefix, to model an `include("/shop", ...)` subtree so a
230
+ * component (route OR layout in the chain) calling `useMount()` returns the
231
+ * mounted prefix instead of "/". Wraps the segment chain in a MountContext
232
+ * exactly as `renderSegments` does in production (a segment whose `mountPath`
233
+ * is set is wrapped in a MountContextProvider). Normalized like a path prefix
234
+ * (leading slash forced, trailing stripped, bare "/" -> root). Defaults to "/".
235
+ * An explicitly-passed `request` must match the leaf `path` directly (paths are
236
+ * include-RELATIVE; the mount does NOT rewrite the request) — pass the relative
237
+ * path, not the mount-prefixed one, or renderRoute throws rather than silently
238
+ * rendering empty params.
239
+ *
240
+ * @example
241
+ * renderRoute([{ path: "/c/wine", Component: ProductPage }], { mount: "/shop" });
242
+ * // useMount() inside ProductPage returns "/shop"
243
+ */
244
+ mount?: string;
245
+ /**
246
+ * Theme config in the `createRouter({ theme })` shape (resolved internally) to
247
+ * wrap the tree in a ThemeProvider. Defaults to no provider. Note: a component
248
+ * that calls `useTheme()` REQUIRES a provider — it throws "used outside
249
+ * ThemeProvider" without one — so pass a config (e.g. `true`) to test such a
250
+ * component.
251
+ */
252
+ theme?: ThemeConfig | true;
253
+ }
254
+
255
+ /**
256
+ * Imperative handle returned alongside the RTL result.
257
+ */
258
+ export interface TestRouterHandle {
259
+ /**
260
+ * Navigate to a new URL. Re-resolves the URL against the supplied `routes`,
261
+ * updates params + location, and re-renders the segment tree. This is a
262
+ * client-only navigation: no server fetch occurs, so only the components in
263
+ * `routes` can be reached.
264
+ */
265
+ navigate(url: string): Promise<void>;
266
+ /** The current committed pathname. */
267
+ pathname(): string;
268
+ /** The current committed params. */
269
+ params(): Record<string, string>;
270
+ /** The underlying navigation store (advanced use). */
271
+ store: NavigationStore;
272
+ /** The underlying event controller (advanced use). */
273
+ eventController: EventController;
274
+ }
275
+
276
+ /** Result of renderRoute: RTL's render result plus the router handle. */
277
+ export type RenderRouteResult = RenderResult & { router: TestRouterHandle };
278
+
279
+ interface ResolvedMatch {
280
+ params: Record<string, string>;
281
+ pathname: string;
282
+ }
283
+
284
+ function matchLeaf(
285
+ pattern: string,
286
+ pathname: string,
287
+ ): Record<string, string> | null {
288
+ const compiled = compilePattern(pattern);
289
+ const match = compiled.regex.exec(pathname);
290
+ if (!match) return null;
291
+ const params: Record<string, string> = {};
292
+ compiled.paramNames.forEach((name, index) => {
293
+ const value = match[index + 1];
294
+ if (value !== undefined) {
295
+ params[name] = decodeURIComponent(value);
296
+ }
297
+ });
298
+ return params;
299
+ }
300
+
301
+ function staticPrefix(pattern: string): string {
302
+ const out: string[] = [];
303
+ for (const part of pattern.split("/")) {
304
+ if (part === "") continue;
305
+ if (part.startsWith(":") || part === "*") break;
306
+ out.push(part);
307
+ }
308
+ return "/" + out.join("/");
309
+ }
310
+
311
+ function buildSegments(
312
+ routes: RenderRouteSpec[],
313
+ params: Record<string, string>,
314
+ loaderData: Record<string, unknown>,
315
+ mount?: string,
316
+ ): ResolvedSegment[] {
317
+ const segments: ResolvedSegment[] = [];
318
+ const leafIndex = routes.length - 1;
319
+ let idPath = "";
320
+
321
+ const seededIds = Object.keys(loaderData);
322
+ const explicitlyOwned = new Set<string>();
323
+ for (const spec of routes) {
324
+ for (const id of spec.loaderIds ?? []) explicitlyOwned.add(id);
325
+ }
326
+
327
+ routes.forEach((spec, i) => {
328
+ const isLeaf = i === leafIndex;
329
+ const tag = isLeaf ? `R${i}` : `L${i}`;
330
+ idPath = idPath + tag;
331
+ const segmentId = idPath;
332
+
333
+ const Component = spec.Component;
334
+ const node: ResolvedSegment = {
335
+ id: segmentId,
336
+ namespace: "",
337
+ type: isLeaf ? "route" : "layout",
338
+ index: i,
339
+ component: <Component />,
340
+ params,
341
+ belongsToRoute: true,
342
+ };
343
+ if (mount) node.mountPath = mount;
344
+ if (isLeaf && spec.layout) {
345
+ const Layout = spec.layout;
346
+ node.layout = <Layout />;
347
+ }
348
+ segments.push(node);
349
+
350
+ const ownedIds = spec.loaderIds
351
+ ? spec.loaderIds.filter((id) => id in loaderData)
352
+ : isLeaf
353
+ ? seededIds.filter((id) => !explicitlyOwned.has(id))
354
+ : [];
355
+
356
+ ownedIds.forEach((loaderId, li) => {
357
+ segments.push({
358
+ id: `${segmentId}D${li}.${loaderId}`,
359
+ namespace: "",
360
+ type: "loader",
361
+ index: li,
362
+ component: null,
363
+ loaderId,
364
+ loaderData: loaderData[loaderId],
365
+ params,
366
+ });
367
+ });
368
+ });
369
+
370
+ return segments;
371
+ }
372
+
373
+ export async function renderRoute(
374
+ routes: RenderRouteSpec[],
375
+ options: RenderRouteOptions = {},
376
+ ): Promise<RenderRouteResult> {
377
+ if (routes.length === 0) {
378
+ throw new Error("renderRoute: `routes` must contain at least one entry");
379
+ }
380
+ if ("initialUrl" in options) {
381
+ throw new Error(
382
+ "renderRoute: the `initialUrl` option was renamed to `request`. " +
383
+ "Pass { request: <Request | url> } instead.",
384
+ );
385
+ }
386
+
387
+ const { render, act } = await import("@testing-library/react");
388
+
389
+ const leaf = routes[routes.length - 1];
390
+ const requestUrl =
391
+ options.request instanceof Request ? options.request.url : options.request;
392
+ const initialUrl = requestUrl ?? staticPrefix(leaf.path) ?? "/";
393
+ const url = new URL(initialUrl, TEST_ORIGIN);
394
+
395
+ const loaderData: Record<string, unknown> = { ...(options.loaderData ?? {}) };
396
+ for (const [loader, data] of options.loaders ?? []) {
397
+ loaderData[ensureSyntheticId(loader as object, "$$id")] = data;
398
+ }
399
+
400
+ if (typeof window !== "undefined") {
401
+ const stateObj: Record<string, unknown> = {};
402
+ for (const [def, value] of options.locationState ?? []) {
403
+ stateObj[ensureSyntheticId(def as object, "__rsc_ls_key")] = value;
404
+ }
405
+ window.history.replaceState(stateObj, "");
406
+ }
407
+
408
+ const resolve = (pathname: string): ResolvedMatch => {
409
+ const matched = matchLeaf(leaf.path, pathname) ?? {};
410
+ return {
411
+ params: { ...matched, ...(options.params ?? {}) },
412
+ pathname,
413
+ };
414
+ };
415
+ const initialMatch = resolve(url.pathname);
416
+
417
+ const historyKey = generateHistoryKey(url.href);
418
+ const mount = normalizeBasename(options.mount);
419
+ // Fail loud on a request that cannot resolve the leaf route (a typo, or the
420
+ // mount-prefixed-vs-relative confusion) instead of silently rendering empty
421
+ // params (matchLeaf -> null -> {}). renderRoute paths are include-RELATIVE and
422
+ // resolve() matches the request against the leaf as-is, so the request must be
423
+ // the relative form — a mount does NOT rewrite it. Only checked when `request`
424
+ // was passed explicitly (a defaulted request is staticPrefix of the leaf and
425
+ // always matches). Skipped when explicit `params` are supplied: those are
426
+ // merged over the URL-extracted params in resolve(), so the request is
427
+ // intentionally not the param source and an empty matchLeaf is not the trap.
428
+ // Gated on the test runner so it can never affect production.
429
+ if (
430
+ options.request !== undefined &&
431
+ Object.keys(options.params ?? {}).length === 0 &&
432
+ isUnderTestRunner() &&
433
+ matchLeaf(leaf.path, url.pathname) === null
434
+ ) {
435
+ throw new Error(
436
+ `renderRoute: request "${url.pathname}" does not match the leaf route ` +
437
+ `"${leaf.path}"${mount ? ` (mount "${mount}")` : ""}. renderRoute paths ` +
438
+ `are include-RELATIVE: pass a request that matches "${leaf.path}" ` +
439
+ `(e.g. "${staticPrefix(leaf.path)}"). A mount does NOT auto-rewrite the ` +
440
+ `request — pass the relative path, not the mount-prefixed one. If the ` +
441
+ `request URL intentionally does not carry the params, pass them ` +
442
+ `explicitly via the \`params\` option to bypass this check.`,
443
+ );
444
+ }
445
+ const initialSegments = buildSegments(
446
+ routes,
447
+ initialMatch.params,
448
+ loaderData,
449
+ mount,
450
+ );
451
+ const store = createNavigationStore({
452
+ initialLocation: { href: url.href },
453
+ initialSegmentIds: initialSegments.map((s) => s.id),
454
+ initialHistoryKey: historyKey,
455
+ initialSegments,
456
+ crossTabSync: false,
457
+ });
458
+ const leafRouteSegmentId =
459
+ [...initialSegments].reverse().find((s) => s.type === "route")?.id ??
460
+ initialSegments[initialSegments.length - 1]?.id;
461
+ const handleSeed: HandleDataSeed = cloneHandleSeed(options.handle);
462
+ for (const [handle, values] of options.handles ?? []) {
463
+ if (leafRouteSegmentId === undefined) continue;
464
+ const id = (handle as unknown as { $$id: string }).$$id;
465
+ (handleSeed[id] ??= {})[leafRouteSegmentId] = values;
466
+ }
467
+
468
+ const eventController = createEventController({ initialLocation: url });
469
+ eventController.setParams(initialMatch.params);
470
+ eventController.setHandleData(
471
+ handleSeed,
472
+ initialSegments.map((s) => s.id),
473
+ );
474
+
475
+ let warnedNavLifecycle = false;
476
+ const navigate = async (target: string): Promise<void> => {
477
+ // renderRoute commits navigations synchronously (no server fetch, no Flight
478
+ // stream), so it never drives the navigation lifecycle. The transition state
479
+ // useNavigation()/useLinkStatus()/useAction() read stays "idle" — asserting a
480
+ // pending/loading/submitting state here proves nothing. Warn once (per render)
481
+ // under the test runner so that false-confidence trap is loud, not silent.
482
+ if (isUnderTestRunner() && !warnedNavLifecycle) {
483
+ warnedNavLifecycle = true;
484
+ console.warn(
485
+ "renderRoute: navigate()/useRouter().push commit synchronously and do " +
486
+ "NOT drive the navigation lifecycle. useNavigation().state, " +
487
+ 'useLinkStatus().pending, and useAction().state stay "idle" here. ' +
488
+ "Assert params/pathname/content after navigate(); use renderServerTree " +
489
+ "or e2e to assert pending/loading/submitting transition states.",
490
+ );
491
+ }
492
+ const nextUrl = new URL(target, TEST_ORIGIN);
493
+ const match = resolve(nextUrl.pathname);
494
+ const segments = buildSegments(routes, match.params, loaderData, mount);
495
+ const metadata = makeMetadata(nextUrl.pathname, segments, match.params);
496
+ const root = await renderSegments(segments);
497
+ eventController.setLocation(nextUrl);
498
+ eventController.setParams(match.params);
499
+ store.setCurrentUrl(nextUrl.href);
500
+ store.setSegmentIds(segments.map((s) => s.id));
501
+ await act(async () => {
502
+ store.emitUpdate({ root, metadata });
503
+ });
504
+ };
505
+
506
+ const bridge: NavigationBridge = {
507
+ navigate: (target) => navigate(target),
508
+ refresh: () => navigate(url.pathname + url.search),
509
+ handlePopstate: async () => {},
510
+ registerLinkInterception: () => () => {},
511
+ getVersion: () => undefined,
512
+ updateVersion: () => {},
513
+ };
514
+
515
+ const initialMetadata = makeMetadata(
516
+ url.pathname,
517
+ initialSegments,
518
+ initialMatch.params,
519
+ );
520
+ const initialTree = await renderSegments(initialSegments);
521
+
522
+ // Wrap render in an awaited async act so a tree that suspends (async loaders,
523
+ // loading states, deferred handle entries that arrive as a Promise) settles its
524
+ // Suspense within act — otherwise React orphans the resolution ("a component
525
+ // suspended inside an act scope, but the act call was not awaited") and the
526
+ // resolved content never reaches the asserted DOM.
527
+ let result!: Awaited<ReturnType<typeof render>>;
528
+ await act(async () => {
529
+ result = render(
530
+ <NavigationProvider
531
+ store={store}
532
+ eventController={eventController}
533
+ initialPayload={{ root: initialTree, metadata: initialMetadata }}
534
+ bridge={bridge}
535
+ basename={normalizeBasename(options.basename)}
536
+ themeConfig={
537
+ options.theme === undefined ? null : resolveThemeConfig(options.theme)
538
+ }
539
+ />,
540
+ );
541
+ });
542
+
543
+ const router: TestRouterHandle = {
544
+ navigate,
545
+ pathname: () => new URL(eventController.getLocation().href).pathname,
546
+ params: () => eventController.getParams(),
547
+ store,
548
+ eventController,
549
+ };
550
+
551
+ return Object.assign(result, { router });
552
+ }
553
+
554
+ function makeMetadata(
555
+ pathname: string,
556
+ segments: ResolvedSegment[],
557
+ params: Record<string, string>,
558
+ ): RscMetadata {
559
+ return {
560
+ pathname,
561
+ segments,
562
+ params,
563
+ matched: segments.map((s) => s.id),
564
+ isPartial: false,
565
+ };
566
+ }