@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,13 +6,21 @@ argument-hint: [setup]
6
6
 
7
7
  # Store-backed Document Cache
8
8
 
9
- Caches complete HTTP responses (HTML/RSC) in the app-level cache store based on
10
- `Cache-Control`. Routes opt in by setting `s-maxage`.
9
+ Caches complete HTTP responses (HTML documents and RSC payloads) in the
10
+ app-level cache store. Routes opt in by sending a `Cache-Control` header with
11
+ `s-maxage`; `stale-while-revalidate` adds a stale window. On a hit, nothing
12
+ below the middleware runs — no route middleware, handlers, or loaders.
13
+
14
+ Use it for pages whose **whole** response is public and identical for every
15
+ visitor sharing a URL, when you want the reuse to happen inside the app (so
16
+ outer middleware still runs and invalidation stays in Rango's tag system).
11
17
 
12
18
  This middleware runs **inside** the worker/function. It is not itself a platform
13
- CDN cache. With `CFCacheStore` the response family can use Cloudflare's edge/KV
14
- tiers; with `VercelCacheStore` it uses Vercel Runtime Cache. The request still
15
- reaches Rango before the middleware can return a store hit.
19
+ CDN cache. It works with every built-in store: with `CFCacheStore` the response
20
+ family uses Cloudflare's Cache API (and KV when configured), with
21
+ `VercelCacheStore` it uses Vercel Runtime Cache, and `MemorySegmentCacheStore`
22
+ keeps responses in process memory. The request still reaches Rango before the
23
+ middleware can return a store hit.
16
24
 
17
25
  ## Not this skill if…
18
26
 
@@ -27,9 +35,9 @@ reaches Rango before the middleware can return a store hit.
27
35
  ## Setup
28
36
 
29
37
  Document caching is a middleware. Add `createDocumentCacheMiddleware()` to the
30
- router with `.use()`. The cache store it reads from is the app-level store you
31
- configure on `createRouter({ cache })` (available on the request context as
32
- `requestCtx._cacheStore`), not a store passed to the middleware.
38
+ router with `.use()`. It reads and writes the app-level store you configure on
39
+ `createRouter({ cache })`; the store is not a middleware option. Without an app
40
+ store, the middleware passes every request through.
33
41
 
34
42
  ```typescript
35
43
  import { createRouter } from "@rangojs/router";
@@ -39,7 +47,7 @@ import {
39
47
  } from "@rangojs/router/cache";
40
48
  import { urlpatterns } from "./urls";
41
49
 
42
- const router = createRouter<AppBindings>({
50
+ export const router = createRouter<AppBindings>({
43
51
  document: Document,
44
52
  urls: urlpatterns,
45
53
  // App-level cache store. The document cache middleware uses this store's
@@ -53,13 +61,14 @@ router.use(
53
61
  debug: process.env.NODE_ENV === "development",
54
62
  }),
55
63
  );
56
-
57
- export default router;
58
64
  ```
59
65
 
66
+ Middleware registered before it runs on every request, hits included; everything
67
+ after it (and all route middleware) runs only on a miss.
68
+
60
69
  ## Route Opt-In with Cache-Control
61
70
 
62
- Routes opt-in to document caching by setting a `Cache-Control` response header
71
+ Routes opt in to document caching by setting a `Cache-Control` response header
63
72
  with `s-maxage`. The middleware caches responses whose `Cache-Control` includes
64
73
  `s-maxage`; `stale-while-revalidate` enables background revalidation (SWR).
65
74
 
@@ -86,6 +95,21 @@ function BlogPostHandler(ctx) {
86
95
  // Dashboard sets no Cache-Control header, so it is never document-cached.
87
96
  ```
88
97
 
98
+ A handler inside a `cache()` boundary or on a `ppr` route cannot write response
99
+ headers (the write throws). Set the header from route middleware instead — it
100
+ runs on every request that reaches the route:
101
+
102
+ ```typescript
103
+ cache({ ttl: 60 }, () => [
104
+ path("/blog", BlogIndex, { name: "blog" }, () => [
105
+ middleware(async (ctx, next) => {
106
+ ctx.header("Cache-Control", "s-maxage=300, stale-while-revalidate=3600");
107
+ await next();
108
+ }),
109
+ ]),
110
+ ]);
111
+ ```
112
+
89
113
  ## Document Cache Options
90
114
 
91
115
  `createDocumentCacheMiddleware(options?)` accepts:
@@ -95,10 +119,11 @@ createDocumentCacheMiddleware({
95
119
  // Skip specific paths (matched by pathname prefix)
96
120
  skipPaths: ["/api", "/admin"],
97
121
 
98
- // Custom cache key generator
99
- keyGenerator: (url) => url.pathname,
122
+ // Custom cache key base. Replaces host + pathname + search entirely, so
123
+ // include the host yourself when one deployment serves several domains.
124
+ keyGenerator: (url) => `${url.host}${url.pathname}`,
100
125
 
101
- // Conditional caching, evaluated per request
126
+ // Conditional caching, evaluated per request (receives the middleware ctx)
102
127
  isEnabled: (ctx) => !ctx.request.headers.has("x-preview"),
103
128
 
104
129
  // Debug logging (HIT, MISS, STALE, REVALIDATED)
@@ -150,18 +175,26 @@ header or logs to identify an actual CDN hit.
150
175
 
151
176
  ## Cache Key Generation
152
177
 
153
- Default keys differentiate:
178
+ The default key is the host, pathname, and sorted search string (internal
179
+ `_rsc*` params removed, `cache.searchParams` filtering applied), followed by
180
+ suffixes that separate response variants:
154
181
 
155
- - HTML requests: `{pathname}:html`
156
- - RSC partials: `{pathname}:{segmentHash}:rsc`
182
+ - HTML document requests: `{host}{pathname}[?search]:html`
183
+ - RSC requests (client navigations and full-document Flight fetches):
184
+ `{host}{pathname}[?search]:rsc`
185
+ - Client navigations additionally add a hash of the segments the client already
186
+ has (`:{segmentHash}`), so navigations from pages with different layouts get
187
+ different cached payloads, and a `:fragments` marker when the client accepts
188
+ fragment envelopes.
157
189
 
158
- Segment hash ensures different cached responses for navigations from different source pages (with different layouts).
190
+ A custom `keyGenerator(url)` replaces only the `{host}{pathname}[?search]` part;
191
+ the variant suffixes are still appended.
159
192
 
160
193
  ## What Gets Cached
161
194
 
162
195
  - Full HTML responses (document requests)
163
196
  - RSC payloads (client navigation)
164
- - Only 200 OK responses whose `Cache-Control` includes `s-maxage`
197
+ - Only `GET` 200 responses whose `Cache-Control` includes `s-maxage`
165
198
 
166
199
  ## What's NOT Cached
167
200
 
@@ -169,7 +202,35 @@ Segment hash ensures different cached responses for navigations from different s
169
202
  - Loader requests (`_rsc_loader`)
170
203
  - Non-GET requests
171
204
  - Responses without an `s-maxage` `Cache-Control` directive
205
+ - Responses whose `Cache-Control` also contains `private`, `no-store`, or an
206
+ unqualified `no-cache` (those win over `s-maxage` in a shared cache)
207
+ - Responses carrying `Set-Cookie` (a per-client value must not be replayed to
208
+ everyone)
172
209
  - Non-200 responses
210
+ - A 200 whose render reported an error: a component that threw after the
211
+ response started streaming, such as an async server component whose fetch
212
+ failed. The status is already 200, so the body carries the error (an error
213
+ row in the RSC payload, an errored Suspense boundary in the HTML). The write
214
+ is skipped and logged as `[DocumentCache] cache write`; the error goes to
215
+ `onError` as `cache-write` unless the router already reported that same
216
+ error during the render. The current request still gets the errored render,
217
+ the next request renders fresh, and on a stale refresh the stale entry keeps
218
+ serving.
219
+
220
+ ## Invalidation
221
+
222
+ Entries expire by `s-maxage` (+ `stale-while-revalidate`). They are also tagged
223
+ with the request's tag set, so `updateTag()`/`revalidateTag()` evict them:
224
+
225
+ - a render-callable `cacheTag("...")` in any server component on the page;
226
+ - tags of `cache()` segments, cached loaders, and `"use cache"` functions the
227
+ render read.
228
+
229
+ Tags are snapshotted after the response body has fully streamed, so tags from
230
+ Suspense-streamed content are included. A `Prerender()` route's build-time
231
+ segments contribute no tags (only its live loaders can), so such an entry
232
+ usually expires by TTL only.
233
+ See `/caching` → "Tag-Based Invalidation".
173
234
 
174
235
  ## Complete Example
175
236
 
@@ -179,7 +240,7 @@ import { createRouter } from "@rangojs/router";
179
240
  import { createDocumentCacheMiddleware, CFCacheStore } from "@rangojs/router/cache";
180
241
  import { urlpatterns } from "./urls";
181
242
 
182
- const router = createRouter<AppBindings>({
243
+ export const router = createRouter<AppBindings>({
183
244
  document: Document,
184
245
  urls: urlpatterns,
185
246
  cache: (_env, ctx) => ({ store: new CFCacheStore({ ctx: ctx! }) }),
@@ -192,7 +253,6 @@ router.use(
192
253
  }),
193
254
  );
194
255
 
195
- export default router;
196
256
 
197
257
  // urls.tsx
198
258
  import { urls } from "@rangojs/router";
@@ -225,14 +285,18 @@ function BlogPost(ctx) {
225
285
  }
226
286
  ```
227
287
 
288
+ Note that `BlogPostLoader` output is frozen into the cached response along with
289
+ everything else.
290
+
228
291
  ## Document Cache vs Segment Cache
229
292
 
230
- | Feature | Document Cache | Segment Cache |
231
- | ------------ | -------------------------- | --------------------- |
232
- | Granularity | Full response | Individual segments |
233
- | Opt-in | `Cache-Control` `s-maxage` | `cache({ ttl, swr })` |
234
- | Use case | Static pages | Dynamic compositions |
235
- | Key includes | URL + segment hash | Route params |
293
+ | Feature | Document Cache | Segment Cache (`cache()`) |
294
+ | ------------ | ------------------------------------- | --------------------------------- |
295
+ | Granularity | Full response | Individual segments |
296
+ | Opt-in | `Cache-Control` `s-maxage` | `cache({ ttl, swr })` |
297
+ | Loaders | Frozen with the response | Always run fresh |
298
+ | Use case | Static pages | Dynamic compositions |
299
+ | Key includes | host + URL + variant (+ segment hash) | host + pathname + params + search |
236
300
 
237
301
  Use document cache for mostly-static pages. Use segment cache when different parts of a page have different cache requirements.
238
302
 
@@ -6,7 +6,10 @@ argument-hint: [provider]
6
6
 
7
7
  # Fonts
8
8
 
9
- Load web fonts in the Document component with `<link rel="preload">` for optimal performance. Fonts are declared in `<head>` alongside your stylesheet.
9
+ Three ways to load web fonts in a Rango app: a hosted provider (Google Fonts),
10
+ self-hosted files in `public/`, or Fontsource packages bundled by Vite (the
11
+ recommended default). Font links go in the Document `<head>` next to your
12
+ stylesheet, which follows the `?url` + `precedence` pattern from `/css`.
10
13
 
11
14
  ## Google Fonts
12
15
 
@@ -15,7 +18,7 @@ Load web fonts in the Document component with `<link rel="preload">` for optimal
15
18
  "use client";
16
19
 
17
20
  import type { ReactNode } from "react";
18
- import { MetaTags } from "@rangojs/router/client";
21
+ import { Html } from "@rangojs/router/client";
19
22
  import styles from "./index.css?url";
20
23
 
21
24
  export function Document({ children }: { children: ReactNode }) {
@@ -30,16 +33,17 @@ export function Document({ children }: { children: ReactNode }) {
30
33
  crossOrigin="anonymous"
31
34
  />
32
35
 
33
- {/* Load font stylesheet */}
36
+ {/* Font stylesheet first, so app styles can override it */}
34
37
  <link
35
38
  href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
36
39
  rel="stylesheet"
40
+ precedence="default"
37
41
  />
38
42
 
39
43
  {/* App styles */}
40
44
  <link rel="preload" href={styles} as="style" />
41
- <link rel="stylesheet" href={styles} />
42
- <MetaTags />
45
+ <link rel="stylesheet" href={styles} precedence="default" />
46
+ <Html.Meta />
43
47
  </head>
44
48
  <body>{children}</body>
45
49
  </html>
@@ -60,6 +64,8 @@ Or with Tailwind (see `/tailwind`):
60
64
 
61
65
  ```css
62
66
  /* src/index.css */
67
+ @import "tailwindcss";
68
+
63
69
  @theme {
64
70
  --font-sans: "Inter", sans-serif;
65
71
  }
@@ -92,7 +98,9 @@ body {
92
98
  }
93
99
  ```
94
100
 
95
- Preload the most critical weight in the Document:
101
+ Preload the most critical weight in the Document (same imports as the Google
102
+ Fonts example above). Font preloads need `crossOrigin` even for same-origin
103
+ files, or the browser fetches the font twice:
96
104
 
97
105
  ```tsx
98
106
  export function Document({ children }: { children: ReactNode }) {
@@ -107,8 +115,8 @@ export function Document({ children }: { children: ReactNode }) {
107
115
  crossOrigin="anonymous"
108
116
  />
109
117
  <link rel="preload" href={styles} as="style" />
110
- <link rel="stylesheet" href={styles} />
111
- <MetaTags />
118
+ <link rel="stylesheet" href={styles} precedence="default" />
119
+ <Html.Meta />
112
120
  </head>
113
121
  <body>{children}</body>
114
122
  </html>
@@ -6,7 +6,7 @@ argument-hint: "[handler]"
6
6
 
7
7
  # Handler-Attached `.use`
8
8
 
9
- A handler function (or branded `Static`/`Prerender`/`Passthrough` definition) can carry its own defaults via a `.use` callback that returns an array of `use` items (loader, middleware, parallel, intercept, layout, loading, etc.). The mount-site DSL (`path()`, `layout()`, `parallel()`, `intercept()`) merges those defaults with any explicit `use()` callback supplied at the registration site.
9
+ A handler function (or branded `Static`/`Prerender`/`Passthrough` definition) can carry its own defaults via a `.use` callback that returns an array of `use` items (loader, middleware, parallel, intercept, layout, loading, etc.). The mount-site DSL (`path()`, `layout()`, `parallel()`, `intercept()`) merges those defaults with any explicit `use()` callback supplied at the registration site. Loader definitions from `createLoader()` accept a `.use` too, limited to `revalidate` and `cache` (see "Loaders" below).
10
10
 
11
11
  This lets handlers be **self-contained, reusable units** — a page brings its own loader, a layout brings its own middleware, a parallel slot brings its own data + skeleton — without forcing every caller to wire the same items at every mount site.
12
12
 
@@ -51,13 +51,16 @@ Now `ProductPage` carries its loader, loading state, and response-header middlew
51
51
 
52
52
  `handler.use()` is the same callback shape regardless of where the handler runs, but the runtime validates that the items it returns are valid for the mount site. Driven by `MOUNT_SITE_ALLOWED_TYPES` in [resolve-handler-use.ts](../../src/route-definition/resolve-handler-use.ts):
53
53
 
54
- | Mount site | Allowed item types |
55
- | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
56
- | `path()` / `route()` | `layout`, `parallel`, `intercept`, `middleware`, `revalidate`, `loader`, `loading`, `errorBoundary`, `notFoundBoundary`, `cache`, `transition` |
57
- | `layout()` | All of the above, plus `route`, `include` |
58
- | `parallel()` (per slot) | `revalidate`, `loader`, `loading`, `errorBoundary`, `notFoundBoundary`, `transition` |
59
- | `intercept()` | `middleware`, `revalidate`, `loader`, `loading`, `errorBoundary`, `notFoundBoundary`, `layout`, `route`, `transition` (conditional activation is config-only: `intercept(..., { when })`) |
60
- | Response routes (`path.json()`, `path.text()`, …) | `middleware`, `cache` |
54
+ | Mount site | Allowed item types |
55
+ | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
56
+ | `path()` | `layout`, `parallel`, `intercept`, `middleware`, `revalidate`, `loader`, `loading`, `errorBoundary`, `notFoundBoundary`, `cache`, `transition` |
57
+ | `layout()` | All of the above, plus `route` (nested `path()` items) and `include` |
58
+ | `parallel()` (per slot) | `revalidate`, `loader`, `loading`, `errorBoundary`, `notFoundBoundary`, `transition` |
59
+ | `intercept()` | `middleware`, `loader`, `loading`, `layout` (modal chrome, without its own `use()` items), `route`, `transition`. Conditional activation is config-only: `intercept(..., { when })`. Anything else throws with a pointer: `revalidate` goes on the intercept's loader (`loader(Def, () => [revalidate(...)])`), `errorBoundary`/`notFoundBoundary` on the enclosing layout, `cache` on the target route |
60
+ | Response routes (`path.json()`, `path.text()`, …) | `middleware`, `cache` |
61
+ | `loader()` (a `createLoader()` definition) | `revalidate`, `cache` |
62
+
63
+ `route` is the item type a `path()` call produces.
61
64
 
62
65
  For per-item semantics see the dedicated skills: [middleware](../middleware/SKILL.md), [loader](../loader/SKILL.md), [parallel](../parallel/SKILL.md), [intercept](../intercept/SKILL.md), [layout](../layout/SKILL.md), [view-transitions](../view-transitions/SKILL.md).
63
66
 
@@ -68,7 +71,13 @@ handler.use() returned middleware() which is not valid inside parallel().
68
71
  Allowed types: revalidate, loader, loading, errorBoundary, notFoundBoundary, transition.
69
72
  ```
70
73
 
71
- The narrowest contract is `parallel()` — slots cannot bring their own middleware or layout; only data, loading, error/notFound boundaries, revalidation, and transitions.
74
+ The narrowest contract for a handler is `parallel()` — slots cannot bring their own middleware or layout; only data, loading, error/notFound boundaries, revalidation, and transitions.
75
+
76
+ This runtime check applies to items returned by `handler.use()`. Items in an explicit mount-site `use()` callback are constrained by the TypeScript use-item types (e.g. a `parallel()` callback is typed to parallel items), not by this runtime allow-list. `intercept()` is the exception: it checks its merged items, explicit `use()` and `handler.use()` together, against the same list at runtime (`validateInterceptUseItems`), and a rejected item throws with a pointer instead:
77
+
78
+ ```
79
+ errorBoundary() is not valid inside intercept("@modal", "product") use() (including the handler's .use): put it on the enclosing layout or path; it handles the intercept's handler and loader errors.
80
+ ```
72
81
 
73
82
  ## Composition with explicit `use()`
74
83
 
@@ -281,7 +290,7 @@ AdminLayout.use = () => [
281
290
 
282
291
  ### Intercepts (`intercept()`)
283
292
 
284
- Intercept handlers can carry their own middleware chain, loaders, and even nested layouts/routes for the modal shell.
293
+ Intercept handlers can carry their own middleware chain, loaders, `loading()`, and the modal chrome: a `layout(Chrome)` with no `use()` items of its own. Boundaries, `cache()` and `revalidate()` throw here (see the table above and [intercept](../intercept/SKILL.md)).
285
294
 
286
295
  ```typescript
287
296
  const QuickViewModal: Handler = async (ctx) => {
@@ -295,6 +304,30 @@ QuickViewModal.use = () => [
295
304
  ];
296
305
  ```
297
306
 
307
+ ### Loaders (`loader()`)
308
+
309
+ A loader definition can carry its own cache and revalidation defaults. They are
310
+ merged into every `loader(Def)` registration, before the explicit
311
+ `loader(Def, () => [...])` items:
312
+
313
+ ```typescript
314
+ import { cache, createLoader, revalidate } from "@rangojs/router";
315
+ import * as CatalogActions from "./actions/catalog";
316
+
317
+ export const CategoriesLoader = createLoader(async () => fetchCategories());
318
+ CategoriesLoader.use = () => [
319
+ cache({ ttl: 300 }),
320
+ // Loaders re-run after every action by default. Re-run this one only for
321
+ // catalog actions; on navigation, undefined keeps the default.
322
+ revalidate((ctx) =>
323
+ ctx.isAction() ? ctx.isAction(CatalogActions) : undefined,
324
+ ),
325
+ ];
326
+
327
+ // Mount sites get the cache + revalidation defaults with no extra wiring.
328
+ layout(<ShopLayout />, () => [loader(CategoriesLoader) /* … */]);
329
+ ```
330
+
298
331
  ## `loading()` is a single-assignment item — scope it correctly
299
332
 
300
333
  Most `use` items accumulate when merged: `handler.use` `middleware()` runs _and_ explicit `middleware()` runs; both `loader()` registrations apply. `loading()` is different — it mutates `entry.loading` directly, last call wins ([dsl-helpers.ts `loading`](../../src/route-definition/dsl-helpers.ts)).
@@ -310,7 +343,7 @@ Cart.use = () => [loader(CartLoader), loading(<CartSkeleton />)];
310
343
  const Notifs: Handler = async (ctx) => { /* … */ };
311
344
  Notifs.use = () => [loader(NotifsLoader), loading(<NotifsSkeleton />)];
312
345
 
313
- // ✅ @cart gets a custom skeleton; @notifs keeps its handler.use default.
346
+ // @cart gets a custom skeleton; @notifs keeps its handler.use default.
314
347
  parallel({
315
348
  "@cart": {
316
349
  handler: Cart,
@@ -319,7 +352,7 @@ parallel({
319
352
  "@notifs": Notifs,
320
353
  });
321
354
 
322
- // ✅ Opt one slot out of streaming while siblings still stream the broadcast.
355
+ // Opt one slot out of streaming while siblings still stream the broadcast.
323
356
  parallel(
324
357
  {
325
358
  "@cart": { handler: Cart, use: () => [loading(false)] },
@@ -333,7 +366,7 @@ Per-slot merge order is **handler.use → shared use → slot-local use**. Slot-
333
366
 
334
367
  Other things to keep in mind about `loading()`:
335
368
 
336
- - Any `loading()` (regardless of source) makes the segment a streaming unit. A handler that includes `loading()` in its `.use` opts every mount site into streaming by default. To opt back out, pass `loading(false)` at the mount site (`loading: false` handling in [match-middleware/segment-resolution.ts](../../src/router/match-middleware/segment-resolution.ts)) — use the slot descriptor form for parallel slots so the opt-out doesn't broadcast.
369
+ - Any `loading()` (regardless of source) makes the segment a streaming unit. A handler that includes `loading()` in its `.use` opts every mount site into streaming by default. To opt back out, pass `loading(false)` at the mount site (`loading === false` handling in [segment-resolution/fresh.ts](../../src/router/segment-resolution/fresh.ts)) — use the slot descriptor form for parallel slots so the opt-out doesn't broadcast.
337
370
 
338
371
  Rule of thumb: only put `loading()` in `handler.use` if you genuinely want every mount site to stream by default. Use the slot descriptor's `use` for any per-slot intent at a `parallel()` call.
339
372
 
@@ -343,10 +376,13 @@ Rule of thumb: only put `loading()` in `handler.use` if you genuinely want every
343
376
  - **Branded handlers** — `Static()`, `Prerender()`, and `Passthrough()` are positional constructors (not object-arg). Construct first, then attach `.use` to the returned definition:
344
377
 
345
378
  ```typescript
346
- const ProductPage = Prerender(async (ctx) => {
347
- const product = await fetchProduct(ctx.params.slug);
348
- return <ProductView product={product} />;
349
- });
379
+ const ProductPage = Prerender(
380
+ async () => [{ slug: "shoes" }, { slug: "jacket" }], // getParams
381
+ async (ctx) => {
382
+ const product = await fetchProduct(ctx.params.slug);
383
+ return <ProductView product={product} />;
384
+ },
385
+ );
350
386
  ProductPage.use = () => [loader(ProductLoader)];
351
387
  ```
352
388
 
@@ -6,8 +6,14 @@ argument-hint: [hook-name]
6
6
 
7
7
  # Client-Side React Hooks
8
8
 
9
+ The client-side API for reading router state in client components: navigation
10
+ state and actions, URL and params, loader data, handles, action status,
11
+ location state, outlets, and client-cache invalidation.
12
+
9
13
  Import the hooks and components in this skill from `@rangojs/router/client`.
10
- The root `@rangojs/router` entrypoint is for server/RSC APIs and shared types.
14
+ The root `@rangojs/router` entrypoint is for server/RSC APIs and shared types;
15
+ the exceptions used here are `invalidateClientCache()` (root only) and
16
+ `createLocationState()` (exported from both).
11
17
 
12
18
  ## Not this skill if…
13
19
 
@@ -16,6 +22,8 @@ The root `@rangojs/router` entrypoint is for server/RSC APIs and shared types.
16
22
  resolved.
17
23
  - You want to control when a loader re-runs after an action — that is
18
24
  `revalidate()` in the server DSL: see `/loader`.
25
+ - You want an action to leave the client cache alone — that is
26
+ `keepClientCache()` inside the action: see `/server-actions`.
19
27
 
20
28
  Each hook's full API and recipe lives in a companion file linked below. Read
21
29
  the one for your case.
@@ -65,22 +73,22 @@ the one for your case.
65
73
 
66
74
  ## Hook Summary
67
75
 
68
- | Hook | Purpose | Returns |
69
- | ------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------ |
70
- | `useParams()` | Route params | `Readonly<T>` (default `Record<string, string>`) or selected value |
71
- | `usePathname()` | Current pathname | `string` |
72
- | `useSearchParams()` | URL search params (read + write) | `[ReadonlyURLSearchParams, SetSearchParams]` (RR-style tuple) |
73
- | `useHref()` | Mount-aware href | `(path) => string` |
74
- | `useMount()` | Current include() mount path | `string` |
75
- | `useReverse()` | Local reverse for imported routes | `(name, params?, search?) => string` |
76
- | `useNavigation()` | Reactive navigation state | state, location, isStreaming |
77
- | `useRouter()` | Stable router actions | push, replace, refresh, prefetch, back, forward |
78
- | `useSegments()` | URL path & segment IDs | path, segmentIds, location |
79
- | `useLinkStatus()` | Link pending state | { pending } |
80
- | `useLoader()` | Loader data (strict) | data, isLoading, error, load, refetch |
81
- | `useFetchLoader()` | Loader with on-demand fetch | data, load, isLoading, error, refetch |
82
- | `useRefreshLoaders()` | Refresh cross-loader group(s) | `() => (groups: string \| string[]) => Promise<void>` |
83
- | `useHandle()` | Accumulated handle data | T (handle type) |
84
- | `useAction()` | Server action state | state, error, result |
85
- | `useLocationState()` | History state (persists or flash) | T \| undefined |
86
- | `invalidateClientCache()` | Force client caches to miss (function, not a hook; root entry) | `void` |
76
+ | Hook | Purpose | Returns |
77
+ | ------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------- |
78
+ | `useParams()` | Route params | `Readonly<T>` (default `Record<string, string \| undefined>`) or selected value |
79
+ | `usePathname()` | Current pathname | `string` |
80
+ | `useSearchParams()` | URL search params (read + write) | `[ReadonlyURLSearchParams, SetSearchParams]` (RR-style tuple) |
81
+ | `useHref()` | Mount-aware href | `(path) => string` |
82
+ | `useMount()` | Current include() mount path | `string` |
83
+ | `useReverse()` | Local reverse for imported routes | `(name, params?, search?) => string` |
84
+ | `useNavigation()` | Reactive navigation state | state, location, pendingUrl, isStreaming |
85
+ | `useRouter()` | Stable router actions | push, replace, refresh, prefetch, back, forward |
86
+ | `useSegments()` | URL path & segment IDs | path, segmentIds, location |
87
+ | `useLinkStatus()` | Link pending state | { pending } |
88
+ | `useLoader()` | Loader data (strict) | data, isLoading, error, load, refetch |
89
+ | `useFetchLoader()` | Loader with on-demand fetch | data, load, isLoading, error, refetch |
90
+ | `useRefreshLoaders()` | Refresh cross-loader group(s) | `() => (groups: string \| string[]) => Promise<void>` |
91
+ | `useHandle()` | Accumulated handle data | T (handle type) |
92
+ | `useAction()` | Server action state | state, actionId, payload, error, result |
93
+ | `useLocationState()` | History state (persists or flash) | T \| undefined |
94
+ | `invalidateClientCache()` | Force client caches to miss (function, not a hook; root entry) | `void` |
@@ -36,9 +36,15 @@ same-route nav, a same-structure search/filter nav), the reader that is still
36
36
  showing the OLD data reports `isLoading: true` from
37
37
  the moment the new tree is committed until the new data lands — render your
38
38
  stale indicator from it. The flag never flashes back to `false` on the old
39
- data: it flips in the same commit that swaps `data`. Only loaders the
40
- navigation actually re-runs are flagged: a layout loader the server does not
41
- re-execute keeps its data and stays `isLoading: false`. It never applies to a
39
+ data: it flips in the same commit that swaps `data`. The pin is per loader
40
+ family (`loader.$$id`), not per segment: a navigation that re-runs
41
+ `CartLoader` flags every `useLoader(CartLoader)`, including a persisting
42
+ layout reader whose own copy is not replaced. That is "this loader is in
43
+ flight," not "this segment's copy is being replaced." A _different_ layout
44
+ loader the server does not re-execute stays `isLoading: false`. A child can
45
+ already read a layout registration via the context walk; a second
46
+ `loader(Same)` on the child is a second run of the same family, not a
47
+ separate loading flag. It never applies to a
42
48
  navigation that remounts the route (that reader is gone; the skeleton shows),
43
49
  to a fully-prefetched nav (its data is already settled when it commits, so
44
50
  nothing is pending to flag), or to an ephemeral `useFetchLoader` read outside
@@ -51,8 +57,19 @@ function ProductPrice() {
51
57
  }
52
58
  ```
53
59
 
60
+ Unit-test the indicator with `renderRoute` (`/testing`, client-components.md
61
+ "Held navigation"): a `transition` spec plus a pending seed in
62
+ `router.navigate(url, { loaders })`.
63
+
54
64
  **Precondition**: Loader must be registered on route via `loader()` helper.
55
65
 
66
+ **`load()` / `refetch` need a fetchable loader.** `load()`, `refetch`, and
67
+ `useRefreshLoaders()` call the loader through the `_rsc_loader` endpoint, which
68
+ only serves loaders created with `createLoader(fn, true)` or
69
+ `createLoader(fn, { middleware })`. A plain `createLoader(fn)` answers 403 there;
70
+ it re-runs only as part of a render (navigation, `router.refresh()`, or the
71
+ revalidation after a server action). See `/loader` → "Fetchable Loaders".
72
+
56
73
  Loaders can also be passed as props from server to client components:
57
74
 
58
75
  ```tsx
@@ -108,6 +125,7 @@ reading the same loader id. Layout, page, and parallel-slot reads
108
125
  all converge on the new value:
109
126
 
110
127
  ```tsx
128
+ // CartLoader = createLoader(fn, true), registered with loader(CartLoader).
111
129
  // Layout button calls load() — the page read below sees the update too.
112
130
  function Layout() {
113
131
  const { data, load } = useLoader(CartLoader);
@@ -262,9 +280,11 @@ server, JSON bodies are available via `ctx.body` and FormData bodies via `ctx.fo
262
280
 
263
281
  ```tsx
264
282
  "use client";
283
+ import { useRef } from "react";
265
284
  import { useFetchLoader } from "@rangojs/router/client";
266
285
  import { FileUploadLoader } from "../loaders/upload";
267
286
 
287
+ // Needs JavaScript: the form action is a client function, not a server action.
268
288
  function FileUploader() {
269
289
  const { data, load, isLoading } = useFetchLoader(FileUploadLoader);
270
290
  const formRef = useRef<HTMLFormElement>(null);
@@ -40,12 +40,16 @@ re-renders on every handle update, late loader pushes included.
40
40
  Handles can be passed as props from server to client components:
41
41
 
42
42
  ```tsx
43
- // Server component
44
- path("/dashboard", (ctx) => {
45
- const push = ctx.use(Breadcrumbs);
46
- push({ label: "Dashboard", href: "/dashboard" });
47
- return <DashboardNav handle={Breadcrumbs} />;
48
- });
43
+ // Server component (route handler)
44
+ path(
45
+ "/dashboard",
46
+ (ctx) => {
47
+ const push = ctx.use(Breadcrumbs);
48
+ push({ label: "Dashboard", href: "/dashboard" });
49
+ return <DashboardNav handle={Breadcrumbs} />;
50
+ },
51
+ { name: "dashboard" },
52
+ );
49
53
  ```
50
54
 
51
55
  ```tsx
@@ -58,7 +62,9 @@ function DashboardNav({ handle }: { handle: typeof Breadcrumbs }) {
58
62
  return (
59
63
  <nav>
60
64
  {crumbs.map((c) => (
61
- <a href={c.href}>{c.label}</a>
65
+ <a key={c.href} href={c.href}>
66
+ {c.label}
67
+ </a>
62
68
  ))}
63
69
  </nav>
64
70
  );
@@ -78,33 +84,42 @@ tracking actions called outside a `<form action={...}>` flow.
78
84
 
79
85
  ### useAction()
80
86
 
81
- Track state of server action invocations:
87
+ Track the lifecycle of a server action, wherever on the page it was invoked.
88
+ If the same action fires several times, the hook reports the latest call.
82
89
 
83
90
  ```tsx
84
91
  "use client";
85
92
  import { useAction } from "@rangojs/router/client";
86
- import { addToCart } from "../actions/cart";
93
+ import { addToCart } from "../actions/cart"; // addToCart(productId: string)
87
94
 
88
95
  function AddToCartButton({ productId }: { productId: string }) {
89
96
  const { state, error, result } = useAction(addToCart);
90
97
 
91
98
  // state: 'idle' | 'loading' | 'streaming'
92
99
  // actionId: string | null
93
- // payload: unknown | null (input data)
94
- // error: Error | null
100
+ // payload: unknown[] | FormData | null (the call's arguments)
101
+ // error: unknown | null (whatever the action threw)
95
102
  // result: unknown | null (return value)
96
103
 
97
104
  return (
98
- <form action={addToCart}>
99
- <input type="hidden" name="productId" value={productId} />
105
+ // .bind keeps the form progressively enhanced; useAction still matches it
106
+ <form action={addToCart.bind(null, productId)}>
100
107
  <button disabled={state === "loading"}>
101
108
  {state === "loading" ? "Adding..." : "Add to Cart"}
102
109
  </button>
103
- {error && <p className="error">{error.message}</p>}
110
+ {error instanceof Error && <p className="error">{error.message}</p>}
104
111
  </form>
105
112
  );
106
113
  }
107
114
 
108
- // Match by string suffix (convenient but may be ambiguous)
109
- const isLoading = useAction("addToCart", (s) => s.state === "loading");
115
+ // With a selector (re-renders only when the selected value changes)
116
+ const isLoading = useAction(addToCart, (s) => s.state === "loading");
117
+
118
+ // Match by export name (suffix match on "#addToCart"; ambiguous if two
119
+ // "use server" files export the same name)
120
+ const isAdding = useAction("addToCart", (s) => s.state === "loading");
110
121
  ```
122
+
123
+ Pass an action imported directly into the client component, or its export
124
+ name as a string. An action received as a prop from a server component has
125
+ lost the metadata `useAction` needs and throws; use the string form there.