@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
@@ -19,11 +19,11 @@ Use the header path when all you have is a black-box `Response` (a Playwright `A
19
19
 
20
20
  ### Options — `assertCacheStatus(target, segment, expected)`
21
21
 
22
- | Field | Type | Meaning |
23
- | ---------- | ------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
24
- | `target` | `Response \| { headers: Headers }` (`CacheStatusTarget`) | The thing carrying the `X-Rango-Cache` header: a `Response` from `router.fetch(...)`, or any `{ headers: Headers }`. A Playwright `APIResponse` exposes headers as a method, so wrap it: `{ headers: new Headers(res.headers()) }`. |
25
- | `segment` | `string` | The route NAME (e.g. `product.detail`), the same id the header carries — NOT the URL pattern (`/products/:id`). |
26
- | `expected` | `"hit" \| "miss" \| "stale" \| "prerendered" \| "passthrough"` (`ExpectedCacheStatus` = `CacheSegmentStatus`) | The cache status you assert for that route. |
22
+ | Field | Type | Meaning |
23
+ | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
24
+ | `target` | `Response \| { headers: Headers }` (`CacheStatusTarget`) | The thing carrying the `X-Rango-Cache` header: a `Response` from `router.fetch(...)`, or any `{ headers: Headers }`. A Playwright `APIResponse` exposes headers as a method, so wrap it: `{ headers: new Headers(res.headers()) }`. |
25
+ | `segment` | `string` | The route NAME (e.g. `product.detail`), the same id the header carries — NOT the URL pattern (`/products/:id`). |
26
+ | `expected` | `"hit" \| "miss" \| "stale" \| "prerendered"` (`ExpectedCacheStatus` = `CacheSegmentStatus`; `"passthrough"` is in the union but never emitted — a passthrough route reports `"miss"`) | The cache status you assert for that route. |
27
27
 
28
28
  ### Context — what your code under test emits
29
29
 
@@ -33,7 +33,7 @@ The header / event is produced by the router's RSC render pipeline from `ctx.rou
33
33
  | ------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------ |
34
34
  | `CacheSegmentSignal.id` | `string` | Segment id. v1: the route key, since status is route-level. |
35
35
  | `CacheSegmentSignal.type` | `string` | Segment type. v1: `"route"` for the coarse route-level entry. |
36
- | `CacheSegmentSignal.cacheStatus` | `CacheSegmentStatus` | Resolved status (`hit`/`miss`/`stale`/`prerendered`/`passthrough`). |
36
+ | `CacheSegmentSignal.cacheStatus` | `CacheSegmentStatus` | Resolved status (`hit`/`miss`/`stale`/`prerendered`; passthrough routes report `miss`). |
37
37
  | `CacheSegmentSignal.shouldRevalidate` | `boolean?` | Whether stale-while-revalidate was triggered for this segment. |
38
38
  | `CacheDecisionEvent.segments` | `CacheSegmentSignal[]?` | The coarse route-level signal array (present only when telemetry or the debug gate is on). |
39
39
 
@@ -92,15 +92,19 @@ parityDescribe("product page caches", (f) => {
92
92
  Zero-prod-surface alternative — the telemetry sink. No header at all; you inspect captured `cache.decision` events:
93
93
 
94
94
  ```ts
95
+ import { expect } from "vitest";
96
+ import { createRouter } from "@rangojs/router";
95
97
  import {
96
98
  createCacheSink,
97
99
  assertCacheDecision,
98
100
  filterCacheDecisions,
99
101
  } from "@rangojs/router/testing";
102
+ import { urlpatterns } from "../src/urls";
100
103
 
101
104
  const { sink, events } = createCacheSink();
102
105
  const router = createRouter({ telemetry: sink }).routes(urlpatterns);
103
- // ...drive a request through the router's RSC fetch path...
106
+ // ...drive a request through the router's RSC fetch path (e.g. an e2e server
107
+ // built from this router); dispatch() never emits cache.decision...
104
108
 
105
109
  // One-call assert (counterpart of assertCacheStatus), keyed by the route NAME:
106
110
  assertCacheDecision(events, "product.detail", "stale");
@@ -115,17 +119,17 @@ expect(decision.segments?.[0].shouldRevalidate).toBe(true);
115
119
 
116
120
  ## PPR shell and navigation replay
117
121
 
118
- **DSL:** `ppr: true | PartialPrerenderProps` on a page route (see `/ppr`). **Not** the same header as `X-Rango-Cache` — shell is a second render axis.
122
+ **DSL:** `ppr: true | PartialPrerenderProps` on a page route (see `/ppr`). **Not** the same header as `X-Rango-Cache` — the shell is cached separately from the segments.
119
123
 
120
- | Helper | Import | Role |
121
- | ----------------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------ |
122
- | `assertShellStatus(res, "HIT" \| "MISS")` | `@rangojs/router/testing` or `…/e2e` | Assert `x-rango-shell` on a **real document** Response |
123
- | `parseShellStatus(res)` | same | `"HIT" \| "MISS" \| null` (null = header absent / unrecognized) |
124
- | `shellCacheKey(url)` | same | Production shell store key (`host+pathname+sorted search+:shell`) for `store.getShell` / custom stores |
125
- | `SHELL_STATUS_HEADER` | same | `"x-rango-shell"` constant |
126
- | `assertPprReplayStatus(res, expected)` | same | Assert fresh/stale replay or a bounded bypass decision |
127
- | `parsePprReplayStatus(res)` | same | Structured replay/bypass status, or null for an absent/unrecognized header |
128
- | `PPR_REPLAY_STATUS_HEADER` | same | `"x-rango-ppr-replay"` constant |
124
+ | Helper | Import | Role |
125
+ | ----------------------------------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
126
+ | `assertShellStatus(res, "HIT" \| "MISS")` | `@rangojs/router/testing` or `…/e2e` | Assert `x-rango-shell` on a **real document** Response |
127
+ | `parseShellStatus(res)` | same | `"HIT" \| "MISS" \| null` (null = header absent / unrecognized) |
128
+ | `shellCacheKey(url, searchParams?, partition?)` | same | Production shell store key (`host+pathname+sorted search+:shell`, then `\|partition`) for `store.getShell` / custom stores. Pass your router's `cache.searchParams` config as the second argument when it sets one — the production key applies it. For a route whose `cache({ key })` or store `keyGenerator` partitions its record, pass what that function returns for the request as `partition`: the shell is partitioned the same way. Under nested keyed `cache()` boundaries, pass their `key()` results as an array, outermost first. |
129
+ | `SHELL_STATUS_HEADER` | same | `"x-rango-shell"` constant |
130
+ | `assertPprReplayStatus(res, expected)` | same | Assert fresh/stale replay or a bounded bypass decision |
131
+ | `parsePprReplayStatus(res)` | same | Structured replay/bypass status, or null for an absent/unrecognized header |
132
+ | `PPR_REPLAY_STATUS_HEADER` | same | `"x-rango-ppr-replay"` constant |
129
133
 
130
134
  ### What unit can prove vs e2e
131
135
 
@@ -133,33 +137,56 @@ expect(decision.segments?.[0].shouldRevalidate).toBe(true);
133
137
  | -------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
134
138
  | **Unit** | store family + key identity | `MemorySegmentCacheStore` + `shellCacheKey(url)` + `putShell`/`getShell` / tag eviction — dogfood in `e2e/mini/test/shell-store-family.test.ts` and `src/testing/__tests__/shell-status.test.ts` |
135
139
  | **Unit** | header helper contract | `assertShellStatus` on a Response that already carries the header (characterizes the helper; **never** invent a HIT to claim capture worked) |
140
+ | **Unit** | real capture → HIT, replay | `serveShellRequest(router, url, { cacheStore })` (react-server project); only the HTML step is stubbed — [`./serve-shell-request.md`](./serve-shell-request.md) |
136
141
  | **E2E** | live MISS → capture → HIT | document GET, poll until `x-rango-shell: HIT` (background capture) — `e2e/shell-cache.test.ts` |
137
142
  | **E2E** | partial replay decision | soft navigation to a warmed shell and `assertPprReplayStatus(response, { outcome: "HIT", freshness: "fresh" })` |
138
143
 
139
- `dispatch` is RSC-free: it never runs shell serve/capture. `renderHandler` only surfaces `ctx.dynamic()` opt-out, not bake/serve.
144
+ `dispatch` is RSC-free: it never runs shell serve/capture. `renderHandler` only surfaces `ctx.dynamic()` opt-out, not bake/serve. The HIT contract — no handler runs, the handler output is the capture's copy, live loaders re-run — is asserted through `serveShellRequest`: count your handler's runs and a live loader's across a MISS and the HITs after it, and check that only the loader's moves ([`./serve-shell-request.md`](./serve-shell-request.md)). A route partitioned by `cache({ key })` or a store `keyGenerator` has a shell per partition: `serveShellRequest` reports the key the serve path resolved (`result.key`), and `shellCacheKey` takes the partition as its third argument.
140
145
 
141
146
  ### Recipe (e2e)
142
147
 
143
148
  ```ts
144
- import { assertShellStatus, shellCacheKey } from "@rangojs/router/testing/e2e";
145
- // or from @rangojs/router/testing under Vitest
146
-
147
- // Document GET (not Accept: text/x-component):
148
- const first = await page.request.get(f.url("/products/1"));
149
- assertShellStatus({ headers: new Headers(first.headers()) }, "MISS");
150
- // After capture flushes (poll — background putShell):
151
- const second = await page.request.get(f.url("/products/1"));
152
- assertShellStatus({ headers: new Headers(second.headers()) }, "HIT");
153
-
154
- // Custom store / unit: same key the serve path uses
155
- const key = shellCacheKey(new URL("http://localhost/products/1"));
156
- expect(await store.getShell(key)).not.toBeNull();
149
+ import { expect, test } from "@playwright/test";
150
+ import {
151
+ createRangoE2E,
152
+ assertShellStatus,
153
+ parseShellStatus,
154
+ } from "@rangojs/router/testing/e2e";
155
+
156
+ const { parityDescribe } = createRangoE2E({ test, expect });
157
+
158
+ parityDescribe("product shell", (f) => {
159
+ test("MISS captures, then HIT", async ({ page }) => {
160
+ // Document GET (not Accept: text/x-component):
161
+ const first = await page.request.get(f.url("/products/1"));
162
+ assertShellStatus({ headers: new Headers(first.headers()) }, "MISS");
163
+ // Capture runs in the background (putShell), so poll for the HIT:
164
+ await expect
165
+ .poll(async () => {
166
+ const res = await page.request.get(f.url("/products/1"));
167
+ return parseShellStatus({ headers: new Headers(res.headers()) });
168
+ })
169
+ .toBe("HIT");
170
+ });
171
+ });
172
+ ```
173
+
174
+ ```ts
175
+ // Unit (Vitest): the key the serve path uses, against your own store instance
176
+ import { expect, it } from "vitest";
177
+ import { shellCacheKey } from "@rangojs/router/testing";
178
+
179
+ it("stores the shell under the production key", async () => {
180
+ // ...after your code under test captured a shell into `store`...
181
+ const key = shellCacheKey(new URL("http://localhost/products/1"));
182
+ expect(await store.getShell(key)).not.toBeNull();
183
+ });
157
184
  ```
158
185
 
159
186
  ## Caveats
160
187
 
161
188
  - The `X-Rango-Cache` header is emitted ONLY when the gate is on: `createRouter({ debugCacheSignal: true })` or `process.env.RANGO_TEST_SIGNALS === "1"`. Off by default — zero production surface. With the gate off, `assertCacheStatus` throws a clear "header missing" error.
162
- - `x-rango-shell` is always set on document GETs the serve path considered for a ppr route (no debug gate). Absent header = axis-1 fall-open or non-ppr.
189
+ - `x-rango-shell` is always set on document GETs the serve path considered for a ppr route (no debug gate). Absent header = a normal render (no shell): the request fell open, or the route has no `ppr`.
163
190
  - v1 is COARSE: route-level, keyed by the route NAME (e.g. `product.detail`), NOT the URL pattern (`/products/:id`); not per-individual-segment. The signal is built from `ctx.routeKey`, so a pattern-shaped key never matches. (`parseCacheHeader` exposes the raw `{ routeKey: status }` map if you need it.)
164
191
  - Prerender is indistinguishable from a cache hit by design — no static `.html`/`.rsc` files, the worker handles every request and looks up a stored Flight payload; the browser cannot tell. Do not assert "prerendered" from the DOM. Assert via the signal (`assertCacheStatus(res, seg, "prerendered")`) and run prerender assertions in PRODUCTION mode (the build-time artifacts only exist after `pnpm build`).
165
192
  - In a Playwright e2e import the cache-status / shell-status helpers from the `/e2e` entry — the `@rangojs/router/testing` barrel is Vitest-only (it pulls a build-only virtual that does not resolve in a plain Playwright runner). Zero-prod-surface alternative for segment cache: the telemetry sink (`createCacheSink`/`filterCacheDecisions`), no header at all. Note: the non-RSC `dispatch()` primitive never emits either header — get the Response from the router's real RSC fetch path.
@@ -168,5 +195,5 @@ expect(await store.getShell(key)).not.toBeNull();
168
195
  ## See also
169
196
 
170
197
  - `/caching`, `/prerender`, `/use-cache`, `/ppr` — the DSL this tests
171
- - Siblings: [`./e2e-parity.md`](./e2e-parity.md), [`./response-routes.md`](./response-routes.md)
198
+ - Siblings: [`./e2e-parity.md`](./e2e-parity.md), [`./response-routes.md`](./response-routes.md), [`./serve-shell-request.md`](./serve-shell-request.md)
172
199
  - Long-form prose: [docs/testing.md](https://github.com/rangojs/rango/blob/main/packages/rangojs-router/docs/testing.md) — section "Cache, SWR, and prerender"
@@ -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
 
@@ -27,6 +27,7 @@
27
27
  | `use` | `UseResolver` | Dynamic resolver for `ctx.use(OtherLoader)` composition. `loaders` wins when both match. |
28
28
  | `cacheStore` | `SegmentCacheStore` | Cache store backing `use cache` functions. Without one, a cached function bypasses and runs uncached (its taint/profile guards never fire). |
29
29
  | `cacheProfiles` | `Record<string, CacheProfile>` | Cache profiles, the `createRouter({ cacheProfiles })` shape. |
30
+ | `cache` | `PartialCacheOptions` | Run the loader through the `cache()` a route binds it with (`loader(Loader, () => [cache({...})])` takes the same options), against `cache.store`, else `cacheStore`. See the caveat below. |
30
31
  | `stateCookie` | `StateCookieSeed` (`{ prefix?, routerId?, version? }`) | Customize the rango state cookie a loader calling `invalidateClientCache()` rotates (the name is always seeded — default `rango-state_router_0`). |
31
32
  | `rendered` | `boolean \| (() => void \| Promise<void>)` | Mock the `ctx.rendered()` render barrier so a loader that `await ctx.rendered()`s can be unit-tested. By default `ctx.rendered()` throws. `true` resolves immediately; a function controls timing/side effects. |
32
33
  | `handles` | `ReadonlyArray<readonly [Handle<any, any>, unknown]>` | Seed the values `ctx.get(SomeHandle)` returns — the ACCUMULATED handle data read after `await ctx.rendered()`. Matched by handle reference. (Loader handle WRITES need no seed — see `runLoaderResult(...).handlePushes`.) |
@@ -51,7 +52,7 @@
51
52
  | `formData` | `FormData \| undefined` | Form data (from `opts.formData`). |
52
53
  | `reverse` | `(name, params?, search?) => string` | Build a URL; throws unless `opts.routeMap` was passed. |
53
54
  | `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). |
55
+ | `waitUntil` | `(fn: () => Promise<void>) => void` | Register background work; takes a function, not a promise (no-op accounting in tests). |
55
56
  | `executionContext` | `ExecutionContext \| undefined` | Platform execution context from the backing request; pairs with `waitUntil`. |
56
57
 
57
58
  ### Returns — `Promise<T>`
@@ -70,8 +71,10 @@ order). `runLoader` discards those effects.
70
71
  ## Recipe
71
72
 
72
73
  ```ts
74
+ import { it, expect } from "vitest";
73
75
  import { runLoader, runLoaderResult } from "@rangojs/router/testing";
74
76
  import { createLoader, createVar } from "@rangojs/router";
77
+ import { AuthLoader } from "../src/loaders/auth"; // sets a `session` cookie, then throw redirect("/")
75
78
 
76
79
  const User = createVar<{ name: string }>();
77
80
  // The registered loader — no separate body export needed for testability:
@@ -100,7 +103,6 @@ it("builds a self link via reverse", async () => {
100
103
  });
101
104
 
102
105
  it("asserts a loader's set-cookie + redirect (runLoaderResult)", async () => {
103
- // AuthLoader validates, sets a `session` cookie, then `throw redirect("/")`.
104
106
  const { thrown, response, cookies } = await runLoaderResult(AuthLoader, {
105
107
  request: new Request("https://app.test/login?token=ok"),
106
108
  });
@@ -115,12 +117,30 @@ it("asserts a loader's set-cookie + redirect (runLoaderResult)", async () => {
115
117
  ## Caveats
116
118
 
117
119
  - `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.
120
+ - `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.
121
+ - 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
122
  - 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).
123
+ - 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
124
  - 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
125
  - `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
- - `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
+ - `ctx.theme`/`ctx.setTheme` are NOT on the loader context — they are handler/middleware-only. The `theme` option seeds the request context's theme config, so a loader body reads the visitor's theme with `getRequestContext().theme` (seed the cookie through `request`). That read is guarded like `cookies()` (#971): allowed in a loader body, but it throws inside a `"use cache"` function. `redirect()` does no basename prefixing unless you seed `basename`.
127
+ - 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)).
128
+ - The `cache` option runs the loader through the production loader-cache read-through, so it needs the react-server project for the same reason. A HIT returns the stored value without running the body and records the tags the body recorded when the entry was written, so `updateTag()` of one (inside `runInRequestContext(fn, { cacheStore })`) drops the entry. There is no in-flight join here: wait for the write before the next call, or that call misses too. A raw body is keyed by its function reference. `runLoaderResult(...).handlePushes` lists the body's pushes, so a HIT reports none (the replay into the page's handle store is not modeled).
129
+
130
+ ```ts
131
+ const load = () =>
132
+ runLoader(productLoader, {
133
+ params: { id: "1" },
134
+ cacheStore,
135
+ cache: { ttl: 300 },
136
+ });
137
+ await load(); // MISS: the body runs and calls cacheTag("product:1")
138
+ await vi.waitFor(() => expect(setItem).toHaveBeenCalledTimes(1));
139
+ await load(); // HIT: the body does not run
140
+ await runInRequestContext(() => updateTag("product:1"), { cacheStore });
141
+ await load(); // MISS again
142
+ ```
143
+
124
144
  - Platform bindings are yours to double via `env` (see `./bindings.md`).
125
145
 
126
146
  ## 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