@rangojs/router 0.24.3 → 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 (274) 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-root.d.ts +4 -1
  23. package/dist/types/client-urls/client-urls.d.ts +28 -2
  24. package/dist/types/client-urls/navigation.d.ts +6 -8
  25. package/dist/types/client-urls/revalidate-registry.d.ts +1 -1
  26. package/dist/types/client-urls/server-projection.d.ts +20 -5
  27. package/dist/types/client-urls/types.d.ts +35 -17
  28. package/dist/types/client.d.ts +2 -2
  29. package/dist/types/client.rsc.d.ts +2 -2
  30. package/dist/types/decode-loader-results.d.ts +28 -7
  31. package/dist/types/href-client.d.ts +3 -1
  32. package/dist/types/index.rsc.d.ts +1 -1
  33. package/dist/types/loader-redirect.d.ts +2 -3
  34. package/dist/types/outlet-context.d.ts +7 -6
  35. package/dist/types/prefetch-markers.d.ts +14 -5
  36. package/dist/types/route-content-wrapper.d.ts +17 -14
  37. package/dist/types/route-definition/helpers-types.d.ts +7 -2
  38. package/dist/types/route-definition/resolve-handler-use.d.ts +6 -1
  39. package/dist/types/route-definition/rewrite.d.ts +48 -5
  40. package/dist/types/route-types.d.ts +2 -2
  41. package/dist/types/router/handler-context.d.ts +14 -2
  42. package/dist/types/router/intercept-resolution.d.ts +19 -46
  43. package/dist/types/router/lazy-includes.d.ts +7 -0
  44. package/dist/types/router/match-middleware/intercept-resolution.d.ts +2 -2
  45. package/dist/types/router/middleware.d.ts +0 -16
  46. package/dist/types/router/navigation-snapshot.d.ts +2 -0
  47. package/dist/types/router/prefetch-cache-ttl.d.ts +8 -11
  48. package/dist/types/router/revalidation.d.ts +8 -1
  49. package/dist/types/router/router-context.d.ts +4 -16
  50. package/dist/types/router/router-interfaces.d.ts +0 -6
  51. package/dist/types/router/router-options.d.ts +4 -7
  52. package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +31 -8
  53. package/dist/types/router/segment-resolution/revalidation.d.ts +38 -4
  54. package/dist/types/router/segment-resolution.d.ts +1 -1
  55. package/dist/types/router/segment-wrappers.d.ts +3 -5
  56. package/dist/types/router/substitute-pattern-params.d.ts +19 -2
  57. package/dist/types/router/tracing.d.ts +2 -2
  58. package/dist/types/rsc/full-payload.d.ts +14 -2
  59. package/dist/types/rsc/helpers.d.ts +8 -0
  60. package/dist/types/rsc/manifest-init.d.ts +20 -0
  61. package/dist/types/rsc/prefetch-render-error.d.ts +22 -0
  62. package/dist/types/rsc/progressive-enhancement.d.ts +20 -0
  63. package/dist/types/rsc/rewrite-passes.d.ts +12 -2
  64. package/dist/types/rsc/server-action.d.ts +6 -0
  65. package/dist/types/rsc/shell-capture-constants.d.ts +10 -1
  66. package/dist/types/rsc/shell-capture.d.ts +7 -0
  67. package/dist/types/rsc/types.d.ts +5 -0
  68. package/dist/types/segment-system.d.ts +15 -0
  69. package/dist/types/server/context.d.ts +64 -4
  70. package/dist/types/server/request-context.d.ts +41 -4
  71. package/dist/types/server/request-headers.d.ts +12 -0
  72. package/dist/types/server/rewrite-trail.d.ts +8 -1
  73. package/dist/types/testing/e2e/index.d.ts +1 -1
  74. package/dist/types/testing/flight.entry.d.ts +1 -1
  75. package/dist/types/testing/index.d.ts +1 -1
  76. package/dist/types/testing/internal/client-urls-projection.d.ts +4 -3
  77. package/dist/types/testing/render-route.d.ts +21 -11
  78. package/dist/types/testing/run-loader.d.ts +7 -0
  79. package/dist/types/testing/run-middleware.d.ts +7 -0
  80. package/dist/types/testing/run-revalidate.d.ts +5 -4
  81. package/dist/types/testing/serve-shell-request.d.ts +41 -4
  82. package/dist/types/testing/shell-status.d.ts +17 -0
  83. package/dist/types/testing/vitest-stubs/version.d.ts +1 -0
  84. package/dist/types/testing/vitest.d.ts +10 -1
  85. package/dist/types/thenable-status.d.ts +0 -6
  86. package/dist/types/types/handler-context.d.ts +12 -1
  87. package/dist/types/types/loader-types.d.ts +4 -3
  88. package/dist/types/types/route-entry.d.ts +2 -1
  89. package/dist/types/urls/path-helper-types.d.ts +4 -2
  90. package/dist/types/urls/path-helper.d.ts +5 -0
  91. package/dist/types/vite/discovery/client-urls-projection.d.ts +55 -5
  92. package/dist/types/vite/discovery/state.d.ts +20 -0
  93. package/dist/types/vite/plugins/client-urls-revalidate-hoist.d.ts +28 -16
  94. package/dist/types/vite/plugins/hoist/source.d.ts +8 -7
  95. package/dist/types/vite/plugins/virtual-entries.d.ts +6 -0
  96. package/dist/types/vite/utils/prerender-utils.d.ts +7 -0
  97. package/dist/vite/index.js +6816 -6547
  98. package/package.json +1 -1
  99. package/skills/app-structure/SKILL.md +255 -0
  100. package/skills/app-structure/content-site.md +333 -0
  101. package/skills/app-structure/e-commerce.md +435 -0
  102. package/skills/app-structure/large-app.md +885 -0
  103. package/skills/app-structure/multi-app.md +280 -0
  104. package/skills/breadcrumbs/SKILL.md +6 -1
  105. package/skills/caching/SKILL.md +3 -1
  106. package/skills/catalog.json +12 -0
  107. package/skills/client-urls/SKILL.md +195 -32
  108. package/skills/composability/SKILL.md +10 -8
  109. package/skills/handler-use/SKILL.md +48 -9
  110. package/skills/hooks/handle-and-actions.md +104 -0
  111. package/skills/hooks/navigation.md +24 -10
  112. package/skills/hooks/urls.md +1 -1
  113. package/skills/i18n/SKILL.md +2 -1
  114. package/skills/intercept/SKILL.md +60 -29
  115. package/skills/layout/SKILL.md +6 -3
  116. package/skills/links/SKILL.md +151 -17
  117. package/skills/loader/SKILL.md +32 -10
  118. package/skills/middleware/SKILL.md +111 -15
  119. package/skills/observability/SKILL.md +11 -0
  120. package/skills/parallel/SKILL.md +5 -5
  121. package/skills/ppr/SKILL.md +43 -8
  122. package/skills/prerender/SKILL.md +1 -2
  123. package/skills/rango/SKILL.md +3 -0
  124. package/skills/response-routes/SKILL.md +4 -1
  125. package/skills/scripts/SKILL.md +12 -1
  126. package/skills/server-actions/SKILL.md +60 -20
  127. package/skills/testing/SKILL.md +1 -0
  128. package/skills/testing/client-components.md +13 -10
  129. package/skills/testing/loader.md +25 -24
  130. package/skills/testing/middleware.md +11 -11
  131. package/skills/testing/response-routes.md +4 -1
  132. package/skills/testing/serve-shell-request.md +85 -21
  133. package/skills/ux-architecture/SKILL.md +980 -0
  134. package/skills/view-transitions/SKILL.md +43 -10
  135. package/src/__augment-tests__/augmented.check.ts +28 -0
  136. package/src/browser/event-controller.ts +44 -18
  137. package/src/browser/intercept-utils.ts +3 -2
  138. package/src/browser/merge-segment-loaders.ts +0 -85
  139. package/src/browser/navigation-bridge.ts +119 -98
  140. package/src/browser/navigation-client.ts +20 -15
  141. package/src/browser/navigation-store.ts +4 -2
  142. package/src/browser/navigation-transaction.ts +9 -8
  143. package/src/browser/partial-update.ts +90 -32
  144. package/src/browser/prefetch/cache.ts +8 -0
  145. package/src/browser/prefetch/fetch.ts +13 -3
  146. package/src/browser/rango-marker.ts +13 -0
  147. package/src/browser/react/NavigationProvider.tsx +25 -7
  148. package/src/browser/react/filter-segment-order.ts +13 -0
  149. package/src/browser/react/use-action.ts +9 -15
  150. package/src/browser/react/use-handle.ts +63 -71
  151. package/src/browser/react/use-href.tsx +24 -5
  152. package/src/browser/react/use-link-status.ts +1 -1
  153. package/src/browser/react/use-navigation.ts +30 -40
  154. package/src/browser/react/use-reverse.ts +4 -17
  155. package/src/browser/react/use-router.ts +37 -6
  156. package/src/browser/react/use-segments.ts +8 -12
  157. package/src/browser/response-adapter.ts +11 -5
  158. package/src/browser/rsc-router.tsx +3 -1
  159. package/src/browser/scroll-restoration.ts +34 -20
  160. package/src/browser/segment-reconciler.ts +11 -29
  161. package/src/browser/server-action-bridge.ts +52 -16
  162. package/src/browser/types.ts +40 -8
  163. package/src/build/generate-manifest.ts +12 -1
  164. package/src/build/route-types/codegen.ts +6 -2
  165. package/src/build/route-types/per-module-writer.ts +94 -61
  166. package/src/cache/cache-runtime.ts +9 -0
  167. package/src/cache/cache-scope.ts +2 -1
  168. package/src/cache/cf/cf-cache-store.ts +2 -0
  169. package/src/cache/cf/cf-shell-frame.ts +3 -0
  170. package/src/cache/types.ts +8 -0
  171. package/src/cache/vercel/vercel-cache-store.ts +6 -1
  172. package/src/client-urls/client-root.tsx +10 -5
  173. package/src/client-urls/client-urls.ts +246 -166
  174. package/src/client-urls/navigation.ts +45 -38
  175. package/src/client-urls/revalidate-registry.ts +12 -3
  176. package/src/client-urls/server-projection.ts +286 -75
  177. package/src/client-urls/types.ts +40 -16
  178. package/src/client.rsc.tsx +11 -1
  179. package/src/client.tsx +9 -1
  180. package/src/decode-loader-results.ts +64 -31
  181. package/src/href-client.ts +3 -1
  182. package/src/index.rsc.ts +1 -0
  183. package/src/index.ts +12 -4
  184. package/src/loader-redirect.tsx +2 -3
  185. package/src/outlet-context.ts +7 -6
  186. package/src/prefetch-markers.ts +15 -5
  187. package/src/reverse.ts +3 -3
  188. package/src/route-content-wrapper.tsx +19 -17
  189. package/src/route-definition/dsl-helpers.ts +27 -7
  190. package/src/route-definition/helpers-types.ts +7 -2
  191. package/src/route-definition/resolve-handler-use.ts +23 -10
  192. package/src/route-definition/rewrite.ts +86 -19
  193. package/src/route-types.ts +1 -2
  194. package/src/router/find-match.ts +37 -1
  195. package/src/router/handler-context.ts +85 -40
  196. package/src/router/intercept-resolution.ts +161 -307
  197. package/src/router/lazy-includes.ts +43 -27
  198. package/src/router/loader-resolution.ts +21 -1
  199. package/src/router/manifest.ts +6 -8
  200. package/src/router/match-api.ts +11 -1
  201. package/src/router/match-middleware/background-revalidation.ts +2 -4
  202. package/src/router/match-middleware/cache-lookup.ts +16 -20
  203. package/src/router/match-middleware/intercept-resolution.ts +41 -60
  204. package/src/router/match-result.ts +31 -2
  205. package/src/router/middleware.ts +6 -147
  206. package/src/router/navigation-snapshot.ts +38 -2
  207. package/src/router/prefetch-cache-ttl.ts +10 -18
  208. package/src/router/prerender-match.ts +2 -1
  209. package/src/router/revalidation.ts +24 -3
  210. package/src/router/router-context.ts +5 -18
  211. package/src/router/router-interfaces.ts +0 -7
  212. package/src/router/router-options.ts +4 -7
  213. package/src/router/segment-resolution/fresh.ts +6 -2
  214. package/src/router/segment-resolution/helpers.ts +7 -1
  215. package/src/router/segment-resolution/prefetch-deferral.ts +62 -12
  216. package/src/router/segment-resolution/revalidation.ts +75 -35
  217. package/src/router/segment-resolution.ts +0 -1
  218. package/src/router/segment-wrappers.ts +8 -13
  219. package/src/router/substitute-pattern-params.ts +37 -2
  220. package/src/router/tracing.ts +2 -2
  221. package/src/router.ts +2 -9
  222. package/src/rsc/full-payload.ts +26 -4
  223. package/src/rsc/handler.ts +83 -41
  224. package/src/rsc/helpers.ts +13 -0
  225. package/src/rsc/manifest-init.ts +56 -3
  226. package/src/rsc/prefetch-render-error.ts +38 -0
  227. package/src/rsc/progressive-enhancement.ts +161 -211
  228. package/src/rsc/rewrite-passes.ts +56 -9
  229. package/src/rsc/rsc-rendering.ts +79 -23
  230. package/src/rsc/server-action.ts +25 -2
  231. package/src/rsc/shell-capture-constants.ts +22 -0
  232. package/src/rsc/shell-capture.ts +11 -0
  233. package/src/rsc/types.ts +5 -0
  234. package/src/segment-system.tsx +125 -119
  235. package/src/server/context.ts +96 -5
  236. package/src/server/handle-store.ts +3 -2
  237. package/src/server/request-context.ts +42 -3
  238. package/src/server/request-headers.ts +32 -0
  239. package/src/server/rewrite-trail.ts +55 -9
  240. package/src/ssr/ssr-root.tsx +6 -10
  241. package/src/testing/dispatch.ts +4 -5
  242. package/src/testing/e2e/index.ts +1 -0
  243. package/src/testing/flight.entry.ts +1 -0
  244. package/src/testing/index.ts +1 -0
  245. package/src/testing/internal/client-urls-projection.ts +31 -11
  246. package/src/testing/render-route.tsx +83 -40
  247. package/src/testing/run-loader.ts +26 -2
  248. package/src/testing/run-middleware.ts +10 -0
  249. package/src/testing/run-revalidate.ts +13 -6
  250. package/src/testing/serve-shell-request.ts +77 -4
  251. package/src/testing/shell-status.ts +40 -0
  252. package/src/testing/vitest-stubs/version.ts +1 -0
  253. package/src/testing/vitest.ts +74 -7
  254. package/src/thenable-status.ts +0 -11
  255. package/src/types/handler-context.ts +12 -1
  256. package/src/types/loader-types.ts +4 -3
  257. package/src/types/route-entry.ts +2 -1
  258. package/src/urls/include-helper.ts +38 -11
  259. package/src/urls/path-helper-types.ts +4 -2
  260. package/src/urls/path-helper.ts +16 -3
  261. package/src/vite/discovery/client-urls-projection.ts +205 -16
  262. package/src/vite/discovery/discover-routers.ts +2 -2
  263. package/src/vite/discovery/prerender-collection.ts +2 -6
  264. package/src/vite/discovery/state.ts +26 -0
  265. package/src/vite/plugins/client-urls-revalidate-hoist.ts +190 -77
  266. package/src/vite/plugins/expose-internal-ids.ts +46 -40
  267. package/src/vite/plugins/hoist/source.ts +8 -7
  268. package/src/vite/plugins/transition-when-hoist.ts +1 -0
  269. package/src/vite/plugins/version-plugin.ts +7 -0
  270. package/src/vite/plugins/version.d.ts +7 -0
  271. package/src/vite/plugins/virtual-entries.ts +20 -0
  272. package/src/vite/rango.ts +4 -1
  273. package/src/vite/router-discovery.ts +39 -7
  274. 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
 
