@rangojs/router 0.24.4 → 0.26.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 (301) 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-cache-store.d.ts +0 -2
  21. package/dist/types/cache/cf/cf-cache-types.d.ts +0 -16
  22. package/dist/types/cache/cf/cf-shell-frame.d.ts +2 -0
  23. package/dist/types/cache/handle-snapshot.d.ts +2 -2
  24. package/dist/types/cache/types.d.ts +16 -6
  25. package/dist/types/cache/vercel/vercel-cache-store.d.ts +1 -15
  26. package/dist/types/client-urls/client-urls.d.ts +6 -2
  27. package/dist/types/client-urls/revalidate-registry.d.ts +1 -1
  28. package/dist/types/client-urls/server-projection.d.ts +9 -5
  29. package/dist/types/client-urls/types.d.ts +29 -16
  30. package/dist/types/client.d.ts +2 -2
  31. package/dist/types/client.rsc.d.ts +2 -2
  32. package/dist/types/decode-loader-results.d.ts +28 -7
  33. package/dist/types/handles/deferred-resolution.d.ts +3 -2
  34. package/dist/types/handles/meta.d.ts +5 -0
  35. package/dist/types/href-client.d.ts +3 -1
  36. package/dist/types/index.rsc.d.ts +1 -1
  37. package/dist/types/loader-redirect.d.ts +2 -3
  38. package/dist/types/outlet-context.d.ts +7 -6
  39. package/dist/types/prefetch-markers.d.ts +14 -5
  40. package/dist/types/prerender/build-shell-capture.d.ts +1 -2
  41. package/dist/types/prerender/writable-store.d.ts +2 -3
  42. package/dist/types/route-content-wrapper.d.ts +17 -14
  43. package/dist/types/route-definition/helpers-types.d.ts +7 -2
  44. package/dist/types/route-definition/resolve-handler-use.d.ts +6 -1
  45. package/dist/types/route-definition/rewrite.d.ts +48 -5
  46. package/dist/types/route-types.d.ts +2 -2
  47. package/dist/types/router/handler-context.d.ts +14 -2
  48. package/dist/types/router/intercept-resolution.d.ts +19 -46
  49. package/dist/types/router/lazy-includes.d.ts +7 -0
  50. package/dist/types/router/match-middleware/intercept-resolution.d.ts +2 -2
  51. package/dist/types/router/middleware.d.ts +0 -16
  52. package/dist/types/router/navigation-snapshot.d.ts +2 -0
  53. package/dist/types/router/prefetch-cache-ttl.d.ts +8 -11
  54. package/dist/types/router/revalidation.d.ts +8 -1
  55. package/dist/types/router/router-context.d.ts +4 -16
  56. package/dist/types/router/router-interfaces.d.ts +0 -11
  57. package/dist/types/router/router-options.d.ts +4 -27
  58. package/dist/types/router/segment-resolution/mask-nested.d.ts +5 -3
  59. package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +31 -8
  60. package/dist/types/router/segment-resolution/revalidation.d.ts +38 -4
  61. package/dist/types/router/segment-resolution.d.ts +1 -1
  62. package/dist/types/router/segment-wrappers.d.ts +3 -5
  63. package/dist/types/router/substitute-pattern-params.d.ts +3 -2
  64. package/dist/types/router/tracing.d.ts +2 -2
  65. package/dist/types/router-versions.d.ts +8 -6
  66. package/dist/types/rsc/full-payload.d.ts +14 -2
  67. package/dist/types/rsc/helpers.d.ts +8 -0
  68. package/dist/types/rsc/manifest-init.d.ts +20 -0
  69. package/dist/types/rsc/prefetch-render-error.d.ts +22 -0
  70. package/dist/types/rsc/progressive-enhancement.d.ts +20 -0
  71. package/dist/types/rsc/rewrite-passes.d.ts +12 -2
  72. package/dist/types/rsc/server-action.d.ts +6 -0
  73. package/dist/types/rsc/shell-capture-constants.d.ts +14 -4
  74. package/dist/types/rsc/shell-capture.d.ts +23 -2
  75. package/dist/types/rsc/types.d.ts +5 -8
  76. package/dist/types/segment-system.d.ts +15 -0
  77. package/dist/types/server/build-version-table.d.ts +10 -13
  78. package/dist/types/server/context.d.ts +90 -6
  79. package/dist/types/server/handle-store.d.ts +17 -6
  80. package/dist/types/server/request-context.d.ts +45 -7
  81. package/dist/types/server/request-headers.d.ts +12 -0
  82. package/dist/types/server/rewrite-trail.d.ts +8 -1
  83. package/dist/types/testing/build-versions.d.ts +0 -3
  84. package/dist/types/testing/e2e/index.d.ts +1 -1
  85. package/dist/types/testing/flight.entry.d.ts +1 -1
  86. package/dist/types/testing/index.d.ts +1 -1
  87. package/dist/types/testing/internal/client-urls-projection.d.ts +4 -3
  88. package/dist/types/testing/internal/seed-vars.d.ts +4 -4
  89. package/dist/types/testing/render-route.d.ts +21 -11
  90. package/dist/types/testing/run-loader.d.ts +7 -0
  91. package/dist/types/testing/run-middleware.d.ts +7 -0
  92. package/dist/types/testing/run-revalidate.d.ts +5 -4
  93. package/dist/types/testing/serve-shell-request.d.ts +42 -5
  94. package/dist/types/testing/shell-status.d.ts +17 -0
  95. package/dist/types/testing/vitest-stubs/version.d.ts +1 -0
  96. package/dist/types/testing/vitest.d.ts +10 -1
  97. package/dist/types/thenable-status.d.ts +0 -6
  98. package/dist/types/types/handler-context.d.ts +12 -1
  99. package/dist/types/types/loader-types.d.ts +10 -4
  100. package/dist/types/types/route-entry.d.ts +2 -1
  101. package/dist/types/urls/path-helper-types.d.ts +4 -2
  102. package/dist/types/vite/discovery/client-urls-projection.d.ts +55 -5
  103. package/dist/types/vite/discovery/state.d.ts +20 -0
  104. package/dist/types/vite/plugins/client-urls-revalidate-hoist.d.ts +28 -16
  105. package/dist/types/vite/plugins/hoist/source.d.ts +8 -7
  106. package/dist/types/vite/plugins/virtual-entries.d.ts +6 -0
  107. package/dist/types/vite/utils/prerender-utils.d.ts +7 -0
  108. package/dist/vite/index.js +6819 -6555
  109. package/package.json +1 -1
  110. package/skills/app-structure/SKILL.md +255 -0
  111. package/skills/app-structure/content-site.md +333 -0
  112. package/skills/app-structure/e-commerce.md +435 -0
  113. package/skills/app-structure/large-app.md +885 -0
  114. package/skills/app-structure/multi-app.md +280 -0
  115. package/skills/breadcrumbs/SKILL.md +6 -1
  116. package/skills/cache-guide/SKILL.md +4 -8
  117. package/skills/caching/SKILL.md +6 -6
  118. package/skills/catalog.json +12 -0
  119. package/skills/client-urls/SKILL.md +185 -32
  120. package/skills/comparison/references/framework-comparison.md +1 -1
  121. package/skills/composability/SKILL.md +10 -8
  122. package/skills/handler-use/SKILL.md +48 -9
  123. package/skills/hooks/handle-and-actions.md +104 -0
  124. package/skills/hooks/navigation.md +24 -10
  125. package/skills/hooks/urls.md +1 -1
  126. package/skills/host-router/SKILL.md +1 -1
  127. package/skills/i18n/SKILL.md +2 -1
  128. package/skills/intercept/SKILL.md +60 -29
  129. package/skills/layout/SKILL.md +6 -3
  130. package/skills/links/SKILL.md +151 -17
  131. package/skills/loader/SKILL.md +42 -19
  132. package/skills/middleware/SKILL.md +111 -15
  133. package/skills/observability/SKILL.md +11 -0
  134. package/skills/parallel/SKILL.md +5 -5
  135. package/skills/ppr/SKILL.md +103 -52
  136. package/skills/prerender/SKILL.md +4 -5
  137. package/skills/rango/SKILL.md +3 -0
  138. package/skills/response-routes/SKILL.md +4 -1
  139. package/skills/router-setup/SKILL.md +0 -2
  140. package/skills/scripts/SKILL.md +12 -1
  141. package/skills/server-actions/SKILL.md +60 -20
  142. package/skills/testing/SKILL.md +1 -0
  143. package/skills/testing/client-components.md +13 -10
  144. package/skills/testing/loader.md +25 -24
  145. package/skills/testing/middleware.md +11 -11
  146. package/skills/testing/render-handler.md +17 -17
  147. package/skills/testing/response-routes.md +4 -1
  148. package/skills/testing/serve-shell-request.md +86 -22
  149. package/skills/testing/server-actions.md +16 -16
  150. package/skills/ux-architecture/SKILL.md +980 -0
  151. package/skills/vercel/SKILL.md +1 -1
  152. package/skills/view-transitions/SKILL.md +43 -10
  153. package/src/__augment-tests__/augmented.check.ts +28 -0
  154. package/src/browser/event-controller.ts +44 -18
  155. package/src/browser/intercept-utils.ts +3 -2
  156. package/src/browser/merge-segment-loaders.ts +0 -85
  157. package/src/browser/navigation-bridge.ts +112 -84
  158. package/src/browser/navigation-client.ts +20 -15
  159. package/src/browser/navigation-store.ts +4 -2
  160. package/src/browser/navigation-transaction.ts +9 -8
  161. package/src/browser/partial-update.ts +90 -32
  162. package/src/browser/prefetch/cache.ts +8 -0
  163. package/src/browser/prefetch/fetch.ts +13 -3
  164. package/src/browser/rango-marker.ts +13 -0
  165. package/src/browser/react/NavigationProvider.tsx +25 -7
  166. package/src/browser/react/filter-segment-order.ts +13 -0
  167. package/src/browser/react/use-action.ts +9 -15
  168. package/src/browser/react/use-handle.ts +63 -71
  169. package/src/browser/react/use-href.tsx +24 -5
  170. package/src/browser/react/use-link-status.ts +1 -1
  171. package/src/browser/react/use-navigation.ts +30 -40
  172. package/src/browser/react/use-router.ts +37 -6
  173. package/src/browser/react/use-segments.ts +8 -12
  174. package/src/browser/response-adapter.ts +11 -5
  175. package/src/browser/rsc-router.tsx +3 -1
  176. package/src/browser/scroll-restoration.ts +34 -20
  177. package/src/browser/segment-reconciler.ts +11 -29
  178. package/src/browser/server-action-bridge.ts +52 -16
  179. package/src/browser/types.ts +40 -8
  180. package/src/build/generate-manifest.ts +12 -1
  181. package/src/build/route-types/codegen.ts +6 -2
  182. package/src/build/route-types/per-module-writer.ts +94 -61
  183. package/src/cache/cache-runtime.ts +9 -0
  184. package/src/cache/cache-scope.ts +2 -1
  185. package/src/cache/cf/cf-cache-store.ts +6 -7
  186. package/src/cache/cf/cf-cache-types.ts +0 -17
  187. package/src/cache/cf/cf-shell-frame.ts +3 -0
  188. package/src/cache/handle-snapshot.ts +2 -2
  189. package/src/cache/types.ts +16 -6
  190. package/src/cache/vercel/vercel-cache-store.ts +8 -19
  191. package/src/client-urls/client-urls.ts +202 -153
  192. package/src/client-urls/revalidate-registry.ts +12 -3
  193. package/src/client-urls/server-projection.ts +137 -66
  194. package/src/client-urls/types.ts +30 -15
  195. package/src/client.rsc.tsx +11 -1
  196. package/src/client.tsx +9 -1
  197. package/src/decode-loader-results.ts +64 -31
  198. package/src/handles/deferred-resolution.ts +3 -2
  199. package/src/handles/meta.ts +5 -0
  200. package/src/href-client.ts +3 -1
  201. package/src/index.rsc.ts +1 -0
  202. package/src/index.ts +12 -4
  203. package/src/loader-redirect.tsx +2 -3
  204. package/src/outlet-context.ts +7 -6
  205. package/src/prefetch-markers.ts +15 -5
  206. package/src/prerender/build-shell-capture.ts +1 -2
  207. package/src/prerender/writable-store.ts +2 -3
  208. package/src/reverse.ts +3 -3
  209. package/src/route-content-wrapper.tsx +19 -17
  210. package/src/route-definition/dsl-helpers.ts +41 -21
  211. package/src/route-definition/helpers-types.ts +7 -2
  212. package/src/route-definition/resolve-handler-use.ts +23 -10
  213. package/src/route-definition/rewrite.ts +86 -19
  214. package/src/route-types.ts +1 -2
  215. package/src/router/find-match.ts +37 -1
  216. package/src/router/handler-context.ts +85 -40
  217. package/src/router/intercept-resolution.ts +161 -307
  218. package/src/router/lazy-includes.ts +43 -27
  219. package/src/router/loader-resolution.ts +21 -1
  220. package/src/router/manifest.ts +15 -10
  221. package/src/router/match-api.ts +11 -1
  222. package/src/router/match-middleware/background-revalidation.ts +2 -4
  223. package/src/router/match-middleware/cache-lookup.ts +16 -20
  224. package/src/router/match-middleware/intercept-resolution.ts +41 -60
  225. package/src/router/match-result.ts +31 -2
  226. package/src/router/middleware.ts +6 -147
  227. package/src/router/navigation-snapshot.ts +38 -2
  228. package/src/router/prefetch-cache-ttl.ts +10 -18
  229. package/src/router/prerender-match.ts +2 -1
  230. package/src/router/revalidation.ts +24 -3
  231. package/src/router/router-context.ts +5 -18
  232. package/src/router/router-interfaces.ts +0 -13
  233. package/src/router/router-options.ts +4 -28
  234. package/src/router/segment-resolution/fresh.ts +6 -2
  235. package/src/router/segment-resolution/helpers.ts +7 -1
  236. package/src/router/segment-resolution/loader-cache.ts +4 -3
  237. package/src/router/segment-resolution/mask-nested.ts +5 -3
  238. package/src/router/segment-resolution/prefetch-deferral.ts +62 -12
  239. package/src/router/segment-resolution/revalidation.ts +75 -35
  240. package/src/router/segment-resolution.ts +0 -1
  241. package/src/router/segment-wrappers.ts +8 -13
  242. package/src/router/substitute-pattern-params.ts +3 -2
  243. package/src/router/tracing.ts +2 -2
  244. package/src/router-versions.ts +7 -7
  245. package/src/router.ts +5 -17
  246. package/src/rsc/full-payload.ts +26 -4
  247. package/src/rsc/handler.ts +86 -48
  248. package/src/rsc/helpers.ts +13 -0
  249. package/src/rsc/manifest-init.ts +56 -3
  250. package/src/rsc/prefetch-render-error.ts +38 -0
  251. package/src/rsc/progressive-enhancement.ts +161 -211
  252. package/src/rsc/rewrite-passes.ts +56 -9
  253. package/src/rsc/rsc-rendering.ts +79 -23
  254. package/src/rsc/server-action.ts +25 -2
  255. package/src/rsc/shell-capture-constants.ts +26 -3
  256. package/src/rsc/shell-capture.ts +137 -51
  257. package/src/rsc/types.ts +5 -9
  258. package/src/segment-system.tsx +125 -119
  259. package/src/server/build-version-table.ts +12 -24
  260. package/src/server/context.ts +160 -7
  261. package/src/server/handle-store.ts +80 -12
  262. package/src/server/request-context.ts +46 -6
  263. package/src/server/request-headers.ts +32 -0
  264. package/src/server/rewrite-trail.ts +55 -9
  265. package/src/ssr/ssr-root.tsx +6 -10
  266. package/src/testing/build-versions.ts +0 -3
  267. package/src/testing/dispatch.ts +5 -8
  268. package/src/testing/e2e/index.ts +1 -0
  269. package/src/testing/flight.entry.ts +1 -0
  270. package/src/testing/index.ts +1 -0
  271. package/src/testing/internal/client-urls-projection.ts +31 -11
  272. package/src/testing/internal/seed-vars.ts +4 -4
  273. package/src/testing/render-route.tsx +83 -40
  274. package/src/testing/run-loader.ts +26 -2
  275. package/src/testing/run-middleware.ts +10 -0
  276. package/src/testing/run-revalidate.ts +13 -6
  277. package/src/testing/serve-shell-request.ts +79 -9
  278. package/src/testing/shell-status.ts +40 -0
  279. package/src/testing/vitest-stubs/version.ts +1 -0
  280. package/src/testing/vitest.ts +74 -7
  281. package/src/thenable-status.ts +0 -11
  282. package/src/types/handler-context.ts +12 -1
  283. package/src/types/loader-types.ts +10 -4
  284. package/src/types/route-entry.ts +2 -1
  285. package/src/urls/include-helper.ts +38 -11
  286. package/src/urls/path-helper-types.ts +4 -2
  287. package/src/vite/discovery/client-urls-projection.ts +205 -16
  288. package/src/vite/discovery/discover-routers.ts +2 -2
  289. package/src/vite/discovery/prerender-collection.ts +2 -6
  290. package/src/vite/discovery/shell-prerender-phase.ts +1 -5
  291. package/src/vite/discovery/state.ts +26 -0
  292. package/src/vite/plugins/client-urls-revalidate-hoist.ts +190 -77
  293. package/src/vite/plugins/expose-internal-ids.ts +46 -40
  294. package/src/vite/plugins/hoist/source.ts +8 -7
  295. package/src/vite/plugins/transition-when-hoist.ts +1 -0
  296. package/src/vite/plugins/version-plugin.ts +7 -0
  297. package/src/vite/plugins/version.d.ts +7 -0
  298. package/src/vite/plugins/virtual-entries.ts +20 -0
  299. package/src/vite/rango.ts +4 -1
  300. package/src/vite/router-discovery.ts +39 -7
  301. package/src/vite/utils/prerender-utils.ts +17 -0
@@ -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
 
@@ -151,12 +151,42 @@ cache miss (no shell).
151
151
 
152
152
  ### A request middleware rewrote
153
153
 
154
- A request middleware rewrote (`throw rewrite()`, `/middleware`) to a `ppr`
155
- route is served the target's shell under the target's key. The capture
156
- renders the target as a direct visit: `ctx.rewrittenFrom` reads `undefined`
157
- there and the payload carries the target's pathname, so the shell is the
158
- same for direct and rewritten visitors (a component that renders
159
- `usePathname()` shows the target's URL to a rewritten visitor on a HIT).
154
+ A URL's shared shell is read and written only by requests on that URL. A
155
+ request middleware rewrote (`throw rewrite()`, `/middleware`) to a `ppr` route
156
+ neither reads the target's shell nor captures it: it renders live, at the
157
+ visitor's location (`usePathname()`, `ctx.rewrittenFrom`), on every request.
158
+ The target's shell is captured by a direct visit or a `router.prerender()` warm
159
+ of the target's URL, so nothing a rewriting middleware set (a variable, the
160
+ visitor's URL) ends up in the shell every direct visitor gets.
161
+
162
+ The rewrite chooses a shell for the rewritten request with `.shell()`:
163
+
164
+ ```ts
165
+ // A routing catch-all: each public URL gets a shell of its own.
166
+ throw rewrite("item", { id }).shell("visitor");
167
+
168
+ // A lock or promo page that renders nothing of the visitor's URL. A lock
169
+ // also refuses mutations, which a rewrite otherwise moves to the target.
170
+ throw rewrite("maintenance").shell("target").disallowMutations();
171
+ ```
172
+
173
+ - `.shell("visitor")` keys the shell by the visitor's URL and the target
174
+ (`visitorShellCacheKey(router, "/summer", "/item/1")` in a test; the target
175
+ route's `cache.searchParams` filter and its own partition apply). The
176
+ capture renders the visitor's location and `ctx.rewrittenFrom`, and a
177
+ variable set before the throw may be baked in, since the key covers the URL
178
+ it came from. Navigation replay uses the same key. A build shell of the
179
+ target is never served to it. Store growth is one shell per public URL;
180
+ `updateTag()` reaches every one of them through the shell's tags.
181
+ - `.shell("target")` reads and captures the target's shared shell from the
182
+ rewritten request. The shell then shows the target's URL to a rewritten
183
+ visitor on a HIT (`usePathname()` hydrates against the address bar, so a
184
+ component that renders it mismatches), and a variable set before the throw
185
+ is baked into the shell direct visitors get. Use it only for a page that
186
+ renders neither.
187
+
188
+ A rewritten navigation without `.shell()` reports
189
+ `x-rango-ppr-replay: BYPASS; reason=rewritten`.
160
190
 
161
191
  ### Request-partitioned shells
162
192
 
@@ -806,14 +836,17 @@ that settled inside the capture window pinned its capture-time value into the
806
836
  shared shell; found live as a storefront basket — with the capturing session's
807
837
  identifiers — served to anonymous visitors.)
808
838
 
809
- ### Handles: handler pushes bake; loader pushes keep "nesting = liveness"
839
+ ### Handles: handler and bake-lane pushes bake; loader pushes keep "nesting = liveness"
810
840
 
811
841
  - `ctx.use(H)(promise)` from a handler — a TOP-LEVEL pushed promise is awaited
812
842
  at capture and BAKED into the shell (bounded by `ppr.captureTimeout`, 15s by
813
843
  default).
814
- - A promise pushed from a LOADER is never in the shell and the capture does not
815
- wait for it: however slow it is (or if it never settles), the shell is still
816
- stored, and the HIT's own loader run delivers the value after hydration.
844
+ - `ctx.use(H)(promise)` (or `.defer()`) from an `ssr: false` LOADER, or a
845
+ loader it awaits — awaited at capture and BAKED the same way, judged by the
846
+ value it settles to, as `push(await promise)` would be (#1182). One that
847
+ rejects, or does not settle within `ppr.captureTimeout`, refuses the
848
+ capture, as a handler's does. A loader without `ssr: false` never runs at
849
+ capture, so its pushes are never in the shell.
817
850
  - `ctx.use(H)({ x: promise })` from a HANDLER — also baked. The capture waits
818
851
  for every promise nested in a handler's push, at any depth of plain objects,
819
852
  arrays and JSX props, before it records the handles every HIT replays. Keep
@@ -967,60 +1000,73 @@ pushes stay as the prelude rendered them, even where a live loader also
967
1000
  awaits it, while its data is fresh. (They follow the run instead once the
968
1001
  entry has lost a pin, because the loader that awaits it then runs.) To keep
969
1002
  its pushes live, declare it as its own `loader()` on the route. A push the
970
- capture cannot record (a deferred push, or one holding a promise) marks every
971
- loader record of the page to run: those bodies then still run on each replay,
972
- in the background, and that push reaches the page (from the loader's own
973
- `cache()` entry when it hits) with the run's value, on a document HIT and on
974
- a client navigation alike. On a document HIT it arrives after hydration.
975
-
976
- **Handles on a shell HIT.** A promise pushed from a loader is live and is
977
- never in a shell. To have a value in the shell's HTML, push it settled
978
- (await it) from the `ssr: false` loader, or push it from the handler.
979
-
980
- Three things follow, in this order of scope:
981
-
982
- 1. **What is in a shell's HTML** changes only for promise pushes of
983
- `ssr: false` loaders and of the loaders they await: `push(promise)`, or a
984
- pushed value that holds a promise. Those run at capture, and the shell
985
- renders no element for such a push. A loader without `ssr: false` never
986
- ran at capture, so nothing of it was ever in the shell.
987
- 2. **On a shell HIT every loader push made during the request arrives after
1003
+ capture cannot record (one whose value, once settled, holds a promise) marks
1004
+ every loader record of the page to run: those bodies then still run on each
1005
+ replay, in the background, and that push reaches the page (from the loader's
1006
+ own `cache()` entry when it hits) with the run's value, on a document HIT and
1007
+ on a client navigation alike. On a document HIT it arrives after hydration.
1008
+
1009
+ **Handles on a shell HIT.** The shell holds what its capture's document
1010
+ rendered: the handlers' pushes and the `ssr: false` loaders' pushes, a
1011
+ promise (`push(promise)`, `.defer()`) resolved. A pushed promise is judged by
1012
+ the value it settles to, as `push(await promise)` would be (#1182). A
1013
+ live-lane loader's push is never in a shell.
1014
+
1015
+ Four things follow, in this order of scope:
1016
+
1017
+ 1. **What is in a shell's HTML.** The capture awaits a promise an
1018
+ `ssr: false` loader (or a loader it awaits) pushes, under
1019
+ `ppr.captureTimeout`, and bakes the value. One that settles to a value
1020
+ holding a promise is live: the shell renders no element for it. A loader
1021
+ without `ssr: false` never runs at capture, so nothing of it is ever in
1022
+ the shell.
1023
+ 2. **A pushed promise that fails refuses the capture.** One that rejects, or
1024
+ does not settle within `ppr.captureTimeout`, keeps the route on MISS, as
1025
+ a handler's does, and the warning names the loader and the handle. Catch
1026
+ it and push a fallback, or push it from a live-lane loader.
1027
+ 3. **On a shell HIT every loader push made during the request arrives after
988
1028
  hydration**, a live-lane loader's included. The document hydrates with
989
1029
  exactly the handle data the shell's HTML was rendered from (the handlers'
990
- pushes and the settled pushes of the loaders that ran at capture), and
991
- the request's own pushes follow as a normal `useHandle` update.
992
- 3. **`useHandle` on every document, `ppr` or not:** a reader that React is
1030
+ pushes and the pushes of the loaders that ran at capture), and the
1031
+ request's own pushes follow as a normal `useHandle` update.
1032
+ 4. **`useHandle` on every document, `ppr` or not:** a reader that React is
993
1033
  hydrating reads the handle data its HTML was rendered from, wherever its
994
1034
  boundary hydrates, and takes the newer data right after. So a reader
995
1035
  inside the `loading()` or `<Suspense>` of a loader that pushes after an
996
1036
  `await` hydrates clean.
997
1037
 
998
- | On the HIT's request | In the HIT's HTML | The client |
999
- | --------------------------------------------------------------------------- | ---------------------- | ------------------------------------------------- |
1000
- | a handler's push, a pinned `ssr: false` loader's settled push | yes (the shell has it) | hydrates with it |
1001
- | a deferred push (`push(promise)`), or a push holding a promise, by a loader | no | gets it right after hydration |
1002
- | a live-lane loader's push | no | gets it right after hydration |
1003
- | the run of an `ssr: false` loader the shell has no pin for | the capture's push | hydrates with the capture's, then shows the run's |
1038
+ | On the HIT's request | In the HIT's HTML | The client |
1039
+ | ------------------------------------------------------------------------------------------ | ---------------------- | ------------------------------------------------- |
1040
+ | a handler's push, a pinned `ssr: false` loader's push (`push(promise)` and `.defer()` too) | yes (the shell has it) | hydrates with it |
1041
+ | a push by an `ssr: false` loader whose value holds a promise | no | gets it right after hydration |
1042
+ | a live-lane loader's push | no | gets it right after hydration |
1043
+ | the run of an `ssr: false` loader the shell has no pin for | the capture's push | hydrates with the capture's, then shows the run's |
1004
1044
 
1005
- A value a loader pushes as a promise is therefore in the HTML of a document
1006
- MISS (the render awaits it) and of a route without `ppr`, and not in the
1007
- HTML of a shell HIT. When a crawler or the first paint needs it, change one
1008
- line:
1045
+ So a title an `ssr: false` loader pushes to `Meta`, promise or not, is in
1046
+ every HIT's `<head>`: a crawler and a link preview see it. It bakes with the
1047
+ shell and is invalidated with it (its TTL, its tags, `updateTag()`). A value
1048
+ that must stay per request comes from a loader without `ssr: false`, which
1049
+ nothing has to read:
1009
1050
 
1010
1051
  ```ts
1011
- ctx.use(Notes)(db.stockNote(id)); // a promise: live, after hydration on a HIT
1012
- ctx.use(Notes)(await db.stockNote(id)); // settled: baked into the shell
1052
+ const StockNote = createLoader(async (ctx) => {
1053
+ ctx.use(Notes)(db.stockNote(ctx.params.id)); // live: after hydration on a HIT
1054
+ return null;
1055
+ });
1056
+ path("/product/:id", ProductPage, { name: "product", ppr: true }, () => [
1057
+ loader(Product, { ssr: false }), // bakes: its data and its pushes
1058
+ loader(StockNote), // live lane: runs on every request
1059
+ ]);
1013
1060
  ```
1014
1061
 
1015
- The settled push bakes with the shell and is invalidated with it (its TTL,
1016
- its tags). A promise is not baked, whenever it settles: it is the shape that
1017
- says "per request", the same as a promise in the loader's data.
1062
+ Or nest the value in a settled push (`{ label, count: promise }`): the whole
1063
+ push then stays live, and every pinned body of the page runs on each HIT.
1018
1064
 
1019
1065
  A pinned loader's pushes are exactly the ones its capture made. If its body
1020
- pushes a settled value only on some runs (behind a condition), a replay that
1021
- runs it shows the capture's answer, a push or none, never a later run's next
1022
- to the pinned data. A deferred push is the exception by design: it is
1023
- delivered on every replay.
1066
+ pushes a value only on some runs (behind a condition), a replay that runs it
1067
+ shows the capture's answer, a push or none, never a later run's next to the
1068
+ pinned data. A push whose value holds a promise is the exception by design:
1069
+ it is delivered on every replay.
1024
1070
 
1025
1071
  One arrangement mixes sources by design. A loader that reads a
1026
1072
  `cache()`-bound loader with `ctx.use()` before that binding starts (a loader
@@ -1299,7 +1345,10 @@ shell-cache only public/shared pages, keep per-user content in live-lane
1299
1345
  loaders (no `ssr: false`) or nested promises in bake-lane loader data — NOT in
1300
1346
  handler output or plain bake-lane container data — mark the variable
1301
1347
  `{ cache: false }` so a stray shell read refuses the capture, or key per
1302
- variant at the CDN tier.
1348
+ variant at the CDN tier. A variable a middleware sets before `throw rewrite()`
1349
+ never reaches the target's shared shell: a rewritten request captures none
1350
+ unless its rewrite opts in with `.shell("target")` (see "A request middleware
1351
+ rewrote"); with `.shell("visitor")` it lands only in that URL's own shell.
1303
1352
 
1304
1353
  ## What always renders without a shell
1305
1354
 
@@ -1307,7 +1356,9 @@ Non-GET, non-partial RSC/action/loader fetches, partial requests without an
1307
1356
  eligible captured segment snapshot, per-request CSP nonce, `streamMode:
1308
1357
  "allReady"`, redirects, 404s, error renders, routes without `ppr`, a document
1309
1358
  request the route's own `cache()` scope refuses (`cache(false)`, or a
1310
- `condition()` returning false for it), and any store without the shell family.
1359
+ `condition()` returning false for it), a request middleware rewrote whose
1360
+ rewrite chose no shell (`rewrite(...).shell()`), and any store without the
1361
+ shell family.
1311
1362
  A stored shell is invalidated when
1312
1363
  `React.version` changes (postponed state is build-coupled) or when the router's
1313
1364
  document version changes, so a deploy that changes the router self-heals via
@@ -1020,8 +1020,7 @@ in dev. A removed page is the exception: its marker answers 404 in dev too.
1020
1020
  that only a refresh produced.
1021
1021
  - **Connected clients can lag.** A refresh has no response to reach open tabs:
1022
1022
  a client that prefetched the page keeps its copy for `prefetchCacheTTL`
1023
- (default 300s) and the browser HTTP cache (`prefetchCacheControl`). Lower
1024
- them if the window matters.
1023
+ (default 300s, in memory only). Lower it if the window matters.
1025
1024
  - **Entries are per deploy.** Keys carry the router's data version, so a deploy
1026
1025
  that changes server code starts from the build entries again; a client-only
1027
1026
  deploy keeps refreshed pages.
@@ -1330,9 +1329,9 @@ write that started before an invalidation of one of its tags is refused.
1330
1329
  ### Limits worth knowing
1331
1330
 
1332
1331
  - **Only the router's own handler.** A warm goes through `router.fetch`, the
1333
- handler `createRouter`'s `cache`, `nonce` and `version` configure. An app
1334
- entry that builds its own `createRSCHandler({ cache, version })` is not what
1335
- a warm runs: put those options on `createRouter`.
1332
+ handler `createRouter`'s `cache` and `nonce` configure. An app
1333
+ entry that builds its own `createRSCHandler({ cache })` is not what a warm
1334
+ runs: put the option on `createRouter`.
1336
1335
  - **Document records only.** Navigation (`partial:`) and intercept records
1337
1336
  fill on the first navigation.
1338
1337
  - **One location at a time.** A warm does not reach a copy another location
@@ -111,6 +111,7 @@ stated, greppable contract.
111
111
  | debug a slow request | `debugPerformance` / telemetry | /observability |
112
112
  | share config across routes | factory returning a helper array | /composability |
113
113
  | compose a sub-app / module | `include()` | /route |
114
+ | lay out a whole app | section modules, guards, groups | /app-structure |
114
115
  | modal / soft navigation | `intercept()` | /intercept |
115
116
  | route group of client components | `clientUrls()` in `"use client"` | /client-urls |
116
117
  | set meta/breadcrumbs from loader data | `ctx.use(Handle)` in the loader | /loader |
@@ -306,8 +307,10 @@ Grouped by concern — read when you need to…
306
307
  | Skill | Description |
307
308
  | ------------------------- | -------------------------------------------------------------------------- |
308
309
  | `/router-setup` | Create and configure the RSC router |
310
+ | `/app-structure` | Whole-app recipes: large app with admin, multi-app, content site, shop |
309
311
  | `/route` | Define routes with `urls()`, `path()`, and `include()` |
310
312
  | `/client-urls` | Client-component route groups with `clientUrls()` — no handlers |
313
+ | `/ux-architecture` | Recipes for fast, stable navigation with `urls()` and `clientUrls()` |
311
314
  | `/layout` | Layouts that wrap child routes |
312
315
  | `/parallel` | Multi-column layouts and sidebars |
313
316
  | `/intercept` | Modal/slide-over patterns for soft navigation |
@@ -617,7 +617,10 @@ path.json(
617
617
 
618
618
  A dot-local name (`ctx.reverse(".comments")`) is a type error here. The
619
619
  runtime reverse has no scope to resolve it against, so untyped (JS) code that
620
- calls it throws `Unknown route` and the route answers 500.
620
+ calls it throws `Unknown route` and the route answers 500. Leaving out a
621
+ required param (`ctx.reverse("blog.api.comments")`) throws
622
+ `Missing param "slug" for route "blog.api.comments"`, with or without a params
623
+ object; it never returns the `/:slug` pattern as a URL.
621
624
 
622
625
  ## Content Negotiation
623
626
 
@@ -266,8 +266,6 @@ Notes:
266
266
  a cleared cache) on every build; the build prints one note when that applies.
267
267
  A router that renders no such action keeps its cache without one (`/use-cache`,
268
268
  `/cache-guide`).
269
- - `createRouter({ version })` takes effect on every preset, including `node`
270
- and `vercel`. It replaces both computed cache versions with that exact value.
271
269
  - `@rangojs/router/vite` also exports `poke()`, a dev-server plugin: type `e`
272
270
  then Enter (or Ctrl+R where the terminal passes it through) to full-reload
273
271
  the browser.
@@ -133,6 +133,17 @@ re-pushed inline script. The GTM demo does exactly this: a root-layout `Script`
133
133
  bootstrap fires the first page_view on document load, and a `"use client"`
134
134
  `<GtmPageViews>` component fires a page_view on every subsequent soft navigation.
135
135
 
136
+ **Key that component on a handle value that carries the page's path, not on the
137
+ URL.** Handle values arrive on their own channel, by design: on a page React
138
+ holds in a transition (`transition()`, a same-route search change, a prefetched
139
+ click) the URL changes before the destination's `content_group` and title land,
140
+ so an effect keyed on `usePathname()` sends the previous page's tagging. Push
141
+ the navigation's `pathname + search` into your `Gtm` handle (collected as `page.path`) from a root
142
+ `parallel()` slot with `revalidate(true)`, read the tagging with `useHandle(Gtm)`,
143
+ and send the page_view when `page.path` differs from the last path sent (seed it
144
+ with the landing path, which the bootstrap already sent). The pattern, its
145
+ trade-offs and the code are in [hooks](../hooks/handle-and-actions.md#when-handle-values-change-by-design).
146
+
136
147
  ## The inline-self-inject rule (GTM/GA4/Segment)
137
148
 
138
149
  If an inline bootstrap must run **before** an external loader, do NOT push the
@@ -202,6 +213,6 @@ warns once per key. See `/ppr` → "What always renders without a shell".
202
213
  - **Partytown / web-worker offloading** — push the worker config with
203
214
  `type: "text/partytown"` and wire Partytown's own nonce config manually.
204
215
 
205
- A full GTM + GA4-style integration (page_view on first render + soft nav, nonce,
216
+ A full GTM + GA4-style integration (page_view on first render + soft nav keyed on a path-carrying handle value, nonce,
206
217
  ecommerce events) lives in the router repository's `tests/vite-rsc-demo` app
207
218
  (not shipped in this package).