@rangojs/router 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (245) hide show
  1. package/README.md +254 -89
  2. package/dist/testing/vitest.js +252 -0
  3. package/dist/types/browser/navigation-transaction.d.ts +6 -0
  4. package/dist/types/browser/partial-update.d.ts +11 -0
  5. package/dist/types/browser/react/ScrollRestoration.d.ts +6 -6
  6. package/dist/types/browser/rsc-router.d.ts +1 -1
  7. package/dist/types/browser/scroll-restoration.d.ts +7 -0
  8. package/dist/types/cache/cache-exec-scope.d.ts +18 -1
  9. package/dist/types/cache/cache-scope.d.ts +34 -5
  10. package/dist/types/cache/handle-capture.d.ts +41 -3
  11. package/dist/types/cache/handle-snapshot.d.ts +53 -13
  12. package/dist/types/cache/segment-codec.d.ts +10 -3
  13. package/dist/types/cache/taint.d.ts +1 -1
  14. package/dist/types/cache/types.d.ts +15 -3
  15. package/dist/types/client.d.ts +4 -4
  16. package/dist/types/client.rsc.d.ts +4 -5
  17. package/dist/types/components/DefaultDocument.d.ts +5 -2
  18. package/dist/types/context-var.d.ts +6 -5
  19. package/dist/types/escape-script.d.ts +1 -1
  20. package/dist/types/handles/MetaTags.d.ts +5 -4
  21. package/dist/types/handles/Scripts.d.ts +8 -7
  22. package/dist/types/handles/meta.d.ts +2 -2
  23. package/dist/types/handles/script.d.ts +17 -16
  24. package/dist/types/href-client.d.ts +1 -1
  25. package/dist/types/html.d.ts +12 -0
  26. package/dist/types/index.d.ts +1 -1
  27. package/dist/types/index.rsc.d.ts +3 -2
  28. package/dist/types/loader-store.d.ts +7 -4
  29. package/dist/types/reverse.d.ts +36 -4
  30. package/dist/types/route-definition/dsl-helpers.d.ts +3 -3
  31. package/dist/types/route-definition/helpers-types.d.ts +33 -9
  32. package/dist/types/route-definition/resolve-handler-use.d.ts +23 -1
  33. package/dist/types/route-types.d.ts +1 -1
  34. package/dist/types/router/debug-manifest.d.ts +3 -2
  35. package/dist/types/router/error-handling.d.ts +20 -0
  36. package/dist/types/router/handler-context.d.ts +6 -0
  37. package/dist/types/router/match-middleware/background-revalidation.d.ts +46 -5
  38. package/dist/types/router/middleware-types.d.ts +3 -2
  39. package/dist/types/router/router-context.d.ts +3 -1
  40. package/dist/types/router/router-interfaces.d.ts +24 -2
  41. package/dist/types/router/router-options.d.ts +8 -5
  42. package/dist/types/router/segment-resolution/fresh.d.ts +10 -0
  43. package/dist/types/router/segment-resolution/loader-cache.d.ts +37 -1
  44. package/dist/types/router/segment-resolution/loader-mask.d.ts +9 -19
  45. package/dist/types/router/segment-resolution/loader-snapshot.d.ts +3 -3
  46. package/dist/types/router/segment-resolution/mask-nested.d.ts +7 -7
  47. package/dist/types/router/segment-resolution/revalidation.d.ts +6 -2
  48. package/dist/types/router/segment-wrappers.d.ts +1 -1
  49. package/dist/types/rsc/response-route-handler.d.ts +1 -1
  50. package/dist/types/rsc/rsc-rendering.d.ts +1 -2
  51. package/dist/types/rsc/shell-capture-constants.d.ts +8 -0
  52. package/dist/types/rsc/shell-capture.d.ts +5 -4
  53. package/dist/types/rsc/ssr-setup.d.ts +6 -6
  54. package/dist/types/rsc/types.d.ts +10 -0
  55. package/dist/types/server/context.d.ts +33 -3
  56. package/dist/types/server/handle-store.d.ts +26 -2
  57. package/dist/types/server/request-context.d.ts +24 -21
  58. package/dist/types/ssr/index.d.ts +15 -0
  59. package/dist/types/static-handler.d.ts +4 -1
  60. package/dist/types/testing/generated-routes.d.ts +2 -2
  61. package/dist/types/testing/internal/context.d.ts +1 -1
  62. package/dist/types/testing/render-route.d.ts +32 -6
  63. package/dist/types/testing/vitest-stubs/plugin-rsc.d.ts +28 -5
  64. package/dist/types/testing/vitest.d.ts +35 -1
  65. package/dist/types/theme/ThemeScript.d.ts +3 -3
  66. package/dist/types/types/cache-types.d.ts +14 -10
  67. package/dist/types/types/handler-context.d.ts +14 -0
  68. package/dist/types/types/loader-types.d.ts +8 -6
  69. package/dist/types/urls/response-types.d.ts +3 -12
  70. package/dist/types/use-loader.d.ts +12 -3
  71. package/dist/types/vite/plugins/use-cache-transform.d.ts +33 -2
  72. package/dist/types/vite/rango.d.ts +1 -1
  73. package/dist/vite/index.js +1381 -1346
  74. package/package.json +2 -2
  75. package/skills/api-client/SKILL.md +22 -10
  76. package/skills/breadcrumbs/SKILL.md +65 -22
  77. package/skills/bundle-analysis/SKILL.md +22 -22
  78. package/skills/cache-guide/SKILL.md +207 -97
  79. package/skills/caching/SKILL.md +295 -93
  80. package/skills/catalog.json +16 -16
  81. package/skills/client-urls/SKILL.md +53 -31
  82. package/skills/cloudflare/SKILL.md +72 -20
  83. package/skills/cloudflare/references/d1-and-local-dev.md +15 -2
  84. package/skills/cloudflare/references/streaming-and-deploy.md +10 -3
  85. package/skills/cloudflare/references/webhooks-and-crypto.md +9 -2
  86. package/skills/comparison/SKILL.md +5 -0
  87. package/skills/comparison/references/framework-comparison.md +39 -33
  88. package/skills/composability/SKILL.md +38 -29
  89. package/skills/css/SKILL.md +10 -13
  90. package/skills/debug-manifest/SKILL.md +74 -31
  91. package/skills/defer-hydration/SKILL.md +2 -1
  92. package/skills/deployment-caching/SKILL.md +6 -1
  93. package/skills/document-cache/SKILL.md +92 -28
  94. package/skills/fonts/SKILL.md +16 -8
  95. package/skills/handler-use/SKILL.md +53 -17
  96. package/skills/hooks/SKILL.md +28 -20
  97. package/skills/hooks/data.md +23 -3
  98. package/skills/hooks/handle-and-actions.md +31 -16
  99. package/skills/hooks/navigation.md +39 -10
  100. package/skills/hooks/outlets.md +13 -6
  101. package/skills/hooks/state.md +37 -15
  102. package/skills/hooks/urls.md +7 -5
  103. package/skills/host-router/SKILL.md +64 -12
  104. package/skills/i18n/SKILL.md +60 -34
  105. package/skills/intercept/SKILL.md +161 -26
  106. package/skills/layout/SKILL.md +106 -50
  107. package/skills/links/SKILL.md +51 -19
  108. package/skills/loader/SKILL.md +242 -106
  109. package/skills/middleware/SKILL.md +119 -23
  110. package/skills/migrate-nextjs/SKILL.md +100 -45
  111. package/skills/migrate-nextjs/backend-host-swap.md +5 -2
  112. package/skills/migrate-react-router/SKILL.md +49 -38
  113. package/skills/migrate-react-router/component-migration.md +25 -6
  114. package/skills/migrate-react-router/data-and-actions.md +18 -5
  115. package/skills/migrate-react-router/route-mapping.md +12 -6
  116. package/skills/mime-routes/SKILL.md +12 -7
  117. package/skills/observability/SKILL.md +28 -14
  118. package/skills/parallel/SKILL.md +75 -39
  119. package/skills/ppr/SKILL.md +345 -150
  120. package/skills/prerender/SKILL.md +189 -100
  121. package/skills/rango/SKILL.md +65 -34
  122. package/skills/react-compiler/SKILL.md +6 -5
  123. package/skills/response-routes/SKILL.md +133 -25
  124. package/skills/route/SKILL.md +165 -60
  125. package/skills/router-setup/SKILL.md +418 -240
  126. package/skills/scripts/SKILL.md +31 -20
  127. package/skills/server-actions/SKILL.md +220 -59
  128. package/skills/shell-manifest/SKILL.md +12 -7
  129. package/skills/streams-and-websockets/SKILL.md +32 -14
  130. package/skills/tailwind/SKILL.md +37 -33
  131. package/skills/testing/SKILL.md +68 -32
  132. package/skills/testing/cache-prerender.md +55 -29
  133. package/skills/testing/client-components.md +59 -6
  134. package/skills/testing/e2e-parity.md +12 -2
  135. package/skills/testing/flight.md +19 -11
  136. package/skills/testing/handles.md +8 -5
  137. package/skills/testing/loader.md +8 -5
  138. package/skills/testing/middleware.md +8 -3
  139. package/skills/testing/predicates.md +120 -0
  140. package/skills/testing/render-handler.md +70 -22
  141. package/skills/testing/response-routes.md +11 -4
  142. package/skills/testing/reverse-and-types.md +20 -14
  143. package/skills/testing/server-actions.md +12 -3
  144. package/skills/testing/server-tree.md +9 -0
  145. package/skills/testing/setup.md +9 -5
  146. package/skills/theme/SKILL.md +106 -29
  147. package/skills/typesafety/SKILL.md +20 -9
  148. package/skills/typesafety/env-and-bindings.md +126 -47
  149. package/skills/typesafety/generated-files-and-cli.md +26 -14
  150. package/skills/typesafety/params-and-search.md +44 -19
  151. package/skills/typesafety/route-types.md +50 -14
  152. package/skills/use-cache/SKILL.md +200 -82
  153. package/skills/vercel/SKILL.md +21 -11
  154. package/skills/view-transitions/SKILL.md +41 -20
  155. package/src/__augment-tests__/augmented.check.ts +26 -1
  156. package/src/browser/index.ts +2 -2
  157. package/src/browser/navigation-bridge.ts +3 -4
  158. package/src/browser/navigation-transaction.ts +22 -2
  159. package/src/browser/partial-update.ts +35 -25
  160. package/src/browser/react/ScrollRestoration.tsx +6 -6
  161. package/src/browser/rsc-router.tsx +1 -1
  162. package/src/browser/scroll-restoration.ts +53 -6
  163. package/src/cache/cache-exec-scope.ts +37 -4
  164. package/src/cache/cache-key-utils.ts +1 -6
  165. package/src/cache/cache-runtime.ts +230 -61
  166. package/src/cache/cache-scope.ts +76 -12
  167. package/src/cache/document-cache.ts +15 -0
  168. package/src/cache/handle-capture.ts +90 -10
  169. package/src/cache/handle-snapshot.ts +132 -28
  170. package/src/cache/segment-codec.ts +26 -7
  171. package/src/cache/tag-invalidation.ts +4 -3
  172. package/src/cache/taint.ts +1 -1
  173. package/src/cache/types.ts +16 -3
  174. package/src/client.rsc.tsx +10 -9
  175. package/src/client.tsx +5 -4
  176. package/src/components/DefaultDocument.tsx +7 -2
  177. package/src/context-var.ts +6 -5
  178. package/src/escape-script.ts +1 -1
  179. package/src/handles/MetaTags.tsx +13 -11
  180. package/src/handles/Scripts.tsx +11 -10
  181. package/src/handles/meta.ts +2 -2
  182. package/src/handles/script.ts +17 -16
  183. package/src/href-client.ts +1 -1
  184. package/src/html.ts +12 -0
  185. package/src/index.rsc.ts +5 -0
  186. package/src/index.ts +1 -0
  187. package/src/loader-store.ts +7 -4
  188. package/src/prerender/build-shell-capture.ts +8 -11
  189. package/src/reverse.ts +44 -4
  190. package/src/route-definition/dsl-helpers.ts +117 -30
  191. package/src/route-definition/helpers-types.ts +33 -9
  192. package/src/route-definition/resolve-handler-use.ts +121 -6
  193. package/src/route-types.ts +0 -3
  194. package/src/router/debug-manifest.ts +10 -2
  195. package/src/router/error-handling.ts +51 -15
  196. package/src/router/handler-context.ts +34 -25
  197. package/src/router/intercept-resolution.ts +125 -39
  198. package/src/router/loader-resolution.ts +56 -11
  199. package/src/router/match-api.ts +12 -17
  200. package/src/router/match-middleware/background-revalidation.ts +161 -96
  201. package/src/router/match-middleware/cache-lookup.ts +93 -10
  202. package/src/router/match-middleware/cache-store.ts +17 -90
  203. package/src/router/middleware-types.ts +3 -5
  204. package/src/router/middleware.ts +52 -9
  205. package/src/router/prerender-match.ts +53 -19
  206. package/src/router/route-snapshot.ts +11 -1
  207. package/src/router/router-context.ts +2 -0
  208. package/src/router/router-interfaces.ts +25 -2
  209. package/src/router/router-options.ts +8 -5
  210. package/src/router/segment-resolution/fresh.ts +44 -12
  211. package/src/router/segment-resolution/loader-cache.ts +205 -45
  212. package/src/router/segment-resolution/loader-mask.ts +12 -22
  213. package/src/router/segment-resolution/loader-snapshot.ts +6 -6
  214. package/src/router/segment-resolution/mask-nested.ts +7 -7
  215. package/src/router/segment-resolution/revalidation.ts +34 -6
  216. package/src/router/segment-wrappers.ts +4 -0
  217. package/src/rsc/render-pipeline.ts +3 -0
  218. package/src/rsc/response-route-handler.ts +11 -3
  219. package/src/rsc/rsc-rendering.ts +8 -45
  220. package/src/rsc/shell-capture-constants.ts +13 -0
  221. package/src/rsc/shell-capture.ts +111 -90
  222. package/src/rsc/ssr-setup.ts +14 -8
  223. package/src/rsc/types.ts +11 -0
  224. package/src/server/context.ts +88 -11
  225. package/src/server/cookie-store.ts +5 -4
  226. package/src/server/handle-store.ts +232 -7
  227. package/src/server/request-context.ts +35 -30
  228. package/src/ssr/index.tsx +40 -7
  229. package/src/static-handler.ts +4 -1
  230. package/src/testing/flight-runtime.d.ts +21 -0
  231. package/src/testing/generated-routes.ts +2 -2
  232. package/src/testing/internal/context.ts +1 -1
  233. package/src/testing/internal/flight-client-globals.ts +7 -5
  234. package/src/testing/render-route.tsx +85 -24
  235. package/src/testing/run-loader.ts +5 -1
  236. package/src/testing/vitest-stubs/plugin-rsc.ts +123 -20
  237. package/src/testing/vitest.ts +62 -1
  238. package/src/theme/ThemeScript.tsx +3 -3
  239. package/src/types/cache-types.ts +14 -10
  240. package/src/types/handler-context.ts +14 -0
  241. package/src/types/loader-types.ts +8 -6
  242. package/src/urls/response-types.ts +3 -23
  243. package/src/use-loader.tsx +12 -3
  244. package/src/vite/plugins/use-cache-transform.ts +118 -74
  245. package/src/vite/rango.ts +7 -3
@@ -38,11 +38,12 @@ export const urlpatterns = urls(({ path }) => [
38
38
  "/events/ticks",
39
39
  (ctx) => {
40
40
  const encoder = new TextEncoder();
41
+ let interval: ReturnType<typeof setInterval> | undefined;
41
42
 
42
43
  const stream = new ReadableStream({
43
- async start(controller) {
44
+ start(controller) {
44
45
  let count = 0;
45
- const interval = setInterval(() => {
46
+ interval = setInterval(() => {
46
47
  controller.enqueue(
47
48
  encoder.encode(`event: tick\ndata: ${++count}\n\n`),
48
49
  );
@@ -51,9 +52,17 @@ export const urlpatterns = urls(({ path }) => [
51
52
  // Honor client disconnect — signal comes from ctx.request.signal
52
53
  ctx.request.signal.addEventListener("abort", () => {
53
54
  clearInterval(interval);
54
- controller.close();
55
+ try {
56
+ controller.close();
57
+ } catch {
58
+ // already closed or cancelled
59
+ }
55
60
  });
56
61
  },
62
+ // The runtime cancels the stream when the reader goes away
63
+ cancel() {
64
+ clearInterval(interval);
65
+ },
57
66
  });
58
67
 
59
68
  return new Response(stream, {
@@ -80,18 +89,21 @@ source.addEventListener("tick", (e) => console.log("tick", e.data));
80
89
 
81
90
  ### SSE caveats
82
91
 
83
- - **Never wrap SSE routes in `cache()`** — a cached `ReadableStream` is read
84
- once and would replay an empty body on the next hit. `path.stream` is
85
- already excluded from response-route caching, but don't layer a custom
86
- cache() middleware on top.
92
+ - **Never put an SSE route inside a `cache()` scope.** Response-route
93
+ `cache()` does not special-case `path.stream`: an SSE response is a `200`
94
+ like any other, so a `cache()` above it would try to store the stream. Keep
95
+ SSE routes outside every `cache()` boundary, and don't layer a custom
96
+ caching middleware on top either.
87
97
  - **Middleware is fine.** Global/route middleware rewraps the SSE `Response`
88
98
  as `new Response(response.body, { status, headers })` to merge stub headers.
89
99
  The `ReadableStream` body is passed by reference, not consumed, so the
90
100
  client sees the stream unchanged. (WebSocket upgrades are the exception —
91
101
  those bypass rewrap entirely; see below.)
92
- - **Honor `ctx.request.signal`.** Without wiring abort to your source
93
- (timer, DB cursor, upstream fetch), the stream leaks when the client
94
- disconnects.
102
+ - **Honor `ctx.request.signal` and `cancel()`.** Without wiring disconnect to
103
+ your source (timer, DB cursor, upstream fetch), the stream leaks when the
104
+ client goes away. Clean up in both places: depending on the runtime, a
105
+ disconnect surfaces as the request signal aborting, the stream being
106
+ cancelled, or both.
95
107
  - **Disable Nginx/CDN buffering** via `x-accel-buffering: no` and ensure
96
108
  no intermediate proxy rebuffers. On Cloudflare Workers this is a non-issue.
97
109
 
@@ -210,6 +222,8 @@ HTTP request instead (e.g. during login), then read them at upgrade time
210
222
  via `ctx.request.headers.get("cookie")`.
211
223
 
212
224
  ```typescript
225
+ import { cookies } from "@rangojs/router";
226
+
213
227
  // Avoid: this cookie may not land on the upgrade response, and the client
214
228
  // never reads it during the handshake regardless.
215
229
  router.use(async (ctx, next) => {
@@ -219,7 +233,7 @@ router.use(async (ctx, next) => {
219
233
 
220
234
  // Prefer: authenticate by reading a cookie set on a prior HTTP request.
221
235
  path.any("/ws", (ctx) => {
222
- const session = parseCookie(ctx.request.headers.get("cookie"))?.session;
236
+ const session = cookies().get("session")?.value;
223
237
  if (!verify(session)) return new Response("unauthorized", { status: 401 });
224
238
  // ...upgrade
225
239
  });
@@ -240,7 +254,8 @@ router.use(async (ctx, next) => {
240
254
 
241
255
  ## Caching
242
256
 
243
- - **SSE** — do not combine with `cache()` (streams can't be replayed).
257
+ - **SSE** — do not place under `cache()`: it is a cacheable `200`, and an
258
+ endless stream cannot be stored or replayed.
244
259
  - **WebSocket** — `cache()` is inert because only `status === 200` is cacheable.
245
260
 
246
261
  ## Runtime caveats
@@ -279,5 +294,8 @@ Object.defineProperty(upgrade, "webSocket", {
279
294
 
280
295
  ## See also
281
296
 
282
- - `response-routes` — the parent skill for `path.json/text/html/stream/any`.
283
- - `middleware` — how global and route-level middleware compose with handlers.
297
+ - `/response-routes` — the parent skill for `path.json/text/html/stream/any`
298
+ and `ResponseHandlerContext` (`ctx.header()`, `ctx.waitUntil()`,
299
+ `ctx.executionContext`).
300
+ - `/middleware` — how global and route-level middleware compose with handlers.
301
+ - `/cloudflare` — Workers bindings and Durable Object setup.
@@ -6,7 +6,9 @@ argument-hint: [setup]
6
6
 
7
7
  # Tailwind CSS
8
8
 
9
- Set up Tailwind CSS v4 with the Rango router. Styles are loaded through the Document component using Vite's `?url` CSS import.
9
+ Set up Tailwind CSS v4 in a Rango app: the Vite plugin, the CSS entry, loading
10
+ it from the Document with Vite's `?url` import, theme tokens, class-based dark
11
+ mode, and fonts. The general stylesheet pattern is explained in `/css`.
10
12
 
11
13
  ## Install
12
14
 
@@ -18,11 +20,14 @@ pnpm add -D tailwindcss @tailwindcss/vite
18
20
 
19
21
  ```typescript
20
22
  // vite.config.ts
23
+ import { defineConfig } from "vite";
21
24
  import tailwindcss from "@tailwindcss/vite";
25
+ import { rango } from "@rangojs/router/vite";
22
26
 
23
27
  export default defineConfig({
24
28
  plugins: [
25
29
  tailwindcss(),
30
+ rango(),
26
31
  // ... other plugins
27
32
  ],
28
33
  });
@@ -37,18 +42,17 @@ export default defineConfig({
37
42
 
38
43
  ## Document Component
39
44
 
40
- Import the CSS file with `?url` to get a hashed URL, then preload and link it in
41
- `<head>`. Give the `<link rel="stylesheet">` a `precedence` prop so React 19
42
- manages it as a resource — de-duped by `href`, ordered, and loaded before paint
43
- (no flash of unstyled content). See
44
- [Stylesheets and cross-app navigation](#stylesheets-and-cross-app-navigation):
45
+ Import the CSS file with `?url` to get its hashed URL, then preload and link it
46
+ in `<head>`. Give the `<link rel="stylesheet">` a `precedence` prop so React 19
47
+ manages it as a resource: de-duped by `href`, ordered, and loaded before paint
48
+ (no flash of unstyled content).
45
49
 
46
50
  ```tsx
47
51
  // src/document.tsx
48
52
  "use client";
49
53
 
50
54
  import type { ReactNode } from "react";
51
- import { MetaTags } from "@rangojs/router/client";
55
+ import { Html } from "@rangojs/router/client";
52
56
  import styles from "./index.css?url";
53
57
 
54
58
  export function Document({ children }: { children: ReactNode }) {
@@ -57,7 +61,7 @@ export function Document({ children }: { children: ReactNode }) {
57
61
  <head>
58
62
  <link rel="preload" href={styles} as="style" precedence="default" />
59
63
  <link rel="stylesheet" href={styles} precedence="default" />
60
- <MetaTags />
64
+ <Html.Meta />
61
65
  </head>
62
66
  <body className="font-sans antialiased text-slate-900 bg-slate-50">
63
67
  {children}
@@ -67,27 +71,15 @@ export function Document({ children }: { children: ReactNode }) {
67
71
  }
68
72
  ```
69
73
 
70
- ## Stylesheets and cross-app navigation
74
+ The `?url` suffix tells Vite to return the processed CSS file's URL instead of
75
+ injecting it as a side effect, giving a stable, hashed asset path in both
76
+ development and production. A bare `import "./index.css"` also produces managed
77
+ CSS, but it is not guaranteed to be in the initial streamed `<head>`; prefer
78
+ `?url` + `<link precedence>` for document CSS (see `/css`).
71
79
 
72
- The `precedence` prop opts a `<link rel="stylesheet">` into React 19's managed
73
- stylesheet model — React de-duplicates it by `href`, orders it by precedence, and
74
- loads it before paint (avoiding a flash of unstyled content). It is the
75
- recommended way to render a stylesheet link, which is why the example above uses
76
- it. (A bare side-effect `import "./index.css"` also produces managed CSS via
77
- `@vitejs/plugin-rsc`, but carries an SSR-streaming caveat — prefer the `?url` +
78
- `<link precedence>` form for document CSS. See `/css`.)
79
-
80
- For **host-router** apps (`/host-router`), a client-side navigation that crosses
81
- an app boundary is a **full document load**, not a soft swap — the framework
82
- redirects on an app switch. So each app's document (its stylesheets, theme, meta)
83
- is always re-established cleanly by the target app's own load; you do not have to
84
- coordinate stylesheet `href`s or `precedence` across apps. (This replaced an
85
- earlier soft cross-app swap, under which a stylesheet shared across apps — every
86
- app's `@import "tailwindcss"` compiles to one hashed asset — could be dropped by
87
- React's by-`href` resource dedup if the apps disagreed on `precedence`. The full
88
- reload removes that footgun entirely.)
89
-
90
- The `?url` suffix tells Vite to return the processed CSS file's URL instead of injecting it as a side effect. This gives you a stable, hashed asset path that works in both development and production.
80
+ For **host-router** apps (`/host-router`), navigating between apps is a full
81
+ document load, so each app's Tailwind stylesheet is loaded by its own Document;
82
+ there is nothing to coordinate across apps.
91
83
 
92
84
  ## Customizing the Theme
93
85
 
@@ -107,17 +99,29 @@ Tailwind v4 uses CSS `@theme` for customization:
107
99
 
108
100
  ## Dark Mode
109
101
 
110
- Combine with the Rango theme system (see `/theme`):
102
+ Combine with the Rango theme system (see `/theme`). With `attribute: "class"`
103
+ (the default) the theme script puts `class="dark"` or `class="light"` on
104
+ `<html>` before first paint; add `suppressHydrationWarning` to the Document's
105
+ `<html>` so React accepts that change:
111
106
 
112
107
  ```typescript
113
- const router = createRouter({
108
+ export const router = createRouter({
114
109
  document: Document,
115
110
  urls: urlpatterns,
116
111
  theme: { attribute: "class" },
117
112
  });
118
113
  ```
119
114
 
120
- Then use Tailwind's `dark:` variant which reads the `class` attribute:
115
+ Tailwind v4's `dark:` variant follows `prefers-color-scheme` by default. Point
116
+ it at the class instead, so the user's stored choice wins over the OS setting:
117
+
118
+ ```css
119
+ /* src/index.css */
120
+ @import "tailwindcss";
121
+ @custom-variant dark (&:where(.dark, .dark *));
122
+ ```
123
+
124
+ Then use the `dark:` variant as usual:
121
125
 
122
126
  ```tsx
123
127
  <div className="bg-white dark:bg-slate-900 text-slate-900 dark:text-white">
@@ -147,7 +151,7 @@ No extra `<link>` tags needed in the Document -- Vite bundles the font files fro
147
151
 
148
152
  ## Notes
149
153
 
150
- - `?url` import is required -- bare CSS imports inject styles as a side effect and do not work with SSR streaming
151
- - `<link rel="preload" as="style">` eliminates render-blocking by starting the download early
154
+ - Use the `?url` import for the document stylesheet: a bare side-effect import is not guaranteed to be in the initial streamed `<head>` (see `/css`)
155
+ - `<link rel="preload" as="style">` starts the stylesheet download early
152
156
  - Tailwind v4 does not need a `tailwind.config.js` -- use `@theme` in CSS instead
153
157
  - The `@tailwindcss/vite` plugin handles content detection automatically
@@ -1,18 +1,24 @@
1
1
  ---
2
2
  name: testing
3
- description: Test @rangojs/router apps — unit (loaders/middleware/reverse/components), integration (dispatch/Flight), and e2e (dev+prod parity, progressive enhancement). Use when writing a unit test for a loader or middleware, or asking how to test a route end-to-end in dev and production.
3
+ description: Test @rangojs/router apps — unit (loaders, middleware, server actions, handles, transition/clientUrls predicates), client components (renderRoute), RSC/Flight (renderServerTree, renderHandler), integration (dispatch), and e2e (dev+prod parity, progressive enhancement). Use when writing a unit test for a loader, middleware, action, or handler, setting up vitest for a Rango app, or testing a route end-to-end in dev and production.
4
4
  argument-hint: [layer]
5
5
  ---
6
6
 
7
7
  # Testing @rangojs/router apps
8
8
 
9
+ This skill picks the right test layer for a behavior in a Rango app and gives
10
+ the API, a recipe, and the caveats for each testing primitive. Use it when
11
+ adding or changing tests, or when wiring the vitest/Playwright setup.
12
+
9
13
  Rango ships six consumer-facing testing entries, one per test runtime/dependency:
10
14
  `@rangojs/router/testing` (unit + integration, under a Vite-driven Vitest
11
15
  project), `@rangojs/router/testing/vitest` (the `rangoTestConfig`/`rangoTestAliases`
12
16
  setup preset), `@rangojs/router/testing/dom` (`renderRoute`, needs RTL + a DOM
13
17
  env), `@rangojs/router/testing/e2e` (the Playwright harness),
14
18
  `@rangojs/router/testing/flight` (real Flight, react-server condition only), and
15
- `@rangojs/router/testing/flight-matchers` (the Flight matchers).
19
+ `@rangojs/router/testing/flight-matchers` (the Flight matchers). Host-router
20
+ pattern helpers live separately at `@rangojs/router/host/testing` (see
21
+ `/host-router`).
16
22
 
17
23
  The hard problem in an RSC app is that the layer you reach for is dictated by
18
24
  **what the behavior touches** — a pure predicate is a one-line vitest test; a real
@@ -42,8 +48,8 @@ Use this skill when adding or changing tests for a Rango app: a loader,
42
48
  middleware, a server action, a route map, a client component, a response route,
43
49
  cache/SWR behavior, prerender, or a navigation/PE flow.
44
50
 
45
- Two non-negotiable mandates (from the repo's `CLAUDE.md`, and they apply to
46
- consumer apps too):
51
+ Two non-negotiable mandates (the Rango repo holds itself to these, and they
52
+ apply to consumer apps too):
47
53
 
48
54
  - **Every e2e covers BOTH dev and production.** A dev-only e2e is not acceptable.
49
55
  Use `parityDescribe` — it generates the dev and production describes from one
@@ -54,8 +60,10 @@ consumer apps too):
54
60
 
55
61
  ## The read-first shape
56
62
 
57
- Four import roots, each matched to the dependency/runtime that can load it — this
58
- split is forced by hard walls, not preference:
63
+ Four of the six entries hold the test primitives, each matched to the
64
+ dependency/runtime that can load it — this split is forced by hard walls, not
65
+ preference. (The other two are helpers: `/testing/vitest` is config-only, and
66
+ `/testing/flight-matchers` holds `toMatchFlight`/`toMatchFlightSnapshot`.)
59
67
 
60
68
  - `@rangojs/router/testing` — unit + integration primitives. Run these under a
61
69
  **Vite-driven Vitest** project with the rango Vite plugin active (the router
@@ -81,26 +89,29 @@ The single rule that drives everything:
81
89
 
82
90
  Each primitive links to its sub-file (API + recipe + caveats).
83
91
 
84
- | The behavior is… | Layer | Primitive | Import root |
85
- | ---------------------------------------------------------------------------------------------------------------------------------- | ------------ | --------------------------------------------------------------------------------------------- | -------------------------------- |
86
- | a pure function / `reverse` / `href` / a predicate (`revalidate`, `isAction`) | unit + types | [`reverse`/`@ts-expect-error`](./reverse-and-types.md) | `@rangojs/router/testing` |
87
- | one loader's data logic | unit (node) | [`runLoader`](./loader.md) | `@rangojs/router/testing` |
88
- | a loader's cookie / header / thrown `redirect()`/`notFound()` / handle-push output | unit (node) | [`runLoaderResult`](./loader.md) | `@rangojs/router/testing` |
89
- | one middleware's ordering / short-circuit / cookie+header merge | unit (node) | [`runMiddleware`](./middleware.md) | `@rangojs/router/testing` |
90
- | a `"use server"` action's cookie / header / flash output (even on `throw redirect()`) | unit (node) | [`runInRequestContext`](./server-actions.md) | `@rangojs/router/testing` |
91
- | a `transition({ when })` gate (keep/drop) against nav source / target / action metadata | unit (node) | `runTransitionWhen` (`{ kept, whenContext }`; pass `{ ppr: true }` for pre-handler timing) | `@rangojs/router/testing` |
92
- | a `clientUrls()` `revalidate()` / `isAction(ref)` predicate | unit (node) | `runClientRevalidate` (production `makeIsAction` + locked defaults) | `@rangojs/router/testing` |
93
- | a handle's `collect`/accumulator, a seeded handle read, or a loader handle write | unit | [`collectHandle` / seeded `handles` / `handlePushes`](./handles.md) | `@rangojs/router/testing` |
94
- | a CLIENT component reading router context (`useParams`/`useReverse`/`Outlet`/`useNavigation`/`useLoader`) | unit (DOM) | [`renderRoute`](./client-components.md) | `@rangojs/router/testing/dom` |
95
- | a redirect / status / headers / cookies / **response route** (json/text/html/xml/md), no Flight | integration | [`dispatch`](./response-routes.md) | `@rangojs/router/testing` |
96
- | a real async **Server Component** (assert what it rendered: typed boundary props, server-rendered host content, inlined-vs-island) | RSC unit | [`renderServerTree` + `findClientBoundaries`/`findElements`](./server-tree.md) | `@rangojs/router/testing/flight` |
97
- | the exact Flight **wire payload** shape (a drift snapshot) | RSC unit | [`renderToFlightString` + `toMatchFlightSnapshot`](./flight.md) | `@rangojs/router/testing/flight` |
98
- | a real route **handler** `(ctx) => rsc` (params/loaders/vars -> rendered RSC + effects) | RSC unit | [`renderHandler`](./render-handler.md) | `@rangojs/router/testing/flight` |
99
- | navigation, hydration, PE parity, view transitions, real SSR | e2e | [`createRangoE2E` -> `parityDescribe`/`expectParity`](./e2e-parity.md) | `@rangojs/router/testing/e2e` |
100
- | cache hit/miss/stale, prerender (= a cache hit by design) | e2e + signal | [`assertCacheStatus` (header) / `assertCacheDecision` (telemetry sink)](./cache-prerender.md) | `@rangojs/router/testing[/e2e]` |
101
- | PPR shell HIT/MISS (`ppr` path option), shell store key / getShell after flush | e2e + signal | [`assertShellStatus` / `shellCacheKey` + store](./cache-prerender.md#ppr-shell-x-rango-shell) | `@rangojs/router/testing[/e2e]` |
102
- | generated route map drift vs runtime | unit (node) | [`assertGeneratedRoutesMatch`](./reverse-and-types.md) | `@rangojs/router/testing` |
103
- | a platform binding (`env.DB` / Durable Object / `env.R2`) | unit/integr. | [your own double via `env`](./bindings.md) | (any primitive's `env` option) |
92
+ | The behavior is… | Layer | Primitive | Import root |
93
+ | ---------------------------------------------------------------------------------------------------------------------------------- | ------------ | -------------------------------------------------------------------------------------------------------- | -------------------------------- |
94
+ | a `reverse` / `href` / params / env TYPE contract (a wrong name or missing param must not compile) | types (tsc) | [`@ts-expect-error` / `expectTypeOf`](./reverse-and-types.md) | `@rangojs/router/client` + `tsc` |
95
+ | one loader's data logic | unit (node) | [`runLoader`](./loader.md) | `@rangojs/router/testing` |
96
+ | a loader's cookie / header / thrown `redirect()`/`notFound()` / handle-push output | unit (node) | [`runLoaderResult`](./loader.md) | `@rangojs/router/testing` |
97
+ | one middleware's ordering / short-circuit / cookie+header merge | unit (node) | [`runMiddleware`](./middleware.md) | `@rangojs/router/testing` |
98
+ | a `"use server"` action's cookie / header / flash output (even on `throw redirect()`) | unit (node) | [`runInRequestContext`](./server-actions.md) | `@rangojs/router/testing` |
99
+ | a `transition({ when })` gate (keep/drop) against nav source / target / action metadata | unit (node) | [`runTransitionWhen`](./predicates.md) (`{ kept, whenContext }`; `{ ppr: true }` for pre-handler timing) | `@rangojs/router/testing` |
100
+ | a `clientUrls()` `revalidate()` / `isAction(ref)` predicate | unit (node) | [`runClientRevalidate`](./predicates.md) (production chain evaluator + locked defaults) | `@rangojs/router/testing` |
101
+ | a handle's `collect`/accumulator, a seeded handle read, or a loader handle write | unit | [`collectHandle` / seeded `handles` / `handlePushes`](./handles.md) | `@rangojs/router/testing` |
102
+ | a CLIENT component reading router context (`useParams`/`useReverse`/`Outlet`/`useNavigation`/`useLoader`) | unit (DOM) | [`renderRoute`](./client-components.md) | `@rangojs/router/testing/dom` |
103
+ | a stale indicator from `useLoader().isLoading` while a `transition()` navigation re-runs the loader | unit (DOM) | [`renderRoute` + `navigate(url, { loaders })`](./client-components.md#held-navigation) | `@rangojs/router/testing/dom` |
104
+ | a redirect / status / headers / cookies / **response route** (json/text/html/xml/md), no Flight | integration | [`dispatch`](./response-routes.md) | `@rangojs/router/testing` |
105
+ | a real async **Server Component** (assert what it rendered: typed boundary props, server-rendered host content, inlined-vs-island) | RSC unit | [`renderServerTree` + `findClientBoundaries`/`findElements`](./server-tree.md) | `@rangojs/router/testing/flight` |
106
+ | the exact Flight **wire payload** shape (a drift snapshot) | RSC unit | [`renderToFlightString` + `toMatchFlightSnapshot`](./flight.md) | `@rangojs/router/testing/flight` |
107
+ | a real route **handler** `(ctx) => rsc` (params/loaders/vars -> rendered RSC + effects) | RSC unit | [`renderHandler`](./render-handler.md) | `@rangojs/router/testing/flight` |
108
+ | navigation, hydration, PE parity, view transitions, real SSR | e2e | [`createRangoE2E` -> `parityDescribe`/`expectParity`](./e2e-parity.md) | `@rangojs/router/testing/e2e` |
109
+ | a `"use cache"` hit in a handler (the body runs once across renders; the value round-trips) | RSC unit | [`renderHandler` + seeded `cacheStore`](./render-handler.md#a-use-cache-hit) | `@rangojs/router/testing/flight` |
110
+ | a `"use cache"` hit in a loader (the same, in the react-server project) | RSC unit | [`runLoader` + seeded `cacheStore`](./loader.md) | `@rangojs/router/testing` |
111
+ | cache hit/miss/stale, prerender (= a cache hit by design) | e2e + signal | [`assertCacheStatus` (header) / `assertCacheDecision` (telemetry sink)](./cache-prerender.md) | `@rangojs/router/testing[/e2e]` |
112
+ | PPR shell HIT/MISS (`ppr` path option), shell store key / getShell after flush | e2e + signal | [`assertShellStatus` / `shellCacheKey` + store](./cache-prerender.md#ppr-shell-and-navigation-replay) | `@rangojs/router/testing[/e2e]` |
113
+ | generated route map drift vs runtime | unit (node) | [`assertGeneratedRoutesMatch`](./reverse-and-types.md) | `@rangojs/router/testing` |
114
+ | a platform binding (`env.DB` / Durable Object / `env.R2`) | unit/integr. | [your own double via `env`](./bindings.md) | (any primitive's `env` option) |
104
115
 
105
116
  Cross-references to the DSL skills: `/loader`, `/middleware`, `/server-actions`,
106
117
  `/handler-use`, `/hooks`, `/response-routes`, `/route`, `/caching`, `/prerender`,
@@ -111,22 +122,47 @@ Cross-references to the DSL skills: `/loader`, `/middleware`, `/server-actions`,
111
122
  - Cross-cutting: [`setup.md`](./setup.md), [`bindings.md`](./bindings.md)
112
123
  - Unit (node): [`loader.md`](./loader.md), [`middleware.md`](./middleware.md),
113
124
  [`server-actions.md`](./server-actions.md), [`handles.md`](./handles.md),
114
- [`reverse-and-types.md`](./reverse-and-types.md)
125
+ [`predicates.md`](./predicates.md), [`reverse-and-types.md`](./reverse-and-types.md)
115
126
  - Unit (DOM): [`client-components.md`](./client-components.md)
116
127
  - RSC unit: [`flight.md`](./flight.md), [`server-tree.md`](./server-tree.md),
117
128
  [`render-handler.md`](./render-handler.md)
118
129
  - Integration: [`response-routes.md`](./response-routes.md)
119
130
  - E2E: [`e2e-parity.md`](./e2e-parity.md), [`cache-prerender.md`](./cache-prerender.md)
120
131
 
121
- ## Pre-push checklist (mirror CLAUDE.md)
122
-
123
- Before pushing, run all of these and fix any failure:
132
+ ## Every export, by entry
133
+
134
+ Where each runtime export is documented (types such as `RunLoaderOptions` sit
135
+ beside their function):
136
+
137
+ | Entry | Exports | Documented in |
138
+ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------ |
139
+ | `@rangojs/router/testing` | `runLoader`, `runLoaderResult` | [`loader.md`](./loader.md) |
140
+ | | `runMiddleware` | [`middleware.md`](./middleware.md) |
141
+ | | `runInRequestContext`, `createTestRequestContext`, `runWithRequestContext`, `toRequest`, `seedVariables` | [`server-actions.md`](./server-actions.md) |
142
+ | | `runTransitionWhen`, `runClientRevalidate` | [`predicates.md`](./predicates.md) |
143
+ | | `collectHandle` | [`handles.md`](./handles.md) |
144
+ | | `dispatch` | [`response-routes.md`](./response-routes.md) |
145
+ | | `assertCacheStatus`, `assertCacheDecision`, `parseCacheHeader`, `createCacheSink`, `filterCacheDecisions` | [`cache-prerender.md`](./cache-prerender.md) |
146
+ | | `assertShellStatus`, `parseShellStatus`, `shellCacheKey`, `SHELL_STATUS_HEADER`, `assertPprReplayStatus`, `parsePprReplayStatus`, `PPR_REPLAY_STATUS_HEADER` | [`cache-prerender.md`](./cache-prerender.md) |
147
+ | | `assertGeneratedRoutesMatch`, `diffGeneratedRoutes` | [`reverse-and-types.md`](./reverse-and-types.md) |
148
+ | `@rangojs/router/testing/dom` | `renderRoute` | [`client-components.md`](./client-components.md) |
149
+ | `@rangojs/router/testing/flight` | `renderToFlightString`, `normalizeFlight`, `assertFlightRuntimeAvailable` | [`flight.md`](./flight.md) |
150
+ | | `renderServerTree`, `findClientBoundaries`, `findElements`, `textContent`, `assertFlightTreeRuntimeAvailable` | [`server-tree.md`](./server-tree.md) |
151
+ | | `renderHandler` | [`render-handler.md`](./render-handler.md) |
152
+ | `@rangojs/router/testing/flight-matchers` | `flightMatchers` (`toMatchFlight`, `toMatchFlightSnapshot`) | [`flight.md`](./flight.md) |
153
+ | `@rangojs/router/testing/vitest` | `rangoTestConfig`, `rangoTestAliases`, `rangoInlineDeps`, `rangoUseClientTransform`, `rangoUseCacheTransform` | [`setup.md`](./setup.md) |
154
+ | `@rangojs/router/testing/e2e` | `createRangoE2E` and its standalone helpers; the cache/shell status helpers above are re-exported here | [`e2e-parity.md`](./e2e-parity.md) |
155
+
156
+ ## Pre-push checklist
157
+
158
+ Before pushing, run all of these and fix any failure (script names follow
159
+ [`./setup.md`](./setup.md); adjust to your `package.json`):
124
160
 
125
161
  1. `pnpm run typecheck` (or `pnpm exec tsc --noEmit`)
126
162
  2. `pnpm run test:unit` (node + DOM vitest)
127
163
  3. `pnpm run test:unit:rsc` (the react-server Flight project)
128
- 4. `pnpm run lint`
129
- 5. `pnpm run format`
164
+ 4. your lint and format scripts
165
+ 5. your Playwright suite (dev AND production projects)
130
166
 
131
167
  And: **every e2e has a production counterpart.** `parityDescribe` makes this
132
168
  automatic — if you wrote a plain `test.describe` for a behavior, convert it.
@@ -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");
@@ -117,15 +121,15 @@ expect(decision.segments?.[0].shouldRevalidate).toBe(true);
117
121
 
118
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.
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?)` | 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 |
129
133
 
130
134
  ### What unit can prove vs e2e
131
135
 
@@ -141,19 +145,41 @@ expect(decision.segments?.[0].shouldRevalidate).toBe(true);
141
145
  ### Recipe (e2e)
142
146
 
143
147
  ```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();
148
+ import { expect, test } from "@playwright/test";
149
+ import {
150
+ createRangoE2E,
151
+ assertShellStatus,
152
+ parseShellStatus,
153
+ } from "@rangojs/router/testing/e2e";
154
+
155
+ const { parityDescribe } = createRangoE2E({ test, expect });
156
+
157
+ parityDescribe("product shell", (f) => {
158
+ test("MISS captures, then HIT", async ({ page }) => {
159
+ // Document GET (not Accept: text/x-component):
160
+ const first = await page.request.get(f.url("/products/1"));
161
+ assertShellStatus({ headers: new Headers(first.headers()) }, "MISS");
162
+ // Capture runs in the background (putShell), so poll for the HIT:
163
+ await expect
164
+ .poll(async () => {
165
+ const res = await page.request.get(f.url("/products/1"));
166
+ return parseShellStatus({ headers: new Headers(res.headers()) });
167
+ })
168
+ .toBe("HIT");
169
+ });
170
+ });
171
+ ```
172
+
173
+ ```ts
174
+ // Unit (Vitest): the key the serve path uses, against your own store instance
175
+ import { expect, it } from "vitest";
176
+ import { shellCacheKey } from "@rangojs/router/testing";
177
+
178
+ it("stores the shell under the production key", async () => {
179
+ // ...after your code under test captured a shell into `store`...
180
+ const key = shellCacheKey(new URL("http://localhost/products/1"));
181
+ expect(await store.getShell(key)).not.toBeNull();
182
+ });
157
183
  ```
158
184
 
159
185
  ## Caveats