@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
@@ -6,33 +6,38 @@ argument-hint: "[vendor]"
6
6
 
7
7
  # Scripts
8
8
 
9
- Inject `<script>` tags into the document the idiomatic Rango way: push a config
10
- from a **server** route/layout handler — or a loader body — with
11
- `ctx.use(Script)(config)`, and render them with the built-in **`<Scripts />`**
12
- component (the `Meta` / `<MetaTags>` pair, but for scripts). The request CSP **nonce is applied automatically to
13
- document-rendered scripts** — you never read or pass it. (The one exception is an
14
- async script first encountered on a soft navigation; see the nonce caveat under
15
- "Execution contract".)
9
+ Push a script config from a **server** route/layout handler (or a loader) with
10
+ `ctx.use(Script)(config)`; the built-in **`<Html.Scripts />`** component renders it.
11
+ It is the `Meta` / `<Html.Meta>` pair, but for scripts. The request CSP **nonce
12
+ is applied automatically to document-rendered scripts**; you never read or pass
13
+ it. (The one exception is an async script first encountered on a soft
14
+ navigation; see the nonce caveat under "Execution contract".)
15
+
16
+ Read "Execution contract" before choosing a shape: inline and ordered scripts
17
+ run only on a hard (document) load, never on a soft navigation.
16
18
 
17
19
  ## Setup
18
20
 
