@rangojs/router 0.16.0 → 0.17.0

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 (245) hide show
  1. package/README.md +254 -89
  2. package/dist/testing/vitest.js +252 -0
  3. package/dist/types/browser/navigation-transaction.d.ts +6 -0
  4. package/dist/types/browser/partial-update.d.ts +11 -0
  5. package/dist/types/browser/react/ScrollRestoration.d.ts +6 -6
  6. package/dist/types/browser/rsc-router.d.ts +1 -1
  7. package/dist/types/browser/scroll-restoration.d.ts +7 -0
  8. package/dist/types/cache/cache-exec-scope.d.ts +18 -1
  9. package/dist/types/cache/cache-scope.d.ts +34 -5
  10. package/dist/types/cache/handle-capture.d.ts +41 -3
  11. package/dist/types/cache/handle-snapshot.d.ts +53 -13
  12. package/dist/types/cache/segment-codec.d.ts +10 -3
  13. package/dist/types/cache/taint.d.ts +1 -1
  14. package/dist/types/cache/types.d.ts +15 -3
  15. package/dist/types/client.d.ts +4 -4
  16. package/dist/types/client.rsc.d.ts +4 -5
  17. package/dist/types/components/DefaultDocument.d.ts +5 -2
  18. package/dist/types/context-var.d.ts +6 -5
  19. package/dist/types/escape-script.d.ts +1 -1
  20. package/dist/types/handles/MetaTags.d.ts +5 -4
  21. package/dist/types/handles/Scripts.d.ts +8 -7
  22. package/dist/types/handles/meta.d.ts +2 -2
  23. package/dist/types/handles/script.d.ts +17 -16
  24. package/dist/types/href-client.d.ts +1 -1
  25. package/dist/types/html.d.ts +12 -0
  26. package/dist/types/index.d.ts +1 -1
  27. package/dist/types/index.rsc.d.ts +3 -2
  28. package/dist/types/loader-store.d.ts +7 -4
  29. package/dist/types/reverse.d.ts +36 -4
  30. package/dist/types/route-definition/dsl-helpers.d.ts +3 -3
  31. package/dist/types/route-definition/helpers-types.d.ts +33 -9
  32. package/dist/types/route-definition/resolve-handler-use.d.ts +23 -1
  33. package/dist/types/route-types.d.ts +1 -1
  34. package/dist/types/router/debug-manifest.d.ts +3 -2
  35. package/dist/types/router/error-handling.d.ts +20 -0
  36. package/dist/types/router/handler-context.d.ts +6 -0
  37. package/dist/types/router/match-middleware/background-revalidation.d.ts +46 -5
  38. package/dist/types/router/middleware-types.d.ts +3 -2
  39. package/dist/types/router/router-context.d.ts +3 -1
  40. package/dist/types/router/router-interfaces.d.ts +24 -2
  41. package/dist/types/router/router-options.d.ts +8 -5
  42. package/dist/types/router/segment-resolution/fresh.d.ts +10 -0
  43. package/dist/types/router/segment-resolution/loader-cache.d.ts +37 -1
  44. package/dist/types/router/segment-resolution/loader-mask.d.ts +9 -19
  45. package/dist/types/router/segment-resolution/loader-snapshot.d.ts +3 -3
  46. package/dist/types/router/segment-resolution/mask-nested.d.ts +7 -7
  47. package/dist/types/router/segment-resolution/revalidation.d.ts +6 -2
  48. package/dist/types/router/segment-wrappers.d.ts +1 -1
  49. package/dist/types/rsc/response-route-handler.d.ts +1 -1
  50. package/dist/types/rsc/rsc-rendering.d.ts +1 -2
  51. package/dist/types/rsc/shell-capture-constants.d.ts +8 -0
  52. package/dist/types/rsc/shell-capture.d.ts +5 -4
  53. package/dist/types/rsc/ssr-setup.d.ts +6 -6
  54. package/dist/types/rsc/types.d.ts +10 -0
  55. package/dist/types/server/context.d.ts +33 -3
  56. package/dist/types/server/handle-store.d.ts +26 -2
  57. package/dist/types/server/request-context.d.ts +24 -21
  58. package/dist/types/ssr/index.d.ts +15 -0
  59. package/dist/types/static-handler.d.ts +4 -1
  60. package/dist/types/testing/generated-routes.d.ts +2 -2
  61. package/dist/types/testing/internal/context.d.ts +1 -1
  62. package/dist/types/testing/render-route.d.ts +32 -6
  63. package/dist/types/testing/vitest-stubs/plugin-rsc.d.ts +28 -5
  64. package/dist/types/testing/vitest.d.ts +35 -1
  65. package/dist/types/theme/ThemeScript.d.ts +3 -3
  66. package/dist/types/types/cache-types.d.ts +14 -10
  67. package/dist/types/types/handler-context.d.ts +14 -0
  68. package/dist/types/types/loader-types.d.ts +8 -6
  69. package/dist/types/urls/response-types.d.ts +3 -12
  70. package/dist/types/use-loader.d.ts +12 -3
  71. package/dist/types/vite/plugins/use-cache-transform.d.ts +33 -2
  72. package/dist/types/vite/rango.d.ts +1 -1
  73. package/dist/vite/index.js +1381 -1346
  74. package/package.json +2 -2
  75. package/skills/api-client/SKILL.md +22 -10
  76. package/skills/breadcrumbs/SKILL.md +65 -22
  77. package/skills/bundle-analysis/SKILL.md +22 -22
  78. package/skills/cache-guide/SKILL.md +207 -97
  79. package/skills/caching/SKILL.md +295 -93
  80. package/skills/catalog.json +16 -16
  81. package/skills/client-urls/SKILL.md +53 -31
  82. package/skills/cloudflare/SKILL.md +72 -20
  83. package/skills/cloudflare/references/d1-and-local-dev.md +15 -2
  84. package/skills/cloudflare/references/streaming-and-deploy.md +10 -3
  85. package/skills/cloudflare/references/webhooks-and-crypto.md +9 -2
  86. package/skills/comparison/SKILL.md +5 -0
  87. package/skills/comparison/references/framework-comparison.md +39 -33
  88. package/skills/composability/SKILL.md +38 -29
  89. package/skills/css/SKILL.md +10 -13
  90. package/skills/debug-manifest/SKILL.md +74 -31
  91. package/skills/defer-hydration/SKILL.md +2 -1
  92. package/skills/deployment-caching/SKILL.md +6 -1
  93. package/skills/document-cache/SKILL.md +92 -28
  94. package/skills/fonts/SKILL.md +16 -8
  95. package/skills/handler-use/SKILL.md +53 -17
  96. package/skills/hooks/SKILL.md +28 -20
  97. package/skills/hooks/data.md +23 -3
  98. package/skills/hooks/handle-and-actions.md +31 -16
  99. package/skills/hooks/navigation.md +39 -10
  100. package/skills/hooks/outlets.md +13 -6
  101. package/skills/hooks/state.md +37 -15
  102. package/skills/hooks/urls.md +7 -5
  103. package/skills/host-router/SKILL.md +64 -12
  104. package/skills/i18n/SKILL.md +60 -34
  105. package/skills/intercept/SKILL.md +161 -26
  106. package/skills/layout/SKILL.md +106 -50
  107. package/skills/links/SKILL.md +51 -19
  108. package/skills/loader/SKILL.md +242 -106
  109. package/skills/middleware/SKILL.md +119 -23
  110. package/skills/migrate-nextjs/SKILL.md +100 -45
  111. package/skills/migrate-nextjs/backend-host-swap.md +5 -2
  112. package/skills/migrate-react-router/SKILL.md +49 -38
  113. package/skills/migrate-react-router/component-migration.md +25 -6
  114. package/skills/migrate-react-router/data-and-actions.md +18 -5
  115. package/skills/migrate-react-router/route-mapping.md +12 -6
  116. package/skills/mime-routes/SKILL.md +12 -7
  117. package/skills/observability/SKILL.md +28 -14
  118. package/skills/parallel/SKILL.md +75 -39
  119. package/skills/ppr/SKILL.md +345 -150
  120. package/skills/prerender/SKILL.md +189 -100
  121. package/skills/rango/SKILL.md +65 -34
  122. package/skills/react-compiler/SKILL.md +6 -5
  123. package/skills/response-routes/SKILL.md +133 -25
  124. package/skills/route/SKILL.md +165 -60
  125. package/skills/router-setup/SKILL.md +418 -240
  126. package/skills/scripts/SKILL.md +31 -20
  127. package/skills/server-actions/SKILL.md +220 -59
  128. package/skills/shell-manifest/SKILL.md +12 -7
  129. package/skills/streams-and-websockets/SKILL.md +32 -14
  130. package/skills/tailwind/SKILL.md +37 -33
  131. package/skills/testing/SKILL.md +68 -32
  132. package/skills/testing/cache-prerender.md +55 -29
  133. package/skills/testing/client-components.md +59 -6
  134. package/skills/testing/e2e-parity.md +12 -2
  135. package/skills/testing/flight.md +19 -11
  136. package/skills/testing/handles.md +8 -5
  137. package/skills/testing/loader.md +8 -5
  138. package/skills/testing/middleware.md +8 -3
  139. package/skills/testing/predicates.md +120 -0
  140. package/skills/testing/render-handler.md +70 -22
  141. package/skills/testing/response-routes.md +11 -4
  142. package/skills/testing/reverse-and-types.md +20 -14
  143. package/skills/testing/server-actions.md +12 -3
  144. package/skills/testing/server-tree.md +9 -0
  145. package/skills/testing/setup.md +9 -5
  146. package/skills/theme/SKILL.md +106 -29
  147. package/skills/typesafety/SKILL.md +20 -9
  148. package/skills/typesafety/env-and-bindings.md +126 -47
  149. package/skills/typesafety/generated-files-and-cli.md +26 -14
  150. package/skills/typesafety/params-and-search.md +44 -19
  151. package/skills/typesafety/route-types.md +50 -14
  152. package/skills/use-cache/SKILL.md +200 -82
  153. package/skills/vercel/SKILL.md +21 -11
  154. package/skills/view-transitions/SKILL.md +41 -20
  155. package/src/__augment-tests__/augmented.check.ts +26 -1
  156. package/src/browser/index.ts +2 -2
  157. package/src/browser/navigation-bridge.ts +3 -4
  158. package/src/browser/navigation-transaction.ts +22 -2
  159. package/src/browser/partial-update.ts +35 -25
  160. package/src/browser/react/ScrollRestoration.tsx +6 -6
  161. package/src/browser/rsc-router.tsx +1 -1
  162. package/src/browser/scroll-restoration.ts +53 -6
  163. package/src/cache/cache-exec-scope.ts +37 -4
  164. package/src/cache/cache-key-utils.ts +1 -6
  165. package/src/cache/cache-runtime.ts +230 -61
  166. package/src/cache/cache-scope.ts +76 -12
  167. package/src/cache/document-cache.ts +15 -0
  168. package/src/cache/handle-capture.ts +90 -10
  169. package/src/cache/handle-snapshot.ts +132 -28
  170. package/src/cache/segment-codec.ts +26 -7
  171. package/src/cache/tag-invalidation.ts +4 -3
  172. package/src/cache/taint.ts +1 -1
  173. package/src/cache/types.ts +16 -3
  174. package/src/client.rsc.tsx +10 -9
  175. package/src/client.tsx +5 -4
  176. package/src/components/DefaultDocument.tsx +7 -2
  177. package/src/context-var.ts +6 -5
  178. package/src/escape-script.ts +1 -1
  179. package/src/handles/MetaTags.tsx +13 -11
  180. package/src/handles/Scripts.tsx +11 -10
  181. package/src/handles/meta.ts +2 -2
  182. package/src/handles/script.ts +17 -16
  183. package/src/href-client.ts +1 -1
  184. package/src/html.ts +12 -0
  185. package/src/index.rsc.ts +5 -0
  186. package/src/index.ts +1 -0
  187. package/src/loader-store.ts +7 -4
  188. package/src/prerender/build-shell-capture.ts +8 -11
  189. package/src/reverse.ts +44 -4
  190. package/src/route-definition/dsl-helpers.ts +117 -30
  191. package/src/route-definition/helpers-types.ts +33 -9
  192. package/src/route-definition/resolve-handler-use.ts +121 -6
  193. package/src/route-types.ts +0 -3
  194. package/src/router/debug-manifest.ts +10 -2
  195. package/src/router/error-handling.ts +51 -15
  196. package/src/router/handler-context.ts +34 -25
  197. package/src/router/intercept-resolution.ts +125 -39
  198. package/src/router/loader-resolution.ts +56 -11
  199. package/src/router/match-api.ts +12 -17
  200. package/src/router/match-middleware/background-revalidation.ts +161 -96
  201. package/src/router/match-middleware/cache-lookup.ts +93 -10
  202. package/src/router/match-middleware/cache-store.ts +17 -90
  203. package/src/router/middleware-types.ts +3 -5
  204. package/src/router/middleware.ts +52 -9
  205. package/src/router/prerender-match.ts +53 -19
  206. package/src/router/route-snapshot.ts +11 -1
  207. package/src/router/router-context.ts +2 -0
  208. package/src/router/router-interfaces.ts +25 -2
  209. package/src/router/router-options.ts +8 -5
  210. package/src/router/segment-resolution/fresh.ts +44 -12
  211. package/src/router/segment-resolution/loader-cache.ts +205 -45
  212. package/src/router/segment-resolution/loader-mask.ts +12 -22
  213. package/src/router/segment-resolution/loader-snapshot.ts +6 -6
  214. package/src/router/segment-resolution/mask-nested.ts +7 -7
  215. package/src/router/segment-resolution/revalidation.ts +34 -6
  216. package/src/router/segment-wrappers.ts +4 -0
  217. package/src/rsc/render-pipeline.ts +3 -0
  218. package/src/rsc/response-route-handler.ts +11 -3
  219. package/src/rsc/rsc-rendering.ts +8 -45
  220. package/src/rsc/shell-capture-constants.ts +13 -0
  221. package/src/rsc/shell-capture.ts +111 -90
  222. package/src/rsc/ssr-setup.ts +14 -8
  223. package/src/rsc/types.ts +11 -0
  224. package/src/server/context.ts +88 -11
  225. package/src/server/cookie-store.ts +5 -4
  226. package/src/server/handle-store.ts +232 -7
  227. package/src/server/request-context.ts +35 -30
  228. package/src/ssr/index.tsx +40 -7
  229. package/src/static-handler.ts +4 -1
  230. package/src/testing/flight-runtime.d.ts +21 -0
  231. package/src/testing/generated-routes.ts +2 -2
  232. package/src/testing/internal/context.ts +1 -1
  233. package/src/testing/internal/flight-client-globals.ts +7 -5
  234. package/src/testing/render-route.tsx +85 -24
  235. package/src/testing/run-loader.ts +5 -1
  236. package/src/testing/vitest-stubs/plugin-rsc.ts +123 -20
  237. package/src/testing/vitest.ts +62 -1
  238. package/src/theme/ThemeScript.tsx +3 -3
  239. package/src/types/cache-types.ts +14 -10
  240. package/src/types/handler-context.ts +14 -0
  241. package/src/types/loader-types.ts +8 -6
  242. package/src/urls/response-types.ts +3 -23
  243. package/src/use-loader.tsx +12 -3
  244. package/src/vite/plugins/use-cache-transform.ts +118 -74
  245. package/src/vite/rango.ts +7 -3
