@rangojs/router 0.0.0-experimental.d7eeaa75 → 0.0.0-experimental.dc2bd2b4

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 (253) hide show
  1. package/README.md +120 -25
  2. package/dist/bin/rango.js +147 -57
  3. package/dist/testing/vitest.js +48 -0
  4. package/dist/vite/index.js +2151 -846
  5. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  6. package/package.json +57 -11
  7. package/skills/breadcrumbs/SKILL.md +3 -1
  8. package/skills/bundle-analysis/SKILL.md +159 -0
  9. package/skills/cache-guide/SKILL.md +220 -30
  10. package/skills/caching/SKILL.md +116 -8
  11. package/skills/composability/SKILL.md +27 -2
  12. package/skills/document-cache/SKILL.md +78 -55
  13. package/skills/handler-use/SKILL.md +364 -0
  14. package/skills/hooks/SKILL.md +229 -20
  15. package/skills/host-router/SKILL.md +45 -20
  16. package/skills/i18n/SKILL.md +276 -0
  17. package/skills/intercept/SKILL.md +46 -4
  18. package/skills/layout/SKILL.md +28 -7
  19. package/skills/links/SKILL.md +247 -17
  20. package/skills/loader/SKILL.md +219 -9
  21. package/skills/middleware/SKILL.md +47 -12
  22. package/skills/migrate-nextjs/SKILL.md +562 -0
  23. package/skills/migrate-react-router/SKILL.md +769 -0
  24. package/skills/mime-routes/SKILL.md +27 -0
  25. package/skills/observability/SKILL.md +137 -0
  26. package/skills/parallel/SKILL.md +71 -6
  27. package/skills/prerender/SKILL.md +14 -33
  28. package/skills/rango/SKILL.md +242 -22
  29. package/skills/react-compiler/SKILL.md +168 -0
  30. package/skills/response-routes/SKILL.md +66 -9
  31. package/skills/route/SKILL.md +57 -4
  32. package/skills/router-setup/SKILL.md +3 -3
  33. package/skills/server-actions/SKILL.md +751 -0
  34. package/skills/streams-and-websockets/SKILL.md +283 -0
  35. package/skills/testing/SKILL.md +647 -0
  36. package/skills/typesafety/SKILL.md +319 -27
  37. package/skills/use-cache/SKILL.md +34 -5
  38. package/skills/view-transitions/SKILL.md +294 -0
  39. package/src/__augment-tests__/augment.ts +81 -0
  40. package/src/__augment-tests__/augmented.check.ts +117 -0
  41. package/src/browser/action-coordinator.ts +53 -36
  42. package/src/browser/app-shell.ts +52 -0
  43. package/src/browser/event-controller.ts +86 -70
  44. package/src/browser/history-state.ts +21 -0
  45. package/src/browser/index.ts +3 -3
  46. package/src/browser/navigation-bridge.ts +84 -11
  47. package/src/browser/navigation-client.ts +76 -28
  48. package/src/browser/navigation-store.ts +32 -9
  49. package/src/browser/navigation-transaction.ts +10 -28
  50. package/src/browser/partial-update.ts +64 -26
  51. package/src/browser/prefetch/cache.ts +129 -21
  52. package/src/browser/prefetch/fetch.ts +148 -16
  53. package/src/browser/prefetch/queue.ts +36 -5
  54. package/src/browser/rango-state.ts +53 -13
  55. package/src/browser/react/Link.tsx +30 -2
  56. package/src/browser/react/NavigationProvider.tsx +72 -31
  57. package/src/browser/react/filter-segment-order.ts +51 -7
  58. package/src/browser/react/index.ts +3 -0
  59. package/src/browser/react/location-state-shared.ts +175 -4
  60. package/src/browser/react/location-state.ts +39 -13
  61. package/src/browser/react/use-handle.ts +17 -9
  62. package/src/browser/react/use-navigation.ts +22 -2
  63. package/src/browser/react/use-params.ts +20 -8
  64. package/src/browser/react/use-reverse.ts +106 -0
  65. package/src/browser/react/use-router.ts +22 -2
  66. package/src/browser/react/use-segments.ts +11 -8
  67. package/src/browser/response-adapter.ts +25 -0
  68. package/src/browser/rsc-router.tsx +64 -22
  69. package/src/browser/scroll-restoration.ts +22 -14
  70. package/src/browser/segment-reconciler.ts +36 -14
  71. package/src/browser/segment-structure-assert.ts +2 -2
  72. package/src/browser/server-action-bridge.ts +23 -30
  73. package/src/browser/types.ts +21 -0
  74. package/src/build/collect-fallback-refs.ts +107 -0
  75. package/src/build/generate-manifest.ts +60 -35
  76. package/src/build/generate-route-types.ts +2 -0
  77. package/src/build/index.ts +2 -0
  78. package/src/build/route-trie.ts +52 -25
  79. package/src/build/route-types/codegen.ts +4 -4
  80. package/src/build/route-types/include-resolution.ts +1 -1
  81. package/src/build/route-types/per-module-writer.ts +7 -4
  82. package/src/build/route-types/router-processing.ts +55 -14
  83. package/src/build/route-types/scan-filter.ts +1 -1
  84. package/src/build/route-types/source-scan.ts +118 -0
  85. package/src/build/runtime-discovery.ts +9 -20
  86. package/src/cache/cache-scope.ts +28 -42
  87. package/src/cache/cf/cf-cache-store.ts +54 -13
  88. package/src/client.rsc.tsx +3 -0
  89. package/src/client.tsx +92 -182
  90. package/src/context-var.ts +5 -5
  91. package/src/decode-loader-results.ts +36 -0
  92. package/src/errors.ts +30 -1
  93. package/src/handle.ts +26 -13
  94. package/src/host/index.ts +2 -2
  95. package/src/host/router.ts +129 -57
  96. package/src/host/types.ts +31 -2
  97. package/src/host/utils.ts +1 -1
  98. package/src/href-client.ts +140 -20
  99. package/src/index.rsc.ts +9 -4
  100. package/src/index.ts +53 -15
  101. package/src/loader-store.ts +500 -0
  102. package/src/loader.rsc.ts +2 -5
  103. package/src/loader.ts +3 -10
  104. package/src/missing-id-error.ts +68 -0
  105. package/src/outlet-context.ts +1 -1
  106. package/src/prerender.ts +4 -4
  107. package/src/response-utils.ts +37 -0
  108. package/src/reverse.ts +65 -36
  109. package/src/route-content-wrapper.tsx +6 -28
  110. package/src/route-definition/dsl-helpers.ts +384 -257
  111. package/src/route-definition/helper-factories.ts +29 -139
  112. package/src/route-definition/helpers-types.ts +100 -28
  113. package/src/route-definition/resolve-handler-use.ts +6 -0
  114. package/src/route-definition/use-item-types.ts +32 -0
  115. package/src/route-types.ts +26 -41
  116. package/src/router/basename.ts +14 -0
  117. package/src/router/content-negotiation.ts +15 -2
  118. package/src/router/error-handling.ts +1 -1
  119. package/src/router/handler-context.ts +21 -38
  120. package/src/router/intercept-resolution.ts +4 -18
  121. package/src/router/lazy-includes.ts +8 -8
  122. package/src/router/loader-resolution.ts +19 -2
  123. package/src/router/manifest.ts +22 -13
  124. package/src/router/match-api.ts +4 -3
  125. package/src/router/match-handlers.ts +63 -20
  126. package/src/router/match-middleware/cache-lookup.ts +44 -91
  127. package/src/router/match-middleware/cache-store.ts +3 -2
  128. package/src/router/match-result.ts +53 -32
  129. package/src/router/metrics.ts +1 -1
  130. package/src/router/middleware-types.ts +15 -26
  131. package/src/router/middleware.ts +99 -84
  132. package/src/router/pattern-matching.ts +101 -17
  133. package/src/router/prerender-match.ts +1 -1
  134. package/src/router/preview-match.ts +3 -1
  135. package/src/router/request-classification.ts +4 -28
  136. package/src/router/revalidation.ts +58 -2
  137. package/src/router/router-interfaces.ts +45 -28
  138. package/src/router/router-options.ts +40 -1
  139. package/src/router/router-registry.ts +2 -5
  140. package/src/router/segment-resolution/fresh.ts +27 -6
  141. package/src/router/segment-resolution/revalidation.ts +147 -106
  142. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  143. package/src/router/substitute-pattern-params.ts +56 -0
  144. package/src/router/telemetry.ts +99 -0
  145. package/src/router/trie-matching.ts +18 -13
  146. package/src/router/types.ts +8 -0
  147. package/src/router/url-params.ts +49 -0
  148. package/src/router.ts +38 -23
  149. package/src/rsc/handler-context.ts +2 -2
  150. package/src/rsc/handler.ts +28 -69
  151. package/src/rsc/helpers.ts +91 -43
  152. package/src/rsc/index.ts +1 -1
  153. package/src/rsc/origin-guard.ts +28 -10
  154. package/src/rsc/progressive-enhancement.ts +4 -0
  155. package/src/rsc/response-route-handler.ts +46 -53
  156. package/src/rsc/rsc-rendering.ts +35 -51
  157. package/src/rsc/runtime-warnings.ts +9 -10
  158. package/src/rsc/server-action.ts +17 -37
  159. package/src/rsc/ssr-setup.ts +16 -0
  160. package/src/rsc/types.ts +8 -2
  161. package/src/search-params.ts +4 -4
  162. package/src/segment-content-promise.ts +67 -0
  163. package/src/segment-loader-promise.ts +122 -0
  164. package/src/segment-system.tsx +132 -116
  165. package/src/serialize.ts +243 -0
  166. package/src/server/context.ts +143 -53
  167. package/src/server/cookie-store.ts +28 -4
  168. package/src/server/request-context.ts +20 -42
  169. package/src/ssr/index.tsx +5 -1
  170. package/src/static-handler.ts +1 -1
  171. package/src/testing/cache-status.ts +166 -0
  172. package/src/testing/collect-handle.ts +63 -0
  173. package/src/testing/dispatch.ts +440 -0
  174. package/src/testing/dom.entry.ts +22 -0
  175. package/src/testing/e2e/fixture.ts +154 -0
  176. package/src/testing/e2e/index.ts +149 -0
  177. package/src/testing/e2e/matchers.ts +51 -0
  178. package/src/testing/e2e/page-helpers.ts +272 -0
  179. package/src/testing/e2e/parity.ts +306 -0
  180. package/src/testing/e2e/server.ts +183 -0
  181. package/src/testing/flight-matchers.ts +104 -0
  182. package/src/testing/flight-runtime.d.ts +21 -0
  183. package/src/testing/flight.entry.ts +22 -0
  184. package/src/testing/flight.ts +182 -0
  185. package/src/testing/generated-routes.ts +223 -0
  186. package/src/testing/index.ts +105 -0
  187. package/src/testing/internal/context.ts +193 -0
  188. package/src/testing/render-route.tsx +536 -0
  189. package/src/testing/run-loader.ts +296 -0
  190. package/src/testing/run-middleware.ts +170 -0
  191. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  192. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  193. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  194. package/src/testing/vitest-stubs/version.ts +5 -0
  195. package/src/testing/vitest.ts +183 -0
  196. package/src/types/global-namespace.ts +39 -26
  197. package/src/types/handler-context.ts +68 -50
  198. package/src/types/index.ts +1 -0
  199. package/src/types/loader-types.ts +5 -6
  200. package/src/types/request-scope.ts +126 -0
  201. package/src/types/route-entry.ts +11 -0
  202. package/src/types/segments.ts +35 -2
  203. package/src/urls/include-helper.ts +34 -67
  204. package/src/urls/index.ts +0 -3
  205. package/src/urls/path-helper-types.ts +41 -7
  206. package/src/urls/path-helper.ts +17 -52
  207. package/src/urls/pattern-types.ts +36 -19
  208. package/src/urls/response-types.ts +22 -29
  209. package/src/urls/type-extraction.ts +26 -116
  210. package/src/urls/urls-function.ts +1 -5
  211. package/src/use-loader.tsx +413 -42
  212. package/src/vite/debug.ts +185 -0
  213. package/src/vite/discovery/bundle-postprocess.ts +6 -6
  214. package/src/vite/discovery/discover-routers.ts +101 -51
  215. package/src/vite/discovery/discovery-errors.ts +194 -0
  216. package/src/vite/discovery/gate-state.ts +171 -0
  217. package/src/vite/discovery/prerender-collection.ts +67 -26
  218. package/src/vite/discovery/route-types-writer.ts +40 -84
  219. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  220. package/src/vite/discovery/state.ts +33 -0
  221. package/src/vite/discovery/virtual-module-codegen.ts +13 -23
  222. package/src/vite/index.ts +2 -0
  223. package/src/vite/plugin-types.ts +67 -0
  224. package/src/vite/plugins/cjs-to-esm.ts +8 -7
  225. package/src/vite/plugins/client-ref-dedup.ts +16 -0
  226. package/src/vite/plugins/client-ref-hashing.ts +28 -5
  227. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  228. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  229. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  230. package/src/vite/plugins/expose-action-id.ts +54 -30
  231. package/src/vite/plugins/expose-id-utils.ts +12 -8
  232. package/src/vite/plugins/expose-ids/export-analysis.ts +100 -20
  233. package/src/vite/plugins/expose-ids/handler-transform.ts +8 -61
  234. package/src/vite/plugins/expose-ids/loader-transform.ts +3 -5
  235. package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
  236. package/src/vite/plugins/expose-internal-ids.ts +496 -486
  237. package/src/vite/plugins/performance-tracks.ts +29 -25
  238. package/src/vite/plugins/use-cache-transform.ts +65 -50
  239. package/src/vite/plugins/version-injector.ts +39 -23
  240. package/src/vite/plugins/version-plugin.ts +59 -2
  241. package/src/vite/plugins/virtual-entries.ts +2 -2
  242. package/src/vite/rango.ts +116 -29
  243. package/src/vite/router-discovery.ts +750 -100
  244. package/src/vite/utils/ast-handler-extract.ts +15 -15
  245. package/src/vite/utils/banner.ts +1 -1
  246. package/src/vite/utils/bundle-analysis.ts +4 -2
  247. package/src/vite/utils/client-chunks.ts +190 -0
  248. package/src/vite/utils/forward-user-plugins.ts +193 -0
  249. package/src/vite/utils/manifest-utils.ts +21 -5
  250. package/src/vite/utils/package-resolution.ts +41 -1
  251. package/src/vite/utils/prerender-utils.ts +21 -6
  252. package/src/vite/utils/shared-utils.ts +107 -26
  253. package/src/browser/action-response-classifier.ts +0 -99
