@rangojs/router 0.17.0 → 0.19.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 (293) hide show
  1. package/README.md +3 -2
  2. package/dist/types/browser/cookie-name.d.ts +6 -0
  3. package/dist/types/browser/event-controller.d.ts +3 -3
  4. package/dist/types/browser/history-state.d.ts +18 -0
  5. package/dist/types/browser/navigation-transaction.d.ts +20 -2
  6. package/dist/types/browser/partial-update.d.ts +16 -1
  7. package/dist/types/browser/prefetch/cache.d.ts +6 -6
  8. package/dist/types/browser/react/Link.d.ts +19 -9
  9. package/dist/types/browser/react/context.d.ts +2 -2
  10. package/dist/types/browser/react/location-state-shared.d.ts +95 -56
  11. package/dist/types/browser/react/location-state.d.ts +1 -1
  12. package/dist/types/browser/transition-when.d.ts +80 -0
  13. package/dist/types/browser/types.d.ts +104 -9
  14. package/dist/types/build/generate-manifest.d.ts +8 -0
  15. package/dist/types/cache/background-task.d.ts +24 -2
  16. package/dist/types/cache/cache-exec-scope.d.ts +31 -0
  17. package/dist/types/cache/cache-key-utils.d.ts +48 -2
  18. package/dist/types/cache/cache-policy.d.ts +18 -1
  19. package/dist/types/cache/cache-runtime.d.ts +7 -0
  20. package/dist/types/cache/cache-scope.d.ts +192 -26
  21. package/dist/types/cache/cache-tag.d.ts +116 -11
  22. package/dist/types/cache/cf/cf-base64.d.ts +6 -0
  23. package/dist/types/cache/cf/cf-cache-constants.d.ts +11 -0
  24. package/dist/types/cache/cf/cf-cache-store.d.ts +224 -72
  25. package/dist/types/cache/cf/cf-cache-types.d.ts +31 -4
  26. package/dist/types/cache/cf/cf-shell-frame.d.ts +95 -0
  27. package/dist/types/cache/cf/cf-tag-marker-memo.d.ts +19 -0
  28. package/dist/types/cache/document-cache.d.ts +10 -0
  29. package/dist/types/cache/handle-snapshot.d.ts +41 -8
  30. package/dist/types/cache/index.d.ts +1 -0
  31. package/dist/types/cache/invalidation-order.d.ts +23 -0
  32. package/dist/types/cache/isolate-tag-memo.d.ts +149 -0
  33. package/dist/types/cache/request-tag-mask.d.ts +54 -0
  34. package/dist/types/cache/shell-memo.d.ts +178 -0
  35. package/dist/types/cache/shell-snapshot.d.ts +141 -105
  36. package/dist/types/cache/tag-invalidation.d.ts +54 -3
  37. package/dist/types/cache/types.d.ts +267 -96
  38. package/dist/types/cache/vercel/vercel-cache-store.d.ts +123 -18
  39. package/dist/types/client-urls/navigation.d.ts +28 -2
  40. package/dist/types/client-urls/server-projection.d.ts +5 -2
  41. package/dist/types/client-urls/types.d.ts +5 -5
  42. package/dist/types/client.d.ts +1 -1
  43. package/dist/types/index.d.ts +1 -1
  44. package/dist/types/index.rsc.d.ts +1 -1
  45. package/dist/types/prerender/build-shell-capture.d.ts +11 -4
  46. package/dist/types/route-definition/helpers-types.d.ts +11 -11
  47. package/dist/types/route-name.d.ts +7 -0
  48. package/dist/types/router/error-handling.d.ts +1 -1
  49. package/dist/types/router/instrument.d.ts +8 -0
  50. package/dist/types/router/intercept-resolution.d.ts +2 -0
  51. package/dist/types/router/match-context.d.ts +5 -0
  52. package/dist/types/router/match-middleware/background-revalidation.d.ts +0 -4
  53. package/dist/types/router/middleware-types.d.ts +6 -1
  54. package/dist/types/router/router-options.d.ts +4 -1
  55. package/dist/types/router/segment-resolution/loader-cache.d.ts +58 -12
  56. package/dist/types/router/segment-resolution/loader-mask.d.ts +3 -4
  57. package/dist/types/router/segment-resolution/loader-snapshot.d.ts +33 -48
  58. package/dist/types/router/segment-resolution/mask-nested.d.ts +73 -24
  59. package/dist/types/router/segment-resolution/view-transition-default.d.ts +7 -5
  60. package/dist/types/router/transition-when.d.ts +24 -10
  61. package/dist/types/rsc/attach-transition-when.d.ts +18 -0
  62. package/dist/types/rsc/full-payload.d.ts +21 -0
  63. package/dist/types/rsc/handler.d.ts +1 -1
  64. package/dist/types/rsc/response-cache-serve.d.ts +15 -12
  65. package/dist/types/rsc/shell-build-manifest.d.ts +12 -3
  66. package/dist/types/rsc/shell-capture-constants.d.ts +116 -12
  67. package/dist/types/rsc/shell-capture.d.ts +202 -74
  68. package/dist/types/rsc/shell-serve.d.ts +144 -50
  69. package/dist/types/rsc/types.d.ts +9 -0
  70. package/dist/types/segment-system.d.ts +7 -0
  71. package/dist/types/server/context.d.ts +155 -33
  72. package/dist/types/server/cookie-store.d.ts +33 -0
  73. package/dist/types/server/handle-store.d.ts +52 -1
  74. package/dist/types/server/request-context.d.ts +210 -63
  75. package/dist/types/server/request-headers.d.ts +44 -0
  76. package/dist/types/server.d.ts +1 -0
  77. package/dist/types/ssr/index.d.ts +9 -0
  78. package/dist/types/ssr/ssr-root.d.ts +9 -8
  79. package/dist/types/testing/e2e/index.d.ts +1 -1
  80. package/dist/types/testing/flight.entry.d.ts +7 -0
  81. package/dist/types/testing/index.d.ts +3 -2
  82. package/dist/types/testing/internal/action-id.d.ts +7 -0
  83. package/dist/types/testing/location-state-key.d.ts +24 -0
  84. package/dist/types/testing/render-route.d.ts +17 -1
  85. package/dist/types/testing/run-loader.d.ts +18 -3
  86. package/dist/types/testing/run-transition-when.d.ts +44 -69
  87. package/dist/types/testing/serve-shell-request.d.ts +133 -0
  88. package/dist/types/testing/shell-status.d.ts +44 -19
  89. package/dist/types/transition-when-ref.d.ts +79 -0
  90. package/dist/types/types/cache-types.d.ts +15 -3
  91. package/dist/types/types/handler-context.d.ts +14 -1
  92. package/dist/types/types/index.d.ts +1 -1
  93. package/dist/types/types/segments.d.ts +86 -71
  94. package/dist/types/urls/path-helper-types.d.ts +3 -4
  95. package/dist/types/urls/pattern-types.d.ts +21 -18
  96. package/dist/types/vite/plugins/transition-when-hoist.d.ts +45 -0
  97. package/dist/types/vite/router-discovery.d.ts +29 -2
  98. package/dist/vite/index.js +1005 -105
  99. package/package.json +1 -1
  100. package/skills/breadcrumbs/SKILL.md +17 -7
  101. package/skills/cache-guide/SKILL.md +175 -76
  102. package/skills/caching/SKILL.md +361 -33
  103. package/skills/catalog.json +1 -1
  104. package/skills/client-urls/SKILL.md +12 -7
  105. package/skills/cloudflare/SKILL.md +15 -0
  106. package/skills/comparison/references/framework-comparison.md +52 -37
  107. package/skills/defer-hydration/SKILL.md +50 -30
  108. package/skills/deployment-caching/SKILL.md +5 -2
  109. package/skills/document-cache/SKILL.md +32 -2
  110. package/skills/handler-use/SKILL.md +2 -0
  111. package/skills/hooks/SKILL.md +3 -1
  112. package/skills/hooks/handle-and-actions.md +3 -1
  113. package/skills/hooks/navigation.md +23 -2
  114. package/skills/hooks/state.md +101 -7
  115. package/skills/intercept/SKILL.md +29 -12
  116. package/skills/loader/SKILL.md +197 -44
  117. package/skills/middleware/SKILL.md +34 -9
  118. package/skills/migrate-nextjs/SKILL.md +68 -37
  119. package/skills/migrate-react-router/SKILL.md +2 -1
  120. package/skills/migrate-react-router/component-migration.md +3 -1
  121. package/skills/migrate-react-router/data-and-actions.md +3 -1
  122. package/skills/migrate-react-router/route-mapping.md +7 -4
  123. package/skills/observability/SKILL.md +65 -0
  124. package/skills/parallel/SKILL.md +13 -8
  125. package/skills/ppr/SKILL.md +715 -371
  126. package/skills/prerender/SKILL.md +30 -17
  127. package/skills/rango/SKILL.md +72 -34
  128. package/skills/route/SKILL.md +14 -7
  129. package/skills/router-setup/SKILL.md +4 -2
  130. package/skills/scripts/SKILL.md +8 -1
  131. package/skills/server-actions/SKILL.md +11 -0
  132. package/skills/shell-manifest/SKILL.md +21 -12
  133. package/skills/testing/SKILL.md +49 -43
  134. package/skills/testing/cache-prerender.md +15 -14
  135. package/skills/testing/client-components.md +45 -3
  136. package/skills/testing/loader.md +19 -1
  137. package/skills/testing/middleware.md +1 -0
  138. package/skills/testing/predicates.md +49 -49
  139. package/skills/testing/render-handler.md +1 -0
  140. package/skills/testing/serve-shell-request.md +202 -0
  141. package/skills/testing/server-actions.md +9 -3
  142. package/skills/theme/SKILL.md +56 -4
  143. package/skills/typesafety/env-and-bindings.md +18 -2
  144. package/skills/use-cache/SKILL.md +91 -23
  145. package/skills/vercel/SKILL.md +14 -2
  146. package/skills/view-transitions/SKILL.md +150 -29
  147. package/src/__augment-tests__/augmented.check.ts +23 -0
  148. package/src/browser/cookie-name.ts +23 -0
  149. package/src/browser/event-controller.ts +4 -4
  150. package/src/browser/history-state.ts +75 -1
  151. package/src/browser/link-interceptor.ts +4 -0
  152. package/src/browser/navigation-bridge.ts +77 -18
  153. package/src/browser/navigation-client.ts +4 -3
  154. package/src/browser/navigation-store.ts +43 -1
  155. package/src/browser/navigation-transaction.ts +31 -11
  156. package/src/browser/partial-update.ts +146 -35
  157. package/src/browser/prefetch/cache.ts +6 -6
  158. package/src/browser/prefetch/fetch.ts +2 -2
  159. package/src/browser/react/Link.tsx +38 -33
  160. package/src/browser/react/NavigationProvider.tsx +2 -2
  161. package/src/browser/react/context.ts +2 -2
  162. package/src/browser/react/location-state-shared.ts +241 -49
  163. package/src/browser/react/location-state.ts +5 -2
  164. package/src/browser/rsc-router.tsx +9 -0
  165. package/src/browser/server-action-bridge.ts +84 -15
  166. package/src/browser/transition-when.ts +205 -0
  167. package/src/browser/types.ts +124 -10
  168. package/src/build/generate-manifest.ts +77 -1
  169. package/src/cache/background-task.ts +55 -2
  170. package/src/cache/cache-exec-scope.ts +48 -1
  171. package/src/cache/cache-key-utils.ts +62 -2
  172. package/src/cache/cache-policy.ts +31 -4
  173. package/src/cache/cache-runtime.ts +209 -129
  174. package/src/cache/cache-scope.ts +573 -69
  175. package/src/cache/cache-tag.ts +475 -19
  176. package/src/cache/cf/cf-base64.ts +36 -4
  177. package/src/cache/cf/cf-cache-constants.ts +12 -0
  178. package/src/cache/cf/cf-cache-store.ts +1094 -376
  179. package/src/cache/cf/cf-cache-types.ts +32 -4
  180. package/src/cache/cf/cf-shell-frame.ts +258 -0
  181. package/src/cache/cf/cf-tag-marker-memo.ts +55 -40
  182. package/src/cache/document-cache.ts +53 -7
  183. package/src/cache/handle-snapshot.ts +53 -9
  184. package/src/cache/index.ts +2 -0
  185. package/src/cache/invalidation-order.ts +84 -0
  186. package/src/cache/isolate-tag-memo.ts +308 -0
  187. package/src/cache/request-tag-mask.ts +147 -0
  188. package/src/cache/shell-memo.ts +341 -0
  189. package/src/cache/shell-snapshot.ts +249 -277
  190. package/src/cache/tag-invalidation.ts +109 -18
  191. package/src/cache/types.ts +274 -102
  192. package/src/cache/vercel/vercel-cache-store.ts +561 -74
  193. package/src/client-urls/client-root.tsx +8 -4
  194. package/src/client-urls/client-urls.ts +8 -6
  195. package/src/client-urls/navigation.ts +55 -4
  196. package/src/client-urls/server-projection.ts +31 -10
  197. package/src/client-urls/types.ts +12 -5
  198. package/src/client.tsx +2 -0
  199. package/src/host/cookie-handler.ts +2 -1
  200. package/src/index.rsc.ts +3 -0
  201. package/src/index.ts +3 -0
  202. package/src/loader-redirect.tsx +7 -3
  203. package/src/prerender/build-shell-capture.ts +173 -110
  204. package/src/route-definition/dsl-helpers.ts +6 -3
  205. package/src/route-definition/helpers-types.ts +11 -11
  206. package/src/route-name.ts +12 -0
  207. package/src/router/content-negotiation.ts +4 -1
  208. package/src/router/error-handling.ts +5 -2
  209. package/src/router/handler-context.ts +42 -5
  210. package/src/router/instrument.ts +54 -2
  211. package/src/router/intercept-resolution.ts +30 -8
  212. package/src/router/loader-resolution.ts +89 -33
  213. package/src/router/logging.ts +5 -3
  214. package/src/router/match-api.ts +89 -42
  215. package/src/router/match-context.ts +5 -0
  216. package/src/router/match-handlers.ts +14 -29
  217. package/src/router/match-middleware/background-revalidation.ts +3 -5
  218. package/src/router/match-middleware/cache-lookup.ts +52 -19
  219. package/src/router/match-middleware/cache-store.ts +36 -10
  220. package/src/router/match-result.ts +1 -0
  221. package/src/router/middleware-types.ts +6 -1
  222. package/src/router/middleware.ts +20 -4
  223. package/src/router/navigation-snapshot.ts +6 -5
  224. package/src/router/prerender-match.ts +10 -0
  225. package/src/router/request-classification.ts +7 -4
  226. package/src/router/router-options.ts +4 -1
  227. package/src/router/segment-resolution/fresh.ts +18 -13
  228. package/src/router/segment-resolution/helpers.ts +4 -2
  229. package/src/router/segment-resolution/loader-cache.ts +366 -70
  230. package/src/router/segment-resolution/loader-mask.ts +3 -4
  231. package/src/router/segment-resolution/loader-snapshot.ts +215 -126
  232. package/src/router/segment-resolution/mask-nested.ts +161 -42
  233. package/src/router/segment-resolution/revalidation.ts +27 -13
  234. package/src/router/segment-resolution/view-transition-default.ts +6 -21
  235. package/src/router/telemetry.ts +6 -3
  236. package/src/router/transition-when.ts +34 -52
  237. package/src/router.ts +2 -1
  238. package/src/rsc/attach-transition-when.ts +47 -0
  239. package/src/rsc/full-payload.ts +47 -3
  240. package/src/rsc/handler.ts +24 -10
  241. package/src/rsc/loader-fetch.ts +2 -1
  242. package/src/rsc/manifest-init.ts +4 -0
  243. package/src/rsc/origin-guard.ts +8 -5
  244. package/src/rsc/progressive-enhancement.ts +10 -32
  245. package/src/rsc/response-cache-serve.ts +69 -78
  246. package/src/rsc/response-route-handler.ts +2 -2
  247. package/src/rsc/rsc-rendering.ts +712 -338
  248. package/src/rsc/server-action.ts +8 -20
  249. package/src/rsc/shell-build-manifest.ts +49 -29
  250. package/src/rsc/shell-capture-constants.ts +182 -12
  251. package/src/rsc/shell-capture.ts +1126 -612
  252. package/src/rsc/shell-serve.ts +331 -85
  253. package/src/rsc/ssr-setup.ts +3 -2
  254. package/src/rsc/types.ts +9 -0
  255. package/src/segment-system.tsx +40 -7
  256. package/src/server/context.ts +328 -72
  257. package/src/server/cookie-store.ts +184 -133
  258. package/src/server/handle-store.ts +245 -27
  259. package/src/server/request-context.ts +364 -100
  260. package/src/server/request-headers.ts +134 -0
  261. package/src/server.ts +4 -0
  262. package/src/ssr/index.tsx +96 -37
  263. package/src/ssr/ssr-root.tsx +9 -8
  264. package/src/testing/dispatch.ts +4 -7
  265. package/src/testing/e2e/index.ts +1 -0
  266. package/src/testing/flight.entry.ts +14 -0
  267. package/src/testing/index.ts +5 -0
  268. package/src/testing/internal/action-id.ts +25 -0
  269. package/src/testing/internal/context.ts +1 -1
  270. package/src/testing/location-state-key.ts +45 -0
  271. package/src/testing/render-handler.ts +1 -1
  272. package/src/testing/render-route.tsx +109 -16
  273. package/src/testing/run-client-revalidate.ts +3 -19
  274. package/src/testing/run-loader.ts +67 -5
  275. package/src/testing/run-transition-when.ts +109 -172
  276. package/src/testing/serve-shell-request.ts +381 -0
  277. package/src/testing/shell-status.ts +73 -47
  278. package/src/theme/ThemeProvider.tsx +7 -6
  279. package/src/transition-when-ref.ts +220 -0
  280. package/src/types/cache-types.ts +15 -3
  281. package/src/types/handler-context.ts +14 -1
  282. package/src/types/index.ts +3 -0
  283. package/src/types/segments.ts +94 -91
  284. package/src/urls/path-helper-types.ts +3 -4
  285. package/src/urls/pattern-types.ts +21 -18
  286. package/src/vite/discovery/discover-routers.ts +3 -0
  287. package/src/vite/discovery/shell-prerender-phase.ts +9 -2
  288. package/src/vite/discovery/virtual-module-codegen.ts +16 -6
  289. package/src/vite/plugins/transition-when-hoist.ts +1064 -0
  290. package/src/vite/rango.ts +2 -0
  291. package/src/vite/router-discovery.ts +89 -9
  292. package/dist/types/rsc/transition-gate.d.ts +0 -23
  293. package/src/rsc/transition-gate.ts +0 -86
