@rangojs/router 0.0.0-experimental.124 → 0.0.0-experimental.126

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 (235) hide show
  1. package/README.md +6 -4
  2. package/dist/bin/rango.js +3 -4
  3. package/dist/vite/index.js +315 -68
  4. package/package.json +19 -18
  5. package/skills/breadcrumbs/SKILL.md +60 -0
  6. package/skills/hooks/SKILL.md +2 -2
  7. package/skills/route/SKILL.md +6 -0
  8. package/skills/server-actions/SKILL.md +25 -1
  9. package/skills/testing/SKILL.md +17 -17
  10. package/skills/testing/cache-prerender.md +29 -3
  11. package/skills/testing/flight.md +13 -10
  12. package/skills/testing/render-handler.md +3 -0
  13. package/skills/testing/server-tree.md +1 -1
  14. package/skills/testing/setup.md +1 -1
  15. package/src/__internal.ts +0 -65
  16. package/src/browser/action-coordinator.ts +1 -1
  17. package/src/browser/action-fence.ts +10 -0
  18. package/src/browser/event-controller.ts +1 -83
  19. package/src/browser/navigation-store-handle.ts +3 -4
  20. package/src/browser/navigation-store.ts +0 -39
  21. package/src/browser/navigation-transaction.ts +0 -32
  22. package/src/browser/partial-update.ts +23 -84
  23. package/src/browser/prefetch/cache.ts +6 -45
  24. package/src/browser/prefetch/queue.ts +6 -3
  25. package/src/browser/rango-state.ts +2 -23
  26. package/src/browser/react/Link.tsx +0 -2
  27. package/src/browser/react/NavigationProvider.tsx +2 -1
  28. package/src/browser/react/ScrollRestoration.tsx +10 -6
  29. package/src/browser/react/filter-segment-order.ts +0 -2
  30. package/src/browser/react/index.ts +0 -45
  31. package/src/browser/react/location-state-shared.ts +0 -13
  32. package/src/browser/react/location-state.ts +0 -1
  33. package/src/browser/react/use-action.ts +6 -15
  34. package/src/browser/react/use-handle.ts +0 -5
  35. package/src/browser/react/use-link-status.ts +0 -4
  36. package/src/browser/react/use-navigation.ts +0 -3
  37. package/src/browser/react/use-params.ts +0 -2
  38. package/src/browser/react/use-router.ts +2 -1
  39. package/src/browser/react/use-search-params.ts +0 -5
  40. package/src/browser/react/use-segments.ts +0 -13
  41. package/src/browser/rsc-router.tsx +10 -3
  42. package/src/browser/server-action-bridge.ts +51 -3
  43. package/src/browser/types.ts +23 -5
  44. package/src/browser/validate-redirect-origin.ts +43 -16
  45. package/src/build/index.ts +8 -9
  46. package/src/build/route-trie.ts +46 -11
  47. package/src/build/route-types/param-extraction.ts +6 -3
  48. package/src/build/route-types/router-processing.ts +0 -8
  49. package/src/cache/cache-policy.ts +0 -54
  50. package/src/cache/cache-runtime.ts +48 -24
  51. package/src/cache/cache-scope.ts +0 -27
  52. package/src/cache/cache-tag.ts +0 -37
  53. package/src/cache/cf/cf-cache-store.ts +72 -45
  54. package/src/cache/cf/index.ts +0 -24
  55. package/src/cache/document-cache.ts +10 -36
  56. package/src/cache/handle-snapshot.ts +0 -40
  57. package/src/cache/index.ts +0 -27
  58. package/src/cache/memory-segment-store.ts +0 -52
  59. package/src/cache/profile-registry.ts +6 -30
  60. package/src/cache/read-through-swr.ts +41 -11
  61. package/src/cache/segment-codec.ts +0 -16
  62. package/src/cache/types.ts +0 -98
  63. package/src/client.rsc.tsx +4 -22
  64. package/src/client.tsx +19 -32
  65. package/src/context-var.ts +12 -0
  66. package/src/defer.ts +196 -0
  67. package/src/deps/ssr.ts +0 -1
  68. package/src/handle.ts +2 -12
  69. package/src/handles/MetaTags.tsx +0 -14
  70. package/src/handles/breadcrumbs.ts +16 -5
  71. package/src/handles/meta.ts +0 -39
  72. package/src/host/cookie-handler.ts +0 -36
  73. package/src/host/errors.ts +0 -24
  74. package/src/host/index.ts +6 -0
  75. package/src/host/pattern-matcher.ts +7 -50
  76. package/src/host/router.ts +1 -65
  77. package/src/host/testing.ts +0 -16
  78. package/src/host/types.ts +6 -2
  79. package/src/href-client.ts +0 -4
  80. package/src/index.rsc.ts +27 -2
  81. package/src/index.ts +7 -0
  82. package/src/internal-debug.ts +2 -4
  83. package/src/loader.rsc.ts +4 -15
  84. package/src/loader.ts +3 -9
  85. package/src/network-error-thrower.tsx +1 -6
  86. package/src/outlet-provider.tsx +1 -5
  87. package/src/prerender/param-hash.ts +10 -11
  88. package/src/prerender/store.ts +23 -30
  89. package/src/prerender.ts +34 -0
  90. package/src/redirect-origin.ts +100 -0
  91. package/src/root-error-boundary.tsx +1 -19
  92. package/src/route-content-wrapper.tsx +1 -44
  93. package/src/route-definition/dsl-helpers.ts +7 -19
  94. package/src/route-definition/helpers-types.ts +3 -3
  95. package/src/route-definition/redirect.ts +43 -9
  96. package/src/route-definition/resolve-handler-use.ts +6 -0
  97. package/src/route-map-builder.ts +0 -16
  98. package/src/router/content-negotiation.ts +0 -13
  99. package/src/router/error-handling.ts +12 -16
  100. package/src/router/find-match.ts +4 -31
  101. package/src/router/intercept-resolution.ts +10 -1
  102. package/src/router/lazy-includes.ts +1 -57
  103. package/src/router/loader-resolution.ts +25 -23
  104. package/src/router/logging.ts +0 -6
  105. package/src/router/manifest.ts +1 -25
  106. package/src/router/match-api.ts +0 -20
  107. package/src/router/match-context.ts +0 -22
  108. package/src/router/match-handlers.ts +0 -43
  109. package/src/router/match-middleware/background-revalidation.ts +0 -7
  110. package/src/router/match-middleware/cache-lookup.ts +96 -179
  111. package/src/router/match-middleware/cache-store.ts +0 -31
  112. package/src/router/match-middleware/intercept-resolution.ts +0 -22
  113. package/src/router/match-middleware/segment-resolution.ts +0 -22
  114. package/src/router/match-pipelines.ts +1 -42
  115. package/src/router/match-result.ts +1 -52
  116. package/src/router/metrics.ts +0 -34
  117. package/src/router/middleware-types.ts +0 -116
  118. package/src/router/middleware.ts +77 -60
  119. package/src/router/navigation-snapshot.ts +0 -51
  120. package/src/router/params-util.ts +23 -0
  121. package/src/router/pattern-matching.ts +5 -56
  122. package/src/router/prerender-match.ts +56 -51
  123. package/src/router/request-classification.ts +1 -38
  124. package/src/router/revalidation.ts +14 -62
  125. package/src/router/route-snapshot.ts +0 -1
  126. package/src/router/router-context.ts +0 -27
  127. package/src/router/router-interfaces.ts +10 -0
  128. package/src/router/segment-resolution/fresh.ts +25 -57
  129. package/src/router/segment-resolution/helpers.ts +34 -0
  130. package/src/router/segment-resolution/loader-cache.ts +35 -23
  131. package/src/router/segment-resolution/revalidation.ts +188 -283
  132. package/src/router/segment-resolution/streamed-handler-telemetry.ts +52 -0
  133. package/src/router/segment-resolution.ts +4 -1
  134. package/src/router/segment-wrappers.ts +0 -3
  135. package/src/router/telemetry-otel.ts +0 -20
  136. package/src/router/telemetry.ts +0 -22
  137. package/src/router/timeout.ts +0 -20
  138. package/src/router/trie-matching.ts +66 -45
  139. package/src/router/types.ts +1 -63
  140. package/src/router/url-params.ts +0 -5
  141. package/src/router.ts +8 -11
  142. package/src/rsc/handler-context.ts +1 -0
  143. package/src/rsc/handler.ts +20 -4
  144. package/src/rsc/helpers.ts +71 -3
  145. package/src/rsc/json-route-result.ts +38 -0
  146. package/src/rsc/origin-guard.ts +9 -15
  147. package/src/rsc/progressive-enhancement.ts +10 -1
  148. package/src/rsc/redirect-guard.ts +99 -0
  149. package/src/rsc/response-route-handler.ts +23 -18
  150. package/src/rsc/rsc-rendering.ts +2 -7
  151. package/src/rsc/runtime-warnings.ts +14 -0
  152. package/src/rsc/server-action.ts +34 -29
  153. package/src/rsc/types.ts +6 -3
  154. package/src/search-params.ts +0 -16
  155. package/src/segment-loader-promise.ts +14 -2
  156. package/src/segment-system.tsx +79 -88
  157. package/src/server/handle-store.ts +7 -24
  158. package/src/server/loader-registry.ts +5 -24
  159. package/src/server/request-context.ts +29 -92
  160. package/src/ssr/index.tsx +14 -14
  161. package/src/static-handler.ts +2 -27
  162. package/src/testing/cache-status.ts +44 -48
  163. package/src/testing/collect-handle.ts +1 -24
  164. package/src/testing/dispatch.ts +43 -6
  165. package/src/testing/e2e/index.ts +1 -22
  166. package/src/testing/e2e/matchers.ts +0 -16
  167. package/src/testing/flight-matchers.ts +0 -13
  168. package/src/testing/flight-normalize.ts +3 -30
  169. package/src/testing/flight.ts +46 -48
  170. package/src/testing/generated-routes.ts +1 -41
  171. package/src/testing/index.ts +1 -21
  172. package/src/testing/internal/context.ts +3 -45
  173. package/src/testing/internal/seed-vars.ts +0 -26
  174. package/src/testing/render-handler.ts +31 -61
  175. package/src/testing/render-route.tsx +75 -103
  176. package/src/testing/run-loader.ts +0 -96
  177. package/src/testing/run-middleware.ts +0 -26
  178. package/src/theme/ThemeProvider.tsx +0 -52
  179. package/src/theme/ThemeScript.tsx +0 -6
  180. package/src/theme/constants.ts +0 -12
  181. package/src/theme/index.ts +0 -7
  182. package/src/theme/theme-context.ts +1 -5
  183. package/src/theme/theme-script.ts +0 -14
  184. package/src/theme/use-theme.ts +0 -3
  185. package/src/types/boundaries.ts +0 -35
  186. package/src/types/error-types.ts +25 -89
  187. package/src/types/global-namespace.ts +4 -14
  188. package/src/types/handler-context.ts +28 -9
  189. package/src/types/index.ts +0 -10
  190. package/src/types/request-scope.ts +0 -19
  191. package/src/types/route-config.ts +6 -50
  192. package/src/types/route-entry.ts +0 -6
  193. package/src/types/segments.ts +0 -13
  194. package/src/urls/include-helper.ts +0 -4
  195. package/src/urls/index.ts +0 -6
  196. package/src/urls/path-helper-types.ts +2 -2
  197. package/src/urls/path-helper.ts +0 -54
  198. package/src/urls/urls-function.ts +0 -13
  199. package/src/use-loader.tsx +0 -186
  200. package/src/vite/discovery/bundle-postprocess.ts +2 -1
  201. package/src/vite/discovery/discover-routers.ts +28 -18
  202. package/src/vite/discovery/prerender-collection.ts +2 -4
  203. package/src/vite/discovery/state.ts +5 -0
  204. package/src/vite/discovery/virtual-module-codegen.ts +1 -11
  205. package/src/vite/plugin-types.ts +35 -9
  206. package/src/vite/plugins/cjs-to-esm.ts +0 -11
  207. package/src/vite/plugins/client-ref-dedup.ts +0 -11
  208. package/src/vite/plugins/client-ref-hashing.ts +0 -10
  209. package/src/vite/plugins/cloudflare-protocol-stub.ts +0 -20
  210. package/src/vite/plugins/expose-action-id.ts +2 -73
  211. package/src/vite/plugins/expose-id-utils.ts +0 -55
  212. package/src/vite/plugins/expose-ids/export-analysis.ts +0 -38
  213. package/src/vite/plugins/expose-ids/handler-transform.ts +0 -15
  214. package/src/vite/plugins/expose-ids/loader-transform.ts +0 -15
  215. package/src/vite/plugins/expose-ids/router-transform.ts +0 -13
  216. package/src/vite/plugins/expose-internal-ids.ts +10 -0
  217. package/src/vite/plugins/performance-tracks.ts +0 -3
  218. package/src/vite/plugins/refresh-cmd.ts +1 -1
  219. package/src/vite/plugins/use-cache-transform.ts +21 -46
  220. package/src/vite/plugins/version-injector.ts +0 -20
  221. package/src/vite/plugins/version-plugin.ts +1 -49
  222. package/src/vite/plugins/virtual-entries.ts +0 -15
  223. package/src/vite/rango.ts +2 -108
  224. package/src/vite/router-discovery.ts +9 -1
  225. package/src/vite/utils/ast-handler-extract.ts +0 -16
  226. package/src/vite/utils/bundle-analysis.ts +6 -13
  227. package/src/vite/utils/client-chunks.ts +0 -6
  228. package/src/vite/utils/forward-user-plugins.ts +0 -22
  229. package/src/vite/utils/manifest-utils.ts +0 -4
  230. package/src/vite/utils/package-resolution.ts +1 -73
  231. package/src/vite/utils/prerender-utils.ts +0 -35
  232. package/src/vite/utils/shared-utils.ts +3 -35
  233. package/src/browser/shallow.ts +0 -40
  234. package/src/handles/index.ts +0 -7
  235. package/src/router/middleware-cookies.ts +0 -55