@@ -0,0 +1,536 @@
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 renderServer / 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 renderServer / e2e territory.
21
+ * - Loader data, location state, and handle output are SEEDED directly into
22
+ * client context (see the `loaders` / `locationState` / `handles` options) —
23
+ * nothing is executed on the server. This exercises the read path
24
+ * (useLoader / useLocationState / useHandle from context), not the run path.
25
+ * What it DOES cover: client hooks that read NavigationProvider /
26
+ * OutletContext — useParams, useReverse, useHref, useMount, useNavigation,
27
+ * useRouter, usePathname, useSearchParams, Outlet nesting, useLoader /
28
+ * useFetchLoader (seeded data), useLocationState (seeded), and useHandle (seeded).
29
+ * Basename-mounted apps: pass the `basename` option so useRouter().basename,
30
+ * <Link> prefixing, and useMount/useHref resolve against the mount prefix
31
+ * (without it they resolve at the root "/"). include() subtree mount prefixes
32
+ * are still not modeled — those stay renderServer/e2e territory.
33
+ */
34
+
35
+ import type { ReactNode, ComponentType } from "react";
36
+ import type { RenderResult } from "@testing-library/react";
37
+ import { renderSegments } from "../segment-system.js";
38
+ import {
39
+ createNavigationStore,
40
+ generateHistoryKey,
41
+ } from "../browser/navigation-store.js";
42
+ import { createEventController } from "../browser/event-controller.js";
43
+ import type { NavigationStore, NavigationBridge } from "../browser/types.js";
44
+ import type { EventController } from "../browser/event-controller.js";
45
+ import type { ResolvedSegment, RscMetadata } from "../browser/types.js";
46
+ import { NavigationProvider } from "../browser/react/NavigationProvider.js";
47
+ import { compilePattern } from "../router/pattern-matching.js";
48
+ import { normalizeBasename } from "../router/basename.js";
49
+ import type { LoaderDefinition } from "../types.js";
50
+ import type { LocationStateDefinition } from "../browser/react/location-state-shared.js";
51
+ import type { Handle } from "../handle.js";
52
+ import type { ThemeConfig } from "../theme/types.js";
53
+ import { resolveThemeConfig } from "../theme/constants.js";
54
+
55
+ const TEST_ORIGIN = "http://localhost";
56
+
57
+ /**
58
+ * Seed shape for `options.handle`, matching the handle wire format:
59
+ * `{ [handleName]: { [segmentId]: pushedValues[] } }` (each segment accumulates
60
+ * an array of values pushed for that handle).
61
+ */
62
+ export type HandleDataSeed = Record<string, Record<string, unknown[]>>;
63
+
64
+ // Loaders and location-state defs carry an id (`$$id` / `__rsc_ls_key`) that the
65
+ // Vite plugin injects at build time; in a bare test it is "". These helpers
66
+ // assign a synthetic stable id (mutating the handle, tracked per-object) so that
67
+ // seeding by reference lines up with the read path (useLoader / useLocationState
68
+ // both read the id off the handle at call time).
69
+ const syntheticIds = new WeakMap<object, string>();
70
+ let syntheticIdCounter = 0;
71
+
72
+ function ensureSyntheticId(
73
+ handle: object,
74
+ field: "$$id" | "__rsc_ls_key",
75
+ ): string {
76
+ const existing = (handle as Record<string, string>)[field];
77
+ if (existing) return existing;
78
+ let id = syntheticIds.get(handle);
79
+ if (!id) {
80
+ id = `__rango_test_id_${syntheticIdCounter++}`;
81
+ syntheticIds.set(handle, id);
82
+ }
83
+ (handle as Record<string, string>)[field] = id;
84
+ return id;
85
+ }
86
+
87
+ /** One-level clone of a raw handle seed so we don't mutate the caller's object. */
88
+ function cloneHandleSeed(seed?: HandleDataSeed): HandleDataSeed {
89
+ const out: HandleDataSeed = {};
90
+ for (const [name, segMap] of Object.entries(seed ?? {})) {
91
+ out[name] = { ...segMap };
92
+ }
93
+ return out;
94
+ }
95
+
96
+ /**
97
+ * One node of the route definition passed to renderRoute. The array models a
98
+ * single matched route plus its optional layout chain — element order is
99
+ * outermost layout first, the leaf route last (the same root-to-leaf order the
100
+ * real matcher produces).
101
+ */
102
+ export interface RenderRouteSpec {
103
+ /**
104
+ * The route pattern this node matches, e.g. "/products/:productId". The LAST
105
+ * spec in the array is treated as the leaf route; earlier specs are layouts
106
+ * wrapping it. Only the leaf pattern is matched against `initialUrl` to
107
+ * extract params; layout patterns are informational.
108
+ */
109
+ path: string;
110
+ /** The component rendered for this node (the leaf route or a layout body). */
111
+ Component: ComponentType;
112
+ /**
113
+ * Optional layout component. When set on the LEAF spec it wraps the route in
114
+ * its own layout segment (useful for a route that owns a layout). Prefer
115
+ * expressing layouts as their own array entries instead.
116
+ */
117
+ layout?: ComponentType;
118
+ /**
119
+ * Loader ids ($$id) whose seeded data (from `options.loaderData`) should be
120
+ * attached to THIS node's segment so useLoader/useFetchLoader resolve it from
121
+ * context. When omitted, every key in `options.loaderData` is attached to the
122
+ * leaf route segment.
123
+ */
124
+ loaderIds?: string[];
125
+ /** Optional route name (informational; not used for matching). */
126
+ name?: string;
127
+ }
128
+
129
+ /**
130
+ * Options for renderRoute.
131
+ */
132
+ export interface RenderRouteOptions {
133
+ /** Initial URL to render at. Defaults to the leaf spec's static prefix or "/". */
134
+ initialUrl?: 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
+ * @example
151
+ * renderRoute([{ path: "/cart", Component: CartBadge }], {
152
+ * loaders: [[CartLoader, { itemCount: 3, total: 89.97 }]],
153
+ * });
154
+ */
155
+ loaders?: ReadonlyArray<readonly [LoaderDefinition<any>, unknown]>;
156
+ /**
157
+ * Explicit params. Merged over (and overriding) params extracted from
158
+ * `initialUrl`. Use this when the URL alone cannot express the params, or to
159
+ * avoid relying on URL parsing.
160
+ */
161
+ params?: Record<string, string>;
162
+ /**
163
+ * Location-state values to seed by REFERENCE, for components that call
164
+ * `useLocationState(StateDef)`. Like loaders, a real `createLocationState()`
165
+ * handle has an empty injected key in a bare test, so pass `[def, value]`
166
+ * pairs; renderRoute assigns a synthetic key and writes it to `history.state`.
167
+ *
168
+ * @example
169
+ * renderRoute([{ path: "/", Component: FlashBanner }], {
170
+ * locationState: [[FlashMessage, { text: "Saved" }]],
171
+ * });
172
+ */
173
+ locationState?: ReadonlyArray<
174
+ readonly [LocationStateDefinition<any, any>, unknown]
175
+ >;
176
+ /**
177
+ * Handles to seed by REFERENCE, for components that read handle output via
178
+ * `useHandle(SomeHandle)` (e.g. a client `Breadcrumbs` trail). Each entry is
179
+ * `[handle, pushedValues[]]` — the values a route's handlers would have pushed;
180
+ * renderRoute attaches them to the leaf route segment under the handle's id.
181
+ * Built-in handles (Breadcrumbs/Meta) have stable ids and work directly.
182
+ *
183
+ * Most handle usage is server-side (`ctx.use(...)`) and is better covered by
184
+ * `renderToFlightString`/e2e; this seeds the client read path only.
185
+ *
186
+ * @example
187
+ * renderRoute([{ path: "/p", Component: BreadcrumbTrail }], {
188
+ * handles: [[Breadcrumbs, [{ label: "Home", href: "/" }, { label: "P", href: "/p" }]]],
189
+ * });
190
+ */
191
+ handles?: ReadonlyArray<readonly [Handle<any, any>, unknown[]]>;
192
+ /**
193
+ * Advanced: raw handle data in wire format
194
+ * `{ [handleId]: { [segmentId]: pushedValues[] } }`. Prefer `handles` (which
195
+ * computes the segment id for you). Merged with `handles`.
196
+ */
197
+ handle?: HandleDataSeed;
198
+ /**
199
+ * Route name -> pattern map. Informational for parity with the server test
200
+ * context; client useReverse takes its map directly as an argument, so this
201
+ * is not consumed by the client hooks.
202
+ */
203
+ routeMap?: Record<string, string>;
204
+ /**
205
+ * Router basename (the `createRouter({ basename })` value). Wired into
206
+ * NavigationProvider so `useRouter().basename`, `<Link>` href prefixing, and
207
+ * `useMount`/`useHref` resolve against the mounted prefix instead of the root.
208
+ * Normalized exactly like createRouter (leading slash forced, trailing
209
+ * stripped, bare "/" -> undefined). Defaults to undefined (root mount).
210
+ */
211
+ basename?: string;
212
+ /**
213
+ * Theme config in the `createRouter({ theme })` shape (resolved internally) to
214
+ * wrap the tree in a ThemeProvider. Defaults to no provider. Note: a component
215
+ * that calls `useTheme()` REQUIRES a provider — it throws "used outside
216
+ * ThemeProvider" without one — so pass a config (e.g. `true`) to test such a
217
+ * component.
218
+ */
219
+ theme?: ThemeConfig | true;
220
+ }
221
+
222
+ /**
223
+ * Imperative handle returned alongside the RTL result.
224
+ */
225
+ export interface TestRouterHandle {
226
+ /**
227
+ * Navigate to a new URL. Re-resolves the URL against the supplied `routes`,
228
+ * updates params + location, and re-renders the segment tree. This is a
229
+ * client-only navigation: no server fetch occurs, so only the components in
230
+ * `routes` can be reached.
231
+ */
232
+ navigate(url: string): Promise<void>;
233
+ /** The current committed pathname. */
234
+ pathname(): string;
235
+ /** The current committed params. */
236
+ params(): Record<string, string>;
237
+ /** The underlying navigation store (advanced use). */
238
+ store: NavigationStore;
239
+ /** The underlying event controller (advanced use). */
240
+ eventController: EventController;
241
+ }
242
+
243
+ /** Result of renderRoute: RTL's render result plus the router handle. */
244
+ export type RenderRouteResult = RenderResult & { router: TestRouterHandle };
245
+
246
+ interface ResolvedMatch {
247
+ params: Record<string, string>;
248
+ pathname: string;
249
+ }
250
+
251
+ /**
252
+ * Match a pathname against the leaf spec's pattern and extract params.
253
+ * Returns null when the pattern does not match (params then fall back to the
254
+ * caller-provided `options.params`).
255
+ */
256
+ function matchLeaf(
257
+ pattern: string,
258
+ pathname: string,
259
+ ): Record<string, string> | null {
260
+ const compiled = compilePattern(pattern);
261
+ const match = compiled.regex.exec(pathname);
262
+ if (!match) return null;
263
+ const params: Record<string, string> = {};
264
+ compiled.paramNames.forEach((name, index) => {
265
+ const value = match[index + 1];
266
+ if (value !== undefined) {
267
+ params[name] = decodeURIComponent(value);
268
+ }
269
+ });
270
+ return params;
271
+ }
272
+
273
+ /** Derive a usable initial pathname from a leaf pattern when none is given. */
274
+ function staticPrefix(pattern: string): string {
275
+ const out: string[] = [];
276
+ for (const part of pattern.split("/")) {
277
+ if (part === "") continue;
278
+ if (part.startsWith(":") || part === "*") break;
279
+ out.push(part);
280
+ }
281
+ return "/" + out.join("/");
282
+ }
283
+
284
+ /**
285
+ * Build the synthetic ResolvedSegment[] for a matched route. Produces, in
286
+ * root-to-leaf order: one layout segment per non-leaf spec, then the leaf route
287
+ * segment, plus a loader segment for each seeded loader id attached to the
288
+ * owning spec. Segment ids follow the real convention (L0, L0L1, ..., the leaf
289
+ * route as L0...R{n}; loaders as {parentId}D{i}.{loaderId}).
290
+ */
291
+ function buildSegments(
292
+ routes: RenderRouteSpec[],
293
+ params: Record<string, string>,
294
+ loaderData: Record<string, unknown>,
295
+ ): ResolvedSegment[] {
296
+ const segments: ResolvedSegment[] = [];
297
+ const leafIndex = routes.length - 1;
298
+ let idPath = "";
299
+
300
+ const seededIds = Object.keys(loaderData);
301
+ const explicitlyOwned = new Set<string>();
302
+ for (const spec of routes) {
303
+ for (const id of spec.loaderIds ?? []) explicitlyOwned.add(id);
304
+ }
305
+
306
+ routes.forEach((spec, i) => {
307
+ const isLeaf = i === leafIndex;
308
+ const tag = isLeaf ? `R${i}` : `L${i}`;
309
+ idPath = idPath + tag;
310
+ const segmentId = idPath;
311
+
312
+ const Component = spec.Component;
313
+ const node: ResolvedSegment = {
314
+ id: segmentId,
315
+ namespace: "",
316
+ type: isLeaf ? "route" : "layout",
317
+ index: i,
318
+ component: <Component />,
319
+ params,
320
+ belongsToRoute: true,
321
+ };
322
+ // A leaf-owned layout component wraps the route via its own layout element.
323
+ if (isLeaf && spec.layout) {
324
+ const Layout = spec.layout;
325
+ node.layout = <Layout />;
326
+ }
327
+ segments.push(node);
328
+
329
+ // Determine which seeded loader ids this spec owns.
330
+ const ownedIds = spec.loaderIds
331
+ ? spec.loaderIds.filter((id) => id in loaderData)
332
+ : isLeaf
333
+ ? seededIds.filter((id) => !explicitlyOwned.has(id))
334
+ : [];
335
+
336
+ ownedIds.forEach((loaderId, li) => {
337
+ segments.push({
338
+ id: `${segmentId}D${li}.${loaderId}`,
339
+ namespace: "",
340
+ type: "loader",
341
+ index: li,
342
+ component: null,
343
+ loaderId,
344
+ loaderData: loaderData[loaderId],
345
+ params,
346
+ });
347
+ });
348
+ });
349
+
350
+ return segments;
351
+ }
352
+
353
+ /**
354
+ * Render a CLIENT component (and its layout chain) inside the router's
355
+ * NavigationProvider for unit testing. Exported from `@rangojs/router/testing/dom`
356
+ * (its own entry, kept out of the main `@rangojs/router/testing` barrel so that
357
+ * barrel never references React/@testing-library/react). Async so the heavy
358
+ * @testing-library/react dependency is loaded only at call time.
359
+ *
360
+ * @example
361
+ * ```tsx
362
+ * // @vitest-environment happy-dom
363
+ * import { renderRoute } from "@rangojs/router/testing/dom";
364
+ *
365
+ * function Product() {
366
+ * const { productId } = useParams<{ productId: string }>();
367
+ * const reverse = useReverse({ product: "/products/:productId" });
368
+ * return <a href={reverse("product", { productId: "2" })}>{productId}</a>;
369
+ * }
370
+ *
371
+ * const { getByText, router } = await renderRoute(
372
+ * [{ path: "/products/:productId", Component: Product }],
373
+ * { initialUrl: "/products/1" },
374
+ * );
375
+ * ```
376
+ */
377
+ export async function renderRoute(
378
+ routes: RenderRouteSpec[],
379
+ options: RenderRouteOptions = {},
380
+ ): Promise<RenderRouteResult> {
381
+ if (routes.length === 0) {
382
+ throw new Error("renderRoute: `routes` must contain at least one entry");
383
+ }
384
+
385
+ const { render, act } = await import("@testing-library/react");
386
+
387
+ const leaf = routes[routes.length - 1];
388
+ const initialUrl = options.initialUrl ?? staticPrefix(leaf.path) ?? "/";
389
+ const url = new URL(initialUrl, TEST_ORIGIN);
390
+
391
+ // Seed loader data: explicit-id entries from `loaderData`, plus by-reference
392
+ // entries from `loaders` (assigning synthetic ids to real handles whose `$$id`
393
+ // is empty in a bare test).
394
+ const loaderData: Record<string, unknown> = { ...(options.loaderData ?? {}) };
395
+ for (const [loader, data] of options.loaders ?? []) {
396
+ loaderData[ensureSyntheticId(loader as object, "$$id")] = data;
397
+ }
398
+
399
+ // Seed location state into history.state so useLocationState(def) resolves.
400
+ // Keyed defs read history.state[def.__rsc_ls_key]; assign a synthetic key when
401
+ // the injected one is empty (bare test). RESET history.state to only this
402
+ // call's seeds (not a merge) so a previous render's seeded state does not leak
403
+ // into a later render in the same DOM environment.
404
+ if (typeof window !== "undefined") {
405
+ const stateObj: Record<string, unknown> = {};
406
+ for (const [def, value] of options.locationState ?? []) {
407
+ stateObj[ensureSyntheticId(def as object, "__rsc_ls_key")] = value;
408
+ }
409
+ // No URL arg: useLocationState reads history.state (not the URL), and passing
410
+ // a TEST_ORIGIN URL would trip the DOM env's same-origin check.
411
+ window.history.replaceState(stateObj, "");
412
+ }
413
+
414
+ // Resolve params: URL-extracted params first, explicit params override.
415
+ const resolve = (pathname: string): ResolvedMatch => {
416
+ const matched = matchLeaf(leaf.path, pathname) ?? {};
417
+ return {
418
+ params: { ...matched, ...(options.params ?? {}) },
419
+ pathname,
420
+ };
421
+ };
422
+ const initialMatch = resolve(url.pathname);
423
+
424
+ // Reuse the real browser primitives so context shape matches production.
425
+ const historyKey = generateHistoryKey(url.href);
426
+ const initialSegments = buildSegments(
427
+ routes,
428
+ initialMatch.params,
429
+ loaderData,
430
+ );
431
+ const store = createNavigationStore({
432
+ initialLocation: { href: url.href },
433
+ initialSegmentIds: initialSegments.map((s) => s.id),
434
+ initialHistoryKey: historyKey,
435
+ initialSegments,
436
+ crossTabSync: false,
437
+ });
438
+ // Seed handle data: raw `handle` entries plus by-reference `handles` attached
439
+ // to the leaf route segment under each handle's id (so useHandle(handle)
440
+ // resolves the pushed values).
441
+ const leafRouteSegmentId =
442
+ [...initialSegments].reverse().find((s) => s.type === "route")?.id ??
443
+ initialSegments[initialSegments.length - 1]?.id;
444
+ const handleSeed: HandleDataSeed = cloneHandleSeed(options.handle);
445
+ for (const [handle, values] of options.handles ?? []) {
446
+ if (leafRouteSegmentId === undefined) continue;
447
+ // createHandle always has a non-empty $$id (the Vite plugin injects one, and
448
+ // createHandle assigns a runtime fallback otherwise) with its REAL collect
449
+ // registered — so seeding under handle.$$id makes useHandle(handle) run the
450
+ // handle's actual collect/accumulator (custom collects included), not just a
451
+ // default flatten.
452
+ const id = (handle as unknown as { $$id: string }).$$id;
453
+ (handleSeed[id] ??= {})[leafRouteSegmentId] = values;
454
+ }
455
+
456
+ const eventController = createEventController({ initialLocation: url });
457
+ eventController.setParams(initialMatch.params);
458
+ eventController.setHandleData(
459
+ handleSeed,
460
+ initialSegments.map((s) => s.id),
461
+ );
462
+
463
+ // Client-only navigation: re-resolve against the in-memory routes and emit a
464
+ // re-render. No server fetch — only routes passed to renderRoute exist. The
465
+ // store update is flushed inside act() so React commits before callers
466
+ // assert, mirroring how a real navigation lands a single payload swap.
467
+ const navigate = async (target: string): Promise<void> => {
468
+ const nextUrl = new URL(target, TEST_ORIGIN);
469
+ const match = resolve(nextUrl.pathname);
470
+ const segments = buildSegments(routes, match.params, loaderData);
471
+ const metadata = makeMetadata(nextUrl.pathname, segments, match.params);
472
+ const root = await renderSegments(segments);
473
+ eventController.setLocation(nextUrl);
474
+ eventController.setParams(match.params);
475
+ store.setCurrentUrl(nextUrl.href);
476
+ store.setSegmentIds(segments.map((s) => s.id));
477
+ await act(async () => {
478
+ store.emitUpdate({ root, metadata });
479
+ });
480
+ };
481
+
482
+ const bridge: NavigationBridge = {
483
+ navigate: (target) => navigate(target),
484
+ refresh: () => navigate(url.pathname + url.search),
485
+ handlePopstate: async () => {},
486
+ registerLinkInterception: () => () => {},
487
+ getVersion: () => undefined,
488
+ updateVersion: () => {},
489
+ updateAppShell: () => {},
490
+ };
491
+
492
+ const initialMetadata = makeMetadata(
493
+ url.pathname,
494
+ initialSegments,
495
+ initialMatch.params,
496
+ );
497
+ const initialTree = await renderSegments(initialSegments);
498
+
499
+ const result = render(
500
+ <NavigationProvider
501
+ store={store}
502
+ eventController={eventController}
503
+ initialPayload={{ root: initialTree, metadata: initialMetadata }}
504
+ bridge={bridge}
505
+ basename={normalizeBasename(options.basename)}
506
+ themeConfig={
507
+ options.theme === undefined ? null : resolveThemeConfig(options.theme)
508
+ }
509
+ />,
510
+ );
511
+
512
+ const router: TestRouterHandle = {
513
+ navigate,
514
+ pathname: () => new URL(eventController.getLocation().href).pathname,
515
+ params: () => eventController.getParams(),
516
+ store,
517
+ eventController,
518
+ };
519
+
520
+ return Object.assign(result, { router });
521
+ }
522
+
523
+ /** Minimal RscMetadata for client-side re-renders (no server-only fields). */
524
+ function makeMetadata(
525
+ pathname: string,
526
+ segments: ResolvedSegment[],
527
+ params: Record<string, string>,
528
+ ): RscMetadata {
529
+ return {
530
+ pathname,
531
+ segments,
532
+ params,
533
+ matched: segments.map((s) => s.id),
534
+ isPartial: false,
535
+ };
536
+ }