19
- `<Scripts />` is a client component; place it in your Document (which is
21
+ `<Html.Scripts />` is a client component; place it in your Document (which is
20
22
  `"use client"`). The default Document already includes both sites; a custom one
21
- adds them next to `<MetaTags />`:
23
+ adds them next to `<Html.Meta />`:
22
24
 
23
25
  ```tsx
24
- // document.tsx ("use client")
25
- import { MetaTags, Scripts } from "@rangojs/router/client";
26
+ // document.tsx
27
+ "use client";
28
+ import type { ReactNode } from "react";
29
+ import { Html } from "@rangojs/router/client";
26
30
 
27
- export function Document({ children }) {
31
+ export function Document({ children }: { children: ReactNode }) {
28
32
  return (
29
33
  <html lang="en" suppressHydrationWarning>
30
34
  <head>
31
- <MetaTags />
32
- <Scripts /> {/* renders position: "head" scripts (the default) */}
35
+ <Html.Meta />
36
+ <Html.Scripts /> {/* renders position: "head" scripts (the default) */}
33
37
  </head>
34
38
  <body>
35
- <Scripts position="body" /> {/* renders position: "body" scripts */}
39
+ <Html.Scripts position="body" />{" "}
40
+ {/* renders position: "body" scripts */}
36
41
  {children}
37
42
  </body>
38
43
  </html>
@@ -60,7 +65,7 @@ ctx.use(Script)({
60
65
  attributes: { "data-domain": "example.com" },
61
66
  });
62
67
 
63
- // 3. INLINE — `id` REQUIRED, raw JS body (escaped against </script> by <Scripts>).
68
+ // 3. INLINE — `id` REQUIRED, raw JS body (escaped against </script> by <Html.Scripts>).
64
69
  // For GTM/GA4/Segment let the body self-inject its loader (see below).
65
70
  ctx.use(Script)({ id: "gtm", children: gtmBootstrap("GTM-XXXX") });
66
71
  ```
@@ -80,7 +85,8 @@ ctx.use(Script)({ id: "gtm", children: gtmBootstrap("GTM-XXXX") });
80
85
  - `type` — free string: `"module"`, `"application/ld+json"`, `"text/partytown"`, …
81
86
  - `attributes` — React-cased (`crossOrigin`, not `crossorigin`) and React-typed
82
87
  (`data-*`, `integrity`, `referrerPolicy`, …). Excluded: the fields the handle
83
- manages (`id`/`src`/`async`/`defer`/`type`/`children`/`nonce`) and all `on*`
88
+ manages (`id`/`src`/`async`/`defer`/`type`/`children`/`nonce`/
89
+ `dangerouslySetInnerHTML`) and all `on*`
84
90
  handlers (`onLoad`/`onError`/… — a config is serialized to the client, so a
85
91
  function can't survive; use a `"use client"` component for callbacks).
86
92
 
@@ -95,7 +101,7 @@ innerHTML, which the HTML spec never executes). So:
95
101
  | External ordered (`defer`/plain) | Yes | **No** — document-load only |
96
102
  | External `async` | Yes | **Yes** — React loads the resource on first encounter |
97
103
 
98
- `<Scripts>` enforces this honestly: after hydration it **freezes** the inline +
104
+ `<Html.Scripts>` enforces this: after hydration it **freezes** the inline +
99
105
  ordered set to what was in the initial HTML, so a navigation never inserts an
100
106
  inert (silently dead) `<script>`. Async configs stay reactive. Reusing an `id`
101
107
  shapes the INITIAL document output (last-push-wins) — it does not re-run a script
@@ -133,9 +139,14 @@ the loader could run before the bootstrap. Instead let the bootstrap inject its
133
139
  own loader (Google's snippet does exactly this):
134
140
 
135
141
  ```ts
136
- function gtmBootstrap(id: string): string {
142
+ // `initial` is pushed into dataLayer before the GTM start event, so tags that
143
+ // fire on the first page_view can read it (see per-route tagging below).
144
+ function gtmBootstrap(id: string, initial?: Record<string, string>): string {
137
145
  return [
138
146
  "window.dataLayer=window.dataLayer||[];",
147
+ // <Html.Scripts> escapes the body against </script> breakout, so plain
148
+ // JSON.stringify is enough here.
149
+ initial ? `window.dataLayer.push(${JSON.stringify(initial)});` : "",
139
150
  'window.dataLayer.push({"gtm.start":new Date().getTime(),event:"gtm.js"});',
140
151
  `(function(d,s,i){var j=d.createElement(s);j.async=true;j.src="https://www.googletagmanager.com/gtm.js?id="+encodeURIComponent(i);var f=d.getElementsByTagName(s)[0];f.parentNode.insertBefore(j,f);})(document,"script",${JSON.stringify(id)});`,
141
152
  ].join("");
@@ -157,7 +168,7 @@ ctx.use(Script)({ id: "gtm", children: gtmBootstrap("GTM-XXXX") });
157
168
  // a route: same id, with content_group baked in
158
169
  ctx.use(Script)({
159
170
  id: "gtm",
160
- children: gtmBootstrapWith({ content_group: "blog" }),
171
+ children: gtmBootstrap("GTM-XXXX", { content_group: "blog" }),
161
172
  });
162
173
  ```
163
174
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: server-actions
3
- description: Define and call server actions (`"use server"`) — forms, useActionState, useOptimistic, validation, error handling, redirects, revalidation. Use when handling a form submission on the server, calling a server function from a client component, or needing optimistic UI after a mutation.
3
+ description: Define and call server actions (`"use server"`) — forms, useActionState, useOptimistic, validation, error handling, redirects, post-action revalidation, client-cache control (keepClientCache), and the default CSRF origin check. Use when handling a form submission on the server, calling a server function from a client component, choosing what re-renders after a mutation, needing optimistic UI after a mutation, or asking how actions are protected from CSRF.
4
4
  argument-hint: "[action]"
5
5
  ---
6
6
 
@@ -11,6 +11,10 @@ the client. They are React's RSC mutation primitive — Rango uses them as-is
11
11
  with no framework wrapper. All standard React hooks (`useActionState`,
12
12
  `useFormStatus`, `useOptimistic`, `useTransition`) work directly.
13
13
 
14
+ This skill covers defining actions, calling them (with and without JavaScript),
15
+ validation, redirects, errors, authorization, what re-renders after an action,
16
+ and how the client cache reacts.
17
+
14
18
  ## When to Use Actions vs Loaders
15
19
 
16
20
  | Need | Use |
@@ -48,9 +52,15 @@ export const urlpatterns = urls(({ path, loader, revalidate }) => [
48
52
  // The loader belongs to the route that consumes its data — nest it inside
49
53
  // the owning path() so the segment owns its data dependency.
50
54
  path("/cart", CartPage, { name: "cart" }, () => [
51
- revalidate((ctx) => ctx.isAction(CartActions) || undefined),
55
+ // The route and its loaders re-run after every action by default. Narrow
56
+ // both to cart actions; on navigation, undefined keeps the default.
57
+ revalidate((ctx) =>
58
+ ctx.isAction() ? ctx.isAction(CartActions) : undefined,
59
+ ),
52
60
  loader(CartLoader, () => [
53
- revalidate((ctx) => ctx.isAction(CartActions) || undefined),
61
+ revalidate((ctx) =>
62
+ ctx.isAction() ? ctx.isAction(CartActions) : undefined,
63
+ ),
54
64
  ]),
55
65
  ]),
56
66
  ]);
@@ -99,11 +109,13 @@ import { ChangedTenant } from "./context";
99
109
 
100
110
  export const urlpatterns = urls(({ path, revalidate }) => [
101
111
  path("/dashboard/:tenantId", DashboardPage, { name: "dashboard" }, () => [
112
+ // The route re-runs after every action by default. Narrow it: after an
113
+ // action, re-run only when a tenant action changed this tenant.
102
114
  revalidate((ctx) => {
103
- if (!ctx.isAction(TenantActions)) return undefined;
115
+ if (!ctx.isAction()) return undefined; // navigation: keep the default
104
116
  return (
105
- ctx.context.get(ChangedTenant) === ctx.context.params.tenantId ||
106
- undefined
117
+ ctx.isAction(TenantActions) &&
118
+ ctx.context.get(ChangedTenant) === ctx.context.params.tenantId
107
119
  );
108
120
  }),
109
121
  ]),
@@ -139,6 +151,14 @@ export async function removeFromCart(productId: string): Promise<void> {
139
151
  const userId = cookies().get("user-id")?.value;
140
152
  await db.cart.delete({ userId, productId });
141
153
  }
154
+
155
+ export async function updateQuantity(
156
+ productId: string,
157
+ delta: number,
158
+ ): Promise<void> {
159
+ const userId = cookies().get("user-id")?.value;
160
+ await db.cart.adjust({ userId, productId, delta });
161
+ }
142
162
  ```
143
163
 
144
164
  ### Inline (`"use server"` inside a server component)
@@ -192,9 +212,9 @@ export function AddToCartForm({ productId }: { productId: string }) {
192
212
  ### 2. `useActionState` — preserve return value + pending state
193
213
 
194
214
  Standard React 19 hook. The action receives `(prevState, formData)` and its
195
- return value becomes the new `state`. The form input values are preserved by
196
- the browser on validation errors as long as you re-render the same form
197
- element with `defaultValue` (not `value`).
215
+ return value becomes the new `state`. To keep what the user typed after a
216
+ validation error, return the submitted values in the state and render them as
217
+ `defaultValue` (see below).
198
218
 
199
219
  Define the state shape next to the action so the client and server share
200
220
  one type:
@@ -248,10 +268,12 @@ export function ProfileForm({ initial }: { initial: { name: string } }) {
248
268
  }
249
269
  ```
250
270
 
251
- **Why `defaultValue`, not `value`** — on validation error the form re-renders.
252
- With `value` the inputs reset; with `defaultValue` (and a stable form key) the
253
- browser keeps user input. Re-echo the submitted values in `state.values` so
254
- they survive a full no-JS re-render too.
271
+ **Why echo values into `defaultValue`** — React 19 resets a form's
272
+ uncontrolled fields after its `action` completes, and a no-JS submission
273
+ re-renders the whole page. In both cases the only thing that survives is what
274
+ you render, so return the submitted values in `state.values` and use them as
275
+ `defaultValue`. (A controlled `value` without an `onChange` would make the
276
+ inputs read-only.)
255
277
 
256
278
  ### 3. `useOptimistic` — instant UI before the action settles
257
279
 
@@ -384,12 +406,25 @@ const parsed = SignupSchema.safeParse(ctx.body);
384
406
  When an action mutates data, the matched route tree may need to partially
385
407
  re-render so the UI updates. Rango runs the action, then evaluates
386
408
  `revalidate()` on matched segments and loaders. Each path, layout, parallel,
387
- intercept, or loader rule decides whether that piece re-renders/re-resolves.
409
+ or loader rule decides whether that piece re-renders/re-resolves. An open
410
+ intercept's handler re-runs on each action render (a prerendered intercept
411
+ variant reuses its stored output); its loaders carry their own rules
412
+ (`loader(Def, () => [revalidate(...)])` — an intercept-level `revalidate()`
413
+ throws at definition time).
388
414
 
389
415
  Use `ctx.isAction()` for specific actions or modules. It accepts one action,
390
- several actions, or a namespace import (`import * as CartActions`). Pair it with
391
- `|| undefined` for "revalidate on match, otherwise defer to defaults/downstream
392
- rules."
416
+ several actions, or a namespace import (`import * as CartActions`). Wrap it by
417
+ what the segment's default already does:
418
+
419
+ - **Add a signal** where the default skips (a parent layout or layout-level
420
+ parallel): `ctx.isAction(CartActions) || undefined` re-renders it after cart
421
+ actions and defers otherwise. On a loader or route segment it changes nothing.
422
+ - **Narrow after actions** where the default re-runs (loaders, the route, and
423
+ segments inside the `path()`):
424
+ `ctx.isAction() ? ctx.isAction(CartActions) : undefined` re-runs only for cart
425
+ actions and keeps the navigation default.
426
+ - **Avoid bare `ctx.isAction(CartActions)`.** It is a hard `false` on navigation
427
+ too, so a loader stops refetching when params change.
393
428
 
394
429
  ```typescript
395
430
  // urls.tsx — inside the urls() callback. Nest each loader inside the path(),
@@ -404,18 +439,25 @@ urls(({ path, loader, revalidate }) => [
404
439
  loader(StaticHomepageLoader, () => [revalidate(() => false)]),
405
440
  ]),
406
441
 
407
- // Re-render the cart page handler AND re-resolve its loader after cart actions
442
+ // Re-render the cart page handler AND re-resolve its loader only after cart
443
+ // actions (both re-run after every action by default)
408
444
  path("/cart", CartPage, { name: "cart" }, () => [
409
- revalidate((ctx) => ctx.isAction(CartActions) || undefined),
445
+ revalidate((ctx) =>
446
+ ctx.isAction() ? ctx.isAction(CartActions) : undefined,
447
+ ),
410
448
  loader(CartLoader, () => [
411
- revalidate((ctx) => ctx.isAction(CartActions) || undefined),
449
+ revalidate((ctx) =>
450
+ ctx.isAction() ? ctx.isAction(CartActions) : undefined,
451
+ ),
412
452
  ]),
413
453
  ]),
414
454
 
415
- // Re-run after any action exported by the account actions module
455
+ // Re-run the loader only after actions exported by the account actions module
416
456
  path("/account", AccountPage, { name: "account" }, () => [
417
457
  loader(AccountLoader, () => [
418
- revalidate((ctx) => ctx.isAction(AccountActions) || undefined),
458
+ revalidate((ctx) =>
459
+ ctx.isAction() ? ctx.isAction(AccountActions) : undefined,
460
+ ),
419
461
  ]),
420
462
  ]),
421
463
  ]);
@@ -424,7 +466,8 @@ urls(({ path, loader, revalidate }) => [
424
466
  The raw `actionId` string stays available for broad path filters:
425
467
 
426
468
  ```typescript
427
- // Match any action under src/actions/account/, including modules not imported here.
469
+ // On a parent layout (skipped after actions by default): also re-render after
470
+ // any action under src/actions/account/, including modules not imported here.
428
471
  revalidate(
429
472
  ({ actionId }) => actionId?.startsWith("src/actions/account/") || undefined,
430
473
  );
@@ -446,15 +489,19 @@ stale context. Share the same `revalidate` predicate on both producer and
446
489
  consumer:
447
490
 
448
491
  ```typescript
492
+ import type { Revalidate } from "@rangojs/router";
449
493
  import * as CartActions from "./actions/cart";
450
494
 
451
- const revalidateCart = (ctx) => ctx.isAction(CartActions) || undefined;
495
+ const revalidateCart: Revalidate = (ctx) =>
496
+ ctx.isAction(CartActions) || undefined;
452
497
 
453
498
  urls(({ path, layout, loader, revalidate }) => [
454
499
  layout(CartLayout, () => [
455
- revalidate(revalidateCart), // producer reruns
500
+ revalidate(revalidateCart), // producer: re-renders after cart actions (skipped by default)
456
501
  path("/cart", CartPage, { name: "cart" }, () => [
457
- loader(CartItemsLoader, () => [revalidate(revalidateCart)]), // consumer reruns
502
+ // consumer: loaders re-run after every action anyway; the shared
503
+ // contract names the dependency
504
+ loader(CartItemsLoader, () => [revalidate(revalidateCart)]),
458
505
  ]),
459
506
  ]),
460
507
  ]);
@@ -462,6 +509,52 @@ urls(({ path, layout, loader, revalidate }) => [
462
509
 
463
510
  See `/middleware` for the full cross-segment revalidation contract.
464
511
 
512
+ ## Client Cache After an Action
513
+
514
+ `revalidate()` decides what the server re-renders **in the action's response**.
515
+ Separately, the browser invalidates its own caches once the action's response
516
+ arrives: it marks the history cache stale (Back renders the cached entry, then
517
+ revalidates), flushes the prefetch cache, rotates the router's state cookie,
518
+ and tells other open tabs to do the same. This happens for every action that
519
+ reached the server, including ones that threw. No setup is needed.
520
+
521
+ ### `keepClientCache()` — skip it for no-op actions
522
+
523
+ Some hot actions change nothing a route renders: a debounced draft autosave, an
524
+ analytics event, a save that turned out to be a no-op. Call `keepClientCache()`
525
+ inside the action to tell the client to leave its caches alone for this call:
526
+
527
+ ```typescript
528
+ // app/actions/draft.ts
529
+ "use server";
530
+
531
+ import { keepClientCache } from "@rangojs/router";
532
+
533
+ export async function saveDraft(formData: FormData) {
534
+ const changed = await drafts.persist(formData);
535
+ if (!changed) keepClientCache(); // decided per call, not per action
536
+ return { saved: true };
537
+ }
538
+ ```
539
+
540
+ What it suppresses, for this action only: the stale-marking of the history
541
+ cache, the prefetch flush, the state-cookie rotation, the cross-tab broadcast,
542
+ and the background re-fetch the client runs when the user navigated away while
543
+ the action was in flight. What it does not change:
544
+
545
+ - The action's own response still carries the revalidation render your
546
+ `revalidate()` rules select, and the client applies it.
547
+ - A concurrent action that did not call it still invalidates.
548
+ - An explicit `invalidateClientCache()` in the same action still rotates the
549
+ cookie, so invalidation wins.
550
+
551
+ It is a server directive (an internal response header the client reads).
552
+ Outside a request it is a no-op with a dev warning; the client-side import is
553
+ also a no-op with a dev warning. Like `cookies()`, it throws inside a
554
+ `cache()` / `"use cache"` boundary. For the opposite case — a mutation the
555
+ router cannot see, such as a REST call or WebSocket push — use
556
+ `invalidateClientCache()` (see `/hooks`, `state.md`).
557
+
465
558
  ## Redirects
466
559
 
467
560
  `redirect()` works inside actions. Both `return redirect(...)` and
@@ -527,36 +620,55 @@ is usually too aggressive.
527
620
  ### Unexpected errors — let them throw
528
621
 
529
622
  Throw for genuinely exceptional conditions (network failure, DB outage,
530
- auth violation). The nearest `errorBoundary()` in the route tree catches
531
- them.
623
+ auth violation). The nearest `errorBoundary()` above the page the action was
624
+ called from catches them, and the boundary renders inside route middleware like
625
+ a normal render.
532
626
 
533
627
  ```typescript
534
- import { errorBoundary } from "@rangojs/router";
535
-
628
+ // urls.tsx — inside the urls() callback
536
629
  layout(CheckoutLayout, () => [
537
- errorBoundary(({ error, reset }) => (
538
- <div>
539
- <p>Checkout failed: {error.message}</p>
540
- <button onClick={reset}>Try again</button>
630
+ // Server fallback: receives { error } only (ErrorInfo); there is no reset().
631
+ errorBoundary(({ error }) => (
632
+ <div role="alert">
633
+ <p>
634
+ {error.name === "ForbiddenError"
635
+ ? "You cannot change this order."
636
+ : "Checkout failed."}
637
+ </p>
638
+ <a href="/checkout">Try again</a>
541
639
  </div>
542
640
  )),
543
641
  path("/checkout", CheckoutPage, { name: "checkout" }),
544
642
  ]);
545
643
  ```
546
644
 
645
+ In production `error.message` is replaced with `"An error occurred"` (and
646
+ `stack` / `cause` are dropped), while `error.name` and `error.code` are kept —
647
+ branch on `name` or `code`, not on the message.
648
+
547
649
  ### Not found from an action
548
650
 
651
+ `notFound()` inside an action is handled like any other thrown error: it
652
+ renders the nearest `errorBoundary()` with `error.name === "DataNotFoundError"`.
653
+ `notFoundBoundary()` handles `notFound()` from handlers and loaders, not from
654
+ actions.
655
+
549
656
  ```typescript
657
+ // app/actions/posts.ts
658
+ "use server";
659
+
550
660
  import { notFound } from "@rangojs/router";
551
661
 
552
662
  export async function deletePost(id: string): Promise<void> {
553
- "use server";
554
663
  const post = await db.posts.find(id);
555
- if (!post) notFound("Post not found"); // hits notFoundBoundary
664
+ if (!post) notFound("Post not found"); // → nearest errorBoundary()
556
665
  await db.posts.delete(id);
557
666
  }
558
667
  ```
559
668
 
669
+ For a recoverable "already gone" case, return a result the form can show
670
+ (`{ error: "Post not found" }`) instead of throwing.
671
+
560
672
  ### Authorization in actions
561
673
 
562
674
  Route middleware does **not** wrap action execution — only global
@@ -564,11 +676,18 @@ middleware (`router.use()`) does. Auth checks must therefore live in
564
676
  `router.use()` or inside the action itself. Don't rely on a route-level
565
677
  `middleware()` to gate action access.
566
678
 
679
+ An action is identified by its id, not by a URL: the client posts it to the
680
+ current page's URL, but any URL that matches a route can carry it. A
681
+ pattern-scoped `router.use("/admin/*", ...)` therefore guards action requests
682
+ posted from `/admin/*` pages, not the action itself. Use global middleware to
683
+ establish identity (load the session), and authorize sensitive actions inside
684
+ the action body.
685
+
567
686
  ```typescript
568
- // router.tsx — global guard wraps action + render
687
+ // router.tsx — global middleware wraps action + render
569
688
  const router = createRouter()
570
- .use(authInit)
571
- .use("/admin/*", requireAdmin) // protects actions on /admin too
689
+ .use(authInit) // loads the session for every request, actions included
690
+ .use("/admin/*", requireAdmin) // guards /admin pages and actions posted from them
572
691
  .routes(urlpatterns);
573
692
  ```
574
693
 
@@ -590,7 +709,7 @@ export async function deleteOrder(orderId: string) {
590
709
  if (!user) throw redirect("/login"); // unauthenticated → bounce to login
591
710
 
592
711
  const order = await db.orders.get(orderId);
593
- if (!order) notFound("Order not found"); // → notFoundBoundary
712
+ if (!order) notFound("Order not found"); // → errorBoundary (DataNotFoundError)
594
713
  if (order.userId !== user.id) throw new ForbiddenError(); // → errorBoundary
595
714
 
596
715
  await db.orders.delete(orderId);
@@ -601,10 +720,46 @@ export async function deleteOrder(orderId: string) {
601
720
  > Responses thrown from actions are treated as errors and routed to the nearest
602
721
  > `errorBoundary()`, not returned as real HTTP responses (the dev build warns
603
722
  > when you do this). Use `redirect()` to send unauthenticated users to a login
604
- > page, `notFound()` for missing resources, and a domain error class for
605
- > forbidden access so the boundary can render an appropriate UI. For
606
- > recoverable cases, return `{ error: "..." }` via `useActionState` instead of
607
- > throwing.
723
+ > page, and `notFound()` or a domain error class (branch on `error.name` in the
724
+ > boundary) for missing or forbidden resources. For recoverable cases, return
725
+ > `{ error: "..." }` via `useActionState` instead of throwing.
726
+
727
+ ## CSRF Protection
728
+
729
+ Actions are protected against cross-site request forgery by default, with no
730
+ token to thread through your forms. Before an action runs — called over JS or
731
+ posted by a no-JS form — the router's `originCheck` compares the request's
732
+ `Origin` header (or `Referer` when `Origin` is absent) with the request
733
+ protocol and `Host`:
734
+
735
+ | Request | Result |
736
+ | ---------------------------------------------- | ------------------------------------------------------------ |
737
+ | Same-origin `Origin` / `Referer` | Action runs |
738
+ | Cross-origin `Origin` / `Referer` | `403`, action never runs |
739
+ | `Origin: null` (sandboxed iframe, `data:` URL) | `403`, action never runs |
740
+ | Neither header (curl, server-to-server) | Action runs — a non-browser client carries no victim cookies |
741
+ | `Origin` present but no `Host` header | `403` (fails closed) |
742
+
743
+ The same gate covers fetchable-loader requests (`useFetchLoader()`,
744
+ `load({ method: "POST" })`). It runs before the action is decoded, so a
745
+ rejected request executes no action code. A rejection returns `403` with
746
+ `X-Rango-Origin-Check: failed` (in dev the body shows both header values),
747
+ reports to `onError` with phase `"origin"`, and emits a
748
+ `request.origin-rejected` telemetry event (`/observability`).
749
+
750
+ What the check does not do:
751
+
752
+ - **Authorize.** A same-origin request from a logged-out or unprivileged user
753
+ passes. Keep the checks from "Authorization in actions".
754
+ - **Run before global middleware.** `router.use()` middleware wraps the whole
755
+ request, so it runs for a cross-origin POST before the `403`. Don't mutate
756
+ state in middleware assuming the origin was already checked.
757
+ - **Cover response routes.** `path.json()` and the other response-route tags
758
+ are outside the gate; see `/response-routes` → "CSRF".
759
+ - **Trust proxies.** `X-Forwarded-Host` / `X-Forwarded-Proto` are ignored.
760
+ Behind a proxy that rewrites `Host`, or to allow an extra trusted origin, pass
761
+ an `originCheck` function (`/router-setup` → "Origin check").
762
+ `originCheck: false` disables the gate.
608
763
 
609
764
  ## Action Context
610
765
 
@@ -633,18 +788,15 @@ request scope.
633
788
 
634
789
  ### Constraints
635
790
 
636
- | Constraint | Why |
637
- | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
638
- | Actions cannot return or throw a non-redirect `Response` | Return values go through RSC Flight serialization. A thrown non-redirect `Response` is treated as a regular error and hits the nearest `errorBoundary()` (dev warns). Use `redirect()`, `notFound()`, or domain errors. |
639
- | Route DSL `middleware()` does not wrap actions | Actions execute before route middleware. Only global `router.use()` middleware (and its scoped variants) wrap action execution. |
640
- | `useFetchLoader()` is for reads, not writes | Actions are the mutation primitive; loaders are for data fetching. |
791
+ | Constraint | Why |
792
+ | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
793
+ | Actions cannot return or throw a non-redirect `Response` | Return values go through RSC Flight serialization. A returned non-redirect `Response` is discarded and the page re-renders; a thrown one is treated as a regular error and hits the nearest `errorBoundary()` (dev warns in both cases). Use `redirect()`, `notFound()`, or domain errors. |
794
+ | Route DSL `middleware()` does not wrap actions | Actions execute before route middleware. Only global `router.use()` middleware (and its scoped variants) wrap action execution. |
795
+ | Prefer actions over fetchable-loader POSTs for writes | Only actions trigger the post-action revalidation render and the client-cache invalidation. A `useFetchLoader()` / `load({ method: "POST" })` call does neither (see `/loader` → "Mutation Context"). |
641
796
 
642
797
  Cookies/headers set in **global** `router.use()` middleware DO propagate to
643
- action responses (the same merge path as a normal render). The constraint
644
- specific to **per-fetchable-loader** middleware (`createLoader(fn, {
645
- middleware })` on a POST request) is that it cannot set cookies — set them
646
- in the loader body instead. See `/middleware` for the full middleware
647
- contract.
798
+ action responses (the same merge path as a normal render). See `/middleware`
799
+ for the full middleware contract.
648
800
 
649
801
  ## File Uploads
650
802
 
@@ -729,22 +881,30 @@ function AddButton({ productId }: { productId: string }) {
729
881
  >
730
882
  {state === "loading" ? "Adding…" : "Add"}
731
883
  </button>
732
- {error && <p role="alert">{error.message}</p>}
884
+ {error instanceof Error && <p role="alert">{error.message}</p>}
733
885
  </>
734
886
  );
735
887
  }
736
888
  ```
737
889
 
890
+ `error` is typed `unknown` (whatever the action threw), so narrow it before
891
+ reading `.message`.
892
+
738
893
  `useActionState` and `useAction` are complementary — use `useActionState`
739
894
  for `<form action={...}>` flows, `useAction` for imperative button clicks
740
- or to observe an action triggered elsewhere on the page.
895
+ or to observe an action triggered elsewhere on the page. See `/hooks`
896
+ (`handle-and-actions.md`) for the full `useAction` state shape and string
897
+ matching.
741
898
 
742
899
  ## Progressive Enhancement
743
900
 
744
901
  `<form action={serverAction}>` works without JavaScript: the form posts as a
745
902
  normal HTTP request, the action runs, and the page re-renders server-side.
746
- For PE to work, write actions that accept `FormData` directly (not curried
747
- or wrapped):
903
+ For PE to work, the form's `action` must be the server reference itself, or a
904
+ `.bind(null, ...)` of it (React encodes bound arguments into the form, as in
905
+ pattern 1 above), or the dispatcher `useActionState` returns for it. A
906
+ client-side closure that calls the action is not a server reference and breaks
907
+ PE (see "File Uploads"). The simplest PE-safe shape:
748
908
 
749
909
  ```tsx
750
910
  // Works with no-JS submission
@@ -764,7 +924,8 @@ export async function submitName(formData: FormData) {
764
924
 
765
925
  `useActionState` and `useOptimistic` only enhance the experience once JS is
766
926
  loaded — without JS, the underlying action still runs and the page still
767
- re-renders. Don't rely on client-only state for required form behavior.
927
+ re-renders (with the `useActionState` result restored as its state). Don't rely
928
+ on client-only state for required form behavior.
768
929
 
769
930
  ## Cross-references
770
931
 
@@ -34,9 +34,11 @@ batched.
34
34
  1. **Handles record data at render time.** The handler pushes to a handle
35
35
  (`ctx.use(Handle)`) while it renders — at build time for `Prerender`, on
36
36
  the cache miss for `cache()`. (Loader bodies can push handles too — see
37
- `/loader` — but a loader push is request-time and is NOT part of the
38
- replayed artifact; a manifest handle must be pushed by the code that gets
39
- frozen with the shell.)
37
+ `/loader` — but a live loader's push is request-time and is NOT part of
38
+ the replayed artifact; a manifest handle must be pushed by the code that
39
+ gets frozen with the shell. An `ssr: false` loader on a `ppr` route is the
40
+ exception: its settled pushes are recorded and replayed, and its re-run
41
+ replaces them with its live pushes — `/ppr` "On a shell HIT".)
40
42
  2. **Replay on every hit.** Handle data is stored with the Flight payload
41
43
  and replayed into the handle store on cache/prerender hits — handler code
42
44
  does not re-run, but its pushes do.
@@ -94,7 +96,7 @@ import { RenderedProducts } from "../handles/rendered-products";
94
96
  export const PriceLoader = createLoader(async (ctx) => {
95
97
  await ctx.rendered();
96
98
  const ids = ctx.get(RenderedProducts);
97
- return db.pricesFor(ids); // Map<string, number> keyed by product id
99
+ return db.pricesFor(ids); // Record<string, number> keyed by product id
98
100
  });
99
101
  ```
100
102
 
@@ -138,9 +140,11 @@ cache({ ttl: 600, tags: ["products"] }, () => [
138
140
  ## Contract and gotchas
139
141
 
140
142
  - **The manifest is exactly as fresh as the shell.** Replayed handle data is
141
- frozen with the payload. To change _which_ products render, invalidate the
142
- shell (`updateTag("products")`, TTL expiry, rebuild) — never treat the
143
- loader as the refresh path for the list itself. This is the point:
143
+ frozen with the payload. To change _which_ products render, refresh the
144
+ shell — for `cache()`, `updateTag("products")` or TTL expiry; for
145
+ `Prerender`, a rebuild (tag invalidation does not reach build-time payloads,
146
+ see `/prerender`). Never treat the loader as the refresh path for the list
147
+ itself. This is the point:
144
148
  shell and holes cannot desync because they share one artifact.
145
149
  - **No request-scoped data in a manifest handle.** The handle data is baked
146
150
  into a shared artifact — the same cross-user rule as any cached content.
@@ -187,6 +191,7 @@ production), like every cache-path behavior.
187
191
 
188
192
  - `/prerender` — `Prerender`/`Passthrough`, build flow, passthrough fallback
189
193
  - `/caching` — segment `cache()`, stores, tags
194
+ - `/ppr` — cached HTML shells; its holes are the same live loaders
190
195
  - `/loader` — loader context, `ctx.rendered()`, streaming
191
196
  - `/hooks` — `useHandle` for reading handle data in client components
192
197
  - `/rango` → "Passing data down the tree" — this pattern is the frozen→live