@rangojs/router 0.24.4 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (267) hide show
  1. package/README.md +1 -0
  2. package/dist/bin/rango.js +363 -39
  3. package/dist/testing/vitest.js +37 -4
  4. package/dist/types/browser/event-controller.d.ts +24 -5
  5. package/dist/types/browser/intercept-utils.d.ts +5 -2
  6. package/dist/types/browser/merge-segment-loaders.d.ts +0 -23
  7. package/dist/types/browser/navigation-transaction.d.ts +5 -5
  8. package/dist/types/browser/partial-update.d.ts +21 -13
  9. package/dist/types/browser/prefetch/cache.d.ts +8 -0
  10. package/dist/types/browser/rango-marker.d.ts +7 -0
  11. package/dist/types/browser/react/filter-segment-order.d.ts +10 -0
  12. package/dist/types/browser/react/use-handle.d.ts +20 -0
  13. package/dist/types/browser/react/use-href.d.ts +6 -0
  14. package/dist/types/browser/react/use-router.d.ts +6 -1
  15. package/dist/types/browser/response-adapter.d.ts +5 -3
  16. package/dist/types/browser/scroll-restoration.d.ts +9 -3
  17. package/dist/types/browser/segment-reconciler.d.ts +10 -13
  18. package/dist/types/browser/types.d.ts +38 -8
  19. package/dist/types/build/route-types/per-module-writer.d.ts +12 -0
  20. package/dist/types/cache/cf/cf-shell-frame.d.ts +2 -0
  21. package/dist/types/cache/types.d.ts +8 -0
  22. package/dist/types/client-urls/client-urls.d.ts +6 -2
  23. package/dist/types/client-urls/revalidate-registry.d.ts +1 -1
  24. package/dist/types/client-urls/server-projection.d.ts +9 -5
  25. package/dist/types/client-urls/types.d.ts +29 -16
  26. package/dist/types/client.d.ts +2 -2
  27. package/dist/types/client.rsc.d.ts +2 -2
  28. package/dist/types/decode-loader-results.d.ts +28 -7
  29. package/dist/types/href-client.d.ts +3 -1
  30. package/dist/types/index.rsc.d.ts +1 -1
  31. package/dist/types/loader-redirect.d.ts +2 -3
  32. package/dist/types/outlet-context.d.ts +7 -6
  33. package/dist/types/prefetch-markers.d.ts +14 -5
  34. package/dist/types/route-content-wrapper.d.ts +17 -14
  35. package/dist/types/route-definition/helpers-types.d.ts +7 -2
  36. package/dist/types/route-definition/resolve-handler-use.d.ts +6 -1
  37. package/dist/types/route-definition/rewrite.d.ts +48 -5
  38. package/dist/types/route-types.d.ts +2 -2
  39. package/dist/types/router/handler-context.d.ts +14 -2
  40. package/dist/types/router/intercept-resolution.d.ts +19 -46
  41. package/dist/types/router/lazy-includes.d.ts +7 -0
  42. package/dist/types/router/match-middleware/intercept-resolution.d.ts +2 -2
  43. package/dist/types/router/middleware.d.ts +0 -16
  44. package/dist/types/router/navigation-snapshot.d.ts +2 -0
  45. package/dist/types/router/prefetch-cache-ttl.d.ts +8 -11
  46. package/dist/types/router/revalidation.d.ts +8 -1
  47. package/dist/types/router/router-context.d.ts +4 -16
  48. package/dist/types/router/router-interfaces.d.ts +0 -6
  49. package/dist/types/router/router-options.d.ts +4 -7
  50. package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +31 -8
  51. package/dist/types/router/segment-resolution/revalidation.d.ts +38 -4
  52. package/dist/types/router/segment-resolution.d.ts +1 -1
  53. package/dist/types/router/segment-wrappers.d.ts +3 -5
  54. package/dist/types/router/substitute-pattern-params.d.ts +3 -2
  55. package/dist/types/router/tracing.d.ts +2 -2
  56. package/dist/types/rsc/full-payload.d.ts +14 -2
  57. package/dist/types/rsc/helpers.d.ts +8 -0
  58. package/dist/types/rsc/manifest-init.d.ts +20 -0
  59. package/dist/types/rsc/prefetch-render-error.d.ts +22 -0
  60. package/dist/types/rsc/progressive-enhancement.d.ts +20 -0
  61. package/dist/types/rsc/rewrite-passes.d.ts +12 -2
  62. package/dist/types/rsc/server-action.d.ts +6 -0
  63. package/dist/types/rsc/shell-capture-constants.d.ts +10 -1
  64. package/dist/types/rsc/shell-capture.d.ts +7 -0
  65. package/dist/types/rsc/types.d.ts +5 -0
  66. package/dist/types/segment-system.d.ts +15 -0
  67. package/dist/types/server/context.d.ts +64 -4
  68. package/dist/types/server/request-context.d.ts +41 -4
  69. package/dist/types/server/request-headers.d.ts +12 -0
  70. package/dist/types/server/rewrite-trail.d.ts +8 -1
  71. package/dist/types/testing/e2e/index.d.ts +1 -1
  72. package/dist/types/testing/flight.entry.d.ts +1 -1
  73. package/dist/types/testing/index.d.ts +1 -1
  74. package/dist/types/testing/internal/client-urls-projection.d.ts +4 -3
  75. package/dist/types/testing/render-route.d.ts +21 -11
  76. package/dist/types/testing/run-loader.d.ts +7 -0
  77. package/dist/types/testing/run-middleware.d.ts +7 -0
  78. package/dist/types/testing/run-revalidate.d.ts +5 -4
  79. package/dist/types/testing/serve-shell-request.d.ts +41 -4
  80. package/dist/types/testing/shell-status.d.ts +17 -0
  81. package/dist/types/testing/vitest-stubs/version.d.ts +1 -0
  82. package/dist/types/testing/vitest.d.ts +10 -1
  83. package/dist/types/thenable-status.d.ts +0 -6
  84. package/dist/types/types/handler-context.d.ts +12 -1
  85. package/dist/types/types/loader-types.d.ts +4 -3
  86. package/dist/types/types/route-entry.d.ts +2 -1
  87. package/dist/types/urls/path-helper-types.d.ts +4 -2
  88. package/dist/types/vite/discovery/client-urls-projection.d.ts +55 -5
  89. package/dist/types/vite/discovery/state.d.ts +20 -0
  90. package/dist/types/vite/plugins/client-urls-revalidate-hoist.d.ts +28 -16
  91. package/dist/types/vite/plugins/hoist/source.d.ts +8 -7
  92. package/dist/types/vite/plugins/virtual-entries.d.ts +6 -0
  93. package/dist/types/vite/utils/prerender-utils.d.ts +7 -0
  94. package/dist/vite/index.js +6816 -6547
  95. package/package.json +1 -1
  96. package/skills/app-structure/SKILL.md +255 -0
  97. package/skills/app-structure/content-site.md +333 -0
  98. package/skills/app-structure/e-commerce.md +435 -0
  99. package/skills/app-structure/large-app.md +885 -0
  100. package/skills/app-structure/multi-app.md +280 -0
  101. package/skills/breadcrumbs/SKILL.md +6 -1
  102. package/skills/caching/SKILL.md +3 -1
  103. package/skills/catalog.json +12 -0
  104. package/skills/client-urls/SKILL.md +185 -32
  105. package/skills/composability/SKILL.md +10 -8
  106. package/skills/handler-use/SKILL.md +48 -9
  107. package/skills/hooks/handle-and-actions.md +104 -0
  108. package/skills/hooks/navigation.md +24 -10
  109. package/skills/hooks/urls.md +1 -1
  110. package/skills/i18n/SKILL.md +2 -1
  111. package/skills/intercept/SKILL.md +60 -29
  112. package/skills/layout/SKILL.md +6 -3
  113. package/skills/links/SKILL.md +151 -17
  114. package/skills/loader/SKILL.md +32 -10
  115. package/skills/middleware/SKILL.md +111 -15
  116. package/skills/observability/SKILL.md +11 -0
  117. package/skills/parallel/SKILL.md +5 -5
  118. package/skills/ppr/SKILL.md +43 -8
  119. package/skills/prerender/SKILL.md +1 -2
  120. package/skills/rango/SKILL.md +3 -0
  121. package/skills/response-routes/SKILL.md +4 -1
  122. package/skills/scripts/SKILL.md +12 -1
  123. package/skills/server-actions/SKILL.md +60 -20
  124. package/skills/testing/SKILL.md +1 -0
  125. package/skills/testing/client-components.md +13 -10
  126. package/skills/testing/loader.md +25 -24
  127. package/skills/testing/middleware.md +11 -11
  128. package/skills/testing/response-routes.md +4 -1
  129. package/skills/testing/serve-shell-request.md +85 -21
  130. package/skills/ux-architecture/SKILL.md +980 -0
  131. package/skills/view-transitions/SKILL.md +43 -10
  132. package/src/__augment-tests__/augmented.check.ts +28 -0
  133. package/src/browser/event-controller.ts +44 -18
  134. package/src/browser/intercept-utils.ts +3 -2
  135. package/src/browser/merge-segment-loaders.ts +0 -85
  136. package/src/browser/navigation-bridge.ts +112 -84
  137. package/src/browser/navigation-client.ts +20 -15
  138. package/src/browser/navigation-store.ts +4 -2
  139. package/src/browser/navigation-transaction.ts +9 -8
  140. package/src/browser/partial-update.ts +90 -32
  141. package/src/browser/prefetch/cache.ts +8 -0
  142. package/src/browser/prefetch/fetch.ts +13 -3
  143. package/src/browser/rango-marker.ts +13 -0
  144. package/src/browser/react/NavigationProvider.tsx +25 -7
  145. package/src/browser/react/filter-segment-order.ts +13 -0
  146. package/src/browser/react/use-action.ts +9 -15
  147. package/src/browser/react/use-handle.ts +63 -71
  148. package/src/browser/react/use-href.tsx +24 -5
  149. package/src/browser/react/use-link-status.ts +1 -1
  150. package/src/browser/react/use-navigation.ts +30 -40
  151. package/src/browser/react/use-router.ts +37 -6
  152. package/src/browser/react/use-segments.ts +8 -12
  153. package/src/browser/response-adapter.ts +11 -5
  154. package/src/browser/rsc-router.tsx +3 -1
  155. package/src/browser/scroll-restoration.ts +34 -20
  156. package/src/browser/segment-reconciler.ts +11 -29
  157. package/src/browser/server-action-bridge.ts +52 -16
  158. package/src/browser/types.ts +40 -8
  159. package/src/build/generate-manifest.ts +12 -1
  160. package/src/build/route-types/codegen.ts +6 -2
  161. package/src/build/route-types/per-module-writer.ts +94 -61
  162. package/src/cache/cache-runtime.ts +9 -0
  163. package/src/cache/cache-scope.ts +2 -1
  164. package/src/cache/cf/cf-cache-store.ts +2 -0
  165. package/src/cache/cf/cf-shell-frame.ts +3 -0
  166. package/src/cache/types.ts +8 -0
  167. package/src/cache/vercel/vercel-cache-store.ts +6 -1
  168. package/src/client-urls/client-urls.ts +202 -153
  169. package/src/client-urls/revalidate-registry.ts +12 -3
  170. package/src/client-urls/server-projection.ts +137 -66
  171. package/src/client-urls/types.ts +30 -15
  172. package/src/client.rsc.tsx +11 -1
  173. package/src/client.tsx +9 -1
  174. package/src/decode-loader-results.ts +64 -31
  175. package/src/href-client.ts +3 -1
  176. package/src/index.rsc.ts +1 -0
  177. package/src/index.ts +12 -4
  178. package/src/loader-redirect.tsx +2 -3
  179. package/src/outlet-context.ts +7 -6
  180. package/src/prefetch-markers.ts +15 -5
  181. package/src/reverse.ts +3 -3
  182. package/src/route-content-wrapper.tsx +19 -17
  183. package/src/route-definition/dsl-helpers.ts +27 -7
  184. package/src/route-definition/helpers-types.ts +7 -2
  185. package/src/route-definition/resolve-handler-use.ts +23 -10
  186. package/src/route-definition/rewrite.ts +86 -19
  187. package/src/route-types.ts +1 -2
  188. package/src/router/find-match.ts +37 -1
  189. package/src/router/handler-context.ts +85 -40
  190. package/src/router/intercept-resolution.ts +161 -307
  191. package/src/router/lazy-includes.ts +43 -27
  192. package/src/router/loader-resolution.ts +21 -1
  193. package/src/router/manifest.ts +6 -8
  194. package/src/router/match-api.ts +11 -1
  195. package/src/router/match-middleware/background-revalidation.ts +2 -4
  196. package/src/router/match-middleware/cache-lookup.ts +16 -20
  197. package/src/router/match-middleware/intercept-resolution.ts +41 -60
  198. package/src/router/match-result.ts +31 -2
  199. package/src/router/middleware.ts +6 -147
  200. package/src/router/navigation-snapshot.ts +38 -2
  201. package/src/router/prefetch-cache-ttl.ts +10 -18
  202. package/src/router/prerender-match.ts +2 -1
  203. package/src/router/revalidation.ts +24 -3
  204. package/src/router/router-context.ts +5 -18
  205. package/src/router/router-interfaces.ts +0 -7
  206. package/src/router/router-options.ts +4 -7
  207. package/src/router/segment-resolution/fresh.ts +6 -2
  208. package/src/router/segment-resolution/helpers.ts +7 -1
  209. package/src/router/segment-resolution/prefetch-deferral.ts +62 -12
  210. package/src/router/segment-resolution/revalidation.ts +75 -35
  211. package/src/router/segment-resolution.ts +0 -1
  212. package/src/router/segment-wrappers.ts +8 -13
  213. package/src/router/substitute-pattern-params.ts +3 -2
  214. package/src/router/tracing.ts +2 -2
  215. package/src/router.ts +2 -9
  216. package/src/rsc/full-payload.ts +26 -4
  217. package/src/rsc/handler.ts +83 -41
  218. package/src/rsc/helpers.ts +13 -0
  219. package/src/rsc/manifest-init.ts +56 -3
  220. package/src/rsc/prefetch-render-error.ts +38 -0
  221. package/src/rsc/progressive-enhancement.ts +161 -211
  222. package/src/rsc/rewrite-passes.ts +56 -9
  223. package/src/rsc/rsc-rendering.ts +79 -23
  224. package/src/rsc/server-action.ts +25 -2
  225. package/src/rsc/shell-capture-constants.ts +22 -0
  226. package/src/rsc/shell-capture.ts +11 -0
  227. package/src/rsc/types.ts +5 -0
  228. package/src/segment-system.tsx +125 -119
  229. package/src/server/context.ts +96 -5
  230. package/src/server/handle-store.ts +3 -2
  231. package/src/server/request-context.ts +42 -3
  232. package/src/server/request-headers.ts +32 -0
  233. package/src/server/rewrite-trail.ts +55 -9
  234. package/src/ssr/ssr-root.tsx +6 -10
  235. package/src/testing/dispatch.ts +4 -5
  236. package/src/testing/e2e/index.ts +1 -0
  237. package/src/testing/flight.entry.ts +1 -0
  238. package/src/testing/index.ts +1 -0
  239. package/src/testing/internal/client-urls-projection.ts +31 -11
  240. package/src/testing/render-route.tsx +83 -40
  241. package/src/testing/run-loader.ts +26 -2
  242. package/src/testing/run-middleware.ts +10 -0
  243. package/src/testing/run-revalidate.ts +13 -6
  244. package/src/testing/serve-shell-request.ts +77 -4
  245. package/src/testing/shell-status.ts +40 -0
  246. package/src/testing/vitest-stubs/version.ts +1 -0
  247. package/src/testing/vitest.ts +74 -7
  248. package/src/thenable-status.ts +0 -11
  249. package/src/types/handler-context.ts +12 -1
  250. package/src/types/loader-types.ts +4 -3
  251. package/src/types/route-entry.ts +2 -1
  252. package/src/urls/include-helper.ts +38 -11
  253. package/src/urls/path-helper-types.ts +4 -2
  254. package/src/vite/discovery/client-urls-projection.ts +205 -16
  255. package/src/vite/discovery/discover-routers.ts +2 -2
  256. package/src/vite/discovery/prerender-collection.ts +2 -6
  257. package/src/vite/discovery/state.ts +26 -0
  258. package/src/vite/plugins/client-urls-revalidate-hoist.ts +190 -77
  259. package/src/vite/plugins/expose-internal-ids.ts +46 -40
  260. package/src/vite/plugins/hoist/source.ts +8 -7
  261. package/src/vite/plugins/transition-when-hoist.ts +1 -0
  262. package/src/vite/plugins/version-plugin.ts +7 -0
  263. package/src/vite/plugins/version.d.ts +7 -0
  264. package/src/vite/plugins/virtual-entries.ts +20 -0
  265. package/src/vite/rango.ts +4 -1
  266. package/src/vite/router-discovery.ts +39 -7
  267. package/src/vite/utils/prerender-utils.ts +17 -0
