@rangojs/router 0.16.0 → 0.18.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 (309) hide show
  1. package/README.md +255 -89
  2. package/dist/testing/vitest.js +252 -0
  3. package/dist/types/browser/cookie-name.d.ts +6 -0
  4. package/dist/types/browser/history-state.d.ts +16 -0
  5. package/dist/types/browser/navigation-transaction.d.ts +6 -0
  6. package/dist/types/browser/partial-update.d.ts +11 -0
  7. package/dist/types/browser/react/ScrollRestoration.d.ts +6 -6
  8. package/dist/types/browser/rsc-router.d.ts +1 -1
  9. package/dist/types/browser/scroll-restoration.d.ts +7 -0
  10. package/dist/types/cache/cache-exec-scope.d.ts +18 -1
  11. package/dist/types/cache/cache-key-utils.d.ts +17 -2
  12. package/dist/types/cache/cache-policy.d.ts +17 -0
  13. package/dist/types/cache/cache-scope.d.ts +132 -19
  14. package/dist/types/cache/cache-tag.d.ts +92 -7
  15. package/dist/types/cache/cf/cf-base64.d.ts +6 -0
  16. package/dist/types/cache/cf/cf-cache-constants.d.ts +11 -0
  17. package/dist/types/cache/cf/cf-cache-store.d.ts +185 -44
  18. package/dist/types/cache/cf/cf-cache-types.d.ts +31 -4
  19. package/dist/types/cache/cf/cf-shell-frame.d.ts +95 -0
  20. package/dist/types/cache/cf/cf-tag-marker-memo.d.ts +34 -0
  21. package/dist/types/cache/handle-capture.d.ts +41 -3
  22. package/dist/types/cache/handle-snapshot.d.ts +54 -13
  23. package/dist/types/cache/index.d.ts +1 -0
  24. package/dist/types/cache/isolate-tag-memo.d.ts +149 -0
  25. package/dist/types/cache/segment-codec.d.ts +10 -3
  26. package/dist/types/cache/shell-memo.d.ts +178 -0
  27. package/dist/types/cache/shell-snapshot.d.ts +110 -21
  28. package/dist/types/cache/tag-invalidation.d.ts +30 -3
  29. package/dist/types/cache/taint.d.ts +1 -1
  30. package/dist/types/cache/types.d.ts +220 -44
  31. package/dist/types/cache/vercel/vercel-cache-store.d.ts +111 -13
  32. package/dist/types/client.d.ts +4 -4
  33. package/dist/types/client.rsc.d.ts +4 -5
  34. package/dist/types/components/DefaultDocument.d.ts +5 -2
  35. package/dist/types/context-var.d.ts +6 -5
  36. package/dist/types/escape-script.d.ts +1 -1
  37. package/dist/types/handles/MetaTags.d.ts +5 -4
  38. package/dist/types/handles/Scripts.d.ts +8 -7
  39. package/dist/types/handles/meta.d.ts +2 -2
  40. package/dist/types/handles/script.d.ts +17 -16
  41. package/dist/types/href-client.d.ts +1 -1
  42. package/dist/types/html.d.ts +12 -0
  43. package/dist/types/index.d.ts +1 -1
  44. package/dist/types/index.rsc.d.ts +3 -2
  45. package/dist/types/loader-store.d.ts +7 -4
  46. package/dist/types/prerender/build-shell-capture.d.ts +11 -4
  47. package/dist/types/reverse.d.ts +36 -4
  48. package/dist/types/route-definition/dsl-helpers.d.ts +3 -3
  49. package/dist/types/route-definition/helpers-types.d.ts +33 -9
  50. package/dist/types/route-definition/resolve-handler-use.d.ts +23 -1
  51. package/dist/types/route-types.d.ts +1 -1
  52. package/dist/types/router/debug-manifest.d.ts +3 -2
  53. package/dist/types/router/error-handling.d.ts +20 -0
  54. package/dist/types/router/handler-context.d.ts +6 -0
  55. package/dist/types/router/instrument.d.ts +8 -0
  56. package/dist/types/router/match-middleware/background-revalidation.d.ts +46 -5
  57. package/dist/types/router/middleware-types.d.ts +9 -3
  58. package/dist/types/router/router-context.d.ts +3 -1
  59. package/dist/types/router/router-interfaces.d.ts +24 -2
  60. package/dist/types/router/router-options.d.ts +8 -5
  61. package/dist/types/router/segment-resolution/fresh.d.ts +10 -0
  62. package/dist/types/router/segment-resolution/loader-cache.d.ts +66 -1
  63. package/dist/types/router/segment-resolution/loader-mask.d.ts +9 -19
  64. package/dist/types/router/segment-resolution/loader-snapshot.d.ts +23 -12
  65. package/dist/types/router/segment-resolution/mask-nested.d.ts +67 -26
  66. package/dist/types/router/segment-resolution/revalidation.d.ts +6 -2
  67. package/dist/types/router/segment-wrappers.d.ts +1 -1
  68. package/dist/types/rsc/full-payload.d.ts +14 -0
  69. package/dist/types/rsc/handler.d.ts +1 -1
  70. package/dist/types/rsc/response-route-handler.d.ts +1 -1
  71. package/dist/types/rsc/rsc-rendering.d.ts +1 -2
  72. package/dist/types/rsc/shell-build-manifest.d.ts +8 -0
  73. package/dist/types/rsc/shell-capture-constants.d.ts +61 -6
  74. package/dist/types/rsc/shell-capture.d.ts +151 -19
  75. package/dist/types/rsc/shell-serve.d.ts +140 -17
  76. package/dist/types/rsc/ssr-setup.d.ts +6 -6
  77. package/dist/types/rsc/types.d.ts +17 -0
  78. package/dist/types/server/context.d.ts +139 -15
  79. package/dist/types/server/cookie-store.d.ts +21 -0
  80. package/dist/types/server/handle-store.d.ts +26 -2
  81. package/dist/types/server/request-context.d.ts +182 -53
  82. package/dist/types/ssr/index.d.ts +24 -0
  83. package/dist/types/ssr/ssr-root.d.ts +9 -8
  84. package/dist/types/static-handler.d.ts +4 -1
  85. package/dist/types/testing/flight.entry.d.ts +7 -0
  86. package/dist/types/testing/generated-routes.d.ts +2 -2
  87. package/dist/types/testing/internal/context.d.ts +1 -1
  88. package/dist/types/testing/render-route.d.ts +32 -6
  89. package/dist/types/testing/run-loader.d.ts +18 -3
  90. package/dist/types/testing/serve-shell-request.d.ts +132 -0
  91. package/dist/types/testing/shell-status.d.ts +23 -10
  92. package/dist/types/testing/vitest-stubs/plugin-rsc.d.ts +28 -5
  93. package/dist/types/testing/vitest.d.ts +35 -1
  94. package/dist/types/theme/ThemeScript.d.ts +3 -3
  95. package/dist/types/types/cache-types.d.ts +14 -10
  96. package/dist/types/types/handler-context.d.ts +28 -1
  97. package/dist/types/types/loader-types.d.ts +8 -6
  98. package/dist/types/urls/pattern-types.d.ts +22 -18
  99. package/dist/types/urls/response-types.d.ts +3 -12
  100. package/dist/types/use-loader.d.ts +12 -3
  101. package/dist/types/vite/plugins/use-cache-transform.d.ts +33 -2
  102. package/dist/types/vite/rango.d.ts +1 -1
  103. package/dist/types/vite/router-discovery.d.ts +29 -2
  104. package/dist/vite/index.js +1420 -1348
  105. package/package.json +2 -2
  106. package/skills/api-client/SKILL.md +22 -10
  107. package/skills/breadcrumbs/SKILL.md +75 -22
  108. package/skills/bundle-analysis/SKILL.md +22 -22
  109. package/skills/cache-guide/SKILL.md +306 -124
  110. package/skills/caching/SKILL.md +530 -98
  111. package/skills/catalog.json +16 -16
  112. package/skills/client-urls/SKILL.md +65 -38
  113. package/skills/cloudflare/SKILL.md +87 -20
  114. package/skills/cloudflare/references/d1-and-local-dev.md +15 -2
  115. package/skills/cloudflare/references/streaming-and-deploy.md +10 -3
  116. package/skills/cloudflare/references/webhooks-and-crypto.md +9 -2
  117. package/skills/comparison/SKILL.md +5 -0
  118. package/skills/comparison/references/framework-comparison.md +75 -57
  119. package/skills/composability/SKILL.md +38 -29
  120. package/skills/css/SKILL.md +10 -13
  121. package/skills/debug-manifest/SKILL.md +74 -31
  122. package/skills/defer-hydration/SKILL.md +52 -31
  123. package/skills/deployment-caching/SKILL.md +11 -3
  124. package/skills/document-cache/SKILL.md +98 -28
  125. package/skills/fonts/SKILL.md +16 -8
  126. package/skills/handler-use/SKILL.md +55 -17
  127. package/skills/hooks/SKILL.md +28 -20
  128. package/skills/hooks/data.md +23 -3
  129. package/skills/hooks/handle-and-actions.md +34 -17
  130. package/skills/hooks/navigation.md +39 -10
  131. package/skills/hooks/outlets.md +13 -6
  132. package/skills/hooks/state.md +39 -15
  133. package/skills/hooks/urls.md +7 -5
  134. package/skills/host-router/SKILL.md +64 -12
  135. package/skills/i18n/SKILL.md +60 -34
  136. package/skills/intercept/SKILL.md +161 -26
  137. package/skills/layout/SKILL.md +106 -50
  138. package/skills/links/SKILL.md +51 -19
  139. package/skills/loader/SKILL.md +386 -128
  140. package/skills/middleware/SKILL.md +129 -26
  141. package/skills/migrate-nextjs/SKILL.md +156 -70
  142. package/skills/migrate-nextjs/backend-host-swap.md +5 -2
  143. package/skills/migrate-react-router/SKILL.md +51 -39
  144. package/skills/migrate-react-router/component-migration.md +28 -7
  145. package/skills/migrate-react-router/data-and-actions.md +21 -6
  146. package/skills/migrate-react-router/route-mapping.md +19 -10
  147. package/skills/mime-routes/SKILL.md +12 -7
  148. package/skills/observability/SKILL.md +92 -14
  149. package/skills/parallel/SKILL.md +88 -47
  150. package/skills/ppr/SKILL.md +866 -379
  151. package/skills/prerender/SKILL.md +210 -108
  152. package/skills/rango/SKILL.md +115 -65
  153. package/skills/react-compiler/SKILL.md +6 -5
  154. package/skills/response-routes/SKILL.md +133 -25
  155. package/skills/route/SKILL.md +170 -60
  156. package/skills/router-setup/SKILL.md +420 -240
  157. package/skills/scripts/SKILL.md +39 -21
  158. package/skills/server-actions/SKILL.md +231 -59
  159. package/skills/shell-manifest/SKILL.md +28 -14
  160. package/skills/streams-and-websockets/SKILL.md +32 -14
  161. package/skills/tailwind/SKILL.md +37 -33
  162. package/skills/testing/SKILL.md +73 -33
  163. package/skills/testing/cache-prerender.md +60 -33
  164. package/skills/testing/client-components.md +59 -6
  165. package/skills/testing/e2e-parity.md +12 -2
  166. package/skills/testing/flight.md +19 -11
  167. package/skills/testing/handles.md +8 -5
  168. package/skills/testing/loader.md +26 -6
  169. package/skills/testing/middleware.md +8 -3
  170. package/skills/testing/predicates.md +120 -0
  171. package/skills/testing/render-handler.md +70 -22
  172. package/skills/testing/response-routes.md +11 -4
  173. package/skills/testing/reverse-and-types.md +20 -14
  174. package/skills/testing/serve-shell-request.md +188 -0
  175. package/skills/testing/server-actions.md +12 -3
  176. package/skills/testing/server-tree.md +9 -0
  177. package/skills/testing/setup.md +9 -5
  178. package/skills/theme/SKILL.md +155 -30
  179. package/skills/typesafety/SKILL.md +20 -9
  180. package/skills/typesafety/env-and-bindings.md +137 -50
  181. package/skills/typesafety/generated-files-and-cli.md +26 -14
  182. package/skills/typesafety/params-and-search.md +44 -19
  183. package/skills/typesafety/route-types.md +50 -14
  184. package/skills/use-cache/SKILL.md +224 -83
  185. package/skills/vercel/SKILL.md +35 -13
  186. package/skills/view-transitions/SKILL.md +42 -21
  187. package/src/__augment-tests__/augmented.check.ts +26 -1
  188. package/src/browser/cookie-name.ts +23 -0
  189. package/src/browser/history-state.ts +23 -0
  190. package/src/browser/index.ts +2 -2
  191. package/src/browser/navigation-bridge.ts +3 -4
  192. package/src/browser/navigation-transaction.ts +22 -2
  193. package/src/browser/partial-update.ts +35 -25
  194. package/src/browser/react/ScrollRestoration.tsx +6 -6
  195. package/src/browser/rsc-router.tsx +6 -1
  196. package/src/browser/scroll-restoration.ts +53 -6
  197. package/src/cache/cache-exec-scope.ts +37 -4
  198. package/src/cache/cache-key-utils.ts +23 -8
  199. package/src/cache/cache-policy.ts +22 -0
  200. package/src/cache/cache-runtime.ts +264 -68
  201. package/src/cache/cache-scope.ts +419 -43
  202. package/src/cache/cache-tag.ts +375 -11
  203. package/src/cache/cf/cf-base64.ts +36 -4
  204. package/src/cache/cf/cf-cache-constants.ts +12 -0
  205. package/src/cache/cf/cf-cache-store.ts +990 -348
  206. package/src/cache/cf/cf-cache-types.ts +32 -4
  207. package/src/cache/cf/cf-shell-frame.ts +258 -0
  208. package/src/cache/cf/cf-tag-marker-memo.ts +143 -39
  209. package/src/cache/document-cache.ts +15 -0
  210. package/src/cache/handle-capture.ts +90 -10
  211. package/src/cache/handle-snapshot.ts +133 -28
  212. package/src/cache/index.ts +2 -0
  213. package/src/cache/isolate-tag-memo.ts +308 -0
  214. package/src/cache/segment-codec.ts +26 -7
  215. package/src/cache/shell-memo.ts +341 -0
  216. package/src/cache/shell-snapshot.ts +217 -27
  217. package/src/cache/tag-invalidation.ts +108 -22
  218. package/src/cache/taint.ts +1 -1
  219. package/src/cache/types.ts +225 -44
  220. package/src/cache/vercel/vercel-cache-store.ts +545 -61
  221. package/src/client.rsc.tsx +10 -9
  222. package/src/client.tsx +5 -4
  223. package/src/components/DefaultDocument.tsx +7 -2
  224. package/src/context-var.ts +6 -5
  225. package/src/escape-script.ts +1 -1
  226. package/src/handles/MetaTags.tsx +13 -11
  227. package/src/handles/Scripts.tsx +11 -10
  228. package/src/handles/meta.ts +2 -2
  229. package/src/handles/script.ts +17 -16
  230. package/src/href-client.ts +1 -1
  231. package/src/html.ts +12 -0
  232. package/src/index.rsc.ts +5 -0
  233. package/src/index.ts +1 -0
  234. package/src/loader-store.ts +7 -4
  235. package/src/prerender/build-shell-capture.ts +73 -22
  236. package/src/reverse.ts +44 -4
  237. package/src/route-definition/dsl-helpers.ts +121 -33
  238. package/src/route-definition/helpers-types.ts +33 -9
  239. package/src/route-definition/resolve-handler-use.ts +121 -6
  240. package/src/route-types.ts +0 -3
  241. package/src/router/debug-manifest.ts +10 -2
  242. package/src/router/error-handling.ts +51 -15
  243. package/src/router/handler-context.ts +71 -28
  244. package/src/router/instrument.ts +54 -2
  245. package/src/router/intercept-resolution.ts +141 -43
  246. package/src/router/loader-resolution.ts +115 -39
  247. package/src/router/match-api.ts +40 -26
  248. package/src/router/match-handlers.ts +5 -0
  249. package/src/router/match-middleware/background-revalidation.ts +161 -96
  250. package/src/router/match-middleware/cache-lookup.ts +110 -10
  251. package/src/router/match-middleware/cache-store.ts +48 -101
  252. package/src/router/middleware-types.ts +9 -6
  253. package/src/router/middleware.ts +75 -13
  254. package/src/router/prerender-match.ts +59 -19
  255. package/src/router/route-snapshot.ts +11 -1
  256. package/src/router/router-context.ts +2 -0
  257. package/src/router/router-interfaces.ts +25 -2
  258. package/src/router/router-options.ts +8 -5
  259. package/src/router/segment-resolution/fresh.ts +62 -21
  260. package/src/router/segment-resolution/helpers.ts +4 -2
  261. package/src/router/segment-resolution/loader-cache.ts +407 -76
  262. package/src/router/segment-resolution/loader-mask.ts +12 -22
  263. package/src/router/segment-resolution/loader-snapshot.ts +208 -56
  264. package/src/router/segment-resolution/mask-nested.ts +138 -44
  265. package/src/router/segment-resolution/revalidation.ts +61 -16
  266. package/src/router/segment-wrappers.ts +4 -0
  267. package/src/rsc/full-payload.ts +25 -1
  268. package/src/rsc/handler.ts +14 -2
  269. package/src/rsc/progressive-enhancement.ts +3 -2
  270. package/src/rsc/render-pipeline.ts +3 -0
  271. package/src/rsc/response-cache-serve.ts +9 -6
  272. package/src/rsc/response-route-handler.ts +11 -3
  273. package/src/rsc/rsc-rendering.ts +608 -225
  274. package/src/rsc/shell-build-manifest.ts +27 -9
  275. package/src/rsc/shell-capture-constants.ts +73 -6
  276. package/src/rsc/shell-capture.ts +1068 -296
  277. package/src/rsc/shell-serve.ts +316 -34
  278. package/src/rsc/ssr-setup.ts +14 -8
  279. package/src/rsc/types.ts +18 -0
  280. package/src/server/context.ts +300 -32
  281. package/src/server/cookie-store.ts +87 -61
  282. package/src/server/handle-store.ts +232 -7
  283. package/src/server/request-context.ts +297 -67
  284. package/src/ssr/index.tsx +136 -44
  285. package/src/ssr/ssr-root.tsx +9 -8
  286. package/src/static-handler.ts +4 -1
  287. package/src/testing/flight-runtime.d.ts +21 -0
  288. package/src/testing/flight.entry.ts +14 -0
  289. package/src/testing/generated-routes.ts +2 -2
  290. package/src/testing/internal/context.ts +1 -1
  291. package/src/testing/internal/flight-client-globals.ts +7 -5
  292. package/src/testing/render-route.tsx +85 -24
  293. package/src/testing/run-loader.ts +68 -6
  294. package/src/testing/serve-shell-request.ts +378 -0
  295. package/src/testing/shell-status.ts +34 -12
  296. package/src/testing/vitest-stubs/plugin-rsc.ts +123 -20
  297. package/src/testing/vitest.ts +62 -1
  298. package/src/theme/ThemeProvider.tsx +7 -6
  299. package/src/theme/ThemeScript.tsx +3 -3
  300. package/src/types/cache-types.ts +14 -10
  301. package/src/types/handler-context.ts +28 -1
  302. package/src/types/loader-types.ts +8 -6
  303. package/src/urls/pattern-types.ts +22 -18
  304. package/src/urls/response-types.ts +3 -23
  305. package/src/use-loader.tsx +12 -3
  306. package/src/vite/discovery/shell-prerender-phase.ts +9 -0
  307. package/src/vite/plugins/use-cache-transform.ts +118 -74
  308. package/src/vite/rango.ts +7 -3
  309. package/src/vite/router-discovery.ts +14 -4