@@ -23,6 +23,10 @@
23
23
  * client context (see the `loaders` / `locationState` / `handles` options) —
24
24
  * nothing is executed on the server. This exercises the read path
25
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).
26
30
  * What it DOES cover: client hooks that read NavigationProvider /
27
31
  * OutletContext — useParams, useReverse, useHref, useMount, useNavigation,
28
32
  * useRouter, usePathname, useSearchParams, Outlet nesting, useLoader /
@@ -53,6 +57,7 @@ import type { LocationStateDefinition } from "../browser/react/location-state-sh
53
57
  import type { Handle } from "../handle.js";
54
58
  import type { ThemeConfig } from "../theme/types.js";
55
59
  import { resolveThemeConfig } from "../theme/constants.js";
60
+ import { isUnderTestRunner } from "../runtime-env.js";
56
61
 
57
62
  const TEST_ORIGIN = "http://localhost";
58
63
 
@@ -63,11 +68,6 @@ const TEST_ORIGIN = "http://localhost";
63
68
  */
64
69
  export type HandleDataSeed = Record<string, Record<string, unknown[]>>;
65
70
 
66
- // Loaders and location-state defs carry an id (`$$id` / `__rsc_ls_key`) that the
67
- // Vite plugin injects at build time; in a bare test it is "". These helpers
68
- // assign a synthetic stable id (mutating the handle, tracked per-object) so that
69
- // seeding by reference lines up with the read path (useLoader / useLocationState
70
- // both read the id off the handle at call time).
71
71
  const syntheticIds = new WeakMap<object, string>();
