@rangojs/router 0.24.4 → 0.25.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 (267) hide show
  1. package/README.md +1 -0
  2. package/dist/bin/rango.js +363 -39
  3. package/dist/testing/vitest.js +37 -4
  4. package/dist/types/browser/event-controller.d.ts +24 -5
  5. package/dist/types/browser/intercept-utils.d.ts +5 -2
  6. package/dist/types/browser/merge-segment-loaders.d.ts +0 -23
  7. package/dist/types/browser/navigation-transaction.d.ts +5 -5
  8. package/dist/types/browser/partial-update.d.ts +21 -13
  9. package/dist/types/browser/prefetch/cache.d.ts +8 -0
  10. package/dist/types/browser/rango-marker.d.ts +7 -0
  11. package/dist/types/browser/react/filter-segment-order.d.ts +10 -0
  12. package/dist/types/browser/react/use-handle.d.ts +20 -0
  13. package/dist/types/browser/react/use-href.d.ts +6 -0
  14. package/dist/types/browser/react/use-router.d.ts +6 -1
  15. package/dist/types/browser/response-adapter.d.ts +5 -3
  16. package/dist/types/browser/scroll-restoration.d.ts +9 -3
  17. package/dist/types/browser/segment-reconciler.d.ts +10 -13
  18. package/dist/types/browser/types.d.ts +38 -8
  19. package/dist/types/build/route-types/per-module-writer.d.ts +12 -0
  20. package/dist/types/cache/cf/cf-shell-frame.d.ts +2 -0
  21. package/dist/types/cache/types.d.ts +8 -0
  22. package/dist/types/client-urls/client-urls.d.ts +6 -2
  23. package/dist/types/client-urls/revalidate-registry.d.ts +1 -1
  24. package/dist/types/client-urls/server-projection.d.ts +9 -5
  25. package/dist/types/client-urls/types.d.ts +29 -16
  26. package/dist/types/client.d.ts +2 -2
  27. package/dist/types/client.rsc.d.ts +2 -2
  28. package/dist/types/decode-loader-results.d.ts +28 -7
  29. package/dist/types/href-client.d.ts +3 -1
  30. package/dist/types/index.rsc.d.ts +1 -1
  31. package/dist/types/loader-redirect.d.ts +2 -3
  32. package/dist/types/outlet-context.d.ts +7 -6
  33. package/dist/types/prefetch-markers.d.ts +14 -5
  34. package/dist/types/route-content-wrapper.d.ts +17 -14
  35. package/dist/types/route-definition/helpers-types.d.ts +7 -2
  36. package/dist/types/route-definition/resolve-handler-use.d.ts +6 -1
  37. package/dist/types/route-definition/rewrite.d.ts +48 -5
  38. package/dist/types/route-types.d.ts +2 -2
  39. package/dist/types/router/handler-context.d.ts +14 -2
  40. package/dist/types/router/intercept-resolution.d.ts +19 -46
  41. package/dist/types/router/lazy-includes.d.ts +7 -0
  42. package/dist/types/router/match-middleware/intercept-resolution.d.ts +2 -2
  43. package/dist/types/router/middleware.d.ts +0 -16
  44. package/dist/types/router/navigation-snapshot.d.ts +2 -0
  45. package/dist/types/router/prefetch-cache-ttl.d.ts +8 -11
  46. package/dist/types/router/revalidation.d.ts +8 -1
  47. package/dist/types/router/router-context.d.ts +4 -16
  48. package/dist/types/router/router-interfaces.d.ts +0 -6
  49. package/dist/types/router/router-options.d.ts +4 -7
  50. package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +31 -8
  51. package/dist/types/router/segment-resolution/revalidation.d.ts +38 -4
  52. package/dist/types/router/segment-resolution.d.ts +1 -1
  53. package/dist/types/router/segment-wrappers.d.ts +3 -5
  54. package/dist/types/router/substitute-pattern-params.d.ts +3 -2
  55. package/dist/types/router/tracing.d.ts +2 -2
  56. package/dist/types/rsc/full-payload.d.ts +14 -2
  57. package/dist/types/rsc/helpers.d.ts +8 -0
  58. package/dist/types/rsc/manifest-init.d.ts +20 -0
  59. package/dist/types/rsc/prefetch-render-error.d.ts +22 -0
  60. package/dist/types/rsc/progressive-enhancement.d.ts +20 -0
  61. package/dist/types/rsc/rewrite-passes.d.ts +12 -2
  62. package/dist/types/rsc/server-action.d.ts +6 -0
  63. package/dist/types/rsc/shell-capture-constants.d.ts +10 -1
  64. package/dist/types/rsc/shell-capture.d.ts +7 -0
  65. package/dist/types/rsc/types.d.ts +5 -0
  66. package/dist/types/segment-system.d.ts +15 -0
  67. package/dist/types/server/context.d.ts +64 -4
  68. package/dist/types/server/request-context.d.ts +41 -4
  69. package/dist/types/server/request-headers.d.ts +12 -0
  70. package/dist/types/server/rewrite-trail.d.ts +8 -1
  71. package/dist/types/testing/e2e/index.d.ts +1 -1
  72. package/dist/types/testing/flight.entry.d.ts +1 -1
  73. package/dist/types/testing/index.d.ts +1 -1
  74. package/dist/types/testing/internal/client-urls-projection.d.ts +4 -3
  75. package/dist/types/testing/render-route.d.ts +21 -11
  76. package/dist/types/testing/run-loader.d.ts +7 -0
  77. package/dist/types/testing/run-middleware.d.ts +7 -0
  78. package/dist/types/testing/run-revalidate.d.ts +5 -4
  79. package/dist/types/testing/serve-shell-request.d.ts +41 -4
  80. package/dist/types/testing/shell-status.d.ts +17 -0
  81. package/dist/types/testing/vitest-stubs/version.d.ts +1 -0
  82. package/dist/types/testing/vitest.d.ts +10 -1
  83. package/dist/types/thenable-status.d.ts +0 -6
  84. package/dist/types/types/handler-context.d.ts +12 -1
  85. package/dist/types/types/loader-types.d.ts +4 -3
  86. package/dist/types/types/route-entry.d.ts +2 -1
  87. package/dist/types/urls/path-helper-types.d.ts +4 -2
  88. package/dist/types/vite/discovery/client-urls-projection.d.ts +55 -5
  89. package/dist/types/vite/discovery/state.d.ts +20 -0
  90. package/dist/types/vite/plugins/client-urls-revalidate-hoist.d.ts +28 -16
  91. package/dist/types/vite/plugins/hoist/source.d.ts +8 -7
  92. package/dist/types/vite/plugins/virtual-entries.d.ts +6 -0
  93. package/dist/types/vite/utils/prerender-utils.d.ts +7 -0
  94. package/dist/vite/index.js +6816 -6547
  95. package/package.json +1 -1
  96. package/skills/app-structure/SKILL.md +255 -0
  97. package/skills/app-structure/content-site.md +333 -0
  98. package/skills/app-structure/e-commerce.md +435 -0
  99. package/skills/app-structure/large-app.md +885 -0
  100. package/skills/app-structure/multi-app.md +280 -0
  101. package/skills/breadcrumbs/SKILL.md +6 -1
  102. package/skills/caching/SKILL.md +3 -1
  103. package/skills/catalog.json +12 -0
  104. package/skills/client-urls/SKILL.md +185 -32
  105. package/skills/composability/SKILL.md +10 -8
  106. package/skills/handler-use/SKILL.md +48 -9
  107. package/skills/hooks/handle-and-actions.md +104 -0
  108. package/skills/hooks/navigation.md +24 -10
  109. package/skills/hooks/urls.md +1 -1
  110. package/skills/i18n/SKILL.md +2 -1
  111. package/skills/intercept/SKILL.md +60 -29
  112. package/skills/layout/SKILL.md +6 -3
  113. package/skills/links/SKILL.md +151 -17
  114. package/skills/loader/SKILL.md +32 -10
  115. package/skills/middleware/SKILL.md +111 -15
  116. package/skills/observability/SKILL.md +11 -0
  117. package/skills/parallel/SKILL.md +5 -5
  118. package/skills/ppr/SKILL.md +43 -8
  119. package/skills/prerender/SKILL.md +1 -2
  120. package/skills/rango/SKILL.md +3 -0
  121. package/skills/response-routes/SKILL.md +4 -1
  122. package/skills/scripts/SKILL.md +12 -1
  123. package/skills/server-actions/SKILL.md +60 -20
  124. package/skills/testing/SKILL.md +1 -0
  125. package/skills/testing/client-components.md +13 -10
  126. package/skills/testing/loader.md +25 -24
  127. package/skills/testing/middleware.md +11 -11
  128. package/skills/testing/response-routes.md +4 -1
  129. package/skills/testing/serve-shell-request.md +85 -21
  130. package/skills/ux-architecture/SKILL.md +980 -0
  131. package/skills/view-transitions/SKILL.md +43 -10
  132. package/src/__augment-tests__/augmented.check.ts +28 -0
  133. package/src/browser/event-controller.ts +44 -18
  134. package/src/browser/intercept-utils.ts +3 -2
  135. package/src/browser/merge-segment-loaders.ts +0 -85
  136. package/src/browser/navigation-bridge.ts +112 -84
  137. package/src/browser/navigation-client.ts +20 -15
  138. package/src/browser/navigation-store.ts +4 -2
  139. package/src/browser/navigation-transaction.ts +9 -8
  140. package/src/browser/partial-update.ts +90 -32
  141. package/src/browser/prefetch/cache.ts +8 -0
  142. package/src/browser/prefetch/fetch.ts +13 -3
  143. package/src/browser/rango-marker.ts +13 -0
  144. package/src/browser/react/NavigationProvider.tsx +25 -7
  145. package/src/browser/react/filter-segment-order.ts +13 -0
  146. package/src/browser/react/use-action.ts +9 -15
  147. package/src/browser/react/use-handle.ts +63 -71
  148. package/src/browser/react/use-href.tsx +24 -5
  149. package/src/browser/react/use-link-status.ts +1 -1
  150. package/src/browser/react/use-navigation.ts +30 -40
  151. package/src/browser/react/use-router.ts +37 -6
  152. package/src/browser/react/use-segments.ts +8 -12
  153. package/src/browser/response-adapter.ts +11 -5
  154. package/src/browser/rsc-router.tsx +3 -1
  155. package/src/browser/scroll-restoration.ts +34 -20
  156. package/src/browser/segment-reconciler.ts +11 -29
  157. package/src/browser/server-action-bridge.ts +52 -16
  158. package/src/browser/types.ts +40 -8
  159. package/src/build/generate-manifest.ts +12 -1
  160. package/src/build/route-types/codegen.ts +6 -2
  161. package/src/build/route-types/per-module-writer.ts +94 -61
  162. package/src/cache/cache-runtime.ts +9 -0
  163. package/src/cache/cache-scope.ts +2 -1
  164. package/src/cache/cf/cf-cache-store.ts +2 -0
  165. package/src/cache/cf/cf-shell-frame.ts +3 -0
  166. package/src/cache/types.ts +8 -0
  167. package/src/cache/vercel/vercel-cache-store.ts +6 -1
  168. package/src/client-urls/client-urls.ts +202 -153
  169. package/src/client-urls/revalidate-registry.ts +12 -3
  170. package/src/client-urls/server-projection.ts +137 -66
  171. package/src/client-urls/types.ts +30 -15
  172. package/src/client.rsc.tsx +11 -1
  173. package/src/client.tsx +9 -1
  174. package/src/decode-loader-results.ts +64 -31
  175. package/src/href-client.ts +3 -1
  176. package/src/index.rsc.ts +1 -0
  177. package/src/index.ts +12 -4
  178. package/src/loader-redirect.tsx +2 -3
  179. package/src/outlet-context.ts +7 -6
  180. package/src/prefetch-markers.ts +15 -5
  181. package/src/reverse.ts +3 -3
  182. package/src/route-content-wrapper.tsx +19 -17
  183. package/src/route-definition/dsl-helpers.ts +27 -7
  184. package/src/route-definition/helpers-types.ts +7 -2
  185. package/src/route-definition/resolve-handler-use.ts +23 -10
  186. package/src/route-definition/rewrite.ts +86 -19
  187. package/src/route-types.ts +1 -2
  188. package/src/router/find-match.ts +37 -1
  189. package/src/router/handler-context.ts +85 -40
  190. package/src/router/intercept-resolution.ts +161 -307
  191. package/src/router/lazy-includes.ts +43 -27
  192. package/src/router/loader-resolution.ts +21 -1
  193. package/src/router/manifest.ts +6 -8
  194. package/src/router/match-api.ts +11 -1
  195. package/src/router/match-middleware/background-revalidation.ts +2 -4
  196. package/src/router/match-middleware/cache-lookup.ts +16 -20
  197. package/src/router/match-middleware/intercept-resolution.ts +41 -60
  198. package/src/router/match-result.ts +31 -2
  199. package/src/router/middleware.ts +6 -147
  200. package/src/router/navigation-snapshot.ts +38 -2
  201. package/src/router/prefetch-cache-ttl.ts +10 -18
  202. package/src/router/prerender-match.ts +2 -1
  203. package/src/router/revalidation.ts +24 -3
  204. package/src/router/router-context.ts +5 -18
  205. package/src/router/router-interfaces.ts +0 -7
  206. package/src/router/router-options.ts +4 -7
  207. package/src/router/segment-resolution/fresh.ts +6 -2
  208. package/src/router/segment-resolution/helpers.ts +7 -1
  209. package/src/router/segment-resolution/prefetch-deferral.ts +62 -12
  210. package/src/router/segment-resolution/revalidation.ts +75 -35
  211. package/src/router/segment-resolution.ts +0 -1
  212. package/src/router/segment-wrappers.ts +8 -13
  213. package/src/router/substitute-pattern-params.ts +3 -2
  214. package/src/router/tracing.ts +2 -2
  215. package/src/router.ts +2 -9
  216. package/src/rsc/full-payload.ts +26 -4
  217. package/src/rsc/handler.ts +83 -41
  218. package/src/rsc/helpers.ts +13 -0
  219. package/src/rsc/manifest-init.ts +56 -3
  220. package/src/rsc/prefetch-render-error.ts +38 -0
  221. package/src/rsc/progressive-enhancement.ts +161 -211
  222. package/src/rsc/rewrite-passes.ts +56 -9
  223. package/src/rsc/rsc-rendering.ts +79 -23
  224. package/src/rsc/server-action.ts +25 -2
  225. package/src/rsc/shell-capture-constants.ts +22 -0
  226. package/src/rsc/shell-capture.ts +11 -0
  227. package/src/rsc/types.ts +5 -0
  228. package/src/segment-system.tsx +125 -119
  229. package/src/server/context.ts +96 -5
  230. package/src/server/handle-store.ts +3 -2
  231. package/src/server/request-context.ts +42 -3
  232. package/src/server/request-headers.ts +32 -0
  233. package/src/server/rewrite-trail.ts +55 -9
  234. package/src/ssr/ssr-root.tsx +6 -10
  235. package/src/testing/dispatch.ts +4 -5
  236. package/src/testing/e2e/index.ts +1 -0
  237. package/src/testing/flight.entry.ts +1 -0
  238. package/src/testing/index.ts +1 -0
  239. package/src/testing/internal/client-urls-projection.ts +31 -11
  240. package/src/testing/render-route.tsx +83 -40
  241. package/src/testing/run-loader.ts +26 -2
  242. package/src/testing/run-middleware.ts +10 -0
  243. package/src/testing/run-revalidate.ts +13 -6
  244. package/src/testing/serve-shell-request.ts +77 -4
  245. package/src/testing/shell-status.ts +40 -0
  246. package/src/testing/vitest-stubs/version.ts +1 -0
  247. package/src/testing/vitest.ts +74 -7
  248. package/src/thenable-status.ts +0 -11
  249. package/src/types/handler-context.ts +12 -1
  250. package/src/types/loader-types.ts +4 -3
  251. package/src/types/route-entry.ts +2 -1
  252. package/src/urls/include-helper.ts +38 -11
  253. package/src/urls/path-helper-types.ts +4 -2
  254. package/src/vite/discovery/client-urls-projection.ts +205 -16
  255. package/src/vite/discovery/discover-routers.ts +2 -2
  256. package/src/vite/discovery/prerender-collection.ts +2 -6
  257. package/src/vite/discovery/state.ts +26 -0
  258. package/src/vite/plugins/client-urls-revalidate-hoist.ts +190 -77
  259. package/src/vite/plugins/expose-internal-ids.ts +46 -40
  260. package/src/vite/plugins/hoist/source.ts +8 -7
  261. package/src/vite/plugins/transition-when-hoist.ts +1 -0
  262. package/src/vite/plugins/version-plugin.ts +7 -0
  263. package/src/vite/plugins/version.d.ts +7 -0
  264. package/src/vite/plugins/virtual-entries.ts +20 -0
  265. package/src/vite/rango.ts +4 -1
  266. package/src/vite/router-discovery.ts +39 -7
  267. package/src/vite/utils/prerender-utils.ts +17 -0