@@ -119,17 +119,17 @@ expect(decision.segments?.[0].shouldRevalidate).toBe(true);
119
119
 
120
120
  ## PPR shell and navigation replay
121
121
 
122
- **DSL:** `ppr: true | PartialPrerenderProps` on a page route (see `/ppr`). **Not** the same header as `X-Rango-Cache` — shell is a second render axis.
123
-
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?)` | same | Production shell store key (`host+pathname+sorted search+:shell`) 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. |
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 |
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.
123
+
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 })` partitions its record, pass that function's result for the request as `partition`: the shell is partitioned the same way, the result namespaced as production stores it (`key:` plus its URI encoding). Under nested keyed `cache()` boundaries, pass their `key()` results as an array, outermost first. For a store `keyGenerator` that partitions it, pass `{ keys, generated }` (`ShellCachePartition`): `generated` lists the `keyGenerator` results one per store, outermost first: leave them out when every one returns the default key, else keep each position and pass `""` for one that returns it. |
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 |
133
133
 
134
134
  ### What unit can prove vs e2e
135
135
 
@@ -137,10 +137,11 @@ expect(decision.segments?.[0].shouldRevalidate).toBe(true);
137
137
  | -------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
138
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` |
139
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) |
140
141
  | **E2E** | live MISS → capture → HIT | document GET, poll until `x-rango-shell: HIT` (background capture) — `e2e/shell-cache.test.ts` |
141
142
  | **E2E** | partial replay decision | soft navigation to a warmed shell and `assertPprReplayStatus(response, { outcome: "HIT", freshness: "fresh" })` |
142
143
 
143
- `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.
144
145
 