@@ -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`
@@ -13,7 +13,9 @@ A Rango route handler is a pure function `(ctx) => rsc` — the function you pas
13
13
  | `params` | `Record<string, string>` | Route params surfaced as `ctx.params`. |
14
14
  | `env` | `TEnv` | Environment bindings surfaced as `ctx.env`. |
15
15
  | `request` | `Request \| string` | Backing Request (string or `Request`); defaults to a localhost GET. |
16
- | `headers` | `HeadersInit` | Request headers (e.g. `Cookie`) the handler reads via `cookies()`. |
16
+ | `headers` | `HeadersInit` | Request headers (e.g. `Cookie`) the handler reads via `cookies()`. Used only when `request` is a string or omitted; a passed `Request` keeps its own headers. |
17
+ | `build` | `boolean` | Seed `ctx.build` (default `false`) to test a handler that branches on the build-time pass, including calling `ctx.dynamic()` on a miss. Assert the opt-out via `result.dynamic`. |
18
+ | `theme` | `ThemeConfig \| true` | Theme config (`createRouter({ theme })` shape). Without it `ctx.theme`/`ctx.setTheme` are inert; pass one to test a handler that reads the theme or writes the theme cookie. |
17
19
  | `vars` | `VarsInit` (object or `[[Var, value]]` tuples) | Variables a prior middleware set, read via `ctx.get(...)`. |
18
20
  | `routeName` | `string` | Matched route name (drives `ctx.routeName` and scoped reverse). |
19
21
  | `routeMap` | `Record<string, string>` | Route name -> pattern map enabling `ctx.reverse()`. |
@@ -26,21 +28,21 @@ A Rango route handler is a pure function `(ctx) => rsc` — the function you pas
26
28
 
27
29
  ### Context — `HandlerContext` (what your handler receives)
28
30
 
29
- | Field | Type | Meaning |
30
- | ------------------ | ------------------------------------ | ---------------------------------------------------------------------------------------------- |
31
- | `params` | `Record<string, string>` | The seeded route params. |
32
- | `env` | `TEnv` | The seeded environment bindings. |
33
- | `request` | `Request` | The backing request. |
34
- | `searchParams` | `URLSearchParams` | Parsed query of `request.url`. |
35
- | `pathname` | `string` | Pathname of `request.url`. |
36
- | `url` | `URL` | Parsed `request.url`. |
37
- | `routeName` | `string \| undefined` | The matched route name (from `routeName`). |
38
- | `use` | `(loaderOrHandle) => data \| pushFn` | A loader returns its seeded data; a handle returns a push fn that RECORDS to `result.handles`. |
39
- | `reverse` | `(name, params?) => string` | Build a URL from `routeMap`. |
40
- | `get` | `(Var) => value` | Read a seeded `vars` variable. |
41
- | `headers` | `Headers` | Response headers; set via `ctx.headers.set(...)` (merged into `result.response`). |
42
- | `setLocationState` | `(entries) => void` | Set location state (surfaced on `result.locationState`). |
43
- | `waitUntil` | `(fn: () => Promise<void>) => void` | Register background work. |
31
+ | Field | Type | Meaning |
32
+ | ------------------ | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
33
+ | `params` | `Record<string, string>` | The seeded route params. |
34
+ | `env` | `TEnv` | The seeded environment bindings. |
35
+ | `request` | `Request` | The backing request. |
36
+ | `searchParams` | `URLSearchParams` | Parsed query of `request.url`. |
37
+ | `pathname` | `string` | Pathname of `request.url`. |
38
+ | `url` | `URL` | Parsed `request.url`. |
39
+ | `routeName` | `string \| undefined` | The matched route name (from `routeName`). |
40
+ | `use` | `(loaderOrHandle) => Promise<data> \| pushFn` | A loader returns a Promise of its seeded data (like production); a handle returns a push fn that RECORDS to `result.handles`. |
41
+ | `reverse` | `(name, params?) => string` | Build a URL from `routeMap`. |
42
+ | `get` | `(Var) => value` | Read a seeded `vars` variable. |
43
+ | `headers` | `Headers` | Response headers; set via `ctx.headers.set(...)` (merged into `result.response`). |
44
+ | `setLocationState` | `(entries) => void` | Set location state (surfaced on `result.locationState`). |
45
+ | `waitUntil` | `(fn: () => Promise<void>) => void` | Register background work. |
44
46
 
45
47
  ### Returns — `RenderHandlerResult`
46
48
 
@@ -55,18 +57,22 @@ A Rango route handler is a pure function `(ctx) => rsc` — the function you pas
55
57
  | `stateCookieName` | `string` | The resolved rango state cookie name this run seeded (default `rango-state_router_0`). Assert an `invalidateClientCache()` rotation against it without recomputing. |
56
58
  | `locationState` | `Record<string, unknown>` | Location state the handler set (`ctx.setLocationState`/`redirect({ state })`). |
57
59
  | `handles` | `Map<Handle, unknown[]>` | What the handler pushed via `ctx.use(Handle)(...)` (e.g. `Meta`, `Breadcrumbs`), keyed by handle. |
60
+ | `dynamic` | `boolean` | Whether the handler called `ctx.dynamic()` (the PPR shell opt-out). |
58
61
 
59
62
  ## Recipe
60
63
 
61
64
  ```tsx