@@ -24,14 +24,62 @@ links to JSON/text endpoints see `/response-routes`.
24
24
 
25
25
  Available on the handler context (route handlers, layouts, parallels), on
26
26
  middleware and loader contexts, and on `getRequestContext()` inside server
27
- actions. Resolves named routes using the full route map. This is the default
27
+ actions. In loaders and in actions (after the route match) params the matched
28
+ route carries are optional, as in handlers: `ctx.reverse("account.settings")`
29
+ fills `accountId` from the matched params. In global middleware before `next()`
30
+ nothing is matched, so a route with a required param throws `Missing param`.
31
+ Resolves named routes using the full route map. This is the default
28
32
  way to generate URLs on the server. Patterns in the map already include the
29
33
  router `basename`, so results are basename-prefixed.
30
34
 
31
35
  Middleware and response routes (`path.json()`, `path.text()`, …) get a
32
36
  global-only `ctx.reverse` (`GlobalReverseFunction`): no `include()` scope and no
33
37
  param auto-fill, so pass the fully qualified name and every param. A dot-local
34
- `.name` is a type error there. See `/middleware` and `/response-routes`.
38
+ `.name` is a type error there. A missing required param throws
39
+ `Missing param`, with or without a params object. Route
40
+ middleware can use `getRequestContext().reverse` instead: it is rebound after
41
+ the match, takes `.name` and global names, and fills mount params. In global
42
+ middleware (`router.use`) it fills only after `next()`. See `/middleware` and
43
+ `/response-routes`.
44
+
45
+ A server action's `getRequestContext().reverse("global.name")` fills mount
46
+ params from the page URL the action was called on, so
47
+ `throw redirect(getRequestContext().reverse("account.settings"))` needs no
48
+ hardcoded path. A `.name` there resolves in that page's module; an action
49
+ shared by pages in different modules should use global names.
50
+
51
+ ### The module never knows its mount
52
+
53
+ Inside a module, link by local name: `ctx.reverse(".x")` on the server,
54
+ `useReverse(routes)` with the module's own `.gen.ts` on the client. Both
55
+ resolve against wherever the module is mounted and fill the mount's params
56
+ from the current URL, so the same module can be mounted twice:
57
+
58
+ ```typescript
59
+ include("/:locale", shopPatterns, { name: "shop" });
60
+ include("/outlet", shopPatterns, { name: "outlet" });
61
+
62
+ // in shopPatterns, on /en/... and on /outlet/...
63
+ ctx.reverse(".product", { slug: "b" }); // "/en/product/b" and "/outlet/product/b"
64
+ ctx.reverse(".product", { slug: "b", locale: "de" }); // "/de/product/b" (switch)
65
+ ```
66
+
67
+ `/:locale?` and `/:locale` both auto-fill. `{ locale: undefined }` drops an
68
+ optional locale (`/product/b`) and throws `Missing param "locale"` for a
69
+ required one. Don't rebuild this by hand: `useParams().accountId` in a template
70
+ string, `ctx.params` threaded through props, or a "current account" context
71
+ all duplicate what the mount already knows.
72
+
73
+ - A server layout stays held across navigations, its own mount param
74
+ (`:projectId`) changing included: partial rendering by design (`/layout`,
75
+ "Layout Revalidation"). Links it rendered keep the old params. Render
76
+ param-dependent nav links in a client component with `useReverse` (it
77
+ re-renders from `useParams()`), or give the layout a `revalidate()` that
78
+ compares the param.
79
+ - A layout, a parallel slot and the loaders declared on them resolve `.name`
80
+ in the module that declares them, whichever route below them matched: a
81
+ parent module's layout links within its own module on every page (see
82
+ "Local names" below).
35
83
 