145
146
  ### Recipe (e2e)
146
147
 
@@ -185,7 +186,7 @@ it("stores the shell under the production key", async () => {
185
186
  ## Caveats
186
187
 
187
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.
188
- - `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`.
189
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.)
190
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`).
191
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.
@@ -194,5 +195,5 @@ it("stores the shell under the production key", async () => {
194
195
  ## See also
195
196
 
196
197
  - `/caching`, `/prerender`, `/use-cache`, `/ppr` — the DSL this tests
197
- - 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)
198
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"
@@ -15,7 +15,7 @@
15
15
  | `outletPending` | `boolean` | Seed `useOutlet().pending` through each synthetic segment's production `OutletProvider`. Defaults to `false`; this is context seeding, not a simulated navigation/Suspense/action lifecycle. |
16
16
  | `loaders` | `ReadonlyArray<readonly [LoaderDefinition<any>, unknown]>` | Seed by REFERENCE: `[loader, data]` pairs. Robust for real `createLoader()` handles whose `$$id` is empty in a bare test. Prefer over `loaderData`. |
17
17
  | `params` | `Record<string, string>` | Explicit params, merged over (and overriding) params extracted from the `request` URL. |
18
- | `locationState` | `ReadonlyArray<readonly [LocationStateDefinition<any, any>, unknown]>` | Seed `useLocationState(def)` by REFERENCE: `[def, value]` pairs; written to `history.state`. |
18
+ | `locationState` | `ReadonlyArray<readonly [LocationStateDefinition<any, any>, unknown]>` | Seed `useLocationState(def)` by REFERENCE: `[def, value]` pairs; keys an unkeyed `def` (like `withLocationStateKey(def)`) and writes to `history.state`. See [Location state](#location-state). |
19
19
  | `handles` | `ReadonlyArray<readonly [Handle<any, any>, unknown[]]>` | Seed `useHandle(handle)` by REFERENCE: `[handle, pushedValues[]]`. Accumulated GLOBALLY (not segment-scoped). |
20
20
  | `handle` | `HandleDataSeed` | Advanced: raw wire format `{ [handleId]: { [segmentId]: pushedValues[] } }`. Prefer `handles`. Merged with it. |
21
21
  | `routeMap` | `Record<string, string>` | Name -> pattern map (informational; client `useReverse` takes its map as an argument, so this is not consumed). |
@@ -41,7 +41,7 @@
41
41
  | `useSearchParams` | Search params from the `request` URL. |
42
42
  | `useNonce` | SEEDED CSP nonce (`options.nonce`), else `undefined` (the browser default). |
43
43
  | `useLoader` / `useFetchLoader` | SEEDED loader data (read path, not run path). Held-navigation `isLoading` is modeled (below). |
44
- | `useLocationState` | SEEDED `history.state` value. |
44
+ | `useLocationState` | SEEDED `history.state` value, or the `state` a push/replace/`<Link>` wrote (below). |
45
45
  | `useHandle` | SEEDED handle output (globally accumulated). |
46
46
  | `Outlet` | Renders the next segment in the chain (layout nesting). |
47
47
  | `useOutlet` | Next-segment `content` plus SEEDED `options.outletPending`. |
@@ -54,13 +54,19 @@ Extends RTL's `RenderResult` (`getByTestId`, `getByText`, `getByRole`, `containe
54
54
  ```ts
55
55
  type RenderRouteResult = RenderResult & {
56
56
  router: {
57
- // client-only nav, re-resolves the same routes; `loaders` seeds THIS nav
57
+ // client-only nav, re-resolves the same routes; `loaders` seeds THIS nav.
58
+ // A spec's transition({ when }) decides it as kind "push";
59
+ // `transition: false` commits urgently and calls no predicate.
58
60
  navigate(
59
61
  url: string,
60
62
  options?: {
61
63
  loaders?: ReadonlyArray<readonly [LoaderDefinition<any>, unknown]>;
64
+ transition?: boolean;
62
65
  },
63
66
  ): Promise<void>;
67
+ // re-render the current location, as router.refresh() does:
68
+ // a spec's when gets kind "revalidate" (`to` is `from`)
69
+ refresh(): Promise<void>;
64
70
  pathname(): string;
65
71
  params(): Record<string, string>;
66
72
  store: NavigationStore; // advanced
@@ -114,6 +120,41 @@ it("resolves params + reverse + Outlet through the layout chain", async () => {
114
120
  });
115
121
  ```
116
122
 
123
+ ## Location state
124
+
125
+ A `createLocationState()` definition gets its key from the rango Vite plugin, which a bare Vitest project does not run. Outside production an unkeyed definition throws on first use (`Def(value)`, `useLocationState(Def)`, `.read()`), with a message that points here. Key it with `withLocationStateKey` from `@rangojs/router/testing`:
126
+
127
+ - `withLocationStateKey(GridState, "GridState")` sets `__rsc_ls_GridState` (stable across runs).
128
+ - `withLocationStateKey(GridState)` keeps a key that is already set, else assigns a synthetic `__rsc_ls_test_<n>` that stays the same for that definition.
129
+ - The `locationState` seed option keys an unkeyed definition the same way, so a seeded definition needs no call. Key every definition a component reads WITHOUT a seed.
130
+
131
+ A `useRouter().push/replace(url, { state })` or `<Link state>` navigation writes its history entry through production's path (`resolveNavigationState` -> `buildHistoryState` -> `pushHistoryWithIdx`, then the `__rsc_locationstate` event), so `useLocationState(Def)` re-reads after the click. The dev check for a bare entry or an uncalled definition runs too, and, as in production, a push or `<Link>` without `state` starts an entry with no location state. The URL in `window.location` does not change (renderRoute tracks location on its event controller), and `router.navigate()` from the test leaves `history.state` alone. The click starts an async navigation, so wait for the result with RTL's `waitFor`:
132
+
133
+ ```tsx
134
+ // @vitest-environment happy-dom
135
+ import { afterEach, expect, it } from "vitest";
136
+ import { cleanup, fireEvent, waitFor } from "@testing-library/react";
137
+ import { withLocationStateKey } from "@rangojs/router/testing";
138
+ import { renderRoute } from "@rangojs/router/testing/dom";
139
+ import { LoadMore } from "../src/components/LoadMore"; // router.replace(url, { state: [GridState(...)] })
140
+ import { GridState } from "../src/location-states";
141
+
142
+ afterEach(cleanup);
143
+ withLocationStateKey(GridState, "GridState");
144
+
145
+ it("shows the count it pushed", async () => {
146
+ const { getByTestId } = await renderRoute(
147
+ [{ path: "/grid", Component: LoadMore }],
148
+ { request: "/grid" },
149
+ );
150
+ fireEvent.click(getByTestId("load-more"));
151
+ await waitFor(() => expect(getByTestId("count").textContent).toBe("40"));
152
+ expect(window.history.state).toMatchObject({
153
+ __rsc_ls_GridState: { count: 40 },
154
+ });
155
+ });
156
+ ```
157
+
117
158
  ## Held navigation
118
159
 
119
160
  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.
@@ -154,6 +195,7 @@ it("dims the held price while the next product streams", async () => {
154
195
  | `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
196
  | `transition` navigate(), settled seed (a plain value) | `false`; the data swaps in the commit. |
156
197
  | 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. |
198
+ | `transition: { when }` returning false, or `navigate(url, { transition: false })` | Same as without `transition`: urgent, with production's decision (`kind: "push"`; `refresh()` is `"revalidate"`). `transition: false` calls no predicate. |
157
199
  | 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
200
  | `useNavigation().state`, `useLinkStatus().pending`, `useAction().state` | Stay `idle` — navigate() never starts the navigation lifecycle. e2e. |
159
201
 
@@ -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`.) |
@@ -120,10 +121,27 @@ it("asserts a loader's set-cookie + redirect (runLoaderResult)", async () => {
120
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.
121
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.
122
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).
124
+ - Location state needs a key: a `createLocationState()` definition gets it from the Vite plugin, which a test project does not run. In the node project (`NODE_ENV` is not `production`) an unkeyed `Flash(value)` throws, so a loader's `throw redirect(url, { state: [Flash(value)] })` surfaces on `runLoaderResult`'s `thrown` as that missing-key error, not the redirect; in the react-server project (`NODE_ENV=production`) the value lands on `locationState["undefined"]`. Call `withLocationStateKey(Flash, "Flash")` from `@rangojs/router/testing` once per definition and assert `locationState` as `{ __rsc_ls_Flash: value }` (the helper adds the `__rsc_ls_` prefix).
123
125
  - 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.)
124
126
  - `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.)
125
- - `ctx.theme`/`ctx.setTheme` are NOT on the loader context — theme accessors are handler-only. (The `theme` option seeds the underlying request context for `use cache` theme resolution, but a loader body cannot read theme.) `redirect()` does no basename prefixing unless you seed `basename`.
127
+ - `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. `ctx.request.headers` is guarded the same way. `redirect()` does no basename prefixing unless you seed `basename`.
126
128
  - 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)).