@@ -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;
@@ -8,29 +8,30 @@
8
8
 
9
9
  ### Options — `RunLoaderOptions<TEnv>`
10
10
 
11
- | Field | Type | Meaning |
12
- | --------------- | --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
13
- | `params` | `Record<string, string>` | Route params; surfaced as `ctx.params` and `ctx.routeParams`. |
14
- | `search` | `Record<string, string>` | Search params; merged into the request URL so `ctx.searchParams` reflects them. |
15
- | `searchData` | `Record<string, unknown>` | The TYPED `ctx.search` object a route's search schema would produce. Distinct from `search` (which sets the raw `ctx.searchParams`). |
16
- | `basename` | `string` | Router basename surfaced on the context; drives `redirect()` prefixing. |
17
- | `theme` | `ThemeConfig \| true` | Theme config in the `createRouter({ theme })` shape (e.g. `true` or `{ themes: [...] }`). Without it `ctx.theme`/`ctx.setTheme` are inert. |
18
- | `env` | `TEnv` | Environment bindings surfaced as `ctx.env`. |
19
- | `request` | `Request \| string` | Override the backing Request. Defaults to a localhost GET. |
20
- | `vars` | `VarsInit` | Variables a prior middleware would have set (object `{ key: value }`, or `[key, value]` tuples where the key may be a `createVar()` handle). |
21
- | `routeMap` | `Record<string, string>` | Route name -> pattern map enabling `ctx.reverse()`. |
22
- | `routeName` | `string` | Matched route name for scoped `.name` reverse resolution. |
23
- | `method` | `string` | HTTP method surfaced as `ctx.method`. Defaults to `"GET"`. |
24
- | `body` | `unknown` | Request body surfaced as `ctx.body`. |
25
- | `formData` | `FormData` | Form data surfaced as `ctx.formData` (exposed verbatim; no multipart parsing). |
26
- | `loaders` | `ReadonlyArray<readonly [LoaderDefinition<any, any>, unknown]>` | Seed `ctx.use(OtherLoader)` by REFERENCE as `[[OtherLoader, data]]` tuples (same shape as `renderHandler`/`renderRoute`). Checked before `use`. |
27
- | `use` | `UseResolver` | Dynamic resolver for `ctx.use(OtherLoader)` composition. `loaders` wins when both match. |
28
- | `cacheStore` | `SegmentCacheStore` | Cache store backing `use cache` functions. Without one, a cached function bypasses and runs uncached (its taint/profile guards never fire). |
29
- | `cacheProfiles` | `Record<string, CacheProfile>` | Cache profiles, the `createRouter({ cacheProfiles })` shape. |
30
- | `cache` | `PartialCacheOptions` | Run the loader through the `cache()` a route binds it with (`loader(Loader, () => [cache({...})])` takes the same options), against `cache.store`, else `cacheStore`. See the caveat below. |
31
- | `stateCookie` | `StateCookieSeed` (`{ prefix?, routerId?, version? }`) | Customize the rango state cookie a loader calling `invalidateClientCache()` rotates (the name is always seeded — default `rango-state_router_0`). |
32
- | `rendered` | `boolean \| (() => void \| Promise<void>)` | Mock the `ctx.rendered()` render barrier so a loader that `await ctx.rendered()`s can be unit-tested. By default `ctx.rendered()` throws. `true` resolves immediately; a function controls timing/side effects. |
33
- | `handles` | `ReadonlyArray<readonly [Handle<any, any>, unknown]>` | Seed the values `ctx.get(SomeHandle)` returns — the ACCUMULATED handle data read after `await ctx.rendered()`. Matched by handle reference. (Loader handle WRITES need no seed — see `runLoaderResult(...).handlePushes`.) |
11
+ | Field | Type | Meaning |
12
+ | --------------- | --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
13
+ | `params` | `Record<string, string>` | Route params; surfaced as `ctx.params` and `ctx.routeParams`. |
14
+ | `search` | `Record<string, string>` | Search params; merged into the request URL so `ctx.searchParams` reflects them. |
15
+ | `searchData` | `Record<string, unknown>` | The TYPED `ctx.search` object a route's search schema would produce. Distinct from `search` (which sets the raw `ctx.searchParams`). |
16
+ | `basename` | `string` | Router basename surfaced on the context; drives `redirect()` prefixing. |
17
+ | `theme` | `ThemeConfig \| true` | Theme config in the `createRouter({ theme })` shape (e.g. `true` or `{ themes: [...] }`). Without it `ctx.theme`/`ctx.setTheme` are inert. |
18
+ | `env` | `TEnv` | Environment bindings surfaced as `ctx.env`. |
19
+ | `request` | `Request \| string` | Override the backing Request. Defaults to a localhost GET. |
20
+ | `vars` | `VarsInit` | Variables a prior middleware would have set (object `{ key: value }`, or `[key, value]` tuples where the key may be a `createVar()` handle). |
21
+ | `routeMap` | `Record<string, string>` | Route name -> pattern map enabling `ctx.reverse()`. |
22
+ | `routeName` | `string` | Matched route name for scoped `.name` reverse resolution. |
23
+ | `declaredIn` | `string` | Include name prefix of the module whose `layout()` / `parallel()` / `cache()` declares the loader (`"account"`, `""` for the root module). `.name` then resolves in that module, as such a loader does in production, instead of from `routeName`. |
24
+ | `method` | `string` | HTTP method surfaced as `ctx.method`. Defaults to `"GET"`. |
25
+ | `body` | `unknown` | Request body surfaced as `ctx.body`. |
26
+ | `formData` | `FormData` | Form data surfaced as `ctx.formData` (exposed verbatim; no multipart parsing). |
27
+ | `loaders` | `ReadonlyArray<readonly [LoaderDefinition<any, any>, unknown]>` | Seed `ctx.use(OtherLoader)` by REFERENCE as `[[OtherLoader, data]]` tuples (same shape as `renderHandler`/`renderRoute`). Checked before `use`. |
28
+ | `use` | `UseResolver` | Dynamic resolver for `ctx.use(OtherLoader)` composition. `loaders` wins when both match. |
29
+ | `cacheStore` | `SegmentCacheStore` | Cache store backing `use cache` functions. Without one, a cached function bypasses and runs uncached (its taint/profile guards never fire). |
30
+ | `cacheProfiles` | `Record<string, CacheProfile>` | Cache profiles, the `createRouter({ cacheProfiles })` shape. |
31
+ | `cache` | `PartialCacheOptions` | Run the loader through the `cache()` a route binds it with (`loader(Loader, () => [cache({...})])` takes the same options), against `cache.store`, else `cacheStore`. See the caveat below. |
32
+ | `stateCookie` | `StateCookieSeed` (`{ prefix?, routerId?, version? }`) | Customize the rango state cookie a loader calling `invalidateClientCache()` rotates (the name is always seeded — default `rango-state_router_0`). |
33
+ | `rendered` | `boolean \| (() => void \| Promise<void>)` | Mock the `ctx.rendered()` render barrier so a loader that `await ctx.rendered()`s can be unit-tested. By default `ctx.rendered()` throws. `true` resolves immediately; a function controls timing/side effects. |
34
+ | `handles` | `ReadonlyArray<readonly [Handle<any, any>, unknown]>` | Seed the values `ctx.get(SomeHandle)` returns — the ACCUMULATED handle data read after `await ctx.rendered()`. Matched by handle reference. (Loader handle WRITES need no seed — see `runLoaderResult(...).handlePushes`.) |
34
35
 
