@rangojs/router 0.0.0-experimental.122 → 0.0.0-experimental.125

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