129
+ - 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. With no `key()`, a fill whose body read request identity (`cookies()`, `headers()`, `ctx.request.headers`, a `{ cache: false }` variable) fails with the identity error and stores nothing; add a `key()` that includes the value. `runLoaderResult(...).handlePushes` lists the body's pushes, so a HIT reports none (the replay into the page's handle store is not modeled).
130
+
131
+ ```ts
132
+ const load = () =>
133
+ runLoader(productLoader, {
134
+ params: { id: "1" },
135
+ cacheStore,
136
+ cache: { ttl: 300 },
137
+ });
138
+ await load(); // MISS: the body runs and calls cacheTag("product:1")
139
+ await vi.waitFor(() => expect(setItem).toHaveBeenCalledTimes(1));
140
+ await load(); // HIT: the body does not run
141
+ await runInRequestContext(() => updateTag("product:1"), { cacheStore });
142
+ await load(); // MISS again
143
+ ```
144
+
127
145
  - Platform bindings are yours to double via `env` (see `./bindings.md`).
128
146
 
129
147
  ## See also
@@ -95,6 +95,7 @@ Pass an array to run several in order. Cookies set inside middleware via the sta
95
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.
96
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).
97
97
  - `ctx.theme` is `undefined` unless `theme` is passed; `redirect()` does no basename prefixing unless `basename` is seeded.