@@ -1299,7 +1329,10 @@ shell-cache only public/shared pages, keep per-user content in live-lane
1299
1329
  loaders (no `ssr: false`) or nested promises in bake-lane loader data — NOT in
1300
1330
  handler output or plain bake-lane container data — mark the variable
1301
1331
  `{ cache: false }` so a stray shell read refuses the capture, or key per
1302
- variant at the CDN tier.
1332
+ variant at the CDN tier. A variable a middleware sets before `throw rewrite()`
1333
+ never reaches the target's shared shell: a rewritten request captures none
1334
+ unless its rewrite opts in with `.shell("target")` (see "A request middleware
1335
+ rewrote"); with `.shell("visitor")` it lands only in that URL's own shell.
1303
1336
 
1304
1337
  ## What always renders without a shell
1305
1338
 
@@ -1307,7 +1340,9 @@ Non-GET, non-partial RSC/action/loader fetches, partial requests without an
1307
1340
  eligible captured segment snapshot, per-request CSP nonce, `streamMode:
1308
1341
  "allReady"`, redirects, 404s, error renders, routes without `ppr`, a document
1309
1342
  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.
1343
+ `condition()` returning false for it), a request middleware rewrote whose
1344
+ rewrite chose no shell (`rewrite(...).shell()`), and any store without the
1345
+ shell family.
1311
1346
  A stored shell is invalidated when