36
84
  ```typescript
37
85
  import { urls } from "@rangojs/router";
@@ -47,7 +95,7 @@ export const shopPatterns = urls(({ path, layout }) => [
47
95
 
48
96
  ### Resolution rules
49
97
 
50
- - **`.name`** — local route, resolved within the current `include()` scope
98
+ - **`.name`** — local route, resolved within the `include()` scope of the module that defines the calling route, layout, parallel slot or loader
51
99
  - **`name`** — global route, from the named-routes definition
52
100
 
53
101
  The include site decides which global names exist:
@@ -87,6 +135,24 @@ Prefix a name with `.` to resolve it within the current `include()` scope. The r
87
135
  };
88
136
  ```
89
137
 
138
+ The scope is the module that **defines** the code calling `ctx.reverse`. A route handler resolves from its own module. A `layout()`, a `parallel()` slot and the loaders declared on them resolve from the module where they are declared, whichever route below them matched. So a parent module's layout gives the same link on every page:
139
+
140
+ ```typescript
141
+ // mounted at include("/:accountId", accountPatterns, { name: "account" })
142
+ export const accountPatterns = urls(({ path, layout, include }) => [
143
+ layout(
144
+ (ctx) => <AccountNav settings={ctx.reverse(".settings")} />, // "/acme/settings" on every page below
145
+ () => [
146
+ path("/settings", AccountSettings, { name: "settings" }),
147
+ // projectPatterns also has a "settings" route
148
+ include("/:projectId", projectPatterns, { name: "project" }),
149
+ ],
150
+ ),
151
+ ]);
152
+ // On /acme/web/issues, the account layout still links to "/acme/settings".
153
+ // The project's issues handler's ctx.reverse(".settings") gives "/acme/web/settings".
154
+ ```
155
+
90
156
  ### Auto-fill of mount params