98
+ - Location state needs a key: a `createLocationState()` definition gets it from the Vite plugin, which a test project does not run, and outside production an unkeyed `Flash(value)` throws inside the middleware (before `redirect()` or `ctx.setLocationState()` runs). Call `withLocationStateKey(Flash, "Flash")` from `@rangojs/router/testing` once per definition and assert `locationState` as `{ __rsc_ls_Flash: value }` (the helper adds the `__rsc_ls_` prefix).
98
99
  - Platform bindings are yours to double via `env` (see `./bindings.md`).
99
100
 
100
101
  ## See also
@@ -2,66 +2,66 @@
2
2
 
3
3
  **Layer:** unit (node) · **Import:** `@rangojs/router/testing` · **DSL it tests:** `transition({ when })` (see `/view-transitions`) and `clientUrls()` `revalidate()` (see `/client-urls`)
4
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. |
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 navigation hold?" and "would this `clientUrls()` loader re-run?". Both predicates run in the browser at runtime.
6
+
7
+ ## runTransitionWhen(when | config, opts?)
8
+
9
+ Builds the `TransitionWhenContext` the browser builds and evaluates the predicate through the router's own browser code (`browser/transition-when.ts`): a throw counts as `false` and is logged with `console.error`, exactly as at navigation time. Accepts the predicate or a whole `transition()` config (a config without `when` always applies).
10
+
11
+ ### Options — `RunTransitionWhenOptions`
12
+
13
+ Every field is optional. A location (`from` / `to`) is a URL string, a `URL`, or a partial `RouteLocation`: `{ url?, params?, routeName?, state? }`. A `state` given as location-state entries (`[Def(value)]`, what `Link` and `router.push` take) is stored the way a push stores it, so `Def.read(ctx.to)` reads it back; any other value is used as the raw `history.state`.
14
+
15
+ | Field | Type | Meaning |
16
+ | -------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
17
+ | `kind` | `"push" \| "replace" \| "pop" \| "action" \| "revalidate"` | Defaults to `"action"` when `action` is given, `"push"` otherwise. |
18
+ | `from` | location | The committed location being left. Defaults to `http://localhost/`. |
19
+ | `to` | location | The destination. Defaults to `from`; always `from` for `"action"` and `"revalidate"`. |
20
+ | `action` | imported action \| `string` \| `{ ref, formData, result, error }` | The triggering action (`isAction()` matches it; `action.id` is its id). Like `runClientRevalidate`, an imported action outside a built app needs its id passed as the string. |
21
+
22
+ ### Returns — `RunTransitionWhenResult`
23
+
24
+ | Field | Type | Meaning |
25
+ | ---------- | ----------------------- | --------------------------------------------------------------------------------------------------------------------- |
26
+ | `applied` | `boolean` | True when the navigation holds: the predicate returned anything but `false` and did not throw, or there is no `when`. |
27
+ | `gatedOff` | `boolean` | `!applied`: the navigation commits urgently with no view transition. |
28
+ | `context` | `TransitionWhenContext` | The context the predicate received. |
40
29
 