1312
1347
  `React.version` changes (postponed state is build-coupled) or when the router's
1313
1348
  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.
@@ -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
 
@@ -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).
@@ -446,8 +446,8 @@ import * as CartActions from "./actions/cart";
446
446
 
447
447
  urls(({ path, loader, revalidate }) => [
448
448
  path("/", HomePage, { name: "home" }, () => [
449
- // Loader data re-runs by default after any action. Opt out with revalidate(() => false).
450
- loader(StaticHomepageLoader, () => [revalidate(() => false)]),
449
+ // Loader data re-runs by default after any action. Opt out with revalidate(false).
450
+ loader(StaticHomepageLoader, () => [revalidate(false)]),
451
451
  ]),
452
452
 
453
453
  // Re-render the cart page handler AND re-resolve its loader only after cart
@@ -603,6 +603,12 @@ Redirects from actions render the **target** route tree's matched segments
603
603
  source page's — the target is what the user sees next. See `/hooks
604
604
  useLocationState` for reading flash state on the target page.
605
605
 
606
+ Build the target by name rather than a literal path:
607
+ `throw redirect(getRequestContext().reverse("account.settings"))`. It fills
608
+ mount params (`:accountId`, `:locale`) from the page URL the action was called
609
+ on, so the module that owns the action does not need to know its mount
610
+ (`/links`).
611
+
606
612
  ### Same-origin by default (open-redirect protection)
607
613
 
608
614
  `redirect()` is same-origin by default on every path — JS, no-JS PE, and
@@ -624,6 +630,28 @@ model). Omit it and off-host targets stay blocked. `external` only waives the
624
630
  `javascript:` or `data:` URL is still neutralized, so a forged or mistaken
625
631
  `external` target can never become a scriptable navigation.
626
632
 
633
+ ## Actions on a Rewritten Page
634
+
635
+ An action cannot `throw rewrite()`; use `redirect()`. But a page that
636
+ middleware reached through `throw rewrite()` can call actions. The action
637
+ (JS or a no-JS form post) is posted to the visitor's URL, so the same
638
+ middleware sees it:
639
+
640
+ - A rewrite in **route middleware** runs after the action, around the
641
+ re-render, which route middleware sees as the page's GET, so a rewrite
642
+ written for GETs only fires too. The action runs once, then the re-render
643
+ runs as the target: its global and route middleware, its handlers and
644
+ loaders. The page on screen is revalidated in place, as at the target's
645
+ own URL.
646
+ - A rewrite in **global middleware** runs before the action and moves the
647
+ mutation to the target first: the target's global middleware, then the
648
+ action, once, then the target's route middleware and render, as a direct
649
+ post to the target. A page a routing rewrite showed (a multi-tenant host,
650
+ an A/B split) keeps its forms working, in JS and no-JS. A lock refuses
651
+ mutations instead: `throw rewrite("maintenance").disallowMutations()` is an
652
+ error before the action runs, or return a `Response` for mutations. See
653
+ `/middleware` ("Mutations: where the middleware sits decides").
654
+
627
655
  ## Error Handling
628
656
 
629
657
  ### Validation errors — return them as state
@@ -688,24 +716,36 @@ For a recoverable "already gone" case, return a result the form can show
688
716
  ### A loader that fails on the revalidation render
689
717
 
690
718
  A loader that throws while it reruns after the action does not undo the
691
- action, which has already run and returned. What follows depends on the route:
692
-
693
- - **No `loading()` on the route (and any `clientUrls()` route):** the action
694
- call rejects with the loader's error, nothing of that revalidation commits,
695
- and the page keeps its data and its React state. A `<form action>`,
696
- `useActionState` or `useTransition` call hands that rejection to the nearest
697
- React error boundary above the form.
698
- - **A `urls()` route with `loading()`:** the call resolves with the action's
699
- result, the page renders, and the loader's read throws to the nearest React
700
- error boundary.
701
- - **An `errorBoundary()` above the route:** its fallback replaces the route.
702
-
703
- For a failure the user can retry, return it from the loader as data
704
- (`{ ok: false, reason }`) and render it where the data goes. A failure returned
705
- as data is a value like any other, so a `cache()` on the loader (or a `ppr`
706
- bake, or a prerender) stores and serves it; a thrown error is never stored.
707
- See `/client-urls` → "A loader that fails on its rerun" for the table and an
708
- example.
719
+ action, which has already run and returned. It behaves as on a navigation (a
720
+ plain or fully prefetched click, Back/Forward), on every route, called
721
+ directly or from `<form action>`, `useActionState` or `useTransition`:
722
+
723
+ - The call resolves with the action's result.
724
+ - The loader throws in place, at its `useLoader` read, and the nearest
725
+ boundary to the read catches it: your own React error boundary around the
726
+ read, else the route's `errorBoundary()`, else the router's root one.
727
+ Everything outside that boundary keeps its React state; loaders that reran
728
+ without error commit their new data.
729
+ - The error goes to `onError` on the server (phase `"loader"`) and to
730
+ `useAction(action).revalidationError` in the browser (`error` stays `null`,
731
+ `result` is set).
732
+ - A later rerun that succeeds clears an `errorBoundary()` fallback. Your own
733
+ boundary resets by its own rule: key it on data that reruns with the loader
734
+ and is read outside the boundary.
735
+
736
+ `notFound()` and `redirect()` from the loader keep their path: a `redirect()`
737
+ replaces the route's content and navigates, and the layouts keep their
738
+ instance. Every refetch the router runs for an action follows the same rule
739
+ (after several actions at once, after a navigation during it), and the action
740
+ that ran it reports the loader's error as its `revalidationError`. Not
741
+ covered yet: an `intercept()`'s loaders, and a parallel slot's loaders under
742
+ its own `loading()`. For a failure the user should see in place of the
743
+ data without an error boundary, return it from the loader as data
744
+ (`{ ok: false, reason }`) and render it where the data goes. A failure
745
+ returned as data is a value like any other, so a `cache()` on the loader (or
746
+ a `ppr` bake, or a prerender) stores and serves it; a thrown error is never
747
+ stored. See `/client-urls` → "A loader that fails on its rerun" for the
748
+ details and examples.
709
749
 
710
750
  ### Authorization in actions
711
751
 
@@ -155,6 +155,7 @@ beside their function):
155
155
  | | `setBuildVersions` | [`serve-shell-request.md`](./serve-shell-request.md) |
156
156
  | | `assertCacheStatus`, `assertCacheDecision`, `parseCacheHeader`, `createCacheSink`, `filterCacheDecisions` | [`cache-prerender.md`](./cache-prerender.md) |
157
157
  | | `assertShellStatus`, `parseShellStatus`, `shellCacheKey`, `SHELL_STATUS_HEADER`, `assertPprReplayStatus`, `parsePprReplayStatus`, `PPR_REPLAY_STATUS_HEADER` | [`cache-prerender.md`](./cache-prerender.md) |
158
+ | | `visitorShellCacheKey` | [`serve-shell-request.md`](./serve-shell-request.md) |
158
159
  | | `assertGeneratedRoutesMatch`, `diffGeneratedRoutes` | [`reverse-and-types.md`](./reverse-and-types.md) |
159
160
  | `@rangojs/router/testing/dom` | `renderRoute` | [`client-components.md`](./client-components.md) |
160
161
  | `@rangojs/router/testing/flight` | `renderToFlightString`, `normalizeFlight`, `assertFlightRuntimeAvailable` | [`flight.md`](./flight.md) |
@@ -38,7 +38,7 @@
38
38
  | `useReverse` | Reverse a name->pattern map to a URL; merges `useParams()` and the `mount`/`basename` prefix. |
39
39
  | `useHref` | Resolve an href against the mount/basename. |
40
40
  | `useMount` | The `include()` mount prefix (`options.mount`), else `"/"`. |
41
- | `useNavigation` | Navigation controller state — stays `idle` (see caveat). |
41
+ | `useNavigation` | Navigation controller state: `loading` from a `navigate()` call to its commit, else `idle`. |
42
42
  | `useRouter` | The router handle, including `.basename`. |
43
43
  | `usePathname` | Current committed pathname. |
44
44
  | `useSearchParams` | Search params from the `request` URL. |
@@ -376,14 +376,17 @@ it("dims the held price while the next product streams", async () => {
376
376
  });
377
377
  ```
378
378
 
379
- | Pending state | Under `renderRoute` |
380
- | ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
381
- | `useLoader().isLoading` on the reader a `transition` navigate() holds, pending seed | Modeled: `true` on the OLD data until the promise settles, then `false` with the new data in the same commit. |
382
- | `transition` navigate(), settled seed (a plain value) | `false`; the data swaps in the commit. |
383
- | navigate() without `transition`, pending seed | Urgent commit (production's cold-navigation lane): the read suspends to the nearest `<Suspense>` fallback (with none, React keeps the previous tree). No flag. |
384
- | `transition: { when }` returning false, or `navigate(url, { transition: false })` | Same as without `transition`: urgent, with production's decision (`kind: "push"`; `refresh()` is `"revalidate"`). `transition: false` calls no predicate. |
385
- | Production's other hold lanes (same-structure search/filter nav, fully-prefetched, optimistic) | NOT modeled — without `transition` navigate() stays urgent. Assert those at e2e. |
386
- | `useNavigation().state`, `useLinkStatus().pending`, `useAction().state` | Stay `idle` — navigate() never starts the navigation lifecycle. e2e. |
379
+ | Pending state | Under `renderRoute` |
380
+ | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
381
+ | `useLoader().isLoading` on the reader a `transition` navigate() holds, pending seed | Modeled: `true` on the OLD data until the promise settles, then `false` with the new data in the same commit. |
382
+ | `transition` navigate(), settled seed (a plain value) | `false`; the data swaps in the commit. |
383
+ | navigate() without `transition`, pending seed | Urgent commit (production's cold-navigation lane): the read suspends to the nearest `<Suspense>` fallback (with none, React keeps the previous tree). No flag. |
384
+ | `transition: { when }` returning false, or `navigate(url, { transition: false })` | Same as without `transition`: urgent, with production's decision (`kind: "push"`; `refresh()` is `"revalidate"`). `transition: false` calls no predicate. |
385
+ | Production's other hold lanes (same-structure search/filter nav, fully-prefetched, optimistic) | NOT modeled — without `transition` navigate() stays urgent. Assert those at e2e. |
386
+ | `useNavigation().state`, `useLinkStatus().pending` | Modeled: navigate() starts the navigation as a click does. `loading` from the call, `idle` in the commit, held with it; `pending` true for the destination's link until the commit is handed to React. |
387
+ | `useAction().state` | Stays `idle`: navigate() runs no action. e2e. |
388
+ | `useHandle()` on a held navigation, `navigate(url, { handles, loaders })` | Modeled: `handles` arrive on the response's handle stream, so readers keep the page being left's values while React holds the page and take the new ones with its commit. |
389
+ | A back/forward restored from the history cache (its view transition, `pop`) | NOT modeled: the tree has one location and navigate() caches no entries, so a `popstate` restores nothing. Assert it at e2e. |
387
390
 
388
391
  - Per-navigation seeds merge over the render-time `loaders`/`loaderData` for THAT navigation only; a later `navigate()` without `loaders` falls back to the render-time data.
389
392
  - Pass settled data as a plain value, not `Promise.resolve(value)`: React has not observed a plain promise yet, so it counts as pending and renders one `stale` frame. (A Flight chunk that has already settled carries its status and is skipped, which a plain value mirrors.)
@@ -392,7 +395,7 @@ it("dims the held price while the next product streams", async () => {
392
395
  ## Caveats
393
396
 
394
397
  - Client tree ONLY. Does NOT catch server/client boundary reference-identity remount bugs, real Flight serialization errors, loader execution, middleware, or handler ordering — those are `renderServerTree` / `renderHandler` / e2e territory. Loader data is SEEDED, never run. `hydrate: true` adds an HTML pass of this same client tree ([Hydration](#hydration)), not a render of your routes on a server.
395
- - `router.navigate()` bypasses the navigation lifecycle, so the controller never leaves `idle`. `useNavigation()` / `useLinkStatus()` / `useAction()` non-idle states (loading/streaming/pending, action result/error) are NOT reachable — test those at e2e. The one modeled pending state is held-navigation `useLoader().isLoading` ([Held navigation](#held-navigation)).
398
+ - `router.navigate()` drives the navigation lifecycle: `useNavigation()` reads `loading` from the call and `idle` in the commit, held with it for a `transition` spec ([Held navigation](#held-navigation)). `useAction()` non-idle states (action loading/streaming, result/error) are NOT reachable — no action runs; test those at e2e.
396
399
  - `outletPending` seeds only the production-shaped outlet context. It is useful
397
400
  for the two settled render states of a layout that reads `useOutlet()`, but it
398
401
  does not prove the hydrated `clientUrls()` transition that toggles the value;