65
+ import { it, expect } from "vitest";
62
66
  import {
63
67
  renderHandler,
64
68
  findClientBoundaries,
69
+ textContent,
65
70
  } from "@rangojs/router/testing/flight";
71
+ import { Meta } from "@rangojs/router";
66
72
  import { ProductPage } from "../src/pages/product"; // the real handler: (ctx) => rsc
73
+ import { LogoutPage, QuietPage } from "../src/pages/account"; // call invalidateClientCache() / keepClientCache()
67
74
  import { ProductLoader } from "../src/loaders/product";
68
- import { Tenant } from "../src/middleware/tenant";
69
- import { Meta } from "../src/handles";
75
+ import { Tenant } from "../src/middleware/tenant"; // a createVar()
70
76
 
71
77
  it("renders the product page for a tenant", async () => {
72
78
  const { tree, handles } = await renderHandler(ProductPage, {
@@ -76,8 +82,9 @@ it("renders the product page for a tenant", async () => {
76
82
  routeMap: { product: "/p/:slug" }, // enables ctx.reverse
77
83
  });
78
84
 
79
- expect(JSON.stringify(tree)).toContain("Wine");
80
- const [counter] = findClientBoundaries(tree, "Counter"); // islands inspectable too
85
+ expect(textContent(tree)).toContain("Wine");
86
+ const [addToCart] = findClientBoundaries(tree, "AddToCart"); // islands inspectable too
87
+ expect(addToCart.props.slug).toBe("wine");
81
88
  expect(handles.get(Meta)).toEqual([{ title: "Wine - Shop" }]); // ctx.use(Meta) pushes
82
89
  });
83
90
 
@@ -106,14 +113,55 @@ it("asserts the client-cache directives", async () => {
106
113
  });
107
114
  ```
108
115
 
116
+ ## A `"use cache"` hit
117
+
118
+ With `cacheStore` seeded, a `"use cache"` function the handler calls runs the real cache runtime on real Flight. The first call runs the body and writes the entry in a background (`waitUntil`) task that `renderHandler` does not await. A call made before that write lands joins the still-running execution; a call made after it reads the store. Either way the body runs once, and the value comes back through a Flight round trip, so a `Date`, a `Map`, a client island or a server action keeps its type and reference. A value Flight encodes as an error row, such as a rejected promise, is not stored. The stale (SWR) path is not covered here; assert it at e2e.
119
+
120
+ The rango plugin's `"use cache"` transform runs only in its `rsc` environment (dev or build), and Vitest transforms in `ssr`, so add `rangoUseCacheTransform()` to the react-server project's `plugins` (see [`./setup.md`](./setup.md)). It runs the same transform, with the ids `vite dev` emits (`src/data/products.ts#...`). Without it the directive is an inert string and the function runs on every call.
121
+
122
+ ```tsx
123
+ // src/data/products.ts
124
+ export async function getProduct(slug: string) {
125
+ "use cache: short";
126
+ return db.products.find(slug);
127
+ }
128
+ ```
129
+
130
+ ```tsx
131
+ import { it, expect, vi } from "vitest";
132
+ import { renderHandler } from "@rangojs/router/testing/flight";
133
+ import { MemorySegmentCacheStore } from "@rangojs/router/cache";
134
+ import { ProductPage } from "../src/pages/product"; // calls getProduct(ctx.params.slug)
135
+ import { db } from "../src/db";
136
+
137
+ it("reads the second render's product from the store", async () => {
138
+ const find = vi.spyOn(db.products, "find");
139
+ const cacheStore = new MemorySegmentCacheStore();
140
+ const setItem = vi.spyOn(cacheStore, "setItem");
141
+ const options = {
142
+ params: { slug: "wine" },
143
+ cacheStore,
144
+ cacheProfiles: { short: { ttl: 60 } },
145
+ };
146
+
147
+ await renderHandler(ProductPage, options);
148
+ await vi.waitFor(() => expect(setItem).toHaveBeenCalled()); // the background write
149
+ await renderHandler(ProductPage, options);
150
+
151
+ expect(find).toHaveBeenCalledTimes(1);
152
+ });
153
+ ```
154
+
155
+ Without the plugin, wrap a function yourself with the call the transform emits, `registerCachedFunction(fn, id, profile)` from `@rangojs/router/cache-runtime`.
156
+
109
157
  ## Caveats
110
158
 
111
159
  - An unseeded `ctx.use(loader)` REJECTS with a setup error — seed every dependency via `{ loaders: [[OtherLoader, data]] }`, matched by reference. Loaders are SEEDED, not executed (same as `runLoader`).
112
- - Same alias requirement as flight tests: without the `@rangojs/router -> index.rsc.ts` alias (see [`./setup.md`](./setup.md)), a handler reading `getRequestContext()`/`cookies()` hits the throwing out-of-react-server stub. Symptom: `tree: undefined` with the stub error on `thrown`.
160
+ - Same alias requirement as flight tests: without the `@rangojs/router -> index.rsc.ts` alias (see [`./setup.md`](./setup.md)), a handler reading `getRequestContext()`/`cookies()` hits the throwing out-of-react-server stub. `renderHandler` detects this and REJECTS with a setup error naming `rangoTestAliases` (it is not captured on `thrown`).
113
161
  - A `throw redirect()` is captured on `thrown` (with `tree` undefined, since it produced a `Response`) — assert on `thrown`/`response`, no try/catch needed.
114
162
  - No hydration and no interaction — for clicks, forms, and navigation use e2e.
115
163
  - `renderHandler` runs a handler FUNCTION `(ctx) => rsc`; for a plain ELEMENT `<Page/>` use `renderServerTree` (see [`./server-tree.md`](./server-tree.md)).
116
- - A handler that calls a `"use cache"` function runs UNCACHED unless you seed `cacheStore` (and `cacheProfiles` for a named profile). With nothing seeded the runtime bypasses to the live body and warns once under the test runner — assert real cache behavior by passing `{ cacheStore: new MemorySegmentCacheStore(), cacheProfiles: { default: { ttl: 60 } } }`.
164
+ - A handler that calls a `"use cache"` function runs UNCACHED unless you seed `cacheStore` (and `cacheProfiles` for a named profile). With nothing seeded the runtime bypasses to the live body and warns once under the test runner. With a store seeded, the body runs once across renders and a render after the background write reads the store — see [A `"use cache"` hit](#a-use-cache-hit), including `rangoUseCacheTransform()`, without which Vitest leaves the directive unwrapped.
117
165
 
118
166
  ## See also
119
167
 
@@ -25,7 +25,7 @@ The lightweight context a RESPONSE-route handler reads (mirrors the production `
25
25
  | `searchParams` | `URLSearchParams` | Query params with internal `_rsc*` params stripped. |
26
26
  | `url` | `URL` | Cleaned request URL (internal `_rsc*` params removed). |
27
27
  | `pathname` | `string` | Matched pathname. |
28
- | `reverse` | `ReverseFunction` | URL-from-name. Map-only (NO auto-fill from current params), matching the production response-route handler. |
28
+ | `reverse` | `GlobalReverseFunction` | Global names only (NO auto-fill, `.name` is a type error), matching the production response-route handlers. |
29
29
  | `get` | fn | Read context vars set by prior middleware. |
30
30
  | `header(name, value)` | fn | Set a response header; surfaces on the returned `Response`. |
31
31
  | `waitUntil` | fn | Register a deferred task (no-op fidelity in tests). |
@@ -76,17 +76,24 @@ describe("api routes via dispatch", () => {
76
76
  });
77
77
  ```
78
78
 
79
- `dispatch` also covers trailing-slash/redirect targets (`findMatch`) — a redirected path returns a 308 with the `Location` (query preserved). Pass `env` via `{ env }`.
79
+ `dispatch` also covers, with production code paths:
80
+
81
+ - trailing-slash/redirect targets (`findMatch`) — a redirected path returns a 308 with the `Location` (query preserved);
82
+ - the open-redirect guard on browser-followed redirects (a cross-origin `Location` is rewritten to the basename root unless `redirect(url, { external: true })`);
83
+ - content-negotiated routes (`Vary: Accept` appended);
84
+ - cached response routes (`cache({ ... })` around a `path.json` etc.): hit, SWR, and tag writes. The cache write is scheduled via `waitUntil` (a microtask in tests), so flush microtasks between the seeding `dispatch` and the one that asserts the hit;
85
+ - `createRouter({ telemetry })` `request.start` / `request.end` / `request.error` events, and `createRouter({ onError })` for cache-read/write failures.
80
86
 
81
87
  ## Caveats
82
88
 
83
89
  - Hitting a COMPONENT (RSC) route throws a clear directive error: `dispatch` is for response routes + redirects + 404 + content negotiation, plus the global + route-level middleware guard stack on RESPONSE routes — it never renders React. Use Flight primitives or e2e to exercise component rendering.
84
90
  - A COMPONENT route's guard stack cannot run here. Assert it at e2e, or extract the middleware fn and unit-test it with `runMiddleware` (see `./middleware.md`).
85
- - JSON serialization is bare, applied in `response-route-handler.ts`: a `path.json` handler that returns a value is serialized verbatim (`JSON.stringify(value)`, status 200, `application/json`) — no envelope. Returning or throwing a `Response` (e.g. `Response.json(x)`) uses the same control-flow path. Any other thrown error yields an RFC 9457 problem+json body `{ title, status, detail, code }` (`application/problem+json`) with the error's status (`RouterError.status`, else 500, or a non-200 `ctx.res.status` already set upstream in the request pipeline); `code` is the `RouterError.code`, else `"INTERNAL"`. The `type` member is omitted this phase. Assert the shape matching what your handler returns.
91
+ - JSON serialization is bare, applied in `response-route-handler.ts`: a `path.json` handler that returns a value is serialized verbatim (`JSON.stringify(value)`, status 200, `application/json`) — no envelope. Returning or throwing a `Response` (e.g. `Response.json(x)`) uses the same control-flow path. Any other thrown error takes the error's status (`RouterError.status`, else 500, or a non-200 status already set upstream in the request pipeline). On a `path.json` route the body is an RFC 9457 problem+json object `{ title, status, detail, code }` (`application/problem+json;charset=utf-8`), where `code` is the `RouterError.code`, else `"INTERNAL"`, and the `type` member is omitted. On text/html/xml/md routes the body is `text/plain`: the `RouterError` message, the error message in dev, or `"Internal Server Error"` in production. Assert the shape matching what your handler returns.
86
92
  - Setup: needs the preset (alias + virtual stubs) or a Vite-RSC env (see `./setup.md`); a bare router import throws on Vite virtuals.
87
93
  - A router using `Prerender()`/`createLoader()`/`Static()` now constructs in a bare test (each assigns a runtime fallback `$$id`). Importing the whole router _file_ may still need the plugin (its page modules pull app deps / `virtual:` modules) — build from a focused include (your API routes) for whole-router dispatch.
88
94
  - A `_rsc_partial` request to a response route runs global middleware first (an auth gate can still 401/redirect), then returns `X-RSC-Reload` — route-level middleware is skipped, exactly like production.
89
- - `dispatch` does NOT execute server actions (`?_rsc_action`), but it DOES run the global middleware chain on an action request — middleware can still 401/redirect it, and any 3xx redirect on a partial OR action request becomes a `204` + `X-RSC-Redirect` (fetch-safe interception), the raw `Location` dropped.
95
+ - `dispatch` does NOT execute server actions (`?_rsc_action`), but it DOES run the global middleware chain on an action request — middleware can still 401/redirect it, and any 3xx redirect on a partial OR action request becomes a `204` + `X-RSC-Redirect` (fetch-safe interception), the raw `Location` dropped. Location state carried on such a redirect is dropped too (production embeds it in a Flight payload); cover that at e2e.
96
+ - `createRouter({ onError })` is NOT invoked for a thrown non-Response HANDLER error here (the typed error Response still matches production), and `cache.decision` / `loader.*` telemetry never fires (no RSC render runs). Cover both at e2e.
90
97
 
91
98
  ## See also
92
99
 
@@ -1,6 +1,6 @@
1
1
  # Testing reverse/href and type-level contracts
2
2
 
3
- **Layer:** unit (node) + typecheck · **Import:** `@rangojs/router/client` (useReverse), `@rangojs/router/testing` (assertGeneratedRoutesMatch) · **DSL it tests:** `reverse`/`href`/`useReverse` (see `/typesafety`, `/links`)
3
+ **Layer:** unit (node) + typecheck · **Import:** `@rangojs/router/client` (useReverse), `@rangojs/router/testing` (assertGeneratedRoutesMatch) · **DSL it tests:** `ctx.reverse`/`href`/`useReverse` (see `/typesafety`, `/links`)
4
4
 
5
5
  The reverse/href/params/env types are a real contract: a wrong route name, a missing param, or an unknown env binding should be a COMPILE error, not a runtime surprise. The type-test recipes have no runtime API — `tsc --noEmit` IS the assertion. `assertGeneratedRoutesMatch` is the one runtime helper here: it runs the router's real matching to expand lazy includes, then diffs the live `routeMap` against the generated named-routes map you seed. It (and `diffGeneratedRoutes`) are **async — `await` them** — because expanding an async `include(prefix, () => import("./routes"))` group means importing that module first.
6
6
 
@@ -8,10 +8,10 @@ The reverse/href/params/env types are a real contract: a wrong route name, a mis
8
8
 
9
9
  ### Options — `assertGeneratedRoutesMatch(router, generatedMap?)`
10
10
 
11
- | Field | Type | Meaning |
12
- | -------------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
13
- | `router` | `{ routeMap; findMatch? }` | Your router (real impl). `routeMap` is the live name→pattern map; `findMatch` (when present) is called to force-expand lazy `include()`d routes. |
14
- | `generatedMap` | `Record<string, unknown>` (optional) | The imported `*.named-routes.gen.ts` map (name→pattern, or `{ path }` objects). Omit to diff against the global route map (`getGlobalRouteMap()`) instead. |
11
+ | Field | Type | Meaning |
12
+ | -------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
13
+ | `router` | `{ routeMap; findMatch? }` | Your router (real impl). `routeMap` is the live name→pattern map; `findMatch` (when present) is called to force-expand lazy `include()`d routes. |
14
+ | `generatedMap` | `Record<string, unknown>` (optional) | The `NamedRoutes` export of your `*.named-routes.gen.ts` file (name→pattern, or `{ path }` objects). Omit to diff against the global route map instead. |
15
15
 
16
16
  ### Context — `GeneratedRoutesDiff` (what `diffGeneratedRoutes` resolves to)
17
17
 
@@ -32,14 +32,19 @@ The reverse/href/params/env types are a real contract: a wrong route name, a mis
32
32
  // 1. Negative assertions inline with @ts-expect-error — the directive ERRORS if
33
33
  // the line below it ever starts compiling (i.e. if the type guard regresses).
34
34
  // Validated by `tsc --noEmit`; a runtime test cannot assert this.
35
+ // Keep the hook call inside a function that is never invoked: tsc checks it,
36
+ // and a runtime test file that imports it does not execute a hook outside
37
+ // a component.
35
38
  import { useReverse } from "@rangojs/router/client";
36
39
 
37
- const reverse = useReverse({ post: "/blog/:slug" });
38
- reverse("post", { slug: "hi" }); // ok
39
- // @ts-expect-error - missing required :slug param
40
- reverse("post", {});
41
- // @ts-expect-error - "comment" is not a route in this map
42
- reverse("comment", { id: "1" });
40
+ function typeOnly() {
41
+ const reverse = useReverse({ post: "/blog/:slug" });
42
+ reverse("post", { slug: "hi" }); // ok
43
+ // @ts-expect-error - missing required :slug param
44
+ reverse("post", {});
45
+ // @ts-expect-error - "comment" is not a route in this map
46
+ reverse("comment", { id: "1" });
47
+ }
43
48
  ```
44
49
 
45
50
  ```ts
@@ -62,10 +67,11 @@ expectTypeOf<
62
67
  import { it } from "vitest";
63
68
  import { assertGeneratedRoutesMatch } from "@rangojs/router/testing";
64
69
  import { router } from "../src/router";
65
- import generated from "../src/router.named-routes.gen";
70
+ // The generated file has no default export; it exports `NamedRoutes`.
71
+ import { NamedRoutes } from "../src/router.named-routes.gen";
66
72
 
67
73
  it("generated named-routes map is in sync with the router", async () => {
68
- await assertGeneratedRoutesMatch(router, generated);
74
+ await assertGeneratedRoutesMatch(router, NamedRoutes);
69
75
  });
70
76
  ```
71
77
 
@@ -76,7 +82,7 @@ For a large type-only suite, collect recipe-1/2 assertions in `*.test-d.ts` file
76
82
  - Type tests run at TYPECHECK time (`tsc --noEmit`), NOT in the vitest runner. They are their own layer — wire them into CI as a real step (`pnpm run typecheck`). A type test nobody runs is just a comment.
77
83
  - `@ts-expect-error` ERRORS if the line below it ever starts compiling, so a regressed guard fails the typecheck. A runtime test cannot assert "this should not type-check".
78
84
  - `assertGeneratedRoutesMatch` force-expands lazy `include()`d routes (awaits `findMatch` on a concrete path derived from each generated pattern) before diffing — otherwise every included route reads as a false `missing`. It is **async** so it can await the module import behind an async `include(() => import())` group; `await` the call (or the async group's routes read as `missing`). This makes the whole-app drift check work in a plain unit test. Routers without `findMatch` (a bare `{ routeMap }`) are left as-is.
79
- - MULTI-APP route-map isolation. `href()`/`reverse()` typing is GLOBAL — each app's generated file augments the one `Rango.GeneratedRouteMap` interface. A `renderRoute` suite that imports a client component from app B (which calls `href("/b-route")`) won't typecheck if the same tsconfig program also carries app A's augmentation: A's route union rejects B's name. `renderRoute` is app-agnostic at RUNTIME; the collision is purely the global `href` typing. Keep a `renderRoute` suite single-app, or give each app its OWN tsconfig program (see `/typesafety`); a quick sidestep is to probe `useMount`/`useHref` inline instead of importing the cross-app component.
85
+ - MULTI-APP route-map isolation. `href()`/`ctx.reverse()` typing is GLOBAL — each app's generated file augments the one `Rango.GeneratedRouteMap` interface. A `renderRoute` suite that imports a client component from app B (which calls `href("/b-route")`) won't typecheck if the same tsconfig program also carries app A's augmentation: A's route union rejects B's name. `renderRoute` is app-agnostic at RUNTIME; the collision is purely the global `href` typing. Keep a `renderRoute` suite single-app, or give each app its OWN tsconfig program (see `/typesafety`); a quick sidestep is to probe `useMount`/`useHref` inline instead of importing the cross-app component.
80
86
 
81
87
  ## See also
82
88
 
@@ -0,0 +1,188 @@
1
+ # Testing a PPR shell — serveShellRequest
2
+
3
+ **Layer:** RSC unit (react-server project) · **Import:** `@rangojs/router/testing/flight` · **DSL it tests:** the `ppr` path option (see `/ppr`)
4
+
5
+ `serveShellRequest(router, url, options?)` serves one GET through your router's production request handler — built as `router.fetch` builds it, with the router's `nonce`, `version`, `cache` config and middleware — and settles every background task the request scheduled before it resolves. So the first request for a `ppr` route is a MISS whose real capture has stored its shell (`putShell` landed), and the next request with the same store is a real HIT: shell read, version gates, the tail replaying the handler output the capture baked (no handler runs), live loaders. A `partial` request is a real client navigation with its `x-rango-ppr-replay` decision. Flight is real.
6
+
7
+ The one stub is the HTML step (the SSR module), because `react-dom/server` does not load under the react-server condition:
8
+
9
+ | SSR step | Under `serveShellRequest` |
10
+ | ------------------ | ------------------------------------------------------------------------------------------------------ |
11
+ | `renderHTML` | passes the Flight stream through: a document body is its Flight payload |
12
+ | `captureShellHTML` | stores, as the prelude, the Flight text the capture rendered before it went quiet; `postponed` is null |
13
+ | `resumeShellHTML` | passes the tail's Flight through: a HIT body is the stored prelude, then the tail's Flight payload |
14
+
15
+ So `prelude` is the shell as captured (Flight text, not HTML): a value the shell froze appears there, and a hole (a live loader under `loading()`) does not.
16
+
17
+ ## API
18
+
19
+ ### Options — `ServeShellRequestOptions`
20
+
21
+ | Field | Type | Meaning |
22
+ | ------------ | ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
23
+ | `cacheStore` | `SegmentCacheStore` | The store for this request, in place of the store your `createRouter({ cache })` config returns. The rest of that config (`enabled`, `searchParams`) still applies. Omit it to use the router's own store. |
24
+ | `env` | `TEnv` | Bindings, as `router.fetch(request, { env })` passes them. A function `cache` config receives them. |
25
+ | `headers` | `HeadersInit` | Request headers (e.g. `cookie`). A document request defaults `accept` to `text/html`. |
26
+ | `partial` | `true \| { from?: string; segments?: string[] }` | Serve the navigation request the browser sends instead of a document GET: from `from` (default the site root) with `segments` mounted (default none). |
27
+
28
+ ### Returns — `ServeShellRequestResult`
29
+
30
+ | Field | Type | Meaning |
31
+ | -------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
32
+ | `shellStatus` | `"HIT" \| "MISS" \| null` | `x-rango-shell`; null when the serve path did not consider the request (not a `ppr` route, a nonce'd request, a partial request). |
33
+ | `replayStatus` | `PprReplayStatus \| null` | `x-rango-ppr-replay` on a partial request: `{ outcome: "HIT", freshness }` or `{ outcome: "BYPASS", reason }`. |
34
+ | `prelude` | `string \| undefined` | The prelude a HIT served (the capture's Flight text). `undefined` unless `shellStatus` is `"HIT"`. |
35
+ | `flight` | `string \| undefined` | The Flight payload this request rendered: a HIT's tail, a document render, or a partial response. `undefined` when no Flight rendered (a redirect, a middleware response). |
36
+ | `key` | `string` | The shell key the serve path resolved for a document it read (MISS or HIT), request partition included. Otherwise (no `ppr`, a partial request) the URL's key without a partition. |
37
+ | `readEntry` | `() => Promise<ShellCacheEntry \| null>` | Reads the document entry under `key` from the request's store (a passive `getShell`). A read: on a store with a shell memo it warms the memo, so call it after the reads you count. |
38
+ | `response` | `Response` | Status and headers. Its body is already read. |
39
+ | `body` | `string` | The body text. |
40
+
41
+ ### `resetShellTestState(): Promise<void>`
42
+
43
+ A worker keeps some PPR state across requests, and so does the test process across tests: the capture's stampede guard and backoff (a refused capture backs its URL off for later tests too), the capture's and the serve path's once-per-key warnings, the build-shell manifest memo, and `CFCacheStore`'s isolate memos (shells, tag markers, tag hints), which every `CFCacheStore` shares by namespace and URL — a later test's first request can be a HIT from an earlier test's shell. `resetShellTestState()` clears all of it. Call it in `beforeEach`, never while a request is in flight. `VercelCacheStore`'s memos live on the `cache` handle you pass it: a new handle per test starts empty.
44
+
45
+ ## Recipe
46
+
47
+ ```ts
48
+ // test/product-shell.rsc-test.tsx — in the react-server project (./setup.md)
49
+ import { beforeEach, expect, it } from "vitest";
50
+ import { createRouter, updateTag, urls } from "@rangojs/router";
51
+ import { MemorySegmentCacheStore } from "@rangojs/router/cache";
52
+ import { runInRequestContext } from "@rangojs/router/testing";
53
+ import {
54
+ resetShellTestState,
55
+ serveShellRequest,
56
+ } from "@rangojs/router/testing/flight";
57
+ import { ProductPage } from "../src/pages/product"; // renders the product name
58
+ import { StockLoader } from "../src/loaders/stock"; // live stock count
59
+
60
+ const router = createRouter().routes(
61
+ urls(({ path, loader, loading }) => [
62
+ path("/product/:id", ProductPage, { name: "product", ppr: { tags: ["catalog"] } }, () => [
63
+ loader(StockLoader),
64
+ loading(<p>Checking stock…</p>),
65
+ ]),
66
+ ]),
67
+ );
68
+
69
+ beforeEach(() => resetShellTestState());
70
+
71
+ it("serves the captured shell and fills the stock hole live", async () => {
72
+ const cacheStore = new MemorySegmentCacheStore();
73
+
74
+ const miss = await serveShellRequest(router, "/product/1", { cacheStore });
75
+ expect(miss.shellStatus).toBe("MISS"); // a normal render; the capture stored the shell
76
+
77
+ const hit = await serveShellRequest(router, "/product/1", { cacheStore });
78
+ expect(hit.shellStatus).toBe("HIT");
79
+ expect(hit.prelude).toContain("Widget"); // frozen at capture
80
+ expect(hit.prelude).not.toContain("in stock"); // the hole
81
+ expect(hit.flight).toContain("in stock"); // rendered by this request
82
+
83
+ await runInRequestContext(() => updateTag("catalog"), { cacheStore });
84
+ const recapture = await serveShellRequest(router, "/product/1", { cacheStore });
85
+ expect(recapture.shellStatus).toBe("MISS");
86
+ });
87
+
88
+ it("replays the captured segments on a client navigation", async () => {
89
+ const cacheStore = new MemorySegmentCacheStore();
90
+ await serveShellRequest(router, "/product/2", { cacheStore });
91
+
92
+ const nav = await serveShellRequest(router, "/product/2", {
93
+ cacheStore,
94
+ partial: { from: "/" },
95
+ });
96
+ expect(nav.replayStatus).toEqual({ outcome: "HIT", freshness: "fresh" });
97
+ });
98
+ ```
99
+
100
+ A store built per request from a function `cache` config gets the request's execution context (`ctx`), so its own `waitUntil` writes (`CFCacheStore({ ctx })`) settle with the call. A store built outside the request with its own `waitUntil` (`VercelCacheStore`'s option) is not settled by the call. For `CFCacheStore`, stub the `caches` global and pass a KV double in `env`; for `VercelCacheStore`, pass a `cache` double. A store's shell memo then behaves as in production (count your double's reads across two HITs).
101
+
102
+ ## A HIT runs no handler
103
+
104
+ A HIT replays what the capture's handlers produced — their elements, the promises they passed or pushed, their handle values — and runs only middleware and loaders. Count runs to pin it:
105
+
106
+ ```ts
107
+ let pageRuns = 0;
108
+ // the page handler does `pageRuns += 1` and renders `page-run-${pageRuns}`
109
+
110
+ it("a HIT runs no handler", async () => {
111
+ const cacheStore = new MemorySegmentCacheStore();
112
+ await serveShellRequest(router, "/product/3", { cacheStore }); // MISS + capture
113
+ const baked = pageRuns; // the capture's run
114
+
115
+ const hit = await serveShellRequest(router, "/product/3", { cacheStore });
116
+ expect(hit.shellStatus).toBe("HIT");
117
+ expect(pageRuns).toBe(baked);
118
+ expect(hit.flight).toContain(`page-run-${baked}`);
119
+ });
120
+ ```
121
+
122
+ ## Request-partitioned shells
123
+
124
+ A route whose `cache()` record is partitioned by the request (`cache({ key })`, or the store's `keyGenerator`) has a shell per partition. Serve each partition with the request headers or cookies its key reads; the result's `key` is the key the serve path resolved, partition included, and `readEntry()` reads it:
125
+
126
+ ```ts
127
+ // cache({ ttl: 300, key: (ctx) => `tier:${ctx.request.headers.get("x-tier")}` }, () => [
128
+ // path("/pricing", PricingPage, { ppr: true }),
129
+ // ])
130
+ const tier = (name: string) => ({ cacheStore, headers: { "x-tier": name } });
131
+
132
+ await serveShellRequest(router, "/pricing", tier("gold")); // MISS + capture
133
+ const silver = await serveShellRequest(router, "/pricing", tier("silver"));
134
+ expect(silver.shellStatus).toBe("MISS"); // its own capture, never gold's shell
135
+ const gold = await serveShellRequest(router, "/pricing", tier("gold"));
136
+ expect(gold.shellStatus).toBe("HIT");
137
+ expect(gold.key).toBe(shellCacheKey("/pricing", undefined, "tier:gold"));
138
+ expect(await gold.readEntry()).not.toBeNull();
139
+ ```
140
+
141
+ Under nested keyed `cache()` boundaries the partition is their `key()` results composed, outermost first (see `/caching`, "Keys nest"). Pass them to `shellCacheKey` as an array and it composes them the same way:
142
+
143
+ ```ts
144
+ // cache({ key: tierKey }, () => [
145
+ // layout(TierLayout, () => [
146
+ // cache({ key: (ctx) => `v:${variantOf(ctx)}` }, () => [path("/plans", PlansPage, { ppr: true })]),
147
+ // ]),
148
+ // ])
149
+ expect(result.key).toBe(
150
+ shellCacheKey("/plans", undefined, ["tier:gold", "v:a"]),
151
+ );
152
+ ```
153
+
154
+ A partial request has no HTML step, so its `key` is the URL's key without a partition: read a partitioned route's entry from a document request's result.
155
+
156
+ ## Handles and loader data
157
+
158
+ The result carries no structured `handles` map (unlike `renderHandler`): a handle value and loader data ride the payload, so you assert them as text in `prelude` and `flight`. A value the handler pushed is in the prelude and in every HIT's tail; a live loader's data is only in the tail. No public helper decodes a payload string into values: `normalizeFlight` and the `flightMatchers` (`toMatchFlight`) work on the text too.
159
+
160
+ ```ts
161
+ // The handler pushed ctx.use(Meta)({ title: "Widget - Shop" })
162
+ expect(hit.prelude).toContain('"title":"Widget - Shop"');
163
+ expect(hit.flight).toContain('"title":"Widget - Shop"');
164
+ ```
165
+
166
+ ## Caveats
167
+
168
+ What the HTML stub cannot reproduce, so keep it in e2e:
169
+
170
+ - The prelude's `<body` sanity gate. A route whose production capture refuses for a root postpone (a live loader read with no `loading()` or `<Suspense>` above it) stores a shell here.
171
+ - The capture deadline. `ppr.captureTimeout` (default 15s) bounds the whole capture: the match, the handler output, then the shell render. A capture that runs out of it stores nothing and is not retried in place, here as in production, so the call waits one deadline: give such a route a short `captureTimeout` in a test (Vitest's default test timeout is 5s). One difference: when the handler output settled but the shell render does not go quiet in what is left, the stub returns no shell, while production goes on to its abort and can still store the prelude rendered by then.
172
+ - SSR render errors. In production a fizz render error during the capture refuses it (#915). The stub renders no HTML and reports none, so a shell whose client component throws during SSR is stored here. A server component that throws still refuses the capture, as in production.
173
+ - Fizz resume of the holes, bootstrap scripts, nonce injection, and browser resume.
174
+
175
+ Other caveats:
176
+
177
+ - A HIT's tail carries replayed segments as Flight fragments inside JSON strings, so their quotes are escaped (`\"`). Match plain text, or unescape before matching JSON-shaped text.
178
+ - Call `resetShellTestState()` in `beforeEach` (see above). Without it, a URL a previous test left backed off, or a shell memoized by another `CFCacheStore`, changes what the next test sees.
179
+ - `readEntry()` reads the document entry under `key`. A partial request that finds no entry captures a navigation-only entry, which is stored apart from it.
180
+ - `updateTag()` needs a request context with the store: call it through `runInRequestContext(fn, { cacheStore })` or your app's own endpoint (`dispatch`). A store refuses a capture that starts in the invalidation's millisecond (the invalidation wins) and backs the key off; `serveShellRequest` starts each request in a later millisecond than the call, so an invalidation made before the call cannot collide with it.
181
+ - GET only. Server actions are not served; test their effects with `runInRequestContext`.
182
+ - Same setup as every Flight test: the react-server condition and `rangoTestAliases()` (see [`./setup.md`](./setup.md)).
183
+
184
+ ## See also
185
+
186
+ - `/ppr` — the DSL this tests; `/caching` — stores and tags
187
+ - Siblings: [`./cache-prerender.md`](./cache-prerender.md) (the shell-status helpers and the e2e recipe), [`./render-handler.md`](./render-handler.md)
188
+ - Long-form prose: [docs/testing.md](https://github.com/rangojs/rango/blob/main/packages/rangojs-router/docs/testing.md) — section "serveShellRequest — a real PPR capture and HIT"