41
30
  ### Recipe
42
31
 
43
32
  ```ts
44
33
  import { it, expect } from "vitest";
45
- import { runTransitionWhen } from "@rangojs/router/testing";
46
- import type { TransitionConfig } from "@rangojs/router";
34
+ import {
35
+ runTransitionWhen,
36
+ withLocationStateKey,
37
+ } from "@rangojs/router/testing";
38
+ import { slideWhen } from "../src/transitions.js"; // a "use client" module
39
+ import { Slide } from "../src/location-states.js";
47
40
 
48
- const slide: TransitionConfig = {
49
- when: (c) => c.fromRouteName === "products",
50
- };
41
+ withLocationStateKey(Slide, "Slide");
51
42
 
52
- it("animates only when coming from the product list", () => {
43
+ it("animates unless the Link pushed { animate: false }", () => {
53
44
  expect(
54
- runTransitionWhen(slide, {
55
- request: "/products/1",
56
- fromRouteName: "products",
57
- }).kept,
45
+ runTransitionWhen(slideWhen, {
46
+ from: { url: "/photos/1", params: { id: "1" }, routeName: "photo" },
47
+ to: { url: "/photos/2", params: { id: "2" }, routeName: "photo" },
48
+ }).applied,
58
49
  ).toBe(true);
59
- expect(runTransitionWhen(slide, { request: "/products/1" }).dropped).toBe(
60
- true,
61
- );
50
+
51
+ const { gatedOff, context } = runTransitionWhen(slideWhen, {
52
+ from: "/photos/1",
53
+ to: { url: "/photos/2", state: [Slide({ animate: false })] },
54
+ });
55
+ expect(Slide.read(context.to)).toEqual({ animate: false });
56
+ expect(gatedOff).toBe(true);
62
57
  });
63
58
  ```
64
59
 
60
+ ### Known limits (Vitest)
61
+
62
+ - A Vitest project runs neither the Vite plugin's hoist nor its validation. An inline `when` in a `urls()` file stays a plain function there, and a `"use client"` import is the plain function outside the RSC graph, so a server function is not told apart and is accepted. The loud error for an invalid `when` belongs to dev startup, the build and HMR.
63
+ - `renderRoute` decides each commit with production's decision code: `router.navigate()` (and `useRouter().push/replace`, `<Link>`) as `kind: "push"` / `"replace"`, `router.refresh()` as `kind: "revalidate"`, and `navigate(url, { transition: false })` without calling the predicate. Pop, action and `clientUrls()` optimistic-swap decisions are e2e territory.
64
+
65
65
  ## runClientRevalidate(fn | fn[], opts?)
66
66
 
67
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).
@@ -111,7 +111,7 @@ it("revalidates only when the tab changes", () => {
111
111
  ## Caveats
112
112
 
113
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`).
114
+ - `runTransitionWhen` proves the decision only. Whether the browser actually runs a view transition is e2e territory (see `./e2e-parity.md`).
115
115
  - Both are synchronous; a predicate returning a Promise is a bug in the predicate, not something to await here.
116
116
 
117
117
  ## See also
@@ -159,6 +159,7 @@ Without the plugin, wrap a function yourself with the call the transform emits,
159
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`).
160
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`).
161
161
  - A `throw redirect()` is captured on `thrown` (with `tree` undefined, since it produced a `Response`) — assert on `thrown`/`response`, no try/catch needed.
162
+ - Location state needs a key: a `createLocationState()` definition gets it from the Vite plugin, which a test project does not run. This react-server project runs with `NODE_ENV=production`, so an unkeyed `ctx.setLocationState(Flash(value))` does not throw; the value lands on `locationState["undefined"]`. Call `withLocationStateKey(Flash, "Flash")` from `@rangojs/router/testing` once per definition and assert `{ __rsc_ls_Flash: value }` (the helper adds the `__rsc_ls_` prefix; `{ [Flash.__rsc_ls_key]: value }` works for any key).
162
163
  - No hydration and no interaction — for clicks, forms, and navigation use e2e.
163
164
  - `renderHandler` runs a handler FUNCTION `(ctx) => rsc`; for a plain ELEMENT `<Page/>` use `renderServerTree` (see [`./server-tree.md`](./server-tree.md)).
164
165
  - 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.
@@ -0,0 +1,202 @@
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
+ // "localhost/pricing:shell|key%3Atier%253Agold": the key() result is
139
+ // namespaced as production stores it (issue #975).
140
+ expect(await gold.readEntry()).not.toBeNull();
141
+ ```
142
+
143
+ 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:
144
+
145
+ ```ts
146
+ // cache({ key: tierKey }, () => [
147
+ // layout(TierLayout, () => [
148
+ // cache({ key: (ctx) => `v:${variantOf(ctx)}` }, () => [path("/plans", PlansPage, { ppr: true })]),
149
+ // ]),
150
+ // ])
151
+ expect(result.key).toBe(
152
+ shellCacheKey("/plans", undefined, ["tier:gold", "v:a"]),
153
+ );
154
+ ```
155
+
156
+ A store `keyGenerator` result that partitions the record partitions the shell too, an enclosing `cache({ store })` on another store included (issue #974). Pass those results as `generated`, with any `key()` results as `keys`:
157
+
158
+ ```ts
159
+ // createRouter({ cache: { store: localeStore } }), where localeStore's
160
+ // keyGenerator returns `${defaultKey}|${locale}`
161
+ expect(result.key).toBe(
162
+ shellCacheKey("/pricing", undefined, {
163
+ generated: ["doc:localhost/pricing|de"],
164
+ }),
165
+ );
166
+ ```
167
+
168
+ 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.
169
+
170
+ ## Handles and loader data
171
+
172
+ 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.
173
+
174
+ ```ts
175
+ // The handler pushed ctx.use(Meta)({ title: "Widget - Shop" })
176
+ expect(hit.prelude).toContain('"title":"Widget - Shop"');
177
+ expect(hit.flight).toContain('"title":"Widget - Shop"');
178
+ ```
179
+
180
+ ## Caveats
181
+
182
+ What the HTML stub cannot reproduce, so keep it in e2e:
183
+
184
+ - 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.
185
+ - 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.
186
+ - 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.
187
+ - Fizz resume of the holes, bootstrap scripts, nonce injection, and browser resume.
188
+
189
+ Other caveats:
190
+
191
+ - 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.
192
+ - 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.
193
+ - `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.
194
+ - `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.
195
+ - GET only. Server actions are not served; test their effects with `runInRequestContext`.
196
+ - Same setup as every Flight test: the react-server condition and `rangoTestAliases()` (see [`./setup.md`](./setup.md)).
197
+
198
+ ## See also
199
+
200
+ - `/ppr` — the DSL this tests; `/caching` — stores and tags
201
+ - Siblings: [`./cache-prerender.md`](./cache-prerender.md) (the shell-status helpers and the e2e recipe), [`./render-handler.md`](./render-handler.md)
202
+ - 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"