@@ -2,7 +2,7 @@
2
2
 
3
3
  **Layer:** unit (DOM) · **Import:** `@rangojs/router/testing/dom` · **DSL it tests:** a client component reading router context (see `/hooks`)
4
4
 
5
- RTL-style stub (peer of React Router's `createRoutesStub` / Expo's `renderRouter`). It mounts the router's REAL `NavigationProvider` plus a synthetic segment tree built from the `routes` you pass, so client hooks resolve against production context — no server, no Vite build, no Flight round-trip. Loader data, location state, and handle output are SEEDED into client context; nothing is executed.
5
+ `renderRoute(routes, options?)` is an RTL-style stub (peer of React Router's `createRoutesStub` / Expo's `renderRouter`), and it is async — `await` it. It mounts the router's REAL `NavigationProvider` plus a synthetic segment tree built from the `routes` you pass, so client hooks resolve against production context — no server, no Vite build, no Flight round-trip. Loader data, location state, and handle output are SEEDED into client context; nothing is executed.
6
6
 
7
7
  ## API
8
8
 
@@ -25,7 +25,7 @@ RTL-style stub (peer of React Router's `createRoutesStub` / Expo's `renderRouter
25
25
  | `nonce` | `string` | CSP nonce to seed via `NonceContext`, so a component calling `useNonce()` (e.g. an analytics/GTM head script) sees it — mirroring SSR. Defaults to `undefined` (the browser default). |
26
26
  | `defaultPrefetch` | `PrefetchStrategy` | Router default for `<Link>` and eligible plain anchors. `data-prefetch="false"`/`"none"` opts out one anchor; ancestor `data-prefetch-scope="false"`/`"none"` hard-disables the subtree; `"true"` permits routed resource suffixes elsewhere. |
27
27
 
28
- `RenderRouteSpec = { path, Component, layout?, loaderIds?, name? }` — one node of the route definition. The array is the layout chain root-to-leaf; the LAST entry is the leaf route (its pattern is matched against `request` to extract params; layout patterns are informational). `loaderIds` attaches seeded loaders to THIS node's segment; `layout` on the leaf wraps it; `name` is informational.
28
+ `RenderRouteSpec = { path, Component, layout?, loaderIds?, name?, transition? }` — one node of the route definition. The array is the layout chain root-to-leaf; the LAST entry is the leaf route (its pattern is matched against `request` to extract params; layout patterns are informational). `loaderIds` attaches seeded loaders to THIS node's segment; `layout` on the leaf wraps it; `name` is informational. `transition` is the `transition()` config this node declares (`{}` for a bare `transition()`), attached to its segment exactly as the DSL does — see [Held navigation](#held-navigation).
29
29
 
30
30
  ### Context — client hooks it makes resolve (what your code receives)
31
31
 
@@ -40,7 +40,7 @@ RTL-style stub (peer of React Router's `createRoutesStub` / Expo's `renderRouter
40
40
  | `usePathname` | Current committed pathname. |
41
41
  | `useSearchParams` | Search params from the `request` URL. |
42
42
  | `useNonce` | SEEDED CSP nonce (`options.nonce`), else `undefined` (the browser default). |
43
- | `useLoader` / `useFetchLoader` | SEEDED loader data (read path, not run path). |
43
+ | `useLoader` / `useFetchLoader` | SEEDED loader data (read path, not run path). Held-navigation `isLoading` is modeled (below). |
44
44
  | `useLocationState` | SEEDED `history.state` value. |
45
45
  | `useHandle` | SEEDED handle output (globally accumulated). |
46
46
  | `Outlet` | Renders the next segment in the chain (layout nesting). |
@@ -54,7 +54,13 @@ Extends RTL's `RenderResult` (`getByTestId`, `getByText`, `getByRole`, `containe
54
54
  ```ts
55
55
  type RenderRouteResult = RenderResult & {
56
56
  router: {
57
- navigate(url: string): Promise<void>; // client-only nav, re-resolves the same routes
57
+ // client-only nav, re-resolves the same routes; `loaders` seeds THIS nav
58
+ navigate(
59
+ url: string,
60
+ options?: {
61
+ loaders?: ReadonlyArray<readonly [LoaderDefinition<any>, unknown]>;
62
+ },
63
+ ): Promise<void>;
58
64
  pathname(): string;
59
65
  params(): Record<string, string>;
60
66
  store: NavigationStore; // advanced
@@ -108,17 +114,64 @@ it("resolves params + reverse + Outlet through the layout chain", async () => {
108
114
  });
109
115
  ```
110
116
 
117
+ ## Held navigation
118
+
119
+ A stale indicator driven by `useLoader().isLoading` (see `/hooks` data.md, "A held navigation flags the data it keeps on screen") IS unit-testable. Put `transition` on the spec that declares `transition()` in your `urls()`, and pass the next navigation's loader data to `router.navigate(url, { loaders })` as a PENDING Promise. navigate() then commits through production's `commitInTransition` (browser/partial-update.ts), which calls `loaderStore.announcePendingStreams` inside the `startTransition`. The harness does not fake the flag: React holds the reader on screen and the real `useLoader` pin reports `isLoading: true` until the promise settles.
120
+
121
+ ```tsx
122
+ import { act } from "@testing-library/react";
123
+ import { useLoader } from "@rangojs/router/client";
124
+ import { ProductLoader } from "../loaders/product"; // path("/products/:id", ..., () => [loader(ProductLoader), transition()])
125
+
126
+ function ProductPrice() {
127
+ const { data, isLoading } = useLoader(ProductLoader);
128
+ return (
129
+ <p data-testid="price" aria-busy={isLoading}>
130
+ {data.price}
131
+ </p>
132
+ );
133
+ }
134
+
135
+ it("dims the held price while the next product streams", async () => {
136
+ const { getByTestId, router } = await renderRoute(
137
+ [{ path: "/products/:id", Component: ProductPrice, transition: {} }],
138
+ { request: "/products/1", loaders: [[ProductLoader, { price: 10 }]] },
139
+ );
140
+
141
+ let resolve!: (value: { price: number }) => void;
142
+ const next = new Promise<{ price: number }>((r) => (resolve = r));
143
+ await router.navigate("/products/2", { loaders: [[ProductLoader, next]] });
144
+ expect(getByTestId("price").getAttribute("aria-busy")).toBe("true"); // old price, stale
145
+
146
+ await act(async () => resolve({ price: 12 })); // settle INSIDE act to flush the commit
147
+ expect(getByTestId("price").getAttribute("aria-busy")).toBe("false");
148
+ expect(getByTestId("price").textContent).toBe("12");
149
+ });
150
+ ```
151
+
152
+ | Pending state | Under `renderRoute` |
153
+ | ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
154
+ | `useLoader().isLoading` on the reader a `transition` navigate() holds, pending seed | Modeled: `true` on the OLD data until the promise settles, then `false` with the new data in the same commit. |
155
+ | `transition` navigate(), settled seed (a plain value) | `false`; the data swaps in the commit. |
156
+ | navigate() without `transition`, pending seed | Urgent commit (production's cold-navigation lane): the read suspends to the nearest `<Suspense>` fallback (with none, React keeps the previous tree). No flag. |
157
+ | Production's other hold lanes (same-structure search/filter nav, fully-prefetched, optimistic) | NOT modeled — without `transition` navigate() stays urgent. Assert those at e2e. |
158
+ | `useNavigation().state`, `useLinkStatus().pending`, `useAction().state` | Stay `idle` — navigate() never starts the navigation lifecycle. e2e. |
159
+
160
+ - Per-navigation seeds merge over the render-time `loaders`/`loaderData` for THAT navigation only; a later `navigate()` without `loaders` falls back to the render-time data.
161
+ - Pass settled data as a plain value, not `Promise.resolve(value)`: React has not observed a plain promise yet, so it counts as pending and renders one `stale` frame. (A Flight chunk that has already settled carries its status and is skipped, which a plain value mirrors.)
162
+ - `loading()` is not a spec field: a `<Suspense>` in your layout component stands in for it.
163
+
111
164
  ## Caveats
112
165
 
113
166
  - Client tree ONLY. Does NOT catch server/client boundary reference-identity remount bugs, real Flight serialization errors, loader execution, middleware, or handler ordering — those are `renderServerTree` / `renderHandler` / e2e territory. Loader data is SEEDED, never run.
114
- - `router.navigate()` bypasses the navigation lifecycle, so the controller never leaves `idle`. `useNavigation()` / `useLinkStatus()` / `useAction()` non-idle states (loading/streaming/pending, action result/error) are NOT reachable — test those at e2e.
167
+ - `router.navigate()` bypasses the navigation lifecycle, so the controller never leaves `idle`. `useNavigation()` / `useLinkStatus()` / `useAction()` non-idle states (loading/streaming/pending, action result/error) are NOT reachable — test those at e2e. The one modeled pending state is held-navigation `useLoader().isLoading` ([Held navigation](#held-navigation)).
115
168
  - `outletPending` seeds only the production-shaped outlet context. It is useful
116
169
  for the two settled render states of a layout that reads `useOutlet()`, but it
117
170
  does not prove the hydrated `clientUrls()` transition that toggles the value;
118
171
  keep that transition in dev + production e2e.
119
172
  - CATCH — streaming `use(promise)` Suspense content (e.g. an async breadcrumb `content: Promise<ReactNode>`): a plain `Promise.resolve(node)` does NOT flush its Suspense retry in RTL/happy-dom, so the DOM stays on the fallback. Assert the PENDING fallback with `new Promise(() => {})`; for the ARRIVED state pass an already-settled promise so `use()` reads it synchronously: `const p = Promise.resolve(node) as any; p.status = "fulfilled"; p.value = node;`. The real pending->resolved transition is an e2e concern.
120
173
  - ARIA gotcha — an explicit `role` on a `<Link>` (e.g. `<Link role="tab">` in a tablist) OVERRIDES the implicit `link` role, so `getByRole("link")` finds nothing. Query the explicit role (`getByRole("tab")`) or fall back to `getByText` / `getByTestId` and assert `getAttribute("href")`.
121
- - `ctx.theme` is undefined unless `theme` is passed; the typed `ctx.search` defaults to `{}` (seed `searchData` on `runLoader`, not here).
174
+ - `useTheme()` throws unless `theme` is passed (it needs the `ThemeProvider` that option mounts). Search state comes only from the `request` URL — there is no typed-search seed here (that is `runLoader`'s `searchData`).
122
175
  - Use `mount` only for an `include()` prefix. An OPTIONAL param in the matched pattern (`/:locale?/c/:group` at `/en/c/wine`) auto-fills `locale` from the match — production parity, `useReverse` merges `useParams()` — so no `mount` is needed; a locale "dropping" from a reversed URL is usually a missing `mount` seed, not an auto-fill gap.
123
176
  - Needs a DOM env (`// @vitest-environment happy-dom`, or jsdom) and `@testing-library/react` (optional peers).
124
177
  - Don't hand-roll a `NavigationProvider`/router-context mock to test a client component — `renderRoute` mounts the REAL provider, so a hand-mock both duplicates effort and drifts from the production context shape.
@@ -16,7 +16,7 @@ This is full-stack: the harness builds and serves your real app (`pnpm dev` or `
16
16
  | `expect` | `Expect` | Your Playwright `expect` (used by helpers + matchers). |
17
17
  | `defaultRoot` | `string?` | Fallback app root for `parityDescribe` when a call omits `options.root`. |
18
18
 
19
- ### Fixture options — `FixtureOptions` (`useFixture` / `parityDescribe` 3rd arg)
19
+ ### Fixture options — `FixtureOptions` (`useFixture`; `parityDescribe`'s 3rd arg takes the same fields minus `mode`)
20
20
 
21
21
  | Field | Type | Meaning |
22
22
  | ---------------- | ------------------ | ------------------------------------------------------------------------------ |
@@ -54,7 +54,17 @@ This is full-stack: the harness builds and serves your real app (`pnpm dev` or `
54
54
  - `expectParity(page, intent, opts) => Promise<void>` — runs `intent` over the JS page and a fresh no-JS context, asserts observed testids' text + pathname/search/hash + `document.cookie` are equal. `opts` is the required `observe` plus optional `baseURL`, `waitFor`, and `ignoreCookies` (the rango state cookie is excluded automatically).
55
55
  - `rangoMatchers` — `{ toHaveRangoPathname }` only (pass to `expect.extend`).
56
56
  - `testNoJs` — a `test` variant with JavaScript disabled.
57
- - Page helpers: `waitForHydration`, `expectNoReload`, `expectNoPageError`, `testId`, `waitForNavigation`, `waitForElement`, `goBack`/`goForward`, `getHistoryState`, `waitForTextChange`/`waitForNumericChange`, timing helpers.
57
+ - Page helpers:
58
+ - navigation/state: `waitForHydration(page)`, `waitForNavigation(page, url)`, `goBack(page)` / `goForward(page)`, `getHistoryState(page)`;
59
+ - elements: `testId(page, id)`, `waitForElement`, `isVisibleInViewport`, `parseNumber(text)`, `getNumericContent(locator)`;
60
+ - assertions bound to your `expect`: `expectNoReload(page)` (use with `await using`), `expectNoPageError(page)` (use with `using`), `waitForTextChange`, `waitForNumericChange`;
61
+ - timing: `createStopwatch()`, `measureTime(fn)`, `expectTiming`, `expectMinTiming`, `expectMaxTiming`.
62
+
63
+ The entry also exports standalone functions that are NOT on the factory result:
64
+
65
+ - Prefetch control: `isPrefetchRequest(req)` (true for requests carrying `X-Rango-Prefetch`), `blockPrefetch(page)` / `unblockPrefetch(page)` (abort speculative prefetches — install before `page.goto` when a test needs the click's own navigation fetch, since a warmed Link adopts its prefetch and issues none).
66
+ - The building blocks `createRangoE2E` composes: `createUseFixture(test)`, `createParity({ test, expect, useFixture, defaultRoot? })`, `createPageHelpers(expect)`, `createRangoMatchers(expect)`.
67
+ - The cache/shell status helpers (`assertCacheStatus`, `assertShellStatus`, ... — see `./cache-prerender.md`), re-exported so a plain Playwright runner can use them.
58
68
 
59
69
  ## Recipe
60
70
 
@@ -4,21 +4,24 @@
4
4
 
5
5
  > **Prefer `renderServerTree` (see [`./server-tree.md`](./server-tree.md)) for assertions on a Flight render** — it deserializes to a traversable tree with TYPED boundary props (a `Date` is a `Date`, not the opaque `$D...` encoding). Reach for `renderToFlightString` + the wire matchers (`toMatchFlight`/`toMatchFlightSnapshot`) only to pin the raw wire payload SHAPE — a `toMatchFlightSnapshot` drift snapshot. That is the niche/escape-hatch case; for "testing an async Server Component (assert what it rendered)" start at `./server-tree.md`.
6
6
 
7
- `renderToFlightString` runs the REAL react-server-dom serializer the router uses at runtime — your async Server Component genuinely renders to its Flight wire string in plain node, with a request context active for the render. What you SEED is the request, headers, env, params, routeName, and vars that context exposes.
7
+ `renderToFlightString(element, opts?)` runs the REAL react-server-dom serializer the router uses at runtime — your async Server Component genuinely renders to its Flight wire string in plain node, with a request context active for the render. What you SEED is the request, headers, env, params, routeName, route map, vars, theme, and cache store that context exposes.
8
8
 
9
9
  ## API
10
10
 
11
11
  ### Options — `RenderToFlightStringOptions`
12
12
 
13
- | Field | Type | Meaning |
14
- | ----------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
15
- | `request` | `Request \| string` | The request the render runs under: a `Request`, or a URL string (absolute or path). Defaults to `http://localhost/`. A component reading `getRequestContext()` sees this request's url/cookies. When a `Request` is passed, its headers are used and `headers` is ignored. |
16
- | `headers` | `HeadersInit` | Request headers (e.g. Cookie) visible to the server tree, used only when `request` is a string. |
17
- | `env` | `unknown` | Env / bindings exposed as `ctx.env`. Defaults to `{}`. |
18
- | `params` | `Record<string, string>` | Route params exposed via `ctx.params` and loader contexts. |
19
- | `routeName` | `string` | Matched route name (drives `ctx.routeName` and scoped reverse). |
20
- | `routeMap` | `Record<string, string>` | Route name -> pattern map scoping `ctx.reverse()` (like `renderHandler`). Without it, a component that reverses resolves against the GLOBAL route map and is order-dependent on whatever router registered last. Pass the router-under-test's map for deterministic reverse. |
21
- | `vars` | `VarsInit` | Variables a prior middleware would have set, visible via `ctx.get(...)`. Object form (`{ user }`) or `[key, value]` tuples (`[[userVar, u]]`). |
13
+ | Field | Type | Meaning |
14
+ | --------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
15
+ | `request` | `Request \| string` | The request the render runs under: a `Request`, or a URL string (absolute or path). Defaults to `http://localhost/`. A component reading `getRequestContext()` sees this request's url/cookies. When a `Request` is passed, its headers are used and `headers` is ignored. |
16
+ | `headers` | `HeadersInit` | Request headers (e.g. Cookie) visible to the server tree, used only when `request` is a string. |
17
+ | `env` | `unknown` | Env / bindings exposed as `ctx.env`. Defaults to `{}`. |
18
+ | `params` | `Record<string, string>` | Route params exposed via `ctx.params` and loader contexts. |
19
+ | `routeName` | `string` | Matched route name (drives `ctx.routeName` and scoped reverse). |
20
+ | `routeMap` | `Record<string, string>` | Route name -> pattern map scoping `ctx.reverse()` (like `renderHandler`). Without it, a component that reverses resolves against the GLOBAL route map and is order-dependent on whatever router registered last. Pass the router-under-test's map for deterministic reverse. |
21
+ | `vars` | `VarsInit` | Variables a prior middleware would have set, visible via `ctx.get(...)`. Object form (`{ user }`) or `[key, value]` tuples (`[[userVar, u]]`). |
22
+ | `theme` | `ThemeConfig \| true` | Theme config (`createRouter({ theme })` shape). Without it `getRequestContext().theme` is `undefined` and `setTheme` is inert. |
23
+ | `cacheStore` | `SegmentCacheStore` | Store backing a `"use cache"` function the tree calls. Without it the cached path is bypassed (the function runs uncached). |
24
+ | `cacheProfiles` | `Record<string, CacheProfile>` | Profiles for `"use cache: name"`, the `createRouter({ cacheProfiles })` shape. Pair with `cacheStore`. |
22
25
 
23
26
  ### Context — `RequestContext` (what your component receives)
24
27
 
@@ -42,7 +45,12 @@ expect(await renderToFlightString(<C />)).toMatchFlight("substring"); // contain
42
45
  expect(await renderToFlightString(<C />)).toMatchFlightSnapshot(); // normalized snapshot
43
46
  ```
44
47
 
45
- `toMatchFlight(substring)` is containment (not equality) on the normalized payload; `toMatchFlightSnapshot()` snapshots the normalized payload. Both matchers live at `@rangojs/router/testing/flight-matchers` and run ONLY under the react-server vitest project (see `./setup.md`).
48
+ `toMatchFlight(substring)` is containment (not equality) on the normalized payload; `toMatchFlightSnapshot()` snapshots the normalized payload. Both matchers live at `@rangojs/router/testing/flight-matchers`, which imports `vitest` but not the Flight serializer — so it also loads under the plain node condition (a shared `setupFiles` can `expect.extend(flightMatchers)` for both projects). The Flight strings they assert still come from `renderToFlightString`, which runs only under the react-server project (see `./setup.md`). Importing the entry also adds the matcher types to vitest's `Assertion`.
49
+
50
+ ### Helpers
51
+
52
+ - `normalizeFlight(flight)` — the normalization both matchers apply: strips the dev-only leading `:N<timestamp>` row and rewrites absolute `file://` paths to `file://<path>`. Use it when asserting on the raw string yourself.
53
+ - `assertFlightRuntimeAvailable()` — throws a descriptive error when the vendored react-server-dom serializer is missing (for example after a `@vitejs/plugin-rsc` upgrade moved it). Call it in a `beforeAll` to fail fast with a clear message.
46
54
 
47
55
  ## Recipe
48
56
 
@@ -61,10 +61,10 @@ it("runs a custom 'last wins' collect", () => {
61
61
  ```ts
62
62
  // loader-reads-handle.test.ts — a loader reading accumulated handle data after the barrier
63
63
  import { it, expect } from "vitest";
64
- import { runLoader } from "@rangojs/router/testing";
64
+ import { runLoader, type TestLoaderContext } from "@rangojs/router/testing";
65
65
  import { RenderedProducts } from "../src/handles"; // a createHandle(...)
66
66
 
67
- const livePricesBody = async (ctx) => {
67
+ const livePricesBody = async (ctx: TestLoaderContext) => {
68
68
  await ctx.rendered(); // barrier: handle data is now readable
69
69
  const ids = ctx.get(RenderedProducts) as string[];
70
70
  return ids.map((id) => ({ id, price: 9.99 }));
@@ -85,10 +85,13 @@ it("reads the accumulated handle value (seed the OUTPUT, mock the barrier)", asy
85
85
  ```ts
86
86
  // loader-writes-handle.test.ts — a loader PUSHING meta/breadcrumbs (handler parity)
87
87
  import { it, expect } from "vitest";
88
- import { runLoaderResult } from "@rangojs/router/testing";
89
- import { Meta } from "../src/handles";
88
+ import {
89
+ runLoaderResult,
90
+ type TestLoaderContext,
91
+ } from "@rangojs/router/testing";
92
+ import { Meta } from "@rangojs/router";
90
93
 
91
- const productBody = async (ctx) => {
94
+ const productBody = async (ctx: TestLoaderContext) => {
92
95
  const product = { name: "Widget", slug: "widget" };
93
96
  ctx.use(Meta)({ title: `${product.name} — Shop` });
94
97
  return product;
@@ -2,7 +2,7 @@
2
2
 
3
3
  **Layer:** unit (node) · **Import:** `@rangojs/router/testing` · **DSL it tests:** `loader()` (see `/loader`)
4
4
 
5
- `runLoader` runs a loader against a real `RequestContext` (cookies, headers, `ctx.get`, `ctx.reverse` all resolve) in plain node — that machinery is REAL; what you SEED is the params, env, vars, search, route map, and any `ctx.use` dependency data. Pass a registered `createLoader()` handle (its fn is recovered from the registry) or the raw async body `(ctx) => ...`.
5
+ `runLoader(loader, opts?)` runs a loader against a real `RequestContext` (cookies, headers, `ctx.get`, `ctx.reverse` all resolve) in plain node — that machinery is REAL; what you SEED is the params, env, vars, search, route map, and any `ctx.use` dependency data. Pass a registered `createLoader()` definition (its fn is recovered from the registry) or the raw async body `(ctx) => ...`; type a raw body's argument as `TestLoaderContext` (exported from `@rangojs/router/testing`).
6
6
 
7
7
  ## API
8
8
 
@@ -51,7 +51,7 @@
51
51
  | `formData` | `FormData \| undefined` | Form data (from `opts.formData`). |
52
52
  | `reverse` | `(name, params?, search?) => string` | Build a URL; throws unless `opts.routeMap` was passed. |
53
53
  | `rendered` | `() => Promise<void>` | The render barrier; throws by default, mocked via `opts.rendered`. |
54
- | `waitUntil` | `(p: Promise<unknown>) => void` | Register background work (no-op accounting in tests). |
54
+ | `waitUntil` | `(fn: () => Promise<void>) => void` | Register background work; takes a function, not a promise (no-op accounting in tests). |
55
55
  | `executionContext` | `ExecutionContext \| undefined` | Platform execution context from the backing request; pairs with `waitUntil`. |
56
56
 
57
57
  ### Returns — `Promise<T>`
@@ -70,8 +70,10 @@ order). `runLoader` discards those effects.
70
70
  ## Recipe
71
71
 
72
72
  ```ts
73
+ import { it, expect } from "vitest";
73
74
  import { runLoader, runLoaderResult } from "@rangojs/router/testing";
74
75
  import { createLoader, createVar } from "@rangojs/router";
76
+ import { AuthLoader } from "../src/loaders/auth"; // sets a `session` cookie, then throw redirect("/")
75
77
 
76
78
  const User = createVar<{ name: string }>();
77
79
  // The registered loader — no separate body export needed for testability:
@@ -100,7 +102,6 @@ it("builds a self link via reverse", async () => {
100
102
  });
101
103
 
102
104
  it("asserts a loader's set-cookie + redirect (runLoaderResult)", async () => {
103
- // AuthLoader validates, sets a `session` cookie, then `throw redirect("/")`.
104
105
  const { thrown, response, cookies } = await runLoaderResult(AuthLoader, {
105
106
  request: new Request("https://app.test/login?token=ok"),
106
107
  });
@@ -115,12 +116,14 @@ it("asserts a loader's set-cookie + redirect (runLoaderResult)", async () => {
115
116
  ## Caveats
116
117
 
117
118
  - `ctx.reverse(...)` throws unless you pass `routeMap` (and `routeName` for scoped `.name` resolution). It does NOT fall back to the global route map.
118
- - `ctx.rendered()` throws by default (the render barrier only exists in a full match); pass `{ rendered: true }` to mock it for post-barrier logic, and `{ handles: [[SomeHandle, data]] }` to seed the `ctx.get(SomeHandle)` read. `ctx.isAction(...)` is unavailable — cover those at e2e.
119
+ - `ctx.rendered()` throws by default (the render barrier only exists in a full match); pass `{ rendered: true }` to mock it for post-barrier logic, and `{ handles: [[SomeHandle, data]] }` to seed the `ctx.get(SomeHandle)` read.
120
+ - A loader has no `isAction()`: whether it re-runs after an action is decided by `revalidate()` on its registration, not inside the loader body. Assert post-action re-runs at e2e.
119
121
  - Seeded `loaders` (by-reference tuples) are NOT executed — `ctx.use(OtherLoader)` returns the seeded value. The dynamic `use` resolver, by contrast, IS executed (it is a function called to compute the value). Either way the REAL loader body is not run; real loader execution and side-effects are e2e-only. `loaders` is checked before the `use` resolver.
120
- - A handle imported through the CLIENT build has its body dropped — `runLoader` throws a clear error pointing to the `rangoTestConfig()` preset or the raw body. A router using `Prerender()`/`createLoader()`/`Static()` now constructs in a bare test (each assigns a runtime fallback `$$id`); only the whole router _file_ may still need the plugin (its page modules pull app deps / `virtual:` modules).
122
+ - A `createLoader()` definition imported through the CLIENT build has its body dropped — `runLoader` throws a clear error pointing to the `rangoTestConfig()` preset or the raw body. A router using `Prerender()`/`createLoader()`/`Static()` now constructs in a bare test (each assigns a runtime fallback `$$id`); only the whole router _file_ may still need the plugin (its page modules pull app deps / `virtual:` modules).
121
123
  - No `cookies`/`headers` option: seed a cookie by passing a full Request with a Cookie header — `{ request: new Request(url, { headers: { Cookie: "sid=abc" } }) }`. (`search`/`method` are baked onto this request for you.)
122
124
  - `ctx.search` (typed) defaults to `{}`; `opts.search` only sets the raw `ctx.searchParams`. Seed the typed object with `searchData`. (The harness seeds `searchData` verbatim — it does NOT run a typed-search SCHEMA, so schema parsing/validation is e2e.)
123
125
  - `ctx.theme`/`ctx.setTheme` are NOT on the loader context — theme accessors are handler-only. (The `theme` option seeds the underlying request context for `use cache` theme resolution, but a loader body cannot read theme.) `redirect()` does no basename prefixing unless you seed `basename`.
126
+ - A `"use cache"` hit needs real Flight, which loads only under the react-server condition. In the react-server project (a `*.rsc-test.ts` file, see [`./setup.md`](./setup.md)) with `cacheStore` seeded, the body runs once across `runLoader` calls and the value round-trips. The write is a background (`waitUntil`) task `runLoader` does not await: a call before it lands joins the still-running execution, a call after it reads the store (wait with `vi.waitFor` on a `cacheStore.setItem` spy). In the node project a call looks up the store but never writes, so every call runs the body. A function written with the directive needs `rangoUseCacheTransform()` in the project's `plugins` (see [`./render-handler.md`](./render-handler.md#a-use-cache-hit)).
124
127
  - Platform bindings are yours to double via `env` (see `./bindings.md`).
125
128
 
126
129
  ## See also
@@ -2,14 +2,17 @@
2
2
 
3
3
  **Layer:** unit (node) · **Import:** `@rangojs/router/testing` · **DSL it tests:** `middleware()` (see `/middleware`)
4
4
 
5
- `runMiddleware` executes your chain through the router's REAL `executeLoaderMiddleware`, so `next()`, return-Response and throw-Response short-circuits, double-next guards, and header/cookie merge are production-identical. You SEED the request and any prior-middleware state (`vars`, `params`, `env`, `routeMap`); everything else (cookie/header merge, request-context resolution) is real machinery.
5
+ `runMiddleware(mw | mw[], opts)` executes your chain through the router's REAL `executeLoaderMiddleware`, so `next()`, return-Response and throw-Response short-circuits, double-next guards, and header/cookie merge are production-identical. You SEED the request and any prior-middleware state (`vars`, `params`, `env`, `routeMap`); everything else (cookie/header merge, request-context resolution) is real machinery.
6
6
 
7
7
  ## API
8
8
 
9
9
  ### Options — `RunMiddlewareOptions<TEnv>`
10
10
 
11
+ `opts` is a required argument (unlike `runLoader`); pass `{}` when you need no seeds.
12
+
11
13
  | Field | Type | Meaning |
12
14
  | --------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
15
+ | `build` | `boolean` | Seed `ctx.build` (default `false`) to test a middleware that branches on the build-time PPR shell-capture pass (e.g. `if (ctx.build) ctx.dynamic()`). With `true`, `ctx.waitUntil()` is inert, as at build time. Assert the opt-out via `result.dynamic`. |
13
16
  | `request` | `Request \| string` | The request the chain runs under: a `Request`, or a URL string (absolute or path). Optional — defaults to `http://localhost/`; pass it for path-, header-, or cookie-driven middleware. |
14
17
  | `env` | `TEnv` | Environment bindings surfaced as `ctx.env`. Your seam for doubling platform bindings (see `./bindings.md`). |
15
18
  | `params` | `Record<string, string>` | Route params surfaced as `ctx.params`. |
@@ -33,10 +36,11 @@ The `ctx` your middleware reads. Notable fields:
33
36
  | `env` | `TEnv` | Bindings from `opts.env`. |
34
37
  | `get` / `set` | fns | Read/write context vars (shared with handlers); `get` resolves what `vars` seeded. |
35
38
  | `header(name, value)` | fn | Queue a response header before `next()`, or set it directly after. |
36
- | `reverse` | `ScopedReverseFunction` | URL-from-name. Map-only (no auto-fill); needs `routeMap`. |
39
+ | `reverse` | `GlobalReverseFunction` | Global names only: no auto-fill, `.name` is a type error. Needs `routeMap`. |
37
40
  | `setLocationState(entries)` | fn | Attach flash/location state to the response. |
38
41
  | `theme` / `setTheme` | `Theme` / fn | Current theme; `undefined` unless `theme` is passed. |
39
42
  | `routeName` | `string` | Matched route name (from `opts.routeName`). |
43
+ | `build` / `dynamic()` | `boolean` / fn | Build-pass flag (from `opts.build`) and the PPR shell opt-out (`result.dynamic`). |
40
44
 
41
45
  ### Returns — `RunMiddlewareResult<TEnv>`
42
46
 
@@ -45,6 +49,7 @@ The `ctx` your middleware reads. Notable fields:
45
49
  | `response` | `Response` | The final Response: the downstream response, or a middleware short-circuit. |
46
50
  | `ctx` | `RequestContext<TEnv>` | The underlying RequestContext (NOT a per-middleware `MiddlewareContext`). Use `ctx.get(...)` for anything the envelope above doesn't surface. |
47
51
  | `nextCalled` | `number` | Times the terminal handler ran: `0` on short-circuit, `1` on pass-through. |
52
+ | `dynamic` | `boolean` | Whether the chain called `ctx.dynamic()` (the PPR shell opt-out). |
48
53
  | `cookies` | `Record<string, string>` | Effective cookie view: request cookies merged with chain sets/deletes (last-write-wins), as `{ name: value }`. |
49
54
  | `headers` | `Record<string, string>` | Final response headers as `{ name: value }`, lowercased, EXCLUDING `set-cookie` (use `cookies`). |
50
55
  | `locationState` | `Record<string, unknown>` | Flat `{ key: value }` state set via `setLocationState()` / `redirect({ state })` (empty when none). |
@@ -88,7 +93,7 @@ Pass an array to run several in order. Cookies set inside middleware via the sta
88
93
 
89
94
  - No `handles`/`rendered` option by design: the middleware context has no handle APIs in production — no push (`ctx.use(Handle)` is a handler/loader API) and no post-barrier read (`ctx.get(handle)` is loader-only, and middleware runs BEFORE the render barrier anyway). Read handle data in a loader and test it with `runLoader`; assert loader pushes via `runLoaderResult(...).handlePushes` (see `./handles.md`).
90
95
  - A COMPONENT route's guard stack cannot be exercised through `dispatch` (it throws on component routes), and `renderToFlightString`/`renderRoute` don't run route middleware. Extract the middleware fn and unit-test it here, or assert the guard stack at e2e.
91
- - Middleware-phase `ctx.reverse` is map-only (no auto-fill from current params), matching production — enable it with `routeMap`. `routeName` only feeds `ctx.routeName`; it does NOT scope `.name` reverse (the chain reverse stays map-only by design).
96
+ - Middleware-phase `ctx.reverse` is map-only (global names only, no auto-fill from current params), matching production — enable it with `routeMap`. `routeName` only feeds `ctx.routeName`; it does NOT scope `.name` reverse (the chain reverse stays map-only by design, and `MiddlewareContext["reverse"]` rejects `.name` at compile time).
92
97
  - `ctx.theme` is `undefined` unless `theme` is passed; `redirect()` does no basename prefixing unless `basename` is seeded.
93
98
  - Platform bindings are yours to double via `env` (see `./bindings.md`).
94
99
 
@@ -0,0 +1,120 @@
1
+ # Testing navigation predicates — runTransitionWhen, runClientRevalidate
2
+
3
+ **Layer:** unit (node) · **Import:** `@rangojs/router/testing` · **DSL it tests:** `transition({ when })` (see `/view-transitions`) and `clientUrls()` `revalidate()` (see `/client-urls`)
4
+
5
+ Both primitives are synchronous and run the router's own evaluation code on arguments you seed, so a predicate sees the same fields it sees at runtime. Neither renders anything: they answer "would this gate keep the transition?" and "would this `clientUrls()` loader re-run?".
6
+
7
+ ## runTransitionWhen(config, opts?)
8
+
9
+ Runs a `transition()` config through the production gate (`gateTransitions`, plus the PPR pre-handler evaluator when `ppr: true`) and reports whether the transition survives this request.
10
+
11
+ ### Options — `RunTransitionWhenOptions<TEnv>`
12
+
13
+ Every field is optional. Omitted navigation fields model "source unavailable" (an initial document load); omitted `action*` fields model a plain navigation.
14
+
15
+ | Field | Type | Meaning |
16
+ | --------------- | ------------------------ | ------------------------------------------------------------------------------------------------- |
17
+ | `request` | `Request \| string` | Navigation TARGET (drives `nextUrl`). Defaults to `http://localhost/`. |
18
+ | `params` | `Record<string, string>` | Target params (`nextParams`). |
19
+ | `toRouteName` | `string` | Target route name (`toRouteName`). |
20
+ | `currentUrl` | `string \| URL` | Navigation SOURCE (`currentUrl`). |
21
+ | `currentParams` | `Record<string, string>` | Source params (`currentParams`). |
22
+ | `fromRouteName` | `string` | Source route name (`fromRouteName`). |
23
+ | `actionId` | `string` | Id of the action that triggered the render. |
24
+ | `actionUrl` | `string \| URL` | URL the action was submitted from. |
25
+ | `actionResult` | `unknown` | The action's return value. |
26
+ | `formData` | `FormData` | Form data from a form action. |
27
+ | `env` | `TEnv` | Bindings surfaced as `env`. |
28
+ | `vars` | `VarsInit` | Values the predicate reads through `get()`. With `ppr`, these model pre-handler middleware state. |
29
+ | `onError` | `OnErrorCallback` | Receives an error the predicate throws (the gate reports it with phase `"rendering"`). |
30
+ | `ppr` | `boolean` | Model a `ppr` route, where the predicate runs before route handlers and the cache lookup. |
31
+
32
+ ### Returns — `RunTransitionWhenResult<TEnv>`
33
+
34
+ | Field | Type | Meaning |
35
+ | ------------- | ------------------------------------ | ----------------------------------------------------------------------------------------------- |
36
+ | `kept` | `boolean` | True when the transition applies (the predicate did not return `false`, or there is no `when`). |
37
+ | `dropped` | `boolean` | `!kept`. |
38
+ | `whenContext` | `TransitionWhenContext \| undefined` | The context the predicate received; `undefined` when the config has no `when`. |
39
+ | `ctx` | `RequestContext<TEnv>` | The underlying request context, for extra assertions. |
40
+
41
+ ### Recipe
42
+
43
+ ```ts
44
+ import { it, expect } from "vitest";
45
+ import { runTransitionWhen } from "@rangojs/router/testing";
46
+ import type { TransitionConfig } from "@rangojs/router";
47
+
48
+ const slide: TransitionConfig = {
49
+ when: (c) => c.fromRouteName === "products",
50
+ };
51
+
52
+ it("animates only when coming from the product list", () => {
53
+ expect(
54
+ runTransitionWhen(slide, {
55
+ request: "/products/1",
56
+ fromRouteName: "products",
57
+ }).kept,
58
+ ).toBe(true);
59
+ expect(runTransitionWhen(slide, { request: "/products/1" }).dropped).toBe(
60
+ true,
61
+ );
62
+ });
63
+ ```
64
+
65
+ ## runClientRevalidate(fn | fn[], opts?)
66
+
67
+ Evaluates one `clientUrls()` loader `revalidate()` predicate, or a chain in declaration order, through the production chain evaluator: the locked default, boolean short-circuit, soft verdicts (`{ defaultShouldRevalidate }`), and a throwing predicate deferring to the default all behave as in the browser. Returns the final `boolean` (the locked default when every predicate defers or throws).
68
+
69
+ ### Options — `RunClientRevalidateOptions`
70
+
71
+ Defaults model a same-URL navigation with no action.
72
+
73
+ | Field | Type | Meaning |
74
+ | --------------- | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
75
+ | `currentUrl` | `string \| URL` | Source URL. Defaults to `http://localhost/`. |
76
+ | `nextUrl` | `string \| URL` | Target URL. Defaults to `currentUrl`. |
77
+ | `currentParams` | `Record<string, string>` | Source params. Defaults to `{}`. |
78
+ | `nextParams` | `Record<string, string>` | Target params. Defaults to `currentParams`. |
79
+ | `stale` | `boolean` | The `stale` arg. Defaults to `false`. |
80
+ | `action` | `(...args) => unknown \| string` | The triggering action: one imported server action (its id is read from the build-injected `$id`/`$$id`) or a raw action id string. A namespace object is rejected. Omit for a plain navigation. |
81
+ | `actionRequest` | `boolean` | Model the action-triggered refetch GET: `isAction()` stays true but the locked default is the navigation default. Defaults to treating `action` as the action POST itself. |
82
+
83
+ Outside a built app, an imported action carries no id, so pass the id string your predicate matches (for example `"src/actions/cart.ts#addToCart"`); `runClientRevalidate` throws a clear error otherwise.
84
+
85
+ ### Recipe
86
+
87
+ ```ts
88
+ import { it, expect } from "vitest";
89
+ import { runClientRevalidate } from "@rangojs/router/testing";
90
+ import type { ClientRevalidateFn } from "@rangojs/router/client";
91
+
92
+ const onlyOnTabChange: ClientRevalidateFn = ({ currentUrl, nextUrl }) =>
93
+ currentUrl.searchParams.get("tab") !== nextUrl.searchParams.get("tab");
94
+
95
+ it("revalidates only when the tab changes", () => {
96
+ expect(
97
+ runClientRevalidate(onlyOnTabChange, {
98
+ currentUrl: "/settings?tab=profile",
99
+ nextUrl: "/settings?tab=billing",
100
+ }),
101
+ ).toBe(true);
102
+ expect(
103
+ runClientRevalidate(onlyOnTabChange, {
104
+ currentUrl: "/settings?tab=profile",
105
+ nextUrl: "/settings?tab=profile&sort=asc",
106
+ }),
107
+ ).toBe(false);
108
+ });
109
+ ```
110
+
111
+ ## Caveats
112
+
113
+ - Server-tree `revalidate()` predicates have no dedicated primitive: they are plain functions, so call them with a hand-built args object, or assert the post-action result at e2e.
114
+ - `runTransitionWhen` proves the GATE decision only. Whether the browser actually runs a view transition is e2e territory (see `./e2e-parity.md`).
115
+ - Both are synchronous; a predicate returning a Promise is a bug in the predicate, not something to await here.
116
+
117
+ ## See also
118
+
119
+ - `/view-transitions`, `/client-urls` — the DSL these test
120
+ - Siblings: `./client-components.md`, `./e2e-parity.md`