35
36
  ### Context — `TestLoaderContext<TEnv>` (what your loader receives)
36
37
 
@@ -116,7 +117,7 @@ it("asserts a loader's set-cookie + redirect (runLoaderResult)", async () => {
116
117
 
117
118
  ## Caveats
118
119
 
119
- - `ctx.reverse(...)` throws unless you pass `routeMap` (and `routeName` for scoped `.name` resolution). It does NOT fall back to the global route map.
120
+ - `ctx.reverse(...)` throws unless you pass `routeMap` (and `routeName` for scoped `.name` resolution, or `declaredIn` for a layout's or parallel's loader). It does NOT fall back to the global route map.
120
121
  - `ctx.rendered()` throws by default (the render barrier only exists in a full match); pass `{ rendered: true }` to mock it for post-barrier logic, and `{ handles: [[SomeHandle, data]] }` to seed the `ctx.get(SomeHandle)` read.
121
122
  - A loader has no `isAction()`: whether it re-runs after an action is decided by `revalidate()` on its registration, not inside the loader body. Assert post-action re-runs at e2e.
122
123
  - Seeded `loaders` (by-reference tuples) are NOT executed — `ctx.use(OtherLoader)` returns the seeded value. The dynamic `use` resolver, by contrast, IS executed (it is a function called to compute the value). Either way the REAL loader body is not run; real loader execution and side-effects are e2e-only. `loaders` is checked before the `use` resolver.
@@ -44,17 +44,17 @@ The `ctx` your middleware reads. Notable fields:
44
44
 
45
45
  ### Returns — `RunMiddlewareResult<TEnv>`
46
46
 
47
- | Field | Type | Meaning |
48
- | ----------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
49
- | `response` | `Response` | The final Response: the downstream response, or a middleware short-circuit. |
50
- | `ctx` | `RequestContext<TEnv>` | The underlying RequestContext (NOT a per-middleware `MiddlewareContext`). Use `ctx.get(...)` for anything the envelope above doesn't surface. |
51
- | `nextCalled` | `number` | Times the terminal handler ran: `0` on short-circuit, `1` on pass-through. |
52
- | `rewrite` | `RunMiddlewareRewrite?` | The target of a `throw rewrite(...)`: `{ pathname, search, routeName? }`, resolved as production resolves it (a path basename-prefixed, a name through `routeMap`). `undefined` when nothing rewrote. On a rewrite, `response` carries what the chain set before the throw. |
53
- | `dynamic` | `boolean` | Whether the chain called `ctx.dynamic()` (the PPR shell opt-out). |
54
- | `cookies` | `Record<string, string>` | Effective cookie view: request cookies merged with chain sets/deletes (last-write-wins), as `{ name: value }`. |
55
- | `headers` | `Record<string, string>` | Final response headers as `{ name: value }`, lowercased, EXCLUDING `set-cookie` (use `cookies`). |
56
- | `locationState` | `Record<string, unknown>` | Flat `{ key: value }` state set via `setLocationState()` / `redirect({ state })` (empty when none). |
57
- | `stateCookieName` | `string` | The resolved rango state cookie name seeded for the run (default `rango-state_router_0`). Assert a middleware's `invalidateClientCache()` rotation against it without recomputing — parity with `runInRequestContext` / `runLoaderResult` / `renderHandler`. |
47
+ | Field | Type | Meaning |
48
+ | ----------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
49
+ | `response` | `Response` | The final Response: the downstream response, or a middleware short-circuit. |
50
+ | `ctx` | `RequestContext<TEnv>` | The underlying RequestContext (NOT a per-middleware `MiddlewareContext`). Use `ctx.get(...)` for anything the envelope above doesn't surface. |
51
+ | `nextCalled` | `number` | Times the terminal handler ran: `0` on short-circuit, `1` on pass-through. |
52
+ | `rewrite` | `RunMiddlewareRewrite?` | The target of a `throw rewrite(...)`: `{ pathname, search, routeName?, mutationsDisallowed? }` (`mutationsDisallowed: true` only for `.disallowMutations()`, absent otherwise), resolved as production resolves it (a path basename-prefixed, a name through `routeMap`). `undefined` when nothing rewrote. On a rewrite, `response` carries what the chain set before the throw. |
53
+ | `dynamic` | `boolean` | Whether the chain called `ctx.dynamic()` (the PPR shell opt-out). |
54
+ | `cookies` | `Record<string, string>` | Effective cookie view: request cookies merged with chain sets/deletes (last-write-wins), as `{ name: value }`. |
55
+ | `headers` | `Record<string, string>` | Final response headers as `{ name: value }`, lowercased, EXCLUDING `set-cookie` (use `cookies`). |
56
+ | `locationState` | `Record<string, unknown>` | Flat `{ key: value }` state set via `setLocationState()` / `redirect({ state })` (empty when none). |
57
+ | `stateCookieName` | `string` | The resolved rango state cookie name seeded for the run (default `rango-state_router_0`). Assert a middleware's `invalidateClientCache()` rotation against it without recomputing — parity with `runInRequestContext` / `runLoaderResult` / `renderHandler`. |
58
58
 
59
59
  ## Recipe
60
60
 
@@ -4,6 +4,8 @@
4
4
 
5
5
  `dispatch` runs the router's REAL matching (reusing `previewMatch`) and the real global + route-level middleware chain, with no RSC render — so redirects, 404s, response routes, content negotiation, and middleware short-circuits behave exactly as in production. You SEED the request and `env`; everything else (matching, middleware, header/cookie merge) is real machinery.
6
6
 
7
+ Matching goes through the route trie, as every request in `vite dev` and production does.
8
+
7
9
  ## API
8
10
 
9
11
  ### Options — `DispatchOptions<TEnv>`
@@ -78,7 +80,7 @@ describe("api routes via dispatch", () => {
78
80
 
79
81
  `dispatch` also covers, with production code paths:
80
82
 
81
- - trailing-slash/redirect targets (`findMatch`) — a redirected path returns a 308 with the `Location` (query preserved);
83
+ - trailing-slash redirects from the trie — a route with `trailingSlash: "never"` or `"always"` answers the other form with a 308 and the `Location` (query preserved). A route with no `trailingSlash` serves its URL with and without a trailing slash, with no redirect, as production does;
82
84
  - the open-redirect guard on browser-followed redirects (a cross-origin `Location` is rewritten to the basename root unless `redirect(url, { external: true })`);
83
85
  - content-negotiated routes (`Vary: Accept` appended);
84
86
  - cached response routes (`cache({ ... })` around a `path.json` etc.): hit, SWR, and tag writes. The cache write is scheduled via `waitUntil` (a microtask in tests), so flush microtasks between the seeding `dispatch` and the one that asserts the hit;
@@ -90,6 +92,7 @@ describe("api routes via dispatch", () => {
90
92
  - A COMPONENT route's guard stack cannot run here. Assert it at e2e, or extract the middleware fn and unit-test it with `runMiddleware` (see `./middleware.md`).
91
93
  - JSON serialization is bare, applied in `response-route-handler.ts`: a `path.json` handler that returns a value is serialized verbatim (`JSON.stringify(value)`, status 200, `application/json`) — no envelope. Returning or throwing a `Response` (e.g. `Response.json(x)`) uses the same control-flow path. Any other thrown error takes the error's status (`RouterError.status`, else 500, or a non-200 status already set upstream in the request pipeline). On a `path.json` route the body is an RFC 9457 problem+json object `{ title, status, detail, code }` (`application/problem+json;charset=utf-8`), where `code` is the `RouterError.code`, else `"INTERNAL"`, and the `type` member is omitted. On text/html/xml/md routes the body is `text/plain`: the `RouterError` message, the error message in dev, or `"Internal Server Error"` in production. Assert the shape matching what your handler returns.
92
94
  - Setup: needs the preset (alias + virtual stubs) or a Vite-RSC env (see `./setup.md`); a bare router import throws on Vite virtuals.
95
+ - Building the trie runs route discovery: every `include()`d `urls()` handler runs once more, and every async include provider (`() => import(...)`) is called. Count provider calls or handler runs after the first `dispatch` on that router, not from router construction. A provider that rejects fails the `dispatch`, as it fails `vite dev` and the build.
93
96
  - A router using `Prerender()`/`createLoader()`/`Static()` now constructs in a bare test (each assigns a runtime fallback `$$id`). Importing the whole router _file_ may still need the plugin (its page modules pull app deps / `virtual:` modules) — build from a focused include (your API routes) for whole-router dispatch.
94
97
  - A `throw rewrite(target)` from global or route middleware re-dispatches as the router does: global middleware matched on the target's path, the target's route middleware and handler, the same request context, `x-rango-rewrite` on the response, 508 on a loop. The visitor's URL is matched before middleware runs, so it must be a response route (or a redirect or 404) too; a rewrite target that is a component route throws the same error as a direct request.
95
98
  - A `_rsc_partial` request to a response route runs global middleware first (an auth gate can still 401/redirect), then returns `X-RSC-Reload` — route-level middleware is skipped, exactly like production.