72
72
  let syntheticIdCounter = 0;
73
73
 
@@ -86,7 +86,6 @@ function ensureSyntheticId(
86
86
  return id;
87
87
  }
88
88
 
89
- /** One-level clone of a raw handle seed so we don't mutate the caller's object. */
90
89
  function cloneHandleSeed(seed?: HandleDataSeed): HandleDataSeed {
91
90
  const out: HandleDataSeed = {};
92
91
  for (const [name, segMap] of Object.entries(seed ?? {})) {
@@ -95,12 +94,6 @@ function cloneHandleSeed(seed?: HandleDataSeed): HandleDataSeed {
95
94
  return out;
96
95
  }
97
96
 
98
- /**
99
- * One node of the route definition passed to renderRoute. The array models a
100
- * single matched route plus its optional layout chain — element order is
101
- * outermost layout first, the leaf route last (the same root-to-leaf order the
102
- * real matcher produces).
103
- */
104
97
  export interface RenderRouteSpec {
105
98
  /**
106
99
  * The route pattern this node matches, e.g. "/products/:productId". The LAST
@@ -172,7 +165,10 @@ export interface RenderRouteOptions {
172
165
  /**
173
166
  * Explicit params. Merged over (and overriding) params extracted from the
174
167
  * `request` URL. Use this when the URL alone cannot express the params, or to
175
- * avoid relying on URL parsing.
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.
176
172
  */
177
173
  params?: Record<string, string>;
178
174
  /**
@@ -236,6 +232,10 @@ export interface RenderRouteOptions {
236
232
  * exactly as `renderSegments` does in production (a segment whose `mountPath`
237
233
  * is set is wrapped in a MountContextProvider). Normalized like a path prefix
238
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
239
  *
240
240
  * @example
241
241
  * renderRoute([{ path: "/c/wine", Component: ProductPage }], { mount: "/shop" });
@@ -281,11 +281,6 @@ interface ResolvedMatch {
281
281
  pathname: string;
282
282
  }
283
283
 
284
- /**
285
- * Match a pathname against the leaf spec's pattern and extract params.
286
- * Returns null when the pattern does not match (params then fall back to the
287
- * caller-provided `options.params`).
288
- */
289
284
  function matchLeaf(
290
285
  pattern: string,
291
286
  pathname: string,
@@ -303,7 +298,6 @@ function matchLeaf(
303
298
  return params;
304
299
  }
305
300
 
306
- /** Derive a usable initial pathname from a leaf pattern when none is given. */
307
301
  function staticPrefix(pattern: string): string {
308
302
  const out: string[] = [];
309
303
  for (const part of pattern.split("/")) {
@@ -314,13 +308,6 @@ function staticPrefix(pattern: string): string {
314
308
  return "/" + out.join("/");
315
309
  }
316
310
 
317
- /**
318
- * Build the synthetic ResolvedSegment[] for a matched route. Produces, in
319
- * root-to-leaf order: one layout segment per non-leaf spec, then the leaf route
320
- * segment, plus a loader segment for each seeded loader id attached to the
321
- * owning spec. Segment ids follow the real convention (L0, L0L1, ..., the leaf
322
- * route as L0...R{n}; loaders as {parentId}D{i}.{loaderId}).
323
- */
324
311
  function buildSegments(
325
312
  routes: RenderRouteSpec[],
326
313
  params: Record<string, string>,
@@ -353,20 +340,13 @@ function buildSegments(
353
340
  params,
354
341
  belongsToRoute: true,
355
342
  };
356
- // Model an include() mount: every component segment in the chain shares the
357
- // same prefix, so renderSegments wraps each in a MountContextProvider and
358
- // useMount() resolves the mounted prefix (production sets mountPath on every
359
- // segment of an included subtree). Must be applied identically at both
360
- // buildSegments call sites or segment-structure-assert flags a remount.
361
343
  if (mount) node.mountPath = mount;
362
- // A leaf-owned layout component wraps the route via its own layout element.
363
344
  if (isLeaf && spec.layout) {
364
345
  const Layout = spec.layout;
365
346
  node.layout = <Layout />;
366
347
  }
367
348
  segments.push(node);
368
349
 
369
- // Determine which seeded loader ids this spec owns.
370
350
  const ownedIds = spec.loaderIds
371
351
  ? spec.loaderIds.filter((id) => id in loaderData)
372
352
  : isLeaf
@@ -390,30 +370,6 @@ function buildSegments(
390
370
  return segments;
391
371
  }
392
372
 
393
- /**
394
- * Render a CLIENT component (and its layout chain) inside the router's
395
- * NavigationProvider for unit testing. Exported from `@rangojs/router/testing/dom`
396
- * (its own entry, kept out of the main `@rangojs/router/testing` barrel so that
397
- * barrel never references React/@testing-library/react). Async so the heavy
398
- * @testing-library/react dependency is loaded only at call time.
399
- *
400
- * @example
401
- * ```tsx
402
- * // @vitest-environment happy-dom
403
- * import { renderRoute } from "@rangojs/router/testing/dom";
404
- *
405
- * function Product() {
406
- * const { productId } = useParams<{ productId: string }>();
407
- * const reverse = useReverse({ product: "/products/:productId" });
408
- * return <a href={reverse("product", { productId: "2" })}>{productId}</a>;
409
- * }
410
- *
411
- * const { getByText, router } = await renderRoute(
412
- * [{ path: "/products/:productId", Component: Product }],
413
- * { request: "/products/1" },
414
- * );
415
- * ```
416
- */
417
373
  export async function renderRoute(
418
374
  routes: RenderRouteSpec[],
419
375
  options: RenderRouteOptions = {},
@@ -421,9 +377,6 @@ export async function renderRoute(
421
377
  if (routes.length === 0) {
422
378
  throw new Error("renderRoute: `routes` must contain at least one entry");
423
379
  }
424
- // The pre-rename `initialUrl` option was renamed to `request`. A plain-JS or
425
- // spread-defeated caller still passing it would otherwise be silently ignored;
426
- // fail loud with the migration name instead.
427
380
  if ("initialUrl" in options) {
428
381
  throw new Error(
429
382
  "renderRoute: the `initialUrl` option was renamed to `request`. " +
@@ -439,30 +392,19 @@ export async function renderRoute(
439
392
  const initialUrl = requestUrl ?? staticPrefix(leaf.path) ?? "/";
440
393
  const url = new URL(initialUrl, TEST_ORIGIN);
441
394
 
442
- // Seed loader data: explicit-id entries from `loaderData`, plus by-reference
443
- // entries from `loaders` (assigning synthetic ids to real handles whose `$$id`
444
- // is empty in a bare test).
445
395
  const loaderData: Record<string, unknown> = { ...(options.loaderData ?? {}) };
446
396
  for (const [loader, data] of options.loaders ?? []) {
447
397
  loaderData[ensureSyntheticId(loader as object, "$$id")] = data;
448
398
  }
449
399
 
450
- // Seed location state into history.state so useLocationState(def) resolves.
451
- // Keyed defs read history.state[def.__rsc_ls_key]; assign a synthetic key when
452
- // the injected one is empty (bare test). RESET history.state to only this
453
- // call's seeds (not a merge) so a previous render's seeded state does not leak
454
- // into a later render in the same DOM environment.
455
400
  if (typeof window !== "undefined") {
456
401
  const stateObj: Record<string, unknown> = {};
457
402
  for (const [def, value] of options.locationState ?? []) {
458
403
  stateObj[ensureSyntheticId(def as object, "__rsc_ls_key")] = value;
459
404
  }
460
- // No URL arg: useLocationState reads history.state (not the URL), and passing
461
- // a TEST_ORIGIN URL would trip the DOM env's same-origin check.
462
405
  window.history.replaceState(stateObj, "");
463
406
  }
464
407
 
465
- // Resolve params: URL-extracted params first, explicit params override.
466
408
  const resolve = (pathname: string): ResolvedMatch => {
467
409
  const matched = matchLeaf(leaf.path, pathname) ?? {};
468
410
  return {
@@ -472,11 +414,34 @@ export async function renderRoute(
472
414
  };
473
415
  const initialMatch = resolve(url.pathname);
474
416
 
475
- // Reuse the real browser primitives so context shape matches production.
476
417
  const historyKey = generateHistoryKey(url.href);
477
- // Normalize the include() mount prefix once and apply it at BOTH buildSegments
478
- // call sites (initial + navigate) so mountPath is consistent across renders.
479
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
+ }
480
445
  const initialSegments = buildSegments(
481
446
  routes,
482
447
  initialMatch.params,
@@ -490,20 +455,12 @@ export async function renderRoute(
490
455
  initialSegments,
491
456
  crossTabSync: false,
492
457
  });
493
- // Seed handle data: raw `handle` entries plus by-reference `handles` attached
494
- // to the leaf route segment under each handle's id (so useHandle(handle)
495
- // resolves the pushed values).
496
458
  const leafRouteSegmentId =
497
459
  [...initialSegments].reverse().find((s) => s.type === "route")?.id ??
498
460
  initialSegments[initialSegments.length - 1]?.id;
499
461
  const handleSeed: HandleDataSeed = cloneHandleSeed(options.handle);
500
462
  for (const [handle, values] of options.handles ?? []) {
501
463
  if (leafRouteSegmentId === undefined) continue;
502
- // createHandle always has a non-empty $$id (the Vite plugin injects one, and
503
- // createHandle assigns a runtime fallback otherwise) with its REAL collect
504
- // registered — so seeding under handle.$$id makes useHandle(handle) run the
505
- // handle's actual collect/accumulator (custom collects included), not just a
506
- // default flatten.
507
464
  const id = (handle as unknown as { $$id: string }).$$id;
508
465
  (handleSeed[id] ??= {})[leafRouteSegmentId] = values;
509
466
  }
@@ -515,15 +472,23 @@ export async function renderRoute(
515
472
  initialSegments.map((s) => s.id),
516
473
  );
517
474
 
518
- // Client-only navigation: re-resolve against the in-memory routes and emit a
519
- // re-render. No server fetch — only routes passed to renderRoute exist. The
520
- // store update is flushed inside act() so React commits before callers
521
- // assert, mirroring how a real navigation lands a single payload swap.
522
- // NOTE: the seeded `loaderData` is reused for the target route too (no
523
- // per-route loader fetch in a unit test), so every seeded loader stays
524
- // available after navigate() — unlike a real navigation, which would fetch
525
- // the target route's own loaders. This is a deliberate test-isolation design.
475
+ let warnedNavLifecycle = false;
526
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
+ }
527
492
  const nextUrl = new URL(target, TEST_ORIGIN);
528
493
  const match = resolve(nextUrl.pathname);
529
494
  const segments = buildSegments(routes, match.params, loaderData, mount);
@@ -554,18 +519,26 @@ export async function renderRoute(
554
519
  );
555
520
  const initialTree = await renderSegments(initialSegments);
556
521
 
557
- const result = render(
558
- <NavigationProvider
559
- store={store}
560
- eventController={eventController}
561
- initialPayload={{ root: initialTree, metadata: initialMetadata }}
562
- bridge={bridge}
563
- basename={normalizeBasename(options.basename)}
564
- themeConfig={
565
- options.theme === undefined ? null : resolveThemeConfig(options.theme)
566
- }
567
- />,
568
- );
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
+ });
569
542
 
570
543
  const router: TestRouterHandle = {
571
544
  navigate,
@@ -578,7 +551,6 @@ export async function renderRoute(
578
551
  return Object.assign(result, { router });
579
552
  }
580
553
 
581
- /** Minimal RscMetadata for client-side re-renders (no server-only fields). */
582
554
  function makeMetadata(
583
555
  pathname: string,
584
556
  segments: ResolvedSegment[],
@@ -177,10 +177,6 @@ export interface RunLoaderOptions<TEnv = any> {
177
177
  handles?: ReadonlyArray<readonly [Handle<any, any>, unknown]>;
178
178
  }
179
179
 
180
- /**
181
- * Merge `search` into a request's URL, returning a value `toRequest` can build.
182
- * Keeps the original method/headers/body when a Request was passed.
183
- */
184
180
  function withSearch(
185
181
  request: Request | string | undefined,
186
182
  search: Record<string, string> | undefined,
@@ -206,16 +202,6 @@ export type RunnableLoader<T> =
206
202
  | ((ctx: TestLoaderContext) => Promise<T> | T)
207
203
  | LoaderDefinition<T, any>;
208
204
 
209
- /**
210
- * Resolve the function to run from either a raw body or a `createLoader()` handle.
211
- *
212
- * A handle carries no inline body (`createLoader` registers it in the fetchable
213
- * registry by `$$id`), so recover it from there — `def.fn` first (a hand-built
214
- * def), then the registry. This works when the handle resolves through the
215
- * SERVER build (the consumer's `@rangojs/router` under `rangoTestConfig`, which
216
- * registers the fn); the CLIENT stub drops the body, so a handle imported that
217
- * way is unrecoverable and we say so explicitly.
218
- */
219
205
  function resolveLoaderFn<T>(
220
206
  loader: RunnableLoader<T>,
221
207
  ): (ctx: TestLoaderContext) => Promise<T> | T {
@@ -237,31 +223,11 @@ function resolveLoaderFn<T>(
237
223
  return fn as (ctx: TestLoaderContext) => Promise<T> | T;
238
224
  }
239
225
 
240
- /**
241
- * Run a loader and return its resolved data. Pass the RAW loader body, or a
242
- * registered `createLoader()` handle (its fn is recovered from the registry).
243
- *
244
- * @example
245
- * ```ts
246
- * // raw body
247
- * const a = await runLoader(
248
- * async (ctx) => ({ id: ctx.params.id, user: ctx.get("user") }),
249
- * { params: { id: "42" }, vars: { user: { name: "Ada" } } },
250
- * );
251
- * // registered createLoader() handle (recovered from the registry)
252
- * const b = await runLoader(ProductLoader, { params: { id: "42" } });
253
- * ```
254
- */
255
- // Build the createTestRequestContext options from runLoader's options. Shared by
256
- // runLoader (returns the loader data) and runLoaderResult (also snapshots effects).
257
226
  function buildLoaderCtxOpts(
258
227
  opts: RunLoaderOptions,
259
228
  ): CreateTestContextOptions<any> {
260
229
  return {
261
230
  env: opts.env,
262
- // Bake opts.search into the request URL itself so ctx.request.url, ctx.url,
263
- // and ctx.searchParams all agree (production carries the query string on the
264
- // real request — a loader reading ctx.request.url must see it too).
265
231
  request: withSearch(opts.request, opts.search),
266
232
  requestInit: opts.method ? { method: opts.method } : undefined,
267
233
  vars: opts.vars,
@@ -276,33 +242,19 @@ function buildLoaderCtxOpts(
276
242
  };
277
243
  }
278
244
 
279
- // Enter `reqCtx` and run `fn` with a seeded TestLoaderContext (the same ctx shape
280
- // a real loader receives). The single place the loader context is built, so
281
- // runLoader and runLoaderResult share identical loader-context semantics.
282
245
  function runWithLoaderContext<R>(
283
246
  reqCtx: RequestContext<any>,
284
247
  opts: RunLoaderOptions,
285
248
  fn: (ctx: TestLoaderContext) => R,
286
249
  ): R {
287
- // Seed values for ctx.use(SomeHandle), matched by handle reference (so a real
288
- // handle resolves regardless of its build-injected $$id).
289
250
  const handleSeeds = new Map<unknown, unknown>(opts.handles ?? []);
290
-
291
- // Seed values for ctx.use(OtherLoader), matched by loader reference (same model
292
- // as renderHandler/renderRoute). Checked before the `use` resolver.
293
251
  const loaderSeeds = new Map<unknown, unknown>(opts.loaders ?? []);
294
-
295
- // Tracks whether the mocked render barrier has settled. ctx.use(handle)
296
- // reads are gated on this, matching production (loader-resolution.ts).
297
252
  let renderedResolved = false;
298
253
 
299
254
  return runWithRequestContext(reqCtx, () => {
300
255
  const reverse = opts.routeMap
301
256
  ? createReverseFunction(opts.routeMap, opts.routeName, opts.params ?? {})
302
257
  : ((() => {
303
- // Documented contract: reverse requires routeMap. Do NOT fall back to
304
- // reqCtx.reverse (the global route map) — that leaks whichever routes
305
- // another test registered and contradicts the documented behavior.
306
258
  throw new Error(
307
259
  "ctx.reverse() requires the `routeMap` option in runLoader(). " +
308
260
  "Pass { routeMap: { name: pattern, ... } } to enable reverse().",
@@ -323,10 +275,6 @@ function runWithLoaderContext<R>(
323
275
  executionContext: reqCtx.executionContext,
324
276
  get: reqCtx.get as TestLoaderContext["get"],
325
277
  use: ((dep: LoaderDefinition<any, any> | Handle<any, any>) => {
326
- // Match production (loader-resolution.ts): reading a handle in a loader
327
- // requires the render barrier to have settled. Gate BEFORE returning a
328
- // seed, so a loader that forgets `await ctx.rendered()` fails in the
329
- // test exactly as it would at runtime.
330
278
  if (isHandle(dep) && !renderedResolved) {
331
279
  throw new Error(
332
280
  `ctx.use(handle) in a loader requires "await ctx.rendered()" first. ` +
@@ -334,16 +282,8 @@ function runWithLoaderContext<R>(
334
282
  `the render tree has settled.`,
335
283
  );
336
284
  }
337
- // Handle reads (ctx.use(SomeHandle)) resolve from the seeded map first.
338
285
  if (handleSeeds.has(dep)) return handleSeeds.get(dep);
339
- // Post-barrier, an UNSEEDED handle must match production
340
- // (loader-resolution.ts -> collectHandleData), which runs the handle's
341
- // registered collect over empty segments (collect([])) rather than
342
- // throwing or leaking into the loader resolver. Resolve it via
343
- // collectHandle, which recovers and runs that same collect.
344
286
  if (isHandle(dep)) return collectHandle(dep, []);
345
- // Loader reads (ctx.use(OtherLoader)) resolve from the seeded map next,
346
- // then the dynamic `use` resolver, then the real request-context use().
347
287
  if (loaderSeeds.has(dep)) return loaderSeeds.get(dep);
348
288
  if (opts.use) return opts.use(dep as LoaderDefinition<any, any>);
349
289
  return reqCtx.use(dep as LoaderDefinition<any, any>);
@@ -358,7 +298,6 @@ function runWithLoaderContext<R>(
358
298
  if (typeof opts.rendered === "function") {
359
299
  await opts.rendered();
360
300
  }
361
- // Barrier has settled: subsequent ctx.use(handle) reads resolve.
362
301
  renderedResolved = true;
363
302
  }
364
303
  : () => {
@@ -377,13 +316,6 @@ function runWithLoaderContext<R>(
377
316
  });
378
317
  }
379
318
 
380
- /**
381
- * Run a loader and return its resolved data.
382
- *
383
- * Effects the loader sets (cookies, response headers, a thrown redirect) are NOT
384
- * observable here — use {@link runLoaderResult} for an auth-style loader that
385
- * sets a `Set-Cookie` and/or `throw redirect(...)`.
386
- */
387
319
  export async function runLoader<T>(
388
320
  loader: RunnableLoader<T>,
389
321
  opts: RunLoaderOptions = {},
@@ -395,12 +327,6 @@ export async function runLoader<T>(
395
327
  );
396
328
  }
397
329
 
398
- /**
399
- * What a loader run accumulated: its data PLUS the response effects it produced,
400
- * surfaced as PUBLIC values (parity with `runMiddleware`/`runInRequestContext`)
401
- * so an effect-setting loader is assertable without casting through the
402
- * `@internal` request context.
403
- */
404
330
  export interface RunLoaderResult<T> {
405
331
  /**
406
332
  * The loader's resolved data (the value bare `runLoader` returns), or
@@ -430,25 +356,6 @@ export interface RunLoaderResult<T> {
430
356
  stateCookieName: string;
431
357
  }
432
358
 
433
- /**
434
- * Run a loader AND surface the response effects it produced. The richer sibling
435
- * of {@link runLoader} (which returns the bare data): use this when the loader
436
- * sets a cookie / response header / location-state, or `throw redirect(...)`, and
437
- * the test must assert that output.
438
- *
439
- * @example
440
- * ```ts
441
- * // AuthLoader: validates, sets a `session` cookie, then `throw redirect("/")`.
442
- * const { thrown, response, cookies } = await runLoaderResult(AuthLoader, {
443
- * request: new Request("https://app.test/login?token=ok"),
444
- * });
445
- * expect((thrown as Response).headers.get("Location")).toBe("/");
446
- * expect(cookies.session).toBeDefined();
447
- * expect(
448
- * response.headers.getSetCookie().some((c) => c.startsWith("session=")),
449
- * ).toBe(true);
450
- * ```
451
- */
452
359
  export async function runLoaderResult<T>(
453
360
  loader: RunnableLoader<T>,
454
361
  opts: RunLoaderOptions = {},
@@ -465,9 +372,6 @@ export async function runLoaderResult<T>(
465
372
  Promise.resolve(loaderFn(loaderCtx)),
466
373
  );
467
374
  } catch (error) {
468
- // Capture (do NOT re-throw): a loader's success path is often
469
- // `throw redirect(...)`, and the cookie/flash it set before the throw must
470
- // stay observable (parity with runInRequestContext).
471
375
  thrown = error;
472
376
  }
473
377
  return { result, ...buildRunSnapshot(reqCtx, thrown, stateCookieName) };
@@ -133,21 +133,6 @@ export interface RunMiddlewareResult<TEnv = any> {
133
133
  stateCookieName: string;
134
134
  }
135
135
 
136
- /**
137
- * Run a middleware chain and return the response plus observable context.
138
- *
139
- * @example
140
- * ```ts
141
- * const { response, ctx, nextCalled } = await runMiddleware(
142
- * async (ctx, next) => {
143
- * if (!ctx.get("user")) return new Response(null, { status: 401 });
144
- * return next();
145
- * },
146
- * { request: "/dashboard", vars: [["user", { id: 1 }]] },
147
- * );
148
- * // nextCalled === 1, response.status === 200
149
- * ```
150
- */
151
136
  export async function runMiddleware<TEnv = any>(
152
137
  mw: MiddlewareFn<TEnv> | MiddlewareFn<TEnv>[],
153
138
  opts: RunMiddlewareOptions<TEnv>,
@@ -181,11 +166,6 @@ export async function runMiddleware<TEnv = any>(
181
166
  return opts.next?.() ?? new Response(null, { status: 200 });
182
167
  };
183
168
 
184
- // Match production: app/response middleware receive ctx.reverse built from the
185
- // route map ALONE (no matched route name or current params), so reversing a
186
- // parameterized route without explicit params does NOT auto-fill from the
187
- // current request. Passing routeName/params here would recreate the
188
- // false-confidence class fixed in dispatch.
189
169
  const reverse = opts.routeMap
190
170
  ? (createReverseFunction(opts.routeMap) as (
191
171
  name: string,
@@ -194,12 +174,6 @@ export async function runMiddleware<TEnv = any>(
194
174
  ) => string)
195
175
  : undefined;
196
176
 
197
- // Keep the RETURNED ctx.reverse consistent with the map-only reverse the
198
- // chain receives. createTestRequestContext installs an auto-fill reverse
199
- // (correct for the loader phase) when routeName/params are passed, but
200
- // production app/response middleware see a map-only reverse. Without this,
201
- // a middleware reading getRequestContext().reverse — or a consumer asserting
202
- // on result.ctx.reverse — would observe auto-fill that production never does.
203
177
  if (reverse) {
204
178
  (ctx as RequestContext<TEnv>).reverse =
205
179
  reverse as RequestContext<TEnv>["reverse"];