@rangojs/router 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (309) hide show
  1. package/README.md +255 -89
  2. package/dist/testing/vitest.js +252 -0
  3. package/dist/types/browser/cookie-name.d.ts +6 -0
  4. package/dist/types/browser/history-state.d.ts +16 -0
  5. package/dist/types/browser/navigation-transaction.d.ts +6 -0
  6. package/dist/types/browser/partial-update.d.ts +11 -0
  7. package/dist/types/browser/react/ScrollRestoration.d.ts +6 -6
  8. package/dist/types/browser/rsc-router.d.ts +1 -1
  9. package/dist/types/browser/scroll-restoration.d.ts +7 -0
  10. package/dist/types/cache/cache-exec-scope.d.ts +18 -1
  11. package/dist/types/cache/cache-key-utils.d.ts +17 -2
  12. package/dist/types/cache/cache-policy.d.ts +17 -0
  13. package/dist/types/cache/cache-scope.d.ts +132 -19
  14. package/dist/types/cache/cache-tag.d.ts +92 -7
  15. package/dist/types/cache/cf/cf-base64.d.ts +6 -0
  16. package/dist/types/cache/cf/cf-cache-constants.d.ts +11 -0
  17. package/dist/types/cache/cf/cf-cache-store.d.ts +185 -44
  18. package/dist/types/cache/cf/cf-cache-types.d.ts +31 -4
  19. package/dist/types/cache/cf/cf-shell-frame.d.ts +95 -0
  20. package/dist/types/cache/cf/cf-tag-marker-memo.d.ts +34 -0
  21. package/dist/types/cache/handle-capture.d.ts +41 -3
  22. package/dist/types/cache/handle-snapshot.d.ts +54 -13
  23. package/dist/types/cache/index.d.ts +1 -0
  24. package/dist/types/cache/isolate-tag-memo.d.ts +149 -0
  25. package/dist/types/cache/segment-codec.d.ts +10 -3
  26. package/dist/types/cache/shell-memo.d.ts +178 -0
  27. package/dist/types/cache/shell-snapshot.d.ts +110 -21
  28. package/dist/types/cache/tag-invalidation.d.ts +30 -3
  29. package/dist/types/cache/taint.d.ts +1 -1
  30. package/dist/types/cache/types.d.ts +220 -44
  31. package/dist/types/cache/vercel/vercel-cache-store.d.ts +111 -13
  32. package/dist/types/client.d.ts +4 -4
  33. package/dist/types/client.rsc.d.ts +4 -5
  34. package/dist/types/components/DefaultDocument.d.ts +5 -2
  35. package/dist/types/context-var.d.ts +6 -5
  36. package/dist/types/escape-script.d.ts +1 -1
  37. package/dist/types/handles/MetaTags.d.ts +5 -4
  38. package/dist/types/handles/Scripts.d.ts +8 -7
  39. package/dist/types/handles/meta.d.ts +2 -2
  40. package/dist/types/handles/script.d.ts +17 -16
  41. package/dist/types/href-client.d.ts +1 -1
  42. package/dist/types/html.d.ts +12 -0
  43. package/dist/types/index.d.ts +1 -1
  44. package/dist/types/index.rsc.d.ts +3 -2
  45. package/dist/types/loader-store.d.ts +7 -4
  46. package/dist/types/prerender/build-shell-capture.d.ts +11 -4
  47. package/dist/types/reverse.d.ts +36 -4
  48. package/dist/types/route-definition/dsl-helpers.d.ts +3 -3
  49. package/dist/types/route-definition/helpers-types.d.ts +33 -9
  50. package/dist/types/route-definition/resolve-handler-use.d.ts +23 -1
  51. package/dist/types/route-types.d.ts +1 -1
  52. package/dist/types/router/debug-manifest.d.ts +3 -2
  53. package/dist/types/router/error-handling.d.ts +20 -0
  54. package/dist/types/router/handler-context.d.ts +6 -0
  55. package/dist/types/router/instrument.d.ts +8 -0
  56. package/dist/types/router/match-middleware/background-revalidation.d.ts +46 -5
  57. package/dist/types/router/middleware-types.d.ts +9 -3
  58. package/dist/types/router/router-context.d.ts +3 -1
  59. package/dist/types/router/router-interfaces.d.ts +24 -2
  60. package/dist/types/router/router-options.d.ts +8 -5
  61. package/dist/types/router/segment-resolution/fresh.d.ts +10 -0
  62. package/dist/types/router/segment-resolution/loader-cache.d.ts +66 -1
  63. package/dist/types/router/segment-resolution/loader-mask.d.ts +9 -19
  64. package/dist/types/router/segment-resolution/loader-snapshot.d.ts +23 -12
  65. package/dist/types/router/segment-resolution/mask-nested.d.ts +67 -26
  66. package/dist/types/router/segment-resolution/revalidation.d.ts +6 -2
  67. package/dist/types/router/segment-wrappers.d.ts +1 -1
  68. package/dist/types/rsc/full-payload.d.ts +14 -0
  69. package/dist/types/rsc/handler.d.ts +1 -1
  70. package/dist/types/rsc/response-route-handler.d.ts +1 -1
  71. package/dist/types/rsc/rsc-rendering.d.ts +1 -2
  72. package/dist/types/rsc/shell-build-manifest.d.ts +8 -0
  73. package/dist/types/rsc/shell-capture-constants.d.ts +61 -6
  74. package/dist/types/rsc/shell-capture.d.ts +151 -19
  75. package/dist/types/rsc/shell-serve.d.ts +140 -17
  76. package/dist/types/rsc/ssr-setup.d.ts +6 -6
  77. package/dist/types/rsc/types.d.ts +17 -0
  78. package/dist/types/server/context.d.ts +139 -15
  79. package/dist/types/server/cookie-store.d.ts +21 -0
  80. package/dist/types/server/handle-store.d.ts +26 -2
  81. package/dist/types/server/request-context.d.ts +182 -53
  82. package/dist/types/ssr/index.d.ts +24 -0
  83. package/dist/types/ssr/ssr-root.d.ts +9 -8
  84. package/dist/types/static-handler.d.ts +4 -1
  85. package/dist/types/testing/flight.entry.d.ts +7 -0
  86. package/dist/types/testing/generated-routes.d.ts +2 -2
  87. package/dist/types/testing/internal/context.d.ts +1 -1
  88. package/dist/types/testing/render-route.d.ts +32 -6
  89. package/dist/types/testing/run-loader.d.ts +18 -3
  90. package/dist/types/testing/serve-shell-request.d.ts +132 -0
  91. package/dist/types/testing/shell-status.d.ts +23 -10
  92. package/dist/types/testing/vitest-stubs/plugin-rsc.d.ts +28 -5
  93. package/dist/types/testing/vitest.d.ts +35 -1
  94. package/dist/types/theme/ThemeScript.d.ts +3 -3
  95. package/dist/types/types/cache-types.d.ts +14 -10
  96. package/dist/types/types/handler-context.d.ts +28 -1
  97. package/dist/types/types/loader-types.d.ts +8 -6
  98. package/dist/types/urls/pattern-types.d.ts +22 -18
  99. package/dist/types/urls/response-types.d.ts +3 -12
  100. package/dist/types/use-loader.d.ts +12 -3
  101. package/dist/types/vite/plugins/use-cache-transform.d.ts +33 -2
  102. package/dist/types/vite/rango.d.ts +1 -1
  103. package/dist/types/vite/router-discovery.d.ts +29 -2
  104. package/dist/vite/index.js +1420 -1348
  105. package/package.json +2 -2
  106. package/skills/api-client/SKILL.md +22 -10
  107. package/skills/breadcrumbs/SKILL.md +75 -22
  108. package/skills/bundle-analysis/SKILL.md +22 -22
  109. package/skills/cache-guide/SKILL.md +306 -124
  110. package/skills/caching/SKILL.md +530 -98
  111. package/skills/catalog.json +16 -16
  112. package/skills/client-urls/SKILL.md +65 -38
  113. package/skills/cloudflare/SKILL.md +87 -20
  114. package/skills/cloudflare/references/d1-and-local-dev.md +15 -2
  115. package/skills/cloudflare/references/streaming-and-deploy.md +10 -3
  116. package/skills/cloudflare/references/webhooks-and-crypto.md +9 -2
  117. package/skills/comparison/SKILL.md +5 -0
  118. package/skills/comparison/references/framework-comparison.md +75 -57
  119. package/skills/composability/SKILL.md +38 -29
  120. package/skills/css/SKILL.md +10 -13
  121. package/skills/debug-manifest/SKILL.md +74 -31
  122. package/skills/defer-hydration/SKILL.md +52 -31
  123. package/skills/deployment-caching/SKILL.md +11 -3
  124. package/skills/document-cache/SKILL.md +98 -28
  125. package/skills/fonts/SKILL.md +16 -8
  126. package/skills/handler-use/SKILL.md +55 -17
  127. package/skills/hooks/SKILL.md +28 -20
  128. package/skills/hooks/data.md +23 -3
  129. package/skills/hooks/handle-and-actions.md +34 -17
  130. package/skills/hooks/navigation.md +39 -10
  131. package/skills/hooks/outlets.md +13 -6
  132. package/skills/hooks/state.md +39 -15
  133. package/skills/hooks/urls.md +7 -5
  134. package/skills/host-router/SKILL.md +64 -12
  135. package/skills/i18n/SKILL.md +60 -34
  136. package/skills/intercept/SKILL.md +161 -26
  137. package/skills/layout/SKILL.md +106 -50
  138. package/skills/links/SKILL.md +51 -19
  139. package/skills/loader/SKILL.md +386 -128
  140. package/skills/middleware/SKILL.md +129 -26
  141. package/skills/migrate-nextjs/SKILL.md +156 -70
  142. package/skills/migrate-nextjs/backend-host-swap.md +5 -2
  143. package/skills/migrate-react-router/SKILL.md +51 -39
  144. package/skills/migrate-react-router/component-migration.md +28 -7
  145. package/skills/migrate-react-router/data-and-actions.md +21 -6
  146. package/skills/migrate-react-router/route-mapping.md +19 -10
  147. package/skills/mime-routes/SKILL.md +12 -7
  148. package/skills/observability/SKILL.md +92 -14
  149. package/skills/parallel/SKILL.md +88 -47
  150. package/skills/ppr/SKILL.md +866 -379
  151. package/skills/prerender/SKILL.md +210 -108
  152. package/skills/rango/SKILL.md +115 -65
  153. package/skills/react-compiler/SKILL.md +6 -5
  154. package/skills/response-routes/SKILL.md +133 -25
  155. package/skills/route/SKILL.md +170 -60
  156. package/skills/router-setup/SKILL.md +420 -240
  157. package/skills/scripts/SKILL.md +39 -21
  158. package/skills/server-actions/SKILL.md +231 -59
  159. package/skills/shell-manifest/SKILL.md +28 -14
  160. package/skills/streams-and-websockets/SKILL.md +32 -14
  161. package/skills/tailwind/SKILL.md +37 -33
  162. package/skills/testing/SKILL.md +73 -33
  163. package/skills/testing/cache-prerender.md +60 -33
  164. package/skills/testing/client-components.md +59 -6
  165. package/skills/testing/e2e-parity.md +12 -2
  166. package/skills/testing/flight.md +19 -11
  167. package/skills/testing/handles.md +8 -5
  168. package/skills/testing/loader.md +26 -6
  169. package/skills/testing/middleware.md +8 -3
  170. package/skills/testing/predicates.md +120 -0
  171. package/skills/testing/render-handler.md +70 -22
  172. package/skills/testing/response-routes.md +11 -4
  173. package/skills/testing/reverse-and-types.md +20 -14
  174. package/skills/testing/serve-shell-request.md +188 -0
  175. package/skills/testing/server-actions.md +12 -3
  176. package/skills/testing/server-tree.md +9 -0
  177. package/skills/testing/setup.md +9 -5
  178. package/skills/theme/SKILL.md +155 -30
  179. package/skills/typesafety/SKILL.md +20 -9
  180. package/skills/typesafety/env-and-bindings.md +137 -50
  181. package/skills/typesafety/generated-files-and-cli.md +26 -14
  182. package/skills/typesafety/params-and-search.md +44 -19
  183. package/skills/typesafety/route-types.md +50 -14
  184. package/skills/use-cache/SKILL.md +224 -83
  185. package/skills/vercel/SKILL.md +35 -13
  186. package/skills/view-transitions/SKILL.md +42 -21
  187. package/src/__augment-tests__/augmented.check.ts +26 -1
  188. package/src/browser/cookie-name.ts +23 -0
  189. package/src/browser/history-state.ts +23 -0
  190. package/src/browser/index.ts +2 -2
  191. package/src/browser/navigation-bridge.ts +3 -4
  192. package/src/browser/navigation-transaction.ts +22 -2
  193. package/src/browser/partial-update.ts +35 -25
  194. package/src/browser/react/ScrollRestoration.tsx +6 -6
  195. package/src/browser/rsc-router.tsx +6 -1
  196. package/src/browser/scroll-restoration.ts +53 -6
  197. package/src/cache/cache-exec-scope.ts +37 -4
  198. package/src/cache/cache-key-utils.ts +23 -8
  199. package/src/cache/cache-policy.ts +22 -0
  200. package/src/cache/cache-runtime.ts +264 -68
  201. package/src/cache/cache-scope.ts +419 -43
  202. package/src/cache/cache-tag.ts +375 -11
  203. package/src/cache/cf/cf-base64.ts +36 -4
  204. package/src/cache/cf/cf-cache-constants.ts +12 -0
  205. package/src/cache/cf/cf-cache-store.ts +990 -348
  206. package/src/cache/cf/cf-cache-types.ts +32 -4
  207. package/src/cache/cf/cf-shell-frame.ts +258 -0
  208. package/src/cache/cf/cf-tag-marker-memo.ts +143 -39
  209. package/src/cache/document-cache.ts +15 -0
  210. package/src/cache/handle-capture.ts +90 -10
  211. package/src/cache/handle-snapshot.ts +133 -28
  212. package/src/cache/index.ts +2 -0
  213. package/src/cache/isolate-tag-memo.ts +308 -0
  214. package/src/cache/segment-codec.ts +26 -7
  215. package/src/cache/shell-memo.ts +341 -0
  216. package/src/cache/shell-snapshot.ts +217 -27
  217. package/src/cache/tag-invalidation.ts +108 -22
  218. package/src/cache/taint.ts +1 -1
  219. package/src/cache/types.ts +225 -44
  220. package/src/cache/vercel/vercel-cache-store.ts +545 -61
  221. package/src/client.rsc.tsx +10 -9
  222. package/src/client.tsx +5 -4
  223. package/src/components/DefaultDocument.tsx +7 -2
  224. package/src/context-var.ts +6 -5
  225. package/src/escape-script.ts +1 -1
  226. package/src/handles/MetaTags.tsx +13 -11
  227. package/src/handles/Scripts.tsx +11 -10
  228. package/src/handles/meta.ts +2 -2
  229. package/src/handles/script.ts +17 -16
  230. package/src/href-client.ts +1 -1
  231. package/src/html.ts +12 -0
  232. package/src/index.rsc.ts +5 -0
  233. package/src/index.ts +1 -0
  234. package/src/loader-store.ts +7 -4
  235. package/src/prerender/build-shell-capture.ts +73 -22
  236. package/src/reverse.ts +44 -4
  237. package/src/route-definition/dsl-helpers.ts +121 -33
  238. package/src/route-definition/helpers-types.ts +33 -9
  239. package/src/route-definition/resolve-handler-use.ts +121 -6
  240. package/src/route-types.ts +0 -3
  241. package/src/router/debug-manifest.ts +10 -2
  242. package/src/router/error-handling.ts +51 -15
  243. package/src/router/handler-context.ts +71 -28
  244. package/src/router/instrument.ts +54 -2
  245. package/src/router/intercept-resolution.ts +141 -43
  246. package/src/router/loader-resolution.ts +115 -39
  247. package/src/router/match-api.ts +40 -26
  248. package/src/router/match-handlers.ts +5 -0
  249. package/src/router/match-middleware/background-revalidation.ts +161 -96
  250. package/src/router/match-middleware/cache-lookup.ts +110 -10
  251. package/src/router/match-middleware/cache-store.ts +48 -101
  252. package/src/router/middleware-types.ts +9 -6
  253. package/src/router/middleware.ts +75 -13
  254. package/src/router/prerender-match.ts +59 -19
  255. package/src/router/route-snapshot.ts +11 -1
  256. package/src/router/router-context.ts +2 -0
  257. package/src/router/router-interfaces.ts +25 -2
  258. package/src/router/router-options.ts +8 -5
  259. package/src/router/segment-resolution/fresh.ts +62 -21
  260. package/src/router/segment-resolution/helpers.ts +4 -2
  261. package/src/router/segment-resolution/loader-cache.ts +407 -76
  262. package/src/router/segment-resolution/loader-mask.ts +12 -22
  263. package/src/router/segment-resolution/loader-snapshot.ts +208 -56
  264. package/src/router/segment-resolution/mask-nested.ts +138 -44
  265. package/src/router/segment-resolution/revalidation.ts +61 -16
  266. package/src/router/segment-wrappers.ts +4 -0
  267. package/src/rsc/full-payload.ts +25 -1
  268. package/src/rsc/handler.ts +14 -2
  269. package/src/rsc/progressive-enhancement.ts +3 -2
  270. package/src/rsc/render-pipeline.ts +3 -0
  271. package/src/rsc/response-cache-serve.ts +9 -6
  272. package/src/rsc/response-route-handler.ts +11 -3
  273. package/src/rsc/rsc-rendering.ts +608 -225
  274. package/src/rsc/shell-build-manifest.ts +27 -9
  275. package/src/rsc/shell-capture-constants.ts +73 -6
  276. package/src/rsc/shell-capture.ts +1068 -296
  277. package/src/rsc/shell-serve.ts +316 -34
  278. package/src/rsc/ssr-setup.ts +14 -8
  279. package/src/rsc/types.ts +18 -0
  280. package/src/server/context.ts +300 -32
  281. package/src/server/cookie-store.ts +87 -61
  282. package/src/server/handle-store.ts +232 -7
  283. package/src/server/request-context.ts +297 -67
  284. package/src/ssr/index.tsx +136 -44
  285. package/src/ssr/ssr-root.tsx +9 -8
  286. package/src/static-handler.ts +4 -1
  287. package/src/testing/flight-runtime.d.ts +21 -0
  288. package/src/testing/flight.entry.ts +14 -0
  289. package/src/testing/generated-routes.ts +2 -2
  290. package/src/testing/internal/context.ts +1 -1
  291. package/src/testing/internal/flight-client-globals.ts +7 -5
  292. package/src/testing/render-route.tsx +85 -24
  293. package/src/testing/run-loader.ts +68 -6
  294. package/src/testing/serve-shell-request.ts +378 -0
  295. package/src/testing/shell-status.ts +34 -12
  296. package/src/testing/vitest-stubs/plugin-rsc.ts +123 -20
  297. package/src/testing/vitest.ts +62 -1
  298. package/src/theme/ThemeProvider.tsx +7 -6
  299. package/src/theme/ThemeScript.tsx +3 -3
  300. package/src/types/cache-types.ts +14 -10
  301. package/src/types/handler-context.ts +28 -1
  302. package/src/types/loader-types.ts +8 -6
  303. package/src/urls/pattern-types.ts +22 -18
  304. package/src/urls/response-types.ts +3 -23
  305. package/src/use-loader.tsx +12 -3
  306. package/src/vite/discovery/shell-prerender-phase.ts +9 -0
  307. package/src/vite/plugins/use-cache-transform.ts +118 -74
  308. package/src/vite/rango.ts +7 -3
  309. package/src/vite/router-discovery.ts +14 -4
@@ -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
@@ -108,7 +114,10 @@ during navigation.
108
114
  > means it is silently dropped. If a loader must contribute an inline script
109
115
  > to the document, register it `loader(Def, { ssr: false })` so the
110
116
  > document render awaits the push; otherwise push from a handler (or use an
111
- > `async` config, which stays reactive).
117
+ > `async` config, which stays reactive). On a `ppr` route a loader without
118
+ > `ssr: false` never runs during the shell capture, so its inline/ordered
119
+ > script is dropped on every shell HIT; with `ssr: false` the push bakes into
120
+ > the shell.
112
121
 
113
122
  **Nonce caveat for soft-nav async.** The "nonce is applied automatically" claim
114
123
  holds for DOCUMENT-RENDERED scripts (they carry the nonce in the SSR HTML). An
@@ -133,9 +142,14 @@ the loader could run before the bootstrap. Instead let the bootstrap inject its
133
142
  own loader (Google's snippet does exactly this):
134
143
 
135
144
  ```ts
136
- function gtmBootstrap(id: string): string {
145
+ // `initial` is pushed into dataLayer before the GTM start event, so tags that
146
+ // fire on the first page_view can read it (see per-route tagging below).
147
+ function gtmBootstrap(id: string, initial?: Record<string, string>): string {
137
148
  return [
138
149
  "window.dataLayer=window.dataLayer||[];",
150
+ // <Html.Scripts> escapes the body against </script> breakout, so plain
151
+ // JSON.stringify is enough here.
152
+ initial ? `window.dataLayer.push(${JSON.stringify(initial)});` : "",
139
153
  'window.dataLayer.push({"gtm.start":new Date().getTime(),event:"gtm.js"});',
140
154
  `(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
155
  ].join("");
@@ -157,7 +171,7 @@ ctx.use(Script)({ id: "gtm", children: gtmBootstrap("GTM-XXXX") });
157
171
  // a route: same id, with content_group baked in
158
172
  ctx.use(Script)({
159
173
  id: "gtm",
160
- children: gtmBootstrapWith({ content_group: "blog" }),
174
+ children: gtmBootstrap("GTM-XXXX", { content_group: "blog" }),
161
175
  });
162
176
  ```
163
177
 
@@ -174,6 +188,10 @@ Otherwise allow the vendor hosts. For GTM/GA4 (Google's wildcards): `script-src
174
188
  `frame-src https://*.googletagmanager.com` for the GTM `<noscript>` iframe. See
175
189
  [Google's CSP guide](https://developers.google.com/tag-platform/security/guides/csp).
176
190
 
191
+ A per-request nonce also takes a `ppr` route off its shell: a shared shell
192
+ cannot carry one request's nonce, so the route renders normally (no shell) and
193
+ warns once per key. See `/ppr` → "What always renders without a shell".
194
+
177
195
  ## Not covered (do it yourself)
178
196
 
179
197
  - **`onLoad` / `onReady` / `onError`** — callbacks can't cross the server handle
@@ -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 |
@@ -33,6 +37,17 @@ Actions mutate state; route handlers and loaders read the latest state. After
33
37
  an action finishes, Rango performs a server-side revalidation render for the
34
38
  matched route so the UI receives fresh segment output and loader data.
35
39
 
40
+ Segments under a route `cache()` re-render fresh on that render (actions skip
41
+ the route cache lookup and write). A loader with its own `cache()` and a
42
+ `"use cache"` read return their stored entry, and a `ppr` route's stored shell
43
+ is not evicted, unless the action invalidates their tags.
44
+ `await updateTag(...tags)` purges them before the revalidation render, so the
45
+ action's own response reads fresh (`revalidateTag()` gives that render fresh
46
+ reads too; only its durable write runs in the background). On a store that keeps
47
+ per-isolate memos (`CFCacheStore`, `VercelCacheStore`) the response also sets
48
+ the fresh-reads cookie (`rango-state-fresh` by default), so the same user's next
49
+ requests skip those memos (see `/caching` → "The fresh-reads cookie").
50
+
36
51
  The main control point is `revalidate((ctx) => ...)` on the segment that owns
37
52
  the data. Match specific actions by imported reference with `ctx.isAction()`;
38
53
  use raw `actionId` only when you intentionally need path or directory matching.
@@ -48,9 +63,15 @@ export const urlpatterns = urls(({ path, loader, revalidate }) => [
48
63
  // The loader belongs to the route that consumes its data — nest it inside
49
64
  // the owning path() so the segment owns its data dependency.
50
65
  path("/cart", CartPage, { name: "cart" }, () => [
51
- revalidate((ctx) => ctx.isAction(CartActions) || undefined),
66
+ // The route and its loaders re-run after every action by default. Narrow
67
+ // both to cart actions; on navigation, undefined keeps the default.
68
+ revalidate((ctx) =>
69
+ ctx.isAction() ? ctx.isAction(CartActions) : undefined,
70
+ ),
52
71
  loader(CartLoader, () => [
53
- revalidate((ctx) => ctx.isAction(CartActions) || undefined),
72
+ revalidate((ctx) =>
73
+ ctx.isAction() ? ctx.isAction(CartActions) : undefined,
74
+ ),
54
75
  ]),
55
76
  ]),
56
77
  ]);
@@ -99,11 +120,13 @@ import { ChangedTenant } from "./context";
99
120
 
100
121
  export const urlpatterns = urls(({ path, revalidate }) => [
101
122
  path("/dashboard/:tenantId", DashboardPage, { name: "dashboard" }, () => [
123
+ // The route re-runs after every action by default. Narrow it: after an
124
+ // action, re-run only when a tenant action changed this tenant.
102
125
  revalidate((ctx) => {
103
- if (!ctx.isAction(TenantActions)) return undefined;
126
+ if (!ctx.isAction()) return undefined; // navigation: keep the default
104
127
  return (
105
- ctx.context.get(ChangedTenant) === ctx.context.params.tenantId ||
106
- undefined
128
+ ctx.isAction(TenantActions) &&
129
+ ctx.context.get(ChangedTenant) === ctx.context.params.tenantId
107
130
  );
108
131
  }),
109
132
  ]),
@@ -139,6 +162,14 @@ export async function removeFromCart(productId: string): Promise<void> {
139
162
  const userId = cookies().get("user-id")?.value;
140
163
  await db.cart.delete({ userId, productId });
141
164
  }
165
+
166
+ export async function updateQuantity(
167
+ productId: string,
168
+ delta: number,
169
+ ): Promise<void> {
170
+ const userId = cookies().get("user-id")?.value;
171
+ await db.cart.adjust({ userId, productId, delta });
172
+ }
142
173
  ```
143
174
 
144
175
  ### Inline (`"use server"` inside a server component)
@@ -192,9 +223,9 @@ export function AddToCartForm({ productId }: { productId: string }) {
192
223
  ### 2. `useActionState` — preserve return value + pending state
193
224
 
194
225
  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`).
226
+ return value becomes the new `state`. To keep what the user typed after a
227
+ validation error, return the submitted values in the state and render them as
228
+ `defaultValue` (see below).
198
229
 
199
230
  Define the state shape next to the action so the client and server share
200
231
  one type:
@@ -248,10 +279,12 @@ export function ProfileForm({ initial }: { initial: { name: string } }) {
248
279
  }
249
280
  ```
250
281
 
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.
282
+ **Why echo values into `defaultValue`** — React 19 resets a form's
283
+ uncontrolled fields after its `action` completes, and a no-JS submission
284
+ re-renders the whole page. In both cases the only thing that survives is what
285
+ you render, so return the submitted values in `state.values` and use them as
286
+ `defaultValue`. (A controlled `value` without an `onChange` would make the
287
+ inputs read-only.)
255
288
 
256
289
  ### 3. `useOptimistic` — instant UI before the action settles
257
290
 
@@ -384,12 +417,25 @@ const parsed = SignupSchema.safeParse(ctx.body);
384
417
  When an action mutates data, the matched route tree may need to partially
385
418
  re-render so the UI updates. Rango runs the action, then evaluates
386
419
  `revalidate()` on matched segments and loaders. Each path, layout, parallel,
387
- intercept, or loader rule decides whether that piece re-renders/re-resolves.
420
+ or loader rule decides whether that piece re-renders/re-resolves. An open
421
+ intercept's handler re-runs on each action render (a prerendered intercept
422
+ variant reuses its stored output); its loaders carry their own rules
423
+ (`loader(Def, () => [revalidate(...)])` — an intercept-level `revalidate()`
424
+ throws at definition time).
388
425
 
389
426
  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."
427
+ several actions, or a namespace import (`import * as CartActions`). Wrap it by
428
+ what the segment's default already does:
429
+
430
+ - **Add a signal** where the default skips (a parent layout or layout-level
431
+ parallel): `ctx.isAction(CartActions) || undefined` re-renders it after cart
432
+ actions and defers otherwise. On a loader or route segment it changes nothing.
433
+ - **Narrow after actions** where the default re-runs (loaders, the route, and
434
+ segments inside the `path()`):
435
+ `ctx.isAction() ? ctx.isAction(CartActions) : undefined` re-runs only for cart
436
+ actions and keeps the navigation default.
437
+ - **Avoid bare `ctx.isAction(CartActions)`.** It is a hard `false` on navigation
438
+ too, so a loader stops refetching when params change.
393
439
 
394
440
  ```typescript
395
441
  // urls.tsx — inside the urls() callback. Nest each loader inside the path(),
@@ -404,18 +450,25 @@ urls(({ path, loader, revalidate }) => [
404
450
  loader(StaticHomepageLoader, () => [revalidate(() => false)]),
405
451
  ]),
406
452
 
407
- // Re-render the cart page handler AND re-resolve its loader after cart actions
453
+ // Re-render the cart page handler AND re-resolve its loader only after cart
454
+ // actions (both re-run after every action by default)
408
455
  path("/cart", CartPage, { name: "cart" }, () => [
409
- revalidate((ctx) => ctx.isAction(CartActions) || undefined),
456
+ revalidate((ctx) =>
457
+ ctx.isAction() ? ctx.isAction(CartActions) : undefined,
458
+ ),
410
459
  loader(CartLoader, () => [
411
- revalidate((ctx) => ctx.isAction(CartActions) || undefined),
460
+ revalidate((ctx) =>
461
+ ctx.isAction() ? ctx.isAction(CartActions) : undefined,
462
+ ),
412
463
  ]),
413
464
  ]),
414
465
 
415
- // Re-run after any action exported by the account actions module
466
+ // Re-run the loader only after actions exported by the account actions module
416
467
  path("/account", AccountPage, { name: "account" }, () => [
417
468
  loader(AccountLoader, () => [
418
- revalidate((ctx) => ctx.isAction(AccountActions) || undefined),
469
+ revalidate((ctx) =>
470
+ ctx.isAction() ? ctx.isAction(AccountActions) : undefined,
471
+ ),
419
472
  ]),
420
473
  ]),
421
474
  ]);
@@ -424,7 +477,8 @@ urls(({ path, loader, revalidate }) => [
424
477
  The raw `actionId` string stays available for broad path filters:
425
478
 
426
479
  ```typescript
427
- // Match any action under src/actions/account/, including modules not imported here.
480
+ // On a parent layout (skipped after actions by default): also re-render after
481
+ // any action under src/actions/account/, including modules not imported here.
428
482
  revalidate(
429
483
  ({ actionId }) => actionId?.startsWith("src/actions/account/") || undefined,
430
484
  );
@@ -446,15 +500,19 @@ stale context. Share the same `revalidate` predicate on both producer and
446
500
  consumer:
447
501
 
448
502
  ```typescript
503
+ import type { Revalidate } from "@rangojs/router";
449
504
  import * as CartActions from "./actions/cart";
450
505
 
451
- const revalidateCart = (ctx) => ctx.isAction(CartActions) || undefined;
506
+ const revalidateCart: Revalidate = (ctx) =>
507
+ ctx.isAction(CartActions) || undefined;
452
508
 
453
509
  urls(({ path, layout, loader, revalidate }) => [
454
510
  layout(CartLayout, () => [
455
- revalidate(revalidateCart), // producer reruns
511
+ revalidate(revalidateCart), // producer: re-renders after cart actions (skipped by default)
456
512
  path("/cart", CartPage, { name: "cart" }, () => [
457
- loader(CartItemsLoader, () => [revalidate(revalidateCart)]), // consumer reruns
513
+ // consumer: loaders re-run after every action anyway; the shared
514
+ // contract names the dependency
515
+ loader(CartItemsLoader, () => [revalidate(revalidateCart)]),
458
516
  ]),
459
517
  ]),
460
518
  ]);
@@ -462,6 +520,52 @@ urls(({ path, layout, loader, revalidate }) => [
462
520
 
463
521
  See `/middleware` for the full cross-segment revalidation contract.
464
522
 
523
+ ## Client Cache After an Action
524
+
525
+ `revalidate()` decides what the server re-renders **in the action's response**.
526
+ Separately, the browser invalidates its own caches once the action's response
527
+ arrives: it marks the history cache stale (Back renders the cached entry, then
528
+ revalidates), flushes the prefetch cache, rotates the router's state cookie,
529
+ and tells other open tabs to do the same. This happens for every action that
530
+ reached the server, including ones that threw. No setup is needed.
531
+
532
+ ### `keepClientCache()` — skip it for no-op actions
533
+
534
+ Some hot actions change nothing a route renders: a debounced draft autosave, an
535
+ analytics event, a save that turned out to be a no-op. Call `keepClientCache()`
536
+ inside the action to tell the client to leave its caches alone for this call:
537
+
538
+ ```typescript
539
+ // app/actions/draft.ts
540
+ "use server";
541
+
542
+ import { keepClientCache } from "@rangojs/router";
543
+
544
+ export async function saveDraft(formData: FormData) {
545
+ const changed = await drafts.persist(formData);
546
+ if (!changed) keepClientCache(); // decided per call, not per action
547
+ return { saved: true };
548
+ }
549
+ ```
550
+
551
+ What it suppresses, for this action only: the stale-marking of the history
552
+ cache, the prefetch flush, the state-cookie rotation, the cross-tab broadcast,
553
+ and the background re-fetch the client runs when the user navigated away while
554
+ the action was in flight. What it does not change:
555
+
556
+ - The action's own response still carries the revalidation render your
557
+ `revalidate()` rules select, and the client applies it.
558
+ - A concurrent action that did not call it still invalidates.
559
+ - An explicit `invalidateClientCache()` in the same action still rotates the
560
+ cookie, so invalidation wins.
561
+
562
+ It is a server directive (an internal response header the client reads).
563
+ Outside a request it is a no-op with a dev warning; the client-side import is
564
+ also a no-op with a dev warning. Like `cookies()`, it throws inside a
565
+ `cache()` / `"use cache"` boundary. For the opposite case — a mutation the
566
+ router cannot see, such as a REST call or WebSocket push — use
567
+ `invalidateClientCache()` (see `/hooks`, `state.md`).
568
+
465
569
  ## Redirects
466
570
 
467
571
  `redirect()` works inside actions. Both `return redirect(...)` and
@@ -527,36 +631,55 @@ is usually too aggressive.
527
631
  ### Unexpected errors — let them throw
528
632
 
529
633
  Throw for genuinely exceptional conditions (network failure, DB outage,
530
- auth violation). The nearest `errorBoundary()` in the route tree catches
531
- them.
634
+ auth violation). The nearest `errorBoundary()` above the page the action was
635
+ called from catches them, and the boundary renders inside route middleware like
636
+ a normal render.
532
637
 
533
638
  ```typescript
534
- import { errorBoundary } from "@rangojs/router";
535
-
639
+ // urls.tsx — inside the urls() callback
536
640
  layout(CheckoutLayout, () => [
537
- errorBoundary(({ error, reset }) => (
538
- <div>
539
- <p>Checkout failed: {error.message}</p>
540
- <button onClick={reset}>Try again</button>
641
+ // Server fallback: receives { error } only (ErrorInfo); there is no reset().
642
+ errorBoundary(({ error }) => (
643
+ <div role="alert">
644
+ <p>
645
+ {error.name === "ForbiddenError"
646
+ ? "You cannot change this order."
647
+ : "Checkout failed."}
648
+ </p>
649
+ <a href="/checkout">Try again</a>
541
650
  </div>
542
651
  )),
543
652
  path("/checkout", CheckoutPage, { name: "checkout" }),
544
653
  ]);
545
654
  ```
546
655
 
656
+ In production `error.message` is replaced with `"An error occurred"` (and
657
+ `stack` / `cause` are dropped), while `error.name` and `error.code` are kept —
658
+ branch on `name` or `code`, not on the message.
659
+
547
660
  ### Not found from an action
548
661
 
662
+ `notFound()` inside an action is handled like any other thrown error: it
663
+ renders the nearest `errorBoundary()` with `error.name === "DataNotFoundError"`.
664
+ `notFoundBoundary()` handles `notFound()` from handlers and loaders, not from
665
+ actions.
666
+
549
667
  ```typescript
668
+ // app/actions/posts.ts
669
+ "use server";
670
+
550
671
  import { notFound } from "@rangojs/router";
551
672
 
552
673
  export async function deletePost(id: string): Promise<void> {
553
- "use server";
554
674
  const post = await db.posts.find(id);
555
- if (!post) notFound("Post not found"); // hits notFoundBoundary
675
+ if (!post) notFound("Post not found"); // → nearest errorBoundary()
556
676
  await db.posts.delete(id);
557
677
  }
558
678
  ```
559
679
 
680
+ For a recoverable "already gone" case, return a result the form can show
681
+ (`{ error: "Post not found" }`) instead of throwing.
682
+
560
683
  ### Authorization in actions
561
684
 
562
685
  Route middleware does **not** wrap action execution — only global
@@ -564,11 +687,18 @@ middleware (`router.use()`) does. Auth checks must therefore live in
564
687
  `router.use()` or inside the action itself. Don't rely on a route-level
565
688
  `middleware()` to gate action access.
566
689
 
690
+ An action is identified by its id, not by a URL: the client posts it to the
691
+ current page's URL, but any URL that matches a route can carry it. A
692
+ pattern-scoped `router.use("/admin/*", ...)` therefore guards action requests
693
+ posted from `/admin/*` pages, not the action itself. Use global middleware to
694
+ establish identity (load the session), and authorize sensitive actions inside
695
+ the action body.
696
+
567
697
  ```typescript
568
- // router.tsx — global guard wraps action + render
698
+ // router.tsx — global middleware wraps action + render
569
699
  const router = createRouter()
570
- .use(authInit)
571
- .use("/admin/*", requireAdmin) // protects actions on /admin too
700
+ .use(authInit) // loads the session for every request, actions included
701
+ .use("/admin/*", requireAdmin) // guards /admin pages and actions posted from them
572
702
  .routes(urlpatterns);
573
703
  ```
574
704
 
@@ -590,7 +720,7 @@ export async function deleteOrder(orderId: string) {
590
720
  if (!user) throw redirect("/login"); // unauthenticated → bounce to login
591
721
 
592
722
  const order = await db.orders.get(orderId);
593
- if (!order) notFound("Order not found"); // → notFoundBoundary
723
+ if (!order) notFound("Order not found"); // → errorBoundary (DataNotFoundError)
594
724
  if (order.userId !== user.id) throw new ForbiddenError(); // → errorBoundary
595
725
 
596
726
  await db.orders.delete(orderId);
@@ -601,10 +731,46 @@ export async function deleteOrder(orderId: string) {
601
731
  > Responses thrown from actions are treated as errors and routed to the nearest
602
732
  > `errorBoundary()`, not returned as real HTTP responses (the dev build warns
603
733
  > 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.
734
+ > page, and `notFound()` or a domain error class (branch on `error.name` in the
735
+ > boundary) for missing or forbidden resources. For recoverable cases, return
736
+ > `{ error: "..." }` via `useActionState` instead of throwing.
737
+
738
+ ## CSRF Protection
739
+
740
+ Actions are protected against cross-site request forgery by default, with no
741
+ token to thread through your forms. Before an action runs — called over JS or
742
+ posted by a no-JS form — the router's `originCheck` compares the request's
743
+ `Origin` header (or `Referer` when `Origin` is absent) with the request
744
+ protocol and `Host`:
745
+
746
+ | Request | Result |
747
+ | ---------------------------------------------- | ------------------------------------------------------------ |
748
+ | Same-origin `Origin` / `Referer` | Action runs |
749
+ | Cross-origin `Origin` / `Referer` | `403`, action never runs |
750
+ | `Origin: null` (sandboxed iframe, `data:` URL) | `403`, action never runs |
751
+ | Neither header (curl, server-to-server) | Action runs — a non-browser client carries no victim cookies |
752
+ | `Origin` present but no `Host` header | `403` (fails closed) |
753
+
754
+ The same gate covers fetchable-loader requests (`useFetchLoader()`,
755
+ `load({ method: "POST" })`). It runs before the action is decoded, so a
756
+ rejected request executes no action code. A rejection returns `403` with
757
+ `X-Rango-Origin-Check: failed` (in dev the body shows both header values),
758
+ reports to `onError` with phase `"origin"`, and emits a
759
+ `request.origin-rejected` telemetry event (`/observability`).
760
+
761
+ What the check does not do:
762
+
763
+ - **Authorize.** A same-origin request from a logged-out or unprivileged user
764
+ passes. Keep the checks from "Authorization in actions".
765
+ - **Run before global middleware.** `router.use()` middleware wraps the whole
766
+ request, so it runs for a cross-origin POST before the `403`. Don't mutate
767
+ state in middleware assuming the origin was already checked.
768
+ - **Cover response routes.** `path.json()` and the other response-route tags
769
+ are outside the gate; see `/response-routes` → "CSRF".
770
+ - **Trust proxies.** `X-Forwarded-Host` / `X-Forwarded-Proto` are ignored.
771
+ Behind a proxy that rewrites `Host`, or to allow an extra trusted origin, pass
772
+ an `originCheck` function (`/router-setup` → "Origin check").
773
+ `originCheck: false` disables the gate.
608
774
 
609
775
  ## Action Context
610
776
 
@@ -633,18 +799,15 @@ request scope.
633
799
 
634
800
  ### Constraints
635
801
 
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. |
802
+ | Constraint | Why |
803
+ | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
804
+ | 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. |
805
+ | Route DSL `middleware()` does not wrap actions | Actions execute before route middleware. Only global `router.use()` middleware (and its scoped variants) wrap action execution. |
806
+ | 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
807
 
642
808
  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.
809
+ action responses (the same merge path as a normal render). See `/middleware`
810
+ for the full middleware contract.
648
811
 
649
812
  ## File Uploads
650
813
 
@@ -729,22 +892,30 @@ function AddButton({ productId }: { productId: string }) {
729
892
  >
730
893
  {state === "loading" ? "Adding…" : "Add"}
731
894
  </button>
732
- {error && <p role="alert">{error.message}</p>}
895
+ {error instanceof Error && <p role="alert">{error.message}</p>}
733
896
  </>
734
897
  );
735
898
  }
736
899
  ```
737
900
 
901
+ `error` is typed `unknown` (whatever the action threw), so narrow it before
902
+ reading `.message`.
903
+
738
904
  `useActionState` and `useAction` are complementary — use `useActionState`
739
905
  for `<form action={...}>` flows, `useAction` for imperative button clicks
740
- or to observe an action triggered elsewhere on the page.
906
+ or to observe an action triggered elsewhere on the page. See `/hooks`
907
+ (`handle-and-actions.md`) for the full `useAction` state shape and string
908
+ matching.
741
909
 
742
910
  ## Progressive Enhancement
743
911
 
744
912
  `<form action={serverAction}>` works without JavaScript: the form posts as a
745
913
  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):
914
+ For PE to work, the form's `action` must be the server reference itself, or a
915
+ `.bind(null, ...)` of it (React encodes bound arguments into the form, as in
916
+ pattern 1 above), or the dispatcher `useActionState` returns for it. A
917
+ client-side closure that calls the action is not a server reference and breaks
918
+ PE (see "File Uploads"). The simplest PE-safe shape:
748
919
 
749
920
  ```tsx
750
921
  // Works with no-JS submission
@@ -764,7 +935,8 @@ export async function submitName(formData: FormData) {
764
935
 
765
936
  `useActionState` and `useOptimistic` only enhance the experience once JS is
766
937
  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.
938
+ re-renders (with the `useActionState` result restored as its state). Don't rely
939
+ on client-only state for required form behavior.
768
940
 
769
941
  ## Cross-references
770
942