91
157
 
92
158
  When routes are mounted via a parameterized `include()`, `ctx.reverse()` automatically fills mount params from `ctx.params`. Inner handlers don't need to pass params that are already known from the current URL match. Explicitly passed params override auto-filled values.
@@ -109,7 +175,7 @@ export const tenantPatterns = urls(({ path }) => [
109
175
  ]);
110
176
  ```
111
177
 
112
- Auto-fill uses `{ ...ctx.params, ...hrefParams }` — current request params are defaults, explicit params win. Params not needed by the target route are silently ignored. Because every current param is a default, drop an optional one explicitly with `undefined` or `""` — under `include("/:locale?", ...)` on `/en/...`, a bare `ctx.reverse("menu.index")` keeps `/en`, while `ctx.reverse("menu.index", { locale: undefined })` returns `/` (see `/i18n`). A missing required param throws `Missing param "slug" for route "…"`; an unknown name throws `Unknown route: "…"`.
178
+ Auto-fill uses `{ ...ctx.params, ...hrefParams }` — current request params are defaults, explicit params win. Params not needed by the target route are silently ignored. Because every current param is a default, drop an optional one explicitly with `undefined` or `""` — under `include("/:locale?", ...)` on `/en/...`, a bare `ctx.reverse("menu.index")` keeps `/en`, while `ctx.reverse("menu.index", { locale: undefined })` returns `/` (see `/i18n`). A missing required param throws `Missing param "slug" for route "…"` (in middleware and response routes too, with or without a params object); an unknown name throws `Unknown route: "…"`.
113
179
 
114
180
  ### Global names (unprefixed)
115
181
 
@@ -213,6 +279,48 @@ function Nav() {
213
279
 
214
280
  `useLoader()` requires the loader to be attached to an active route. If you need on-demand fetching instead, use `useFetchLoader()`.
215
281
 
282
+ A loader on a layout is held on navigation, like the layout. When its links follow a param of the layout's own mount (`:projectId`), give it a `revalidate()` that compares that param; links that never change can come from the layout's RSC as props:
283
+
284
+ ```tsx
285
+ // urls/project.tsx (server): build the links where the names resolve
286
+ export const ProjectNavLoader = createLoader((ctx) => ({
287
+ issues: ctx.reverse(".issues"), // /acme/web/issues
288
+ settings: ctx.reverse(".settings"), // /acme/web/settings
289
+ account: ctx.reverse("account.settings"), // /acme/settings, another module
290
+ }));
291
+
292
+ export const projectUrls = urls(({ layout, path, loader, revalidate }) => [
293
+ layout(
294
+ // From the RSC: fine for links that do not follow :projectId.
295
+ (ctx) => <ProjectShell accountHref={ctx.reverse("account.settings")} />,
296
+ () => [
297
+ // From a loader: held like the layout, re-run when the project changes.
298
+ loader(ProjectNavLoader, () => [
299
+ revalidate(({ currentParams, nextParams }) =>
300
+ currentParams.projectId !== nextParams.projectId ? true : undefined,
301
+ ),
302
+ ]),
303
+ path("/issues", Issues, { name: "issues" }),
304
+ path("/settings", Settings, { name: "settings" }),
305
+ ],
306
+ ),
307
+ ]);
308
+ ```
309
+
310
+ ```tsx
311
+ "use client";
312
+ export function ProjectNav() {
313
+ const { data: links } = useLoader(ProjectNavLoader);
314
+ return (
315
+ <nav>
316
+ <Link to={links.issues}>Issues</Link>
317
+ <Link to={links.settings}>Settings</Link>
318
+ <Link to={links.account}>Account</Link>
319
+ </nav>
320
+ );
321
+ }
322
+ ```
323
+
216
324
  3. Return from a server action:
217
325
 
218
326
  ```tsx
@@ -286,9 +394,9 @@ function ShopNav() {
286
394
  }
287
395
  ```
288
396
 
289
- Use `useHref()` for local navigation within a mounted module. Use the bare `href()` function for absolute paths outside the current mount.
397
+ Use `useHref()` for local navigation within a mounted module (`include("/shop", ...)` or `include("/tenant/:tenantId", ...)`). Use the bare `href()` function for absolute paths outside the current mount.
290
398
 
291
- `useHref()` concatenates the mount **pattern** as-is and does not substitute params, and its argument is not checked against your routes. Under a parameterized mount (`include("/tenant/:tenantId", ...)`) it would produce `/tenant/:tenantId/cart` — use `useReverse(routes)` there, which substitutes mount params from `useParams()`.
399
+ Under a parameterized mount (`include("/tenant/:tenantId", ...)`, `/:locale`, `/:locale?`), `useHref()` fills the mount's params from `useParams()`, the same way `useReverse` does: at `/tenant/acme/board`, `href("/cart")` returns `/tenant/acme/cart`. A mount param missing from the current match makes the returned function throw `Missing param "tenantId" for route "/tenant/:tenantId"`; it never returns a URL with a literal `:tenantId`. Relative `useRouter().push("cart")` / `replace` / `prefetch` fill the mount from the committed route params at call time; a missing one rejects the `push`/`replace` promise and skips the `prefetch`. Only the mount is filled: the path argument is appended verbatim and is not checked against your routes, so for a link to a named route with its own params prefer `useReverse(routes)`.
292
400
 
293
401
  ## Client: useMount()
294
402
 
@@ -306,7 +414,7 @@ function MountInfo() {
306
414
  }
307
415
  ```
308
416
 
309
- `useMount()` reads from `MountContext`, which is automatically set by `include()` in the segment tree. Nested includes return the nearest mount's full URL prefix (e.g. `/shop/admin`), and a parameterized include returns its pattern (`/tenant/:tenantId`), not the matched URL. `href(path, mount)` is the non-hook equivalent of `useHref()`.
417
+ `useMount()` reads from `MountContext`, which is automatically set by `include()` in the segment tree. Nested includes return the nearest mount's full URL prefix (e.g. `/shop/admin`), and a parameterized include returns its pattern (`/tenant/:tenantId`), not the matched URL. `href(path, mount)` concatenates the mount it is given as-is; it is the non-hook equivalent of `useHref()` only under a static mount (`useHref()` also fills mount params).
310
418
 
311
419
  ## Client: useReverse(routes)
312
420
 
@@ -331,6 +439,27 @@ export function BlogNav() {
331
439
  }
332
440
  ```
333
441
 
442
+ ### One hook per module, in a userland file
443
+
444
+ Wrap the map once instead of importing it in every component:
445
+
446
+ ```ts
447
+ // src/lib/links.ts
448
+ "use client";
449
+ import { useReverse } from "@rangojs/router/client";
450
+ import { routes as blogRoutes } from "../urls/blog.gen.js";
451
+ import { routes as accountRoutes } from "../urls/account.gen.js";
452
+
453
+ export const useBlogReverse = () => useReverse(blogRoutes);
454
+ export const useAccountReverse = () => useReverse(accountRoutes);
455
+ ```
456
+
457
+ Components call `useAccountReverse()`; a renamed or moved module changes one
458
+ file. The wrapper resolves like `useReverse`: against the nearest `include()`
459
+ mount, so call a module's hook from components under that module's own mount.
460
+ Inside a nested module it builds a wrong URL with no error (dc-t57); pass
461
+ cross-module links from the server there.
462
+
334
463
  ### How it resolves
335
464
 
336
465
  1. Strips an optional leading `.` and looks up the name in the imported `routes` map.
@@ -413,24 +542,29 @@ Both happen synchronously during `reverse()` — wrap calls in try/catch (or an
413
542
 
414
543
  ### The leading dot is optional
415
544
 
416
- `reverse("post")` and `reverse(".post")` resolve **identically** — the leading dot is cosmetic. The map you import IS the scope, so there is no separate global namespace to disambiguate and the dot carries no meaning; it exists only as a readability convention and for parity with `ctx.reverse(".name")` on the server. To link into a different module, import that module's `routes`:
545
+ `reverse("post")` and `reverse(".post")` resolve **identically** — the leading dot is cosmetic. The map you import IS the scope, so there is no separate global namespace to disambiguate and the dot carries no meaning; it exists only as a readability convention and for parity with `ctx.reverse(".name")` on the server. A module's `routes` map is right only inside that module's own mount: it resolves against the current mount, so another module's map gives a wrong URL with no error, and `useReverse(NamedRoutes)` is correct only at the root mount (under a mount it double-prefixes). There is no client API for links that cross modules under a nested mount (dc-t57). Build them on the server, where global names resolve fully, and pass them down:
417
546
 
418
547
  ```tsx
419
- import { routes as blogRoutes } from "../urls/blog.gen.js";
420
- import { routes as shopRoutes } from "../urls/shop.gen.js";
548
+ // server layout or handler
549
+ const links = {
550
+ blog: ctx.reverse("blog.index"),
551
+ cart: ctx.reverse("shop.cart"),
552
+ };
553
+ return <CrossNav links={links} />;
421
554
 
422
- function CrossNav() {
423
- const blog = useReverse(blogRoutes);
424
- const shop = useReverse(shopRoutes);
555
+ // client component
556
+ function CrossNav({ links }: { links: { blog: string; cart: string } }) {
425
557
  return (
426
558
  <nav>
427
- <Link to={blog("index")}>Blog</Link>
428
- <Link to={shop("cart")}>Cart</Link>
559
+ <Link to={links.blog}>Blog</Link>
560
+ <Link to={links.cart}>Cart</Link>
429
561
  </nav>
430
562
  );
431
563
  }
432
564
  ```
433
565
 
566
+ Loader data is the better carrier for chrome that follows a param: a layout handler is kept across param changes, while a layout loader re-runs when its `revalidate()` compares that param (a layout loader is held on navigation by default).
567
+
434
568
  ### Codegen
435
569
 
436
570
  Each `urls()` module gets a sibling `.gen.ts` with the local route names and patterns, produced by `rango generate`:
@@ -512,10 +646,10 @@ a link or a whole section from prefetching at all, use the `prefetch` prop or
512
646
  | Client component | `useReverse(routes)` | Local names from an imported `routes` map | Typed in-module URL generation without round-tripping the server |
513
647
  | Client component | (URL passed as prop / loader data / action return) | Named routes | Cross-module URLs or one-off names you don't want to import |
514
648
  | Client component | `href("/path")` | Absolute paths (static strings) | Static navigation where no named-route lookup is needed |
515
- | Client component | `useHref()` | Mount-prefixed paths | Local navigation inside `include()` |
649
+ | Client component | `useHref()` | Mount-prefixed paths | Local paths inside `include()`; fills `:param` mount segments |
516
650
  | Client component | `useMount()` | Raw mount path | Custom mount-aware logic |
517
651
 
518
- > `ctx.reverse()` is server-only. On the client, either generate URLs on the server and pass them in, or import the `routes` map and use `useReverse(routes)` for in-module names.
652
+ > `ctx.reverse()` is server-only. On the client, either generate URLs on the server and pass them in, or import the `routes` map and use `useReverse(routes)` for in-module names, inside that module's own mount.
519
653
 
520
654
  ## Complete example: mounted module
521
655
 
@@ -241,7 +241,7 @@ so response-shaping belongs in middleware or the handler).
241
241
  | `method` | `string` | HTTP method. `"GET"` for SSR loader runs; reflects real method for fetchable loaders. |
242
242
  | `body` | `TBody \| undefined` | Parsed request body for fetchable POST/PUT/PATCH/DELETE calls. |
243
243
  | `formData` | `FormData \| undefined` | Present when a fetchable loader is invoked via form submission. |
244
- | `reverse` | `ScopedReverseFunction` | Generate type-checked URLs from route names (same scoped semantics as route handlers). |
244
+ | `reverse` | `AutofillAwareReverseFunction` | Generate type-checked URLs from route names (same scoped semantics as route handlers; params the matched route carries are optional). |
245
245
 
246
246
  ### Example
247
247
 
@@ -266,9 +266,10 @@ export const ProductLoader = createLoader(async (ctx) => {
266
266
  // Variables set by middleware (from Rango.Vars augmentation)
267
267
  const user = ctx.get("user");
268
268
 
269
- // Type-checked URLs for payloads. `.name` resolves within the current
270
- // include() scope; a bare `name` resolves globally. See /route and
271
- // /typesafety for scope rules and route-name autocomplete.
269
+ // Type-checked URLs for payloads. `.name` resolves within the include()
270
+ // scope of the module that declares this loader (a layout's loader in a
271
+ // parent module links within that module); a bare `name` resolves
272
+ // globally. See /links and /typesafety for scope rules and autocomplete.
272
273
  const detailUrl = ctx.reverse(".detail", { slug });
273
274
 
274
275
  return {
@@ -321,7 +322,7 @@ path("/product/:slug", ProductPage, { name: "product" }, () => [
321
322
 
322
323
  // Loader with revalidation control
323
324
  loader(RelatedProductsLoader, () => [
324
- revalidate(() => false), // Never revalidate
325
+ revalidate(false), // Never revalidate
325
326
  ]),
326
327
 
327
328
  // Loader that re-runs only after cart actions. Loaders re-run after every
@@ -355,6 +356,7 @@ chain continues:
355
356
  ```typescript
356
357
  // 1) Hard decision — short-circuits the chain, used as the final answer.
357
358
  revalidate(() => true);
359
+ revalidate(true); // shorthand for () => true; revalidate(false) for () => false
358
360
  revalidate(({ actionId }) => actionId?.includes("Cart") ?? false);
359
361
 
360
362
  // 2) Soft decision — updates the running suggestion for downstream
@@ -377,6 +379,14 @@ revalidate(() => null); // explicit defer
377
379
  If every revalidator on a segment defers, the segment default is used
378
380
  (`defaultShouldRevalidate`, see the table below).
379
381
 
382
+ `revalidate(true)` and `revalidate(false)` are shorthand for the constant hard
383
+ decisions `revalidate(() => true)` and `revalidate(() => false)`: `true`
384
+ re-runs the segment on every navigation and action, `false` keeps the client's
385
+ copy once it holds one. A defer has no shorthand: write the function. The
386
+ shorthand works everywhere `revalidate()` does, `clientUrls()` loaders
387
+ included. There the build checks the argument: a literal at its call site,
388
+ any other expression when the server mounts the group.
389
+
380
390
  The callback must be **synchronous**. A returned Promise is ignored (dev
381
391
  warning) and the running suggestion is kept, so move async work into a loader.
382
392
  A callback that throws is logged and treated as a defer; a thrown `Response`
@@ -944,6 +954,8 @@ function ProductPage() {
944
954
 
945
955
  A skeleton that has appeared stays on screen for at least about 300 ms before React replaces it with what it was waiting for: React does not replace a fallback sooner than 300 ms after the last fallback committed ([#1078](https://github.com/rangojs/rango/issues/1078)). On a cold click that mounts the `loading()` boundary, a loader that finishes sooner than that still shows its skeleton for about 300 ms, while a slower loader is not delayed. The router leaves this to React: an early skeleton is preferred to holding the click, and a fully prefetched click commits complete and shows none. A nested `loading()` whose data lands after the outer one has been revealed shows its own fallback, and the 300 ms starts again. The loaders feature page on the docs site has the measured figures. Under `transition()` a related cost applies; see [view-transitions](../view-transitions/SKILL.md).
946
956
 
957
+ A loader that throws (anything but `notFound()` or `redirect()`) throws at its `useLoader` read on every client lane: a plain or fully prefetched click, Back/Forward restored from the history cache, and the rerun after a server action. A prefetch in which a loader threw is never used, in memory or from the browser's HTTP cache: the click asks the server again, so a loader that has recovered shows its data. The nearest boundary to the read catches it (your React error boundary around the read, else the route's `errorBoundary()`, else the router's root one), and the layouts above keep their instance. The background refresh after Back/Forward onto a stale page is the exception: a loader that fails there leaves the page on screen and logs `[Browser] Background revalidation failed`. Not covered: an `intercept()`'s loaders, and a parallel slot's loaders under its own `loading()`, which are decoded above the slot's content.
958
+
947
959
  ## Loader Authority: notFound() and redirect()
948
960
 
949
961
  A loader may **throw** `notFound()` and `redirect()` — data-dependent
@@ -955,7 +967,7 @@ import { createLoader, notFound, redirect } from "@rangojs/router";
955
967
 
956
968
  export const ProductLoader = createLoader(async (ctx) => {
957
969
  const moved = LEGACY_SLUGS[ctx.params.slug];
958
- if (moved) throw redirect(`/shop/product/${moved}`);
970
+ if (moved) throw redirect(ctx.reverse("shop.product", { slug: moved }));
959
971
 
960
972
  // Existence check BEFORE the expensive fetch: a near-instant rejection
961
973
  // usually wins the race to first flush (see the semantics below).
@@ -1012,6 +1024,7 @@ Delivery is async **by the race model**: pushes that settle before the handler
1012
1024
  barrier ride the SSR handle snapshot (in the SSR'd document — `<Html.Meta />`,
1013
1025
  `useHandle` reads); later pushes stream to the client and apply post-hydration
1014
1026
  on document loads (`metadata.handlesLate`) or progressively on navigations.
1027
+ Handle values land on their own channel and never hold the page, so an effect that needs a late push keys on the handle value, not the URL (see [hooks](../hooks/handle-and-actions.md#when-handle-values-change-by-design)).
1015
1028
  A push before your slow fetch usually beats the barrier; a push derived from
1016
1029
  the fetched data usually does not. When it MUST be in the document, use
1017
1030
  `ssr: false` below. A loader with its own `cache()` keeps its pushes on a hit —
@@ -1021,7 +1034,9 @@ there every push a loader makes on that request applies post-hydration (see
1021
1034
  `/ppr`, "Handles on a shell HIT"). A `useHandle` reader inside this loader's
1022
1035
  `loading()` or `<Suspense>` hydrates with the data its HTML was rendered
1023
1036
  from and shows a later push right after, so a push after an `await` causes
1024
- no hydration mismatch there.
1037
+ no hydration mismatch there. A loader body may push after an `await`; a server
1038
+ component that does throws `LateHandlePushError`, so it resolves a slot its
1039
+ handler reserved with `.defer()` instead (`/breadcrumbs`, "Deferred content").
1025
1040
 
1026
1041
  Under `cache()`, loader pushes are not stored with the cached segments: the
1027
1042
  loader re-runs on every hit and its live push is the only copy. (`ppr` keeps
@@ -1279,9 +1294,16 @@ The limits:
1279
1294
  data with the URL, and one fill runs the flagged loaders (see
1280
1295
  `/client-urls` → "`prefetch: false`"). A flagged `loading()` defers nothing
1281
1296
  on a group route, from the group or from a server layout above it.
1282
- - **No effect inside `intercept()`.** Its loaders accept the flag and ignore
1283
- it, in `urls()` and in `clientUrls()`: a prefetch that resolves an
1284
- intercept defers nothing.
1297
+ - **Inside `intercept()`.** In `urls()` and in `clientUrls()`, a prefetch of
1298
+ the intercept's target from its source page defers the flagged loader: the
1299
+ click opens the modal at once, with its `loading()`, and one fill, sent
1300
+ from the source, runs the loader. The page under the modal stays. With no
1301
+ `loading()` the slot is empty until the fill answers, as on a plain click
1302
+ until its response. The modal's loaders resolve together: it shows its
1303
+ `loading()` until every one has arrived. A flagged `loading()` inside
1304
+ `intercept()` defers nothing. Each intercept loader also runs its own
1305
+ `revalidate()`, so an action from the modal re-runs only those that say so
1306
+ (see `/intercept`).
1285
1307
  - **`ppr` and `ssr: false`.** On a `ppr` route an `ssr: false` loader is the
1286
1308
  bake lane and is served from the shell, so `prefetch: false` is ignored for
1287
1309
  it and development logs a warning. Under `cache()` and `Prerender` the flag
@@ -59,7 +59,9 @@ only between a route handler and its child/orphan layouts and parallels (so
59
59
  their latency overlaps rendering rather than blocking it. See `/loader` →
60
60
  "Parallel and streaming".
61
61
 
62
- The contract is: **route middleware wraps rendering regardless of transport** (JS-enabled RSC stream or no-JS HTML). During PE re-render, route middleware observes action-set state (cookies, context variables) the same way it does during JS-enabled post-action revalidation.
62
+ The contract is: **route middleware wraps rendering regardless of transport** (JS-enabled RSC stream or no-JS HTML). During PE re-render, route middleware observes action-set state (cookies, context variables) the same way it does during JS-enabled post-action revalidation. When the action throws, the `errorBoundary()` render is wrapped too, in both transports: the boundary reads the middleware's `ctx.set()` values and the response carries its headers.
63
+
64
+ Around the render after an action, route middleware's `ctx.request` is the page's GET: `method` is `"GET"`, with no body, and the headers are the POST's without `content-type` and `content-length`. It decides as it would for a GET of the page. Global middleware, which wraps the action, sees the POST, and so does `getRequestContext().request`. So a method check in route middleware never sees the mutation: it cannot block or answer one, since the action already ran. A header its GET branch sets also lands on the action's response. If a header must stay off mutation responses (a public `Cache-Control`), set it from global middleware.
63
65
 
64
66
  Revalidation is still partial. Route middleware wraps the render pass that
65
67
  does happen, but it does not force unrelated outer segments to recompute.
@@ -256,7 +258,7 @@ export const myMiddleware: Middleware = async (ctx, next) => {
256
258
  ctx.header("X-Frame-Options", "DENY"); // set one response header
257
259
  ctx.headers; // response Headers (stub before next(), the real response after)
258
260
  ctx.setLocationState(FlashMessage({ text: "Saved" })); // history state for the client
259
- ctx.reverse("home"); // URL for a global route name (no ".name", no param auto-fill)
261
+ ctx.reverse("home"); // URL for a global route name (no ".name", no param auto-fill; a missing required param throws)
260
262
  ctx.rewrittenFrom; // the visitor's URL when this request was rewritten, else undefined
261
263
  ctx.isRewrite(error); // true for a thrown rewrite() (rethrow it from a try/catch)
262
264
 
@@ -442,8 +444,10 @@ import type { Middleware } from "@rangojs/router";
442
444
 
443
445
  export const maintenance: Middleware = async (ctx, next) => {
444
446
  if (!locked || ctx.pathname === "/maintenance") return next();
445
- // Mutations get a Response: a rewrite cannot answer a server action or a form post.
446
- if (ctx.request.method !== "GET") {
447
+ // A rewrite moves a server action or form post to its target, where the
448
+ // action runs. A lock answers mutations itself, so none runs under
449
+ // maintenance. HEAD rewrites like GET, as under .disallowMutations().
450
+ if (ctx.request.method !== "GET" && ctx.request.method !== "HEAD") {
447
451
  return new Response("Maintenance", {
448
452
  status: 503,
449
453
  headers: { "Retry-After": "600" },
@@ -463,6 +467,16 @@ path("/maintenance", MaintenancePage, { name: "maintenance" }, () => [
463
467
  ]);
464
468
  ```
465
469
 
470
+ Without the `Response` branch, `.disallowMutations()` keeps a lock from
471
+ running mutations: a server action or form post under it is an error before
472
+ the action runs (a 500 the client shows as an error, reported through
473
+ `onError` with phase `"routing"`), and GET and HEAD rewrite as usual. The
474
+ `Response` above gives the caller a proper 503 instead.
475
+
476
+ ```typescript
477
+ throw rewrite("maintenance").disallowMutations();
478
+ ```
479
+
466
480
  Targets, like `ctx.reverse` with a path form:
467
481
 
468
482
  ```typescript
@@ -519,7 +533,9 @@ carries `x-rango-rewrite: <target pathname>`.
519
533
  inside a `cache()` boundary or a `"use cache"` function throws (pass what
520
534
  you need in from middleware), and a render other visitors may be served (a
521
535
  PPR shell capture, a page the document cache stores) reads `undefined`, as a
522
- direct visit does.
536
+ direct visit does. A shell keyed by the visitor's URL
537
+ (`rewrite(...).shell("visitor")`, see Caches) reads it, since its key covers
538
+ that URL.
523
539
 
524
540
  **Middleware only, by design.** `rewrite()` is a middleware tool. A route
525
541
  handler or a loader (any loader, `ssr: false` included) cannot rewrite: its
@@ -540,12 +556,59 @@ path("/product/:slug", ProductPage, { name: "product" }, () => [
540
556
  ]);
541
557
  ```
542
558
 
559
+ **Mutations: where the middleware sits decides.** A server action or a no-JS
560
+ form post from a rewritten page is posted to the visitor's URL, so the same
561
+ middleware sees it:
562
+
563
+ - **Global middleware** on the visitor's URL runs before the action. A
564
+ rewrite there moves the mutation to the target first, with its body: the
565
+ target's global middleware, then the action, once, then the target's
566
+ route middleware and render, as a direct post to the target. So a
567
+ routing rewrite (a multi-tenant host, an A/B split, a visitor URL with no
568
+ route) keeps its page's forms working, in JS and no-JS. Rewrite every
569
+ method: a rewrite written for GETs only leaves the mutation on the
570
+ visitor's URL, so the action runs on the visitor's route and its render
571
+ replaces the page. A lock must not run the mutation: return a `Response`
572
+ for mutations, or throw `rewrite(...).disallowMutations()`, which refuses
573
+ it with an error before the action runs. It chains with `.shell()` in
574
+ either order.
575
+ - **Route middleware** runs after the action, around the re-render, and
576
+ sees it as the page's GET. A routing rewrite there (A/B, URL override,
577
+ the `discontinued` example above) fires as on a GET, including one
578
+ written for GETs only (`if (ctx.request.method !== "GET") return next()`).
579
+ The action runs once, then the re-render runs as the target (its global
580
+ and route middleware, its render, or its error boundary when the action
581
+ threw), and the page keeps its layout and state. Route middleware cannot
582
+ refuse the action, so `.disallowMutations()` has no effect there: the
583
+ render moves to the target, dev logs a warning on each throw, and
584
+ production logs one `console.error`. Put a lock in global middleware.
585
+ - Global middleware on the target's pass sees the POST, with its headers.
586
+ A no-JS form post keeps its body, so a form-field check (a CSRF token
587
+ read from `ctx.request.clone().formData()`) passes on every pass, and so
588
+ does a mutation a global rewrite moved before its action, whose action
589
+ runs on the target's pass: do not skip a body check when
590
+ `ctx.rewrittenFrom` is set. Always read a clone; a rewrite after middleware
591
+ read the body itself is an error, since the action would get no
592
+ arguments. After a route-middleware rewrite, a JS action's body is gone
593
+ (the action read it), and that pass drops the `content-type` too, so a
594
+ body check gated on the content-type passes over it; that pass never runs
595
+ the action. Global middleware runs once per pass, as for any rewrite, so a
596
+ one-time token belongs in the action. An action's target must be a page
597
+ route: a rewrite that sends a server action or form post that already ran,
598
+ or a JS action before it runs, to a response route (`path.json()`) or a
599
+ path that redirects is an error. A plain POST a global rewrite moves
600
+ reaches a response route with its body, as a direct post would, and so
601
+ does a no-JS form post, whose action the router finds only on a page
602
+ route.
603
+
543
604
  **Throw it, don't return it.** A returned rewrite is an error. Where a
544
605
  rewrite cannot be honored it is an error that names the rule: a route
545
606
  handler or a loader (see above), a server action (use `redirect()`),
546
- intercept middleware, a loader fetch (`useFetchLoader`, `load()`: it
547
- rejects with the rule) or its middleware, and middleware handling a non-GET
548
- request (a JS action or a no-JS form post: return a `Response`).
607
+ a loader fetch (`useFetchLoader`, `load()`: it
608
+ rejects with the rule) or its middleware. A `.disallowMutations()` rewrite
609
+ from global middleware handling a non-GET, non-HEAD request (a JS action or
610
+ a no-JS form post) is an error too, by your choice: it reads
611
+ `rewrite("…") refused a mutation: .disallowMutations() was set`.
549
612
 
550
613
  **try/catch around next().** A thrown rewrite passes through your
551
614
  middleware's `catch`. Rethrow it:
@@ -570,12 +633,26 @@ the trail, `/a -> /b -> /a`) through `onError` with phase `"routing"`.
570
633
  - The document cache keys a rewritten request by the target's URL, as a
571
634
  direct visit. Register the rewriting middleware **before**
572
635
  `createDocumentCacheMiddleware()`: a cache hit for the visitor's URL would
573
- be served before your middleware runs. A page the document cache stores,
574
- and a PPR shell of the target, are rendered as a direct visit to the
575
- target: they carry the target's pathname and `ctx.rewrittenFrom` is
576
- `undefined` there, so a component that renders `usePathname()` can
577
- mismatch on hydration at the visitor's URL when such a page is served to
578
- a rewritten request.
636
+ be served before your middleware runs. A page the document cache stores
637
+ is rendered as a direct visit to the target: it carries the target's
638
+ pathname and `ctx.rewrittenFrom` is `undefined` there, so a component that
639
+ renders `usePathname()` can mismatch on hydration at the visitor's URL
640
+ when such a page is served to a rewritten request.
641
+ - A PPR shell (`ppr` target) is read and written only by requests on its
642
+ URL. A rewritten request renders live at the visitor's URL, and a direct
643
+ visit captures the target's shell, so values you set before the throw
644
+ never reach it. Pick a shell for the rewritten request with `.shell()` on
645
+ the thrown value: `"visitor"` keys a shell by the visitor's URL, captured
646
+ with the visitor's location and `ctx.rewrittenFrom` (one shell per public
647
+ URL, the right choice for a routing catch-all); `"target"` reads and
648
+ captures the target's shared shell, which shows the target's URL to the
649
+ visitor on a HIT and bakes in what the rewriting middleware set, so keep it
650
+ for a lock or promo page that renders neither:
651
+
652
+ ```typescript
653
+ throw rewrite("item", { id }).shell("visitor");
654
+ ```
655
+
579
656
  - The client's prefetch and history caches keep a rewritten page under the
580
657
  visitor's URL, and nothing tells them about the rewrite: a page prefetched
581
658
  during a lock shows the lock page for up to `prefetchCacheTTL` after the
@@ -600,10 +677,29 @@ the trail, `/a -> /b -> /a`) through `onError` with phase `"routing"`.
600
677
  };
601
678
  ```
602
679
 
680
+ **Navigating from a rewritten page.** A navigation, prefetch or action from
681
+ the page diffs against the route that rendered it, as one from a direct
682
+ visit does: the payload carries the target (`metadata.rewriteTarget`), and
683
+ the browser sends it back with the next request from that page. A
684
+ `revalidate()` sees the target's params and URL (`currentParams.id` against
685
+ `nextParams.id` holds a loader on a sort change), and a navigation to the
686
+ page already shown re-renders nothing. A move to another visitor URL with
687
+ the same target (`/promo/a` to `/promo/b`, both rewritten to
688
+ `/promo/landing`) re-runs the route and its loaders, which may read
689
+ `ctx.rewrittenFrom`. The browser only names the page being left: what
690
+ renders still comes from the request's URL and your middleware.
691
+
603
692
  **Testing.** `runMiddleware` reports a thrown rewrite as `result.rewrite`
604
- (`{ pathname, search, routeName? }`). `serveShellRequest` takes a rewritten
693
+ (`{ pathname, search, routeName?, mutationsDisallowed? }`, the last `true`
694
+ only for `.disallowMutations()`). `serveShellRequest` takes a rewritten
605
695
  page request through the full production pipeline (document or `partial`
606
696
  navigation): the target's middleware, render, status and `x-rango-rewrite`.
697
+ Pass the page's `metadata.rewriteTarget` as `partial.rewriteTarget` (or
698
+ `action.rewriteTarget`) to send a request from it as the browser does.
699
+ With `action` or `form` (from `createActionForm`) it posts a mutation to the
700
+ visitor's URL, so a global rewrite's move, a `.disallowMutations()` refusal
701
+ (it rejects with the refusal) and a route-middleware rewrite's re-render are
702
+ testable too.
607
703
  `dispatch` re-dispatches too, for response routes only: it matches the
608
704
  visitor's URL first and throws on a component (RSC) route.
609
705
  `renderRoute(..., { request: "/shop", rewrite: "/maintenance" })` renders
@@ -302,6 +302,17 @@ Then inspect:
302
302
  first paint.
303
303
  - the `Server-Timing` header to compare local logs with browser-network timing.
304
304
 
305
+ ## Telling a page is a Rango app
306
+
307
+ Every page sets `window.__rango = { version }` (frozen, one key) from the
308
+ bundled client entry before hydration. `version` is the exact npm semver of
309
+ `@rangojs/router`, nothing else: no build hash, router id or config. It is for
310
+ devtools and support; the router never reads it and no option turns it off.
311
+
312
+ ```js
313
+ window.__rango; // { version: "0.24.2" }
314
+ ```
315
+
305
316
  ## Zero-overhead defaults
306
317
 
307
318
  `debugPerformance` is off by default, `telemetry` emits nothing unless a sink
@@ -206,9 +206,9 @@ parallel(
206
206
  () => [
207
207
  // The loader keeps its own revalidation: by default it re-runs after
208
208
  // every action. Scope it here to stop that.
209
- loader(CategoriesLoader, () => [revalidate(() => false)]),
209
+ loader(CategoriesLoader, () => [revalidate(false)]),
210
210
  loading(<SidebarSkeleton />),
211
- revalidate(() => false), // the slot component renders once, then is kept
211
+ revalidate(false), // the slot component renders once, then is kept
212
212
  ]
213
213
  )
214
214
  ```
@@ -430,7 +430,7 @@ boundary above it: the route's `loading()`, replacing the entire route content
430
430
  with the route skeleton while one slot refetches. Either scope the slot's
431
431
  `revalidate()` (share the route's named contract, as above) or give the slot
432
432
  its own `loading()` so the fallback stays local. Don't reach for a bare
433
- `revalidate(() => false)`: the slot then never refreshes on param changes
433
+ `revalidate(false)`: the slot then never refreshes on param changes
434
434
  either, and keeps the previous param's content on e.g. a product-to-product
435
435
  navigation.
436
436
 
@@ -438,7 +438,7 @@ navigation.
438
438
  it the first time. A slot the browser has not rendered yet has nothing to keep
439
439
  showing, so on that first render the decision is clamped to `true` and your
440
440
  callback's `false` is ignored; from then on it is honored and the browser keeps
441
- the copy it already has. So `revalidate(() => false)` means "render once, then
441
+ the copy it already has. So `revalidate(false)` means "render once, then
442
442
  never re-render", never "don't appear".
443
443
 
444
444
  Revalidate callbacks must be synchronous. A callback that returns a Promise is
@@ -565,7 +565,7 @@ export const shopPatterns = urls(({
565
565
  { "@sidebar": () => <CategorySidebar /> },
566
566
  () => [
567
567
  loader(CategoriesLoader),
568
- revalidate(() => false),
568
+ revalidate(false),
569
569
  ]
570
570
  ),
571
571