@rangojs/router 0.15.1 → 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 (249) 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 +52 -0
  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 +36 -10
  32. package/dist/types/route-definition/resolve-handler-use.d.ts +23 -1
  33. package/dist/types/route-types.d.ts +3 -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/thenable-status.d.ts +9 -0
  67. package/dist/types/types/cache-types.d.ts +14 -10
  68. package/dist/types/types/handler-context.d.ts +14 -0
  69. package/dist/types/types/loader-types.d.ts +8 -6
  70. package/dist/types/urls/response-types.d.ts +3 -12
  71. package/dist/types/use-loader.d.ts +12 -3
  72. package/dist/types/vite/plugins/use-cache-transform.d.ts +33 -2
  73. package/dist/types/vite/rango.d.ts +1 -1
  74. package/dist/vite/index.js +884 -768
  75. package/package.json +4 -4
  76. package/skills/api-client/SKILL.md +22 -10
  77. package/skills/breadcrumbs/SKILL.md +65 -22
  78. package/skills/bundle-analysis/SKILL.md +22 -22
  79. package/skills/cache-guide/SKILL.md +207 -97
  80. package/skills/caching/SKILL.md +295 -93
  81. package/skills/catalog.json +16 -16
  82. package/skills/client-urls/SKILL.md +53 -31
  83. package/skills/cloudflare/SKILL.md +72 -20
  84. package/skills/cloudflare/references/d1-and-local-dev.md +15 -2
  85. package/skills/cloudflare/references/streaming-and-deploy.md +10 -3
  86. package/skills/cloudflare/references/webhooks-and-crypto.md +9 -2
  87. package/skills/comparison/SKILL.md +5 -0
  88. package/skills/comparison/references/framework-comparison.md +39 -33
  89. package/skills/composability/SKILL.md +38 -29
  90. package/skills/css/SKILL.md +10 -13
  91. package/skills/debug-manifest/SKILL.md +74 -31
  92. package/skills/defer-hydration/SKILL.md +2 -1
  93. package/skills/deployment-caching/SKILL.md +6 -1
  94. package/skills/document-cache/SKILL.md +92 -28
  95. package/skills/fonts/SKILL.md +16 -8
  96. package/skills/handler-use/SKILL.md +53 -17
  97. package/skills/hooks/SKILL.md +28 -20
  98. package/skills/hooks/data.md +43 -1
  99. package/skills/hooks/handle-and-actions.md +31 -16
  100. package/skills/hooks/navigation.md +39 -10
  101. package/skills/hooks/outlets.md +13 -6
  102. package/skills/hooks/state.md +37 -15
  103. package/skills/hooks/urls.md +7 -5
  104. package/skills/host-router/SKILL.md +64 -12
  105. package/skills/i18n/SKILL.md +60 -34
  106. package/skills/intercept/SKILL.md +161 -26
  107. package/skills/layout/SKILL.md +106 -50
  108. package/skills/links/SKILL.md +51 -19
  109. package/skills/loader/SKILL.md +242 -106
  110. package/skills/middleware/SKILL.md +119 -23
  111. package/skills/migrate-nextjs/SKILL.md +100 -45
  112. package/skills/migrate-nextjs/backend-host-swap.md +5 -2
  113. package/skills/migrate-react-router/SKILL.md +49 -38
  114. package/skills/migrate-react-router/component-migration.md +25 -6
  115. package/skills/migrate-react-router/data-and-actions.md +18 -5
  116. package/skills/migrate-react-router/route-mapping.md +12 -6
  117. package/skills/mime-routes/SKILL.md +12 -7
  118. package/skills/observability/SKILL.md +28 -14
  119. package/skills/parallel/SKILL.md +75 -39
  120. package/skills/ppr/SKILL.md +345 -150
  121. package/skills/prerender/SKILL.md +189 -100
  122. package/skills/rango/SKILL.md +65 -34
  123. package/skills/react-compiler/SKILL.md +6 -5
  124. package/skills/response-routes/SKILL.md +133 -25
  125. package/skills/route/SKILL.md +165 -60
  126. package/skills/router-setup/SKILL.md +418 -240
  127. package/skills/scripts/SKILL.md +31 -20
  128. package/skills/server-actions/SKILL.md +220 -59
  129. package/skills/shell-manifest/SKILL.md +12 -7
  130. package/skills/streams-and-websockets/SKILL.md +32 -14
  131. package/skills/tailwind/SKILL.md +37 -33
  132. package/skills/testing/SKILL.md +68 -32
  133. package/skills/testing/cache-prerender.md +55 -29
  134. package/skills/testing/client-components.md +59 -6
  135. package/skills/testing/e2e-parity.md +12 -2
  136. package/skills/testing/flight.md +19 -11
  137. package/skills/testing/handles.md +8 -5
  138. package/skills/testing/loader.md +8 -5
  139. package/skills/testing/middleware.md +8 -3
  140. package/skills/testing/predicates.md +120 -0
  141. package/skills/testing/render-handler.md +70 -22
  142. package/skills/testing/response-routes.md +11 -4
  143. package/skills/testing/reverse-and-types.md +20 -14
  144. package/skills/testing/server-actions.md +12 -3
  145. package/skills/testing/server-tree.md +9 -0
  146. package/skills/testing/setup.md +9 -5
  147. package/skills/theme/SKILL.md +106 -29
  148. package/skills/typesafety/SKILL.md +20 -9
  149. package/skills/typesafety/env-and-bindings.md +126 -47
  150. package/skills/typesafety/generated-files-and-cli.md +26 -14
  151. package/skills/typesafety/params-and-search.md +44 -19
  152. package/skills/typesafety/route-types.md +50 -14
  153. package/skills/use-cache/SKILL.md +200 -82
  154. package/skills/vercel/SKILL.md +21 -11
  155. package/skills/view-transitions/SKILL.md +41 -20
  156. package/src/__augment-tests__/augmented.check.ts +26 -1
  157. package/src/browser/index.ts +2 -2
  158. package/src/browser/navigation-bridge.ts +3 -4
  159. package/src/browser/navigation-transaction.ts +22 -2
  160. package/src/browser/partial-update.ts +75 -80
  161. package/src/browser/react/ScrollRestoration.tsx +6 -6
  162. package/src/browser/rsc-router.tsx +1 -1
  163. package/src/browser/scroll-restoration.ts +53 -6
  164. package/src/cache/cache-exec-scope.ts +37 -4
  165. package/src/cache/cache-key-utils.ts +1 -6
  166. package/src/cache/cache-runtime.ts +230 -61
  167. package/src/cache/cache-scope.ts +76 -12
  168. package/src/cache/document-cache.ts +15 -0
  169. package/src/cache/handle-capture.ts +90 -10
  170. package/src/cache/handle-snapshot.ts +132 -28
  171. package/src/cache/segment-codec.ts +26 -7
  172. package/src/cache/tag-invalidation.ts +4 -3
  173. package/src/cache/taint.ts +1 -1
  174. package/src/cache/types.ts +16 -3
  175. package/src/client.rsc.tsx +10 -9
  176. package/src/client.tsx +5 -4
  177. package/src/components/DefaultDocument.tsx +7 -2
  178. package/src/context-var.ts +6 -5
  179. package/src/escape-script.ts +1 -1
  180. package/src/handles/MetaTags.tsx +13 -11
  181. package/src/handles/Scripts.tsx +11 -10
  182. package/src/handles/meta.ts +2 -2
  183. package/src/handles/script.ts +17 -16
  184. package/src/href-client.ts +1 -1
  185. package/src/html.ts +12 -0
  186. package/src/index.rsc.ts +5 -0
  187. package/src/index.ts +1 -0
  188. package/src/loader-store.ts +106 -0
  189. package/src/prerender/build-shell-capture.ts +8 -11
  190. package/src/reverse.ts +44 -4
  191. package/src/route-definition/dsl-helpers.ts +134 -42
  192. package/src/route-definition/helpers-types.ts +36 -10
  193. package/src/route-definition/resolve-handler-use.ts +121 -6
  194. package/src/route-types.ts +2 -3
  195. package/src/router/debug-manifest.ts +10 -2
  196. package/src/router/error-handling.ts +51 -15
  197. package/src/router/handler-context.ts +34 -25
  198. package/src/router/intercept-resolution.ts +125 -39
  199. package/src/router/loader-resolution.ts +56 -11
  200. package/src/router/match-api.ts +12 -17
  201. package/src/router/match-middleware/background-revalidation.ts +161 -96
  202. package/src/router/match-middleware/cache-lookup.ts +93 -10
  203. package/src/router/match-middleware/cache-store.ts +17 -90
  204. package/src/router/middleware-types.ts +3 -5
  205. package/src/router/middleware.ts +52 -9
  206. package/src/router/prerender-match.ts +53 -19
  207. package/src/router/route-snapshot.ts +11 -1
  208. package/src/router/router-context.ts +2 -0
  209. package/src/router/router-interfaces.ts +25 -2
  210. package/src/router/router-options.ts +8 -5
  211. package/src/router/segment-resolution/fresh.ts +44 -12
  212. package/src/router/segment-resolution/loader-cache.ts +205 -45
  213. package/src/router/segment-resolution/loader-mask.ts +12 -22
  214. package/src/router/segment-resolution/loader-snapshot.ts +6 -6
  215. package/src/router/segment-resolution/mask-nested.ts +7 -7
  216. package/src/router/segment-resolution/revalidation.ts +34 -6
  217. package/src/router/segment-wrappers.ts +4 -0
  218. package/src/rsc/render-pipeline.ts +3 -0
  219. package/src/rsc/response-route-handler.ts +11 -3
  220. package/src/rsc/rsc-rendering.ts +8 -45
  221. package/src/rsc/shell-capture-constants.ts +13 -0
  222. package/src/rsc/shell-capture.ts +111 -90
  223. package/src/rsc/ssr-setup.ts +14 -8
  224. package/src/rsc/types.ts +11 -0
  225. package/src/server/context.ts +88 -11
  226. package/src/server/cookie-store.ts +5 -4
  227. package/src/server/handle-store.ts +232 -7
  228. package/src/server/request-context.ts +35 -30
  229. package/src/ssr/index.tsx +40 -7
  230. package/src/ssr-suspension-warning.ts +2 -13
  231. package/src/static-handler.ts +4 -1
  232. package/src/testing/flight-runtime.d.ts +21 -0
  233. package/src/testing/generated-routes.ts +2 -2
  234. package/src/testing/internal/context.ts +1 -1
  235. package/src/testing/internal/flight-client-globals.ts +7 -5
  236. package/src/testing/render-route.tsx +85 -24
  237. package/src/testing/run-loader.ts +5 -1
  238. package/src/testing/vitest-stubs/plugin-rsc.ts +123 -20
  239. package/src/testing/vitest.ts +62 -1
  240. package/src/theme/ThemeProvider.tsx +14 -12
  241. package/src/theme/ThemeScript.tsx +3 -3
  242. package/src/thenable-status.ts +16 -0
  243. package/src/types/cache-types.ts +14 -10
  244. package/src/types/handler-context.ts +14 -0
  245. package/src/types/loader-types.ts +8 -6
  246. package/src/urls/response-types.ts +3 -23
  247. package/src/use-loader.tsx +24 -4
  248. package/src/vite/plugins/use-cache-transform.ts +118 -74
  249. package/src/vite/rango.ts +7 -3
@@ -13,7 +13,9 @@ A Rango route handler is a pure function `(ctx) => rsc` — the function you pas
13
13
  | `params` | `Record<string, string>` | Route params surfaced as `ctx.params`. |
14
14
  | `env` | `TEnv` | Environment bindings surfaced as `ctx.env`. |
15
15
  | `request` | `Request \| string` | Backing Request (string or `Request`); defaults to a localhost GET. |
16
- | `headers` | `HeadersInit` | Request headers (e.g. `Cookie`) the handler reads via `cookies()`. |
16
+ | `headers` | `HeadersInit` | Request headers (e.g. `Cookie`) the handler reads via `cookies()`. Used only when `request` is a string or omitted; a passed `Request` keeps its own headers. |
17
+ | `build` | `boolean` | Seed `ctx.build` (default `false`) to test a handler that branches on the build-time pass, including calling `ctx.dynamic()` on a miss. Assert the opt-out via `result.dynamic`. |
18
+ | `theme` | `ThemeConfig \| true` | Theme config (`createRouter({ theme })` shape). Without it `ctx.theme`/`ctx.setTheme` are inert; pass one to test a handler that reads the theme or writes the theme cookie. |
17
19
  | `vars` | `VarsInit` (object or `[[Var, value]]` tuples) | Variables a prior middleware set, read via `ctx.get(...)`. |
18
20
  | `routeName` | `string` | Matched route name (drives `ctx.routeName` and scoped reverse). |
19
21
  | `routeMap` | `Record<string, string>` | Route name -> pattern map enabling `ctx.reverse()`. |
@@ -26,21 +28,21 @@ A Rango route handler is a pure function `(ctx) => rsc` — the function you pas
26
28
 
27
29
  ### Context — `HandlerContext` (what your handler receives)
28
30
 
29
- | Field | Type | Meaning |
30
- | ------------------ | ------------------------------------ | ---------------------------------------------------------------------------------------------- |
31
- | `params` | `Record<string, string>` | The seeded route params. |
32
- | `env` | `TEnv` | The seeded environment bindings. |
33
- | `request` | `Request` | The backing request. |
34
- | `searchParams` | `URLSearchParams` | Parsed query of `request.url`. |
35
- | `pathname` | `string` | Pathname of `request.url`. |
36
- | `url` | `URL` | Parsed `request.url`. |
37
- | `routeName` | `string \| undefined` | The matched route name (from `routeName`). |
38
- | `use` | `(loaderOrHandle) => data \| pushFn` | A loader returns its seeded data; a handle returns a push fn that RECORDS to `result.handles`. |
39
- | `reverse` | `(name, params?) => string` | Build a URL from `routeMap`. |
40
- | `get` | `(Var) => value` | Read a seeded `vars` variable. |
41
- | `headers` | `Headers` | Response headers; set via `ctx.headers.set(...)` (merged into `result.response`). |
42
- | `setLocationState` | `(entries) => void` | Set location state (surfaced on `result.locationState`). |
43
- | `waitUntil` | `(fn: () => Promise<void>) => void` | Register background work. |
31
+ | Field | Type | Meaning |
32
+ | ------------------ | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
33
+ | `params` | `Record<string, string>` | The seeded route params. |
34
+ | `env` | `TEnv` | The seeded environment bindings. |
35
+ | `request` | `Request` | The backing request. |
36
+ | `searchParams` | `URLSearchParams` | Parsed query of `request.url`. |
37
+ | `pathname` | `string` | Pathname of `request.url`. |
38
+ | `url` | `URL` | Parsed `request.url`. |
39
+ | `routeName` | `string \| undefined` | The matched route name (from `routeName`). |
40
+ | `use` | `(loaderOrHandle) => Promise<data> \| pushFn` | A loader returns a Promise of its seeded data (like production); a handle returns a push fn that RECORDS to `result.handles`. |
41
+ | `reverse` | `(name, params?) => string` | Build a URL from `routeMap`. |
42
+ | `get` | `(Var) => value` | Read a seeded `vars` variable. |
43
+ | `headers` | `Headers` | Response headers; set via `ctx.headers.set(...)` (merged into `result.response`). |
44
+ | `setLocationState` | `(entries) => void` | Set location state (surfaced on `result.locationState`). |
45
+ | `waitUntil` | `(fn: () => Promise<void>) => void` | Register background work. |
44
46
 
45
47
  ### Returns — `RenderHandlerResult`
46
48
 
@@ -55,18 +57,22 @@ A Rango route handler is a pure function `(ctx) => rsc` — the function you pas
55
57
  | `stateCookieName` | `string` | The resolved rango state cookie name this run seeded (default `rango-state_router_0`). Assert an `invalidateClientCache()` rotation against it without recomputing. |
56
58
  | `locationState` | `Record<string, unknown>` | Location state the handler set (`ctx.setLocationState`/`redirect({ state })`). |
57
59
  | `handles` | `Map<Handle, unknown[]>` | What the handler pushed via `ctx.use(Handle)(...)` (e.g. `Meta`, `Breadcrumbs`), keyed by handle. |
60
+ | `dynamic` | `boolean` | Whether the handler called `ctx.dynamic()` (the PPR shell opt-out). |
58
61
 
59
62
  ## Recipe
60
63
 
61
64
  ```tsx
65
+ import { it, expect } from "vitest";
62
66
  import {
63
67
  renderHandler,
64
68
  findClientBoundaries,
69
+ textContent,
65
70
  } from "@rangojs/router/testing/flight";
71
+ import { Meta } from "@rangojs/router";
66
72
  import { ProductPage } from "../src/pages/product"; // the real handler: (ctx) => rsc
73
+ import { LogoutPage, QuietPage } from "../src/pages/account"; // call invalidateClientCache() / keepClientCache()
67
74
  import { ProductLoader } from "../src/loaders/product";
68
- import { Tenant } from "../src/middleware/tenant";
69
- import { Meta } from "../src/handles";
75
+ import { Tenant } from "../src/middleware/tenant"; // a createVar()
70
76
 
71
77
  it("renders the product page for a tenant", async () => {
72
78
  const { tree, handles } = await renderHandler(ProductPage, {
@@ -76,8 +82,9 @@ it("renders the product page for a tenant", async () => {
76
82
  routeMap: { product: "/p/:slug" }, // enables ctx.reverse
77
83
  });
78
84
 
79
- expect(JSON.stringify(tree)).toContain("Wine");
80
- const [counter] = findClientBoundaries(tree, "Counter"); // islands inspectable too
85
+ expect(textContent(tree)).toContain("Wine");
86
+ const [addToCart] = findClientBoundaries(tree, "AddToCart"); // islands inspectable too
87
+ expect(addToCart.props.slug).toBe("wine");
81
88
  expect(handles.get(Meta)).toEqual([{ title: "Wine - Shop" }]); // ctx.use(Meta) pushes
82
89
  });
83
90
 
@@ -106,14 +113,55 @@ it("asserts the client-cache directives", async () => {
106
113
  });
107
114
  ```
108
115
 
116
+ ## A `"use cache"` hit
117
+
118
+ With `cacheStore` seeded, a `"use cache"` function the handler calls runs the real cache runtime on real Flight. The first call runs the body and writes the entry in a background (`waitUntil`) task that `renderHandler` does not await. A call made before that write lands joins the still-running execution; a call made after it reads the store. Either way the body runs once, and the value comes back through a Flight round trip, so a `Date`, a `Map`, a client island or a server action keeps its type and reference. A value Flight encodes as an error row, such as a rejected promise, is not stored. The stale (SWR) path is not covered here; assert it at e2e.
119
+
120
+ The rango plugin's `"use cache"` transform runs only in its `rsc` environment (dev or build), and Vitest transforms in `ssr`, so add `rangoUseCacheTransform()` to the react-server project's `plugins` (see [`./setup.md`](./setup.md)). It runs the same transform, with the ids `vite dev` emits (`src/data/products.ts#...`). Without it the directive is an inert string and the function runs on every call.
121
+
122
+ ```tsx
123
+ // src/data/products.ts
124
+ export async function getProduct(slug: string) {
125
+ "use cache: short";
126
+ return db.products.find(slug);
127
+ }
128
+ ```
129
+
130
+ ```tsx
131
+ import { it, expect, vi } from "vitest";
132
+ import { renderHandler } from "@rangojs/router/testing/flight";
133
+ import { MemorySegmentCacheStore } from "@rangojs/router/cache";
134
+ import { ProductPage } from "../src/pages/product"; // calls getProduct(ctx.params.slug)
135
+ import { db } from "../src/db";
136
+
137
+ it("reads the second render's product from the store", async () => {
138
+ const find = vi.spyOn(db.products, "find");
139
+ const cacheStore = new MemorySegmentCacheStore();
140
+ const setItem = vi.spyOn(cacheStore, "setItem");
141
+ const options = {
142
+ params: { slug: "wine" },
143
+ cacheStore,
144
+ cacheProfiles: { short: { ttl: 60 } },
145
+ };
146
+
147
+ await renderHandler(ProductPage, options);
148
+ await vi.waitFor(() => expect(setItem).toHaveBeenCalled()); // the background write
149
+ await renderHandler(ProductPage, options);
150
+
151
+ expect(find).toHaveBeenCalledTimes(1);
152
+ });
153
+ ```
154
+
155
+ Without the plugin, wrap a function yourself with the call the transform emits, `registerCachedFunction(fn, id, profile)` from `@rangojs/router/cache-runtime`.
156
+
109
157
  ## Caveats
110
158
 
111
159
  - An unseeded `ctx.use(loader)` REJECTS with a setup error — seed every dependency via `{ loaders: [[OtherLoader, data]] }`, matched by reference. Loaders are SEEDED, not executed (same as `runLoader`).
112
- - Same alias requirement as flight tests: without the `@rangojs/router -> index.rsc.ts` alias (see [`./setup.md`](./setup.md)), a handler reading `getRequestContext()`/`cookies()` hits the throwing out-of-react-server stub. Symptom: `tree: undefined` with the stub error on `thrown`.
160
+ - Same alias requirement as flight tests: without the `@rangojs/router -> index.rsc.ts` alias (see [`./setup.md`](./setup.md)), a handler reading `getRequestContext()`/`cookies()` hits the throwing out-of-react-server stub. `renderHandler` detects this and REJECTS with a setup error naming `rangoTestAliases` (it is not captured on `thrown`).
113
161
  - A `throw redirect()` is captured on `thrown` (with `tree` undefined, since it produced a `Response`) — assert on `thrown`/`response`, no try/catch needed.
114
162
  - No hydration and no interaction — for clicks, forms, and navigation use e2e.
115
163
  - `renderHandler` runs a handler FUNCTION `(ctx) => rsc`; for a plain ELEMENT `<Page/>` use `renderServerTree` (see [`./server-tree.md`](./server-tree.md)).
116
- - A handler that calls a `"use cache"` function runs UNCACHED unless you seed `cacheStore` (and `cacheProfiles` for a named profile). With nothing seeded the runtime bypasses to the live body and warns once under the test runner — assert real cache behavior by passing `{ cacheStore: new MemorySegmentCacheStore(), cacheProfiles: { default: { ttl: 60 } } }`.
164
+ - A handler that calls a `"use cache"` function runs UNCACHED unless you seed `cacheStore` (and `cacheProfiles` for a named profile). With nothing seeded the runtime bypasses to the live body and warns once under the test runner. With a store seeded, the body runs once across renders and a render after the background write reads the store — see [A `"use cache"` hit](#a-use-cache-hit), including `rangoUseCacheTransform()`, without which Vitest leaves the directive unwrapped.
117
165
 
118
166
  ## See also
119
167
 
@@ -25,7 +25,7 @@ The lightweight context a RESPONSE-route handler reads (mirrors the production `
25
25
  | `searchParams` | `URLSearchParams` | Query params with internal `_rsc*` params stripped. |
26
26
  | `url` | `URL` | Cleaned request URL (internal `_rsc*` params removed). |
27
27
  | `pathname` | `string` | Matched pathname. |
28
- | `reverse` | `ReverseFunction` | URL-from-name. Map-only (NO auto-fill from current params), matching the production response-route handler. |
28
+ | `reverse` | `GlobalReverseFunction` | Global names only (NO auto-fill, `.name` is a type error), matching the production response-route handlers. |
29
29
  | `get` | fn | Read context vars set by prior middleware. |
30
30
  | `header(name, value)` | fn | Set a response header; surfaces on the returned `Response`. |
31
31
  | `waitUntil` | fn | Register a deferred task (no-op fidelity in tests). |
@@ -76,17 +76,24 @@ describe("api routes via dispatch", () => {
76
76
  });
77
77
  ```
78
78
 
79
- `dispatch` also covers trailing-slash/redirect targets (`findMatch`) — a redirected path returns a 308 with the `Location` (query preserved). Pass `env` via `{ env }`.
79
+ `dispatch` also covers, with production code paths:
80
+
81
+ - trailing-slash/redirect targets (`findMatch`) — a redirected path returns a 308 with the `Location` (query preserved);
82
+ - the open-redirect guard on browser-followed redirects (a cross-origin `Location` is rewritten to the basename root unless `redirect(url, { external: true })`);
83
+ - content-negotiated routes (`Vary: Accept` appended);
84
+ - cached response routes (`cache({ ... })` around a `path.json` etc.): hit, SWR, and tag writes. The cache write is scheduled via `waitUntil` (a microtask in tests), so flush microtasks between the seeding `dispatch` and the one that asserts the hit;
85
+ - `createRouter({ telemetry })` `request.start` / `request.end` / `request.error` events, and `createRouter({ onError })` for cache-read/write failures.
80
86
 
81
87
  ## Caveats
82
88
 
83
89
  - Hitting a COMPONENT (RSC) route throws a clear directive error: `dispatch` is for response routes + redirects + 404 + content negotiation, plus the global + route-level middleware guard stack on RESPONSE routes — it never renders React. Use Flight primitives or e2e to exercise component rendering.
84
90
  - A COMPONENT route's guard stack cannot run here. Assert it at e2e, or extract the middleware fn and unit-test it with `runMiddleware` (see `./middleware.md`).
85
- - JSON serialization is bare, applied in `response-route-handler.ts`: a `path.json` handler that returns a value is serialized verbatim (`JSON.stringify(value)`, status 200, `application/json`) — no envelope. Returning or throwing a `Response` (e.g. `Response.json(x)`) uses the same control-flow path. Any other thrown error yields an RFC 9457 problem+json body `{ title, status, detail, code }` (`application/problem+json`) with the error's status (`RouterError.status`, else 500, or a non-200 `ctx.res.status` already set upstream in the request pipeline); `code` is the `RouterError.code`, else `"INTERNAL"`. The `type` member is omitted this phase. Assert the shape matching what your handler returns.
91
+ - JSON serialization is bare, applied in `response-route-handler.ts`: a `path.json` handler that returns a value is serialized verbatim (`JSON.stringify(value)`, status 200, `application/json`) — no envelope. Returning or throwing a `Response` (e.g. `Response.json(x)`) uses the same control-flow path. Any other thrown error takes the error's status (`RouterError.status`, else 500, or a non-200 status already set upstream in the request pipeline). On a `path.json` route the body is an RFC 9457 problem+json object `{ title, status, detail, code }` (`application/problem+json;charset=utf-8`), where `code` is the `RouterError.code`, else `"INTERNAL"`, and the `type` member is omitted. On text/html/xml/md routes the body is `text/plain`: the `RouterError` message, the error message in dev, or `"Internal Server Error"` in production. Assert the shape matching what your handler returns.
86
92
  - Setup: needs the preset (alias + virtual stubs) or a Vite-RSC env (see `./setup.md`); a bare router import throws on Vite virtuals.
87
93
  - A router using `Prerender()`/`createLoader()`/`Static()` now constructs in a bare test (each assigns a runtime fallback `$$id`). Importing the whole router _file_ may still need the plugin (its page modules pull app deps / `virtual:` modules) — build from a focused include (your API routes) for whole-router dispatch.
88
94
  - A `_rsc_partial` request to a response route runs global middleware first (an auth gate can still 401/redirect), then returns `X-RSC-Reload` — route-level middleware is skipped, exactly like production.
89
- - `dispatch` does NOT execute server actions (`?_rsc_action`), but it DOES run the global middleware chain on an action request — middleware can still 401/redirect it, and any 3xx redirect on a partial OR action request becomes a `204` + `X-RSC-Redirect` (fetch-safe interception), the raw `Location` dropped.
95
+ - `dispatch` does NOT execute server actions (`?_rsc_action`), but it DOES run the global middleware chain on an action request — middleware can still 401/redirect it, and any 3xx redirect on a partial OR action request becomes a `204` + `X-RSC-Redirect` (fetch-safe interception), the raw `Location` dropped. Location state carried on such a redirect is dropped too (production embeds it in a Flight payload); cover that at e2e.
96
+ - `createRouter({ onError })` is NOT invoked for a thrown non-Response HANDLER error here (the typed error Response still matches production), and `cache.decision` / `loader.*` telemetry never fires (no RSC render runs). Cover both at e2e.
90
97
 
91
98
  ## See also
92
99
 
@@ -1,6 +1,6 @@
1
1
  # Testing reverse/href and type-level contracts
2
2
 
3
- **Layer:** unit (node) + typecheck · **Import:** `@rangojs/router/client` (useReverse), `@rangojs/router/testing` (assertGeneratedRoutesMatch) · **DSL it tests:** `reverse`/`href`/`useReverse` (see `/typesafety`, `/links`)
3
+ **Layer:** unit (node) + typecheck · **Import:** `@rangojs/router/client` (useReverse), `@rangojs/router/testing` (assertGeneratedRoutesMatch) · **DSL it tests:** `ctx.reverse`/`href`/`useReverse` (see `/typesafety`, `/links`)
4
4
 
5
5
  The reverse/href/params/env types are a real contract: a wrong route name, a missing param, or an unknown env binding should be a COMPILE error, not a runtime surprise. The type-test recipes have no runtime API — `tsc --noEmit` IS the assertion. `assertGeneratedRoutesMatch` is the one runtime helper here: it runs the router's real matching to expand lazy includes, then diffs the live `routeMap` against the generated named-routes map you seed. It (and `diffGeneratedRoutes`) are **async — `await` them** — because expanding an async `include(prefix, () => import("./routes"))` group means importing that module first.
6
6
 
@@ -8,10 +8,10 @@ The reverse/href/params/env types are a real contract: a wrong route name, a mis
8
8
 
9
9
  ### Options — `assertGeneratedRoutesMatch(router, generatedMap?)`
10
10
 
11
- | Field | Type | Meaning |
12
- | -------------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
13
- | `router` | `{ routeMap; findMatch? }` | Your router (real impl). `routeMap` is the live name→pattern map; `findMatch` (when present) is called to force-expand lazy `include()`d routes. |
14
- | `generatedMap` | `Record<string, unknown>` (optional) | The imported `*.named-routes.gen.ts` map (name→pattern, or `{ path }` objects). Omit to diff against the global route map (`getGlobalRouteMap()`) instead. |
11
+ | Field | Type | Meaning |
12
+ | -------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
13
+ | `router` | `{ routeMap; findMatch? }` | Your router (real impl). `routeMap` is the live name→pattern map; `findMatch` (when present) is called to force-expand lazy `include()`d routes. |
14
+ | `generatedMap` | `Record<string, unknown>` (optional) | The `NamedRoutes` export of your `*.named-routes.gen.ts` file (name→pattern, or `{ path }` objects). Omit to diff against the global route map instead. |
15
15
 
16
16
  ### Context — `GeneratedRoutesDiff` (what `diffGeneratedRoutes` resolves to)
17
17
 
@@ -32,14 +32,19 @@ The reverse/href/params/env types are a real contract: a wrong route name, a mis
32
32
  // 1. Negative assertions inline with @ts-expect-error — the directive ERRORS if
33
33
  // the line below it ever starts compiling (i.e. if the type guard regresses).
34
34
  // Validated by `tsc --noEmit`; a runtime test cannot assert this.
35
+ // Keep the hook call inside a function that is never invoked: tsc checks it,
36
+ // and a runtime test file that imports it does not execute a hook outside
37
+ // a component.
35
38
  import { useReverse } from "@rangojs/router/client";
36
39
 
37
- const reverse = useReverse({ post: "/blog/:slug" });
38
- reverse("post", { slug: "hi" }); // ok
39
- // @ts-expect-error - missing required :slug param
40
- reverse("post", {});
41
- // @ts-expect-error - "comment" is not a route in this map
42
- reverse("comment", { id: "1" });
40
+ function typeOnly() {
41
+ const reverse = useReverse({ post: "/blog/:slug" });
42
+ reverse("post", { slug: "hi" }); // ok
43
+ // @ts-expect-error - missing required :slug param
44
+ reverse("post", {});
45
+ // @ts-expect-error - "comment" is not a route in this map
46
+ reverse("comment", { id: "1" });
47
+ }
43
48
  ```
44
49
 
45
50
  ```ts
@@ -62,10 +67,11 @@ expectTypeOf<
62
67
  import { it } from "vitest";
63
68
  import { assertGeneratedRoutesMatch } from "@rangojs/router/testing";
64
69
  import { router } from "../src/router";
65
- import generated from "../src/router.named-routes.gen";
70
+ // The generated file has no default export; it exports `NamedRoutes`.
71
+ import { NamedRoutes } from "../src/router.named-routes.gen";
66
72
 
67
73
  it("generated named-routes map is in sync with the router", async () => {
68
- await assertGeneratedRoutesMatch(router, generated);
74
+ await assertGeneratedRoutesMatch(router, NamedRoutes);
69
75
  });
70
76
  ```
71
77
 
@@ -76,7 +82,7 @@ For a large type-only suite, collect recipe-1/2 assertions in `*.test-d.ts` file
76
82
  - Type tests run at TYPECHECK time (`tsc --noEmit`), NOT in the vitest runner. They are their own layer — wire them into CI as a real step (`pnpm run typecheck`). A type test nobody runs is just a comment.
77
83
  - `@ts-expect-error` ERRORS if the line below it ever starts compiling, so a regressed guard fails the typecheck. A runtime test cannot assert "this should not type-check".
78
84
  - `assertGeneratedRoutesMatch` force-expands lazy `include()`d routes (awaits `findMatch` on a concrete path derived from each generated pattern) before diffing — otherwise every included route reads as a false `missing`. It is **async** so it can await the module import behind an async `include(() => import())` group; `await` the call (or the async group's routes read as `missing`). This makes the whole-app drift check work in a plain unit test. Routers without `findMatch` (a bare `{ routeMap }`) are left as-is.
79
- - MULTI-APP route-map isolation. `href()`/`reverse()` typing is GLOBAL — each app's generated file augments the one `Rango.GeneratedRouteMap` interface. A `renderRoute` suite that imports a client component from app B (which calls `href("/b-route")`) won't typecheck if the same tsconfig program also carries app A's augmentation: A's route union rejects B's name. `renderRoute` is app-agnostic at RUNTIME; the collision is purely the global `href` typing. Keep a `renderRoute` suite single-app, or give each app its OWN tsconfig program (see `/typesafety`); a quick sidestep is to probe `useMount`/`useHref` inline instead of importing the cross-app component.
85
+ - MULTI-APP route-map isolation. `href()`/`ctx.reverse()` typing is GLOBAL — each app's generated file augments the one `Rango.GeneratedRouteMap` interface. A `renderRoute` suite that imports a client component from app B (which calls `href("/b-route")`) won't typecheck if the same tsconfig program also carries app A's augmentation: A's route union rejects B's name. `renderRoute` is app-agnostic at RUNTIME; the collision is purely the global `href` typing. Keep a `renderRoute` suite single-app, or give each app its OWN tsconfig program (see `/typesafety`); a quick sidestep is to probe `useMount`/`useHref` inline instead of importing the cross-app component.
80
86
 
81
87
  ## See also
82
88
 
@@ -22,6 +22,7 @@
22
22
  | `cacheStore` | `SegmentCacheStore` | Backing store for `use cache` functions (same shape as `createRouter({ cache })`). Without it, cached functions run uncached and their guards never fire. |
23
23
  | `cacheProfiles` | `Record<string, CacheProfile>` | Profiles for `use cache: "name"`, same shape as `createRouter({ cacheProfiles })`. An unknown profile throws. |
24
24
  | `theme` | `ThemeConfig \| true` | Theme config (same shape as `createRouter({ theme })`). Without it `ctx.theme` / `ctx.setTheme` are inert. |
25
+ | `build` | `boolean` | Seed `ctx.build` (default `false`) for code that branches on the build-time PPR shell-capture pass. With `true`, `ctx.waitUntil()` is inert, as at build time. |
25
26
  | `stateCookie` | `StateCookieSeed` (`{ prefix?, routerId?, version? }`) | Customize the rango state cookie an action calling `invalidateClientCache()` rotates. The name is ALWAYS seeded (default `rango-state_router_0`) so the rotation `Set-Cookie` fires like production; override `prefix`/`routerId` to match `createRouter({ stateCookiePrefix, id })`, or `version` (value is `{version}:{timestamp}`, default `"0"`). |
26
27
 
27
28
  ### Context — `RequestContext<TEnv>` (what your code receives)
@@ -52,7 +53,12 @@
52
53
  | `stateCookieName` | `string` | The resolved rango state cookie name this run seeded (default `rango-state_router_0`). Assert an `invalidateClientCache()` rotation against it without recomputing. |
53
54
  | `locationState` | `Record<string, unknown>` | The flash set via `ctx.setLocationState()` / `redirect({ state })`, as the flat `{ key: value }` the client reads. |
54
55
 
55
- Low-level variant: when you already hold a context from `createTestRequestContext(opts)`, call `runWithRequestContext(ctx, fn)` (re-exported from `@rangojs/router/testing`) to enter it directly. `runInRequestContext` is the one-call convenience over `createTestRequestContext` + `runWithRequestContext`.
56
+ Low-level building blocks (all from `@rangojs/router/testing`):
57
+
58
+ - `createTestRequestContext(opts)` builds the context WITHOUT entering it and returns `{ ctx, request, url, variables, stateCookieName }`. Its `ctx.reverse` accepts any name from your `routeMap`.
59
+ - `runWithRequestContext(ctx, fn)` enters a context you already hold. `runInRequestContext` is the one-call convenience over these two, plus the result snapshot.
60
+ - `toRequest(request?, init?)` normalizes a `Request | string | undefined` into a `Request` (strings resolve against `http://localhost/`).
61
+ - `seedVariables(variables, vars?)` writes a `VarsInit` (object or `[key, value]` tuples) into a raw variables record, the same way the `vars` option does.
56
62
 
57
63
  ## Recipe
58
64
 
@@ -60,10 +66,13 @@ Low-level variant: when you already hold a context from `createTestRequestContex
60
66
  import { it, expect } from "vitest";
61
67
  import { runInRequestContext } from "@rangojs/router/testing";
62
68
  import { loginAction } from "../src/actions/login"; // sets a session cookie + flash, then throw redirect("/app")
69
+ import { logoutAction, dismissBannerAction } from "../src/actions/session"; // invalidateClientCache() / keepClientCache()
70
+
71
+ const env = { DB: fakeDb }; // your binding double (see ./bindings.md)
63
72
 
64
73
  it("sets the session cookie + flash and redirects", async () => {
65
74
  const { thrown, cookies, locationState } = await runInRequestContext(
66
- () => loginAction(input),
75
+ () => loginAction({ email: "ada@example.com", password: "pw" }),
67
76
  {
68
77
  env,
69
78
  request: new Request("https://app.test/admin", {
@@ -97,7 +106,7 @@ it("asserts the client-cache directives an action issued", async () => {
97
106
 
98
107
  - The snapshot fires whether `fn` RETURNS or THROWS. A `throw redirect("/app")` on the success path is captured on `thrown` (NOT re-thrown), so no try/catch is needed; assert on `thrown` for a throwing action.
99
108
  - There is no cookies / headers option. Seed a request cookie by passing a full `Request` with the `Cookie` header (as in the recipe).
100
- - `runWithRequestContext(ctx, fn)` is the low-level entry when you already hold a context; `runInRequestContext` is the one-call convenience over `createTestRequestContext` + `runWithRequestContext`.
109
+ - An action has no loader context, so `runLoader` is the wrong shape for it even when the action reads cookies or vars; use `runInRequestContext`.
101
110
  - Platform bindings are yours to double via `env` (see `./bindings.md`).
102
111
 
103
112
  ## See also
@@ -18,6 +18,8 @@
18
18
  | `vars` | `VarsInit` | Context variables visible via `ctx.get(...)`, as a prior middleware would have set them. Object form (`{ user }`) or `[key, value]` tuples. |
19
19
  | `clientComponents` | `Record<string, unknown>` | The `"use client"` components reachable from the tree, keyed by the boundary name to register each as a client reference (in place) so it serializes as an `I` row. Omit when `rangoUseClientTransform()` auto-discovers them, or for pure server-only trees. First-wins per worker; already-registered references are left untouched. |
20
20
 
21
+ It also accepts the rest of `RenderToFlightStringOptions` — `routeMap`, `theme`, `cacheStore`, `cacheProfiles` — with the same meaning as in [`./flight.md`](./flight.md).
22
+
21
23
  ### Context — what your code receives
22
24
 
23
25
  A server component rendered here runs under a real request context: `getRequestContext()` resolves, `ctx.params`/`ctx.routeName`/`ctx.env` reflect the options, `ctx.get(MyVar)` reads a seeded `var`, and cookies come off the request. Same seeding as the handler-test primitives — you render an **element** you build (`<Page />`); to run a route **handler** `(ctx) => rsc` use `renderHandler` (see `./render-handler.md`).
@@ -64,6 +66,10 @@ Every SERVER/HOST element a server component produced (`<article>`, `<h2>`), in
64
66
 
65
67
  Concatenates every string/number leaf of a node's subtree in document order — the clean way to assert rendered text, instead of `JSON.stringify(tree).toContain(...)`.
66
68
 
69
+ #### `assertFlightTreeRuntimeAvailable()`
70
+
71
+ Throws a descriptive error when a `@vitejs/plugin-rsc` subpath the round trip relies on (the vendored client deserializer or `registerClientReference`) no longer exports the expected function — the counterpart of `assertFlightRuntimeAvailable()` in `./flight.md`. Call it in a `beforeAll` to fail fast after a plugin-rsc upgrade.
72
+
67
73
  ## Recipe
68
74
 
69
75
  ```tsx
@@ -74,8 +80,11 @@ import {
74
80
  findElements,
75
81
  textContent,
76
82
  } from "@rangojs/router/testing/flight";
83
+ import { flightMatchers } from "@rangojs/router/testing/flight-matchers";
77
84
  import { PriceTag } from "./PriceTag.js"; // a "use client" component (any filename)
78
85
 
86
+ expect.extend(flightMatchers); // for toMatchFlight below
87
+
79
88
  async function ProductPanel({ amount, asOf }: { amount: number; asOf: Date }) {
80
89
  await Promise.resolve();
81
90
  return (
@@ -12,13 +12,15 @@ Real machinery: Vite transpiles `@rangojs/router`'s shipped TS source and resolv
12
12
  | -------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
13
13
  | `preset` | `"node" \| "cloudflare"` | Deployment preset, matching `rango({ preset })` in the Vite plugin. `"cloudflare"` additionally stubs the `cloudflare:workers` / `cloudflare:email` runtime virtuals a CF route tree imports. A string (not a boolean) so more presets can be added without an API change. Default `"node"`. |
14
14
 
15
- ### Functions
15
+ ### Exports
16
16
 
17
- | Function | Returns | Use |
17
+ | Export | Returns / value | Use |
18
18
  | --------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
19
19
  | `rangoTestConfig(opts?)` | `{ alias, server: { deps: { inline } } }` | Recommended. Spread into the node/DOM project's `test` block. Bundles the resolve aliases AND `server.deps.inline`. |
20
20
  | `rangoTestAliases(opts?)` | `TestAlias[]` (`{ find, replacement }[]`) | Lower-level. The bare `@rangojs/router` -> `index.rsc.ts` alias plus the `:version` / `@vitejs/plugin-rsc/rsc` (`/rsc/server`, `/rsc/client`) stubs (and CF stubs under `preset:"cloudflare"`). Used in the rsc project's `resolve.alias`. |
21
- | `rangoUseClientTransform()` | a Vite plugin (`{ name, transform }`) | Add to the rsc project `plugins`. Applies the `"use client"` transform so `renderServerTree` auto-discovers client islands from the server tree's imports. |
21
+ | `rangoInlineDeps` | `RegExp[]` (`[/@rangojs[/\\]router/]`) | The `server.deps.inline` patterns on their own. Use in the rsc project (`test.server.deps.inline`), or wherever you call `rangoTestAliases` directly instead of `rangoTestConfig`. |
22
+ | `rangoUseClientTransform()` | a Vite plugin (`{ name, transform }`) | Add to the rsc project `plugins`. Applies the `"use client"` transform so `renderServerTree` / `renderHandler` auto-discover client islands from the server tree's imports. |
23
+ | `rangoUseCacheTransform()` | a Vite plugin (`enforce: "post"`) | Add to the rsc project `plugins`. Runs the rango plugin's `"use cache"` transform with dev ids (`<path>#<name>`, the path relative to the root its `configResolved` reads) so a directive function caches. |
22
24
 
23
25
  ### Returns — `RangoTestConfig` (from `rangoTestConfig`)
24
26
 
@@ -53,6 +55,7 @@ import { defineConfig } from "vitest/config";
53
55
  import {
54
56
  rangoTestAliases,
55
57
  rangoUseClientTransform,
58
+ rangoUseCacheTransform,
56
59
  rangoInlineDeps,
57
60
  } from "@rangojs/router/testing/vitest";
58
61
 
@@ -60,7 +63,7 @@ import {
60
63
  process.env.NODE_ENV = "production";
61
64
 
62
65
  export default defineConfig({
63
- plugins: [rangoUseClientTransform()],
66
+ plugins: [rangoUseClientTransform(), rangoUseCacheTransform()],
64
67
  resolve: {
65
68
  conditions: ["react-server"],
66
69
  alias: rangoTestAliases({ preset: "cloudflare" }), // or { preset: "node" }
@@ -109,12 +112,13 @@ Scripts:
109
112
 
110
113
  - Node >= 23 requires `rangoTestConfig()`, not bare `rangoTestAliases()`. `@rangojs/router` is consumed as SOURCE (exports -> `./src/*.ts`), and Node >= 23 refuses to type-strip `.ts` under `node_modules` (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`). `rangoTestConfig` ships as compiled JS AND adds `server.deps.inline: [/@rangojs[/\\]router/]` so Vite (not Node) transpiles rango source. With bare `rangoTestAliases` you must wire `deps.inline` yourself.
111
114
  - Two separate projects. The node/DOM project keeps React on its CLIENT build; the Flight project uses the `react-server` condition in a separate `vitest.rsc.config.ts`. The main project must NOT set `react-server` — it flips React to the no-hooks server build and breaks every `renderRoute` / client test.
112
- - The rsc project needs BOTH `resolve.conditions: ["react-server"]` AND the bare `@rangojs/router` -> `index.rsc.ts` alias from `rangoTestAliases({ preset })`. `resolve.conditions` alone is not reliably applied to bare-package export resolution; without the alias a handler/component reading `getRequestContext()` / `cookies()` resolves the throwing out-of-react-server stub (symptom: `renderHandler` returns `tree: undefined`). `renderToFlightString` / `renderServerTree` now self-diagnose this exact misconfiguration — they reject with an actionable message naming `rangoTestAliases`, rather than surfacing the opaque stub error.
115
+ - The rsc project needs BOTH `resolve.conditions: ["react-server"]` AND the bare `@rangojs/router` -> `index.rsc.ts` alias from `rangoTestAliases({ preset })`. `resolve.conditions` alone is not reliably applied to bare-package export resolution; without the alias a handler/component reading `getRequestContext()` / `cookies()` resolves the throwing out-of-react-server stub. `renderToFlightString`, `renderServerTree`, and `renderHandler` all detect this exact misconfiguration and reject with an actionable message naming `rangoTestAliases`, rather than surfacing the opaque stub error.
113
116
  - `NODE_ENV` must be `"production"` in the rsc project. Dev `NODE_ENV` crashes the bare worker (jsxDEV owner-stack machinery uninitialized) and emits volatile debug rows that defeat stable Flight snapshots.
114
117
  - The forked rsc worker (`pool: "forks"`) must force the condition via `execArgv: ["--conditions=react-server"]`, or React throws "the react-server condition must be enabled".
115
118
  - The `@rangojs/router:version` and `@vitejs/plugin-rsc/rsc` (`/rsc/server`, `/rsc/client`) virtuals must be stubbed; the preset does it. A bare router import without stubbing throws.
116
119
  - The rango fragment goes under `test` (`test.alias` + `test.server.deps.inline`, both returned by `rangoTestConfig`), NOT under top-level `resolve`.
117
120
  - Wire `rangoUseClientTransform()` into the rsc project `plugins` so islands auto-discover from the server tree imports (see `./server-tree.md`); without it, register islands explicitly with `clientComponents`. The transform's `registerClientReference` import is resolved from `@rangojs/router`'s `@vitejs/plugin-rsc` — the consumer does not need a direct plugin-rsc dependency.
121
+ - Wire `rangoUseCacheTransform()` into the rsc project `plugins` so a function written with `"use cache"` is wrapped as in dev. `rango()` runs that transform only in its `rsc` environment and Vitest transforms in `ssr`, so without it the directive is inert and the function runs on every call. See [`./render-handler.md`](./render-handler.md#a-use-cache-hit).
118
122
 
119
123
  ## See also
120
124
 
@@ -1,82 +1,159 @@
1
1
  ---
2
2
  name: theme
3
- description: Opt-in theme system with FOUC prevention for light/dark mode. Use when adding a light/dark mode toggle, or the page flashes the wrong theme on load (FOUC).
3
+ description: Opt-in light/dark theme system with FOUC prevention, server-side theme access, and a useTheme() hook. Use when adding a light/dark mode toggle, reading or setting the theme in handlers or middleware, or when the page flashes the wrong theme on load (FOUC).
4
4
  argument-hint: [setup]
5
5
  ---
6
6
 
7
7
  # Theme Support
8
8
 
9
- Opt-in theme system with FOUC prevention.
9
+ Opt-in theme system. Enabling it gives you:
10
+
11
+ - an inline script (rendered by `<Html.Meta />`) that applies the stored theme
12
+ to `<html>` before first paint, so there is no flash of the wrong theme;
13
+ - `ctx.theme` / `ctx.setTheme()` in handlers and middleware;
14
+ - the `useTheme()` hook in client components (the provider is added for you).
15
+
16
+ The theme is stored in a cookie (so the server can read it) and in
17
+ localStorage.
10
18
 
11
19
  ## Enable
12
20
 
13
21
  ```typescript
14
22
  import { createRouter } from "@rangojs/router";
15
23
 
16
- // Simple - all defaults
17
- const router = createRouter<Env>({
24
+ // All defaults
25
+ export const router = createRouter<AppBindings>({
18
26
  document: Document,
19
27
  urls: urlpatterns,
20
28
  theme: true,
21
29
  });
22
30
 
23
- // Custom config
24
- const router = createRouter<Env>({
31
+ // Custom config (values shown are the defaults unless noted)
32
+ export const router = createRouter<AppBindings>({
25
33
  document: Document,
26
34
  urls: urlpatterns,
27
35
  theme: {
28
36
  defaultTheme: "system", // "light" | "dark" | "system"
29
37
  themes: ["light", "dark"],
30
- attribute: "class", // or "data-theme"
31
- storageKey: "theme",
38
+ attribute: "class", // or any "data-*" attribute, e.g. "data-theme"
39
+ storageKey: "theme", // cookie and localStorage key
40
+ enableSystem: true, // resolve "system" via prefers-color-scheme
41
+ enableColorScheme: true, // set style="color-scheme: ..." on <html>
42
+ value: { dark: "theme-dark" }, // optional: attribute value per theme (not a default)
32
43
  },
33
44
  });
34
45
  ```
35
46
 
36
- ## Server (in handlers/middleware)
47
+ With `attribute: "class"` the resolved theme is added as a class on `<html>`
48
+ (`<html class="dark">`); with a `data-*` attribute it is set as that
49
+ attribute's value (`<html data-theme="dark">`). `"system"` resolves to
50
+ `"light"` or `"dark"` before it is applied. With `enableSystem: false`,
51
+ `"system"` is not a valid theme and a `"system"` default falls back to the
52
+ first entry in `themes`.
53
+
54
+ ## Document requirements
55
+
56
+ - Render `<Html.Meta />` in `<head>`: it emits the FOUC-prevention script
57
+ first, before the meta tags. The default Document already does this.
58
+ - Add `suppressHydrationWarning` to `<html>`: the script changes its
59
+ `class`/`style` before React hydrates.
60
+
61
+ ```tsx
62
+ "use client";
63
+ import type { ReactNode } from "react";
64
+ import { Html } from "@rangojs/router/client";
65
+
66
+ export function Document({ children }: { children: ReactNode }) {
67
+ return (
68
+ <html lang="en" suppressHydrationWarning>
69
+ <head>
70
+ <Html.Meta />
71
+ </head>
72
+ <body>{children}</body>
73
+ </html>
74
+ );
75
+ }
76
+ ```
77
+
78
+ A Document that does not render `<Html.Meta />` can place `<ThemeScript />`
79
+ from `@rangojs/router/theme` in `<head>` instead. It takes the resolved config
80
+ (`config: ResolvedThemeConfig`) and an optional `nonce`.
81
+
82
+ ## Server (handlers and middleware)
83
+
84
+ `ctx.theme` is the stored theme, or `defaultTheme` when there is none.
85
+ `ctx.setTheme(theme)` sets the theme cookie on the response; invalid values
86
+ are rejected with a console warning. Both are typed optional because they only
87
+ exist when `theme` is configured.
37
88
 
38
89
  ```typescript
39
90
  import type { Middleware } from "@rangojs/router";
40
91
 
41
92
  // In a handler
42
93
  path("/settings", (ctx) => {
43
- const currentTheme = ctx.theme; // read from cookie
94
+ const currentTheme = ctx.theme; // "light" | "dark" | "system" | undefined
44
95
  return <SettingsPage theme={currentTheme} />;
45
96
  });
46
97
 
47
- // In middleware
48
- export const themeMiddleware: Middleware = async (ctx, next) => {
49
- // Set theme based on user preference
50
- ctx.setTheme("dark");
98
+ // In middleware: apply a theme chosen through a query param
99
+ export const themeFromQuery: Middleware = async (ctx, next) => {
100
+ const requested = ctx.url.searchParams.get("theme");
101
+ if (requested === "light" || requested === "dark") {
102
+ ctx.setTheme?.(requested);
103
+ }
51
104
  await next();
52
105
  };
53
106
  ```
54
107
 
55
- Theme accessors are handler/middleware-only — the loader context has no
56
- `ctx.theme`/`ctx.setTheme`. A loader that needs the theme reads the cookie
57
- directly via `cookies()` (loaders always run fresh, so the read is safe).
108
+ Loaders do not get `ctx.theme` or `ctx.setTheme`. A loader that needs the
109
+ theme reads the cookie directly (loaders run fresh on every request, so the
110
+ read is safe):
111
+
112
+ ```typescript
113
+ import { cookies, createLoader } from "@rangojs/router";
114
+
115
+ export const ThemedLoader = createLoader(async () => {
116
+ const theme = cookies().get("theme")?.value ?? "system"; // storageKey
117
+ return loadThemedAssets(theme);
118
+ });
119
+ ```
58
120
 
59
121
  ## Client
60
122
 
61
123
  ```tsx
62
124
  "use client";
63
- import { useTheme } from "@rangojs/router/theme";
125
+ import { useTheme, type Theme } from "@rangojs/router/theme";
64
126
 
65
- function ThemeToggle() {
66
- const { theme, setTheme, resolvedTheme, systemTheme, themes } = useTheme();
127
+ export function ThemeToggle() {
128
+ const { theme, setTheme, resolvedTheme, themes } = useTheme();
67
129
 
68
130
  return (
69
- <select value={theme} onChange={(e) => setTheme(e.target.value)}>
70
- <option value="system">System</option>
71
- <option value="light">Light</option>
72
- <option value="dark">Dark</option>
73
- </select>
131
+ <label>
132
+ Theme ({resolvedTheme})
133
+ <select value={theme} onChange={(e) => setTheme(e.target.value as Theme)}>
134
+ {themes.map((t) => (
135
+ <option key={t} value={t}>
136
+ {t}
137
+ </option>
138
+ ))}
139
+ </select>
140
+ </label>
74
141
  );
75
142
  }
76
143
  ```
77
144
 
78
- ## Notes
145
+ `useTheme()` returns:
146
+
147
+ - `theme`: the stored setting (`"light" | "dark" | "system"`)
148
+ - `setTheme(theme)`: update the theme; writes the cookie and localStorage
149
+ - `resolvedTheme`: what is actually shown (`"system"` resolved)
150
+ - `systemTheme`: the current OS preference (`"light" | "dark"`)
151
+ - `themes`: the configured themes, with `"system"` first when `enableSystem` is on
152
+
153
+ `useTheme()` throws when `theme` is not enabled in `createRouter()`.
154
+
155
+ ## Related
79
156
 
80
- - `<MetaTags />` auto-renders inline script for FOUC prevention
81
- - Add `suppressHydrationWarning` to `<html>`
82
- - Theme persists in localStorage + cookie (for SSR)
157
+ - `/tailwind`: pairing `attribute: "class"` with Tailwind's `dark:` variant
158
+ - `/css`: where document stylesheets go
159
+ - `/router-setup`: the Document component