@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
@@ -0,0 +1,980 @@
1
+ ---
2
+ name: ux-architecture
3
+ description: Architect a Rango app so navigation is instant and stable — when a server urls() page or a clientUrls() group wins, where layouts, loaders, Suspense boundaries, revalidate() rules, prefetch, transition(), intercept() modals and actions go, with worked recipes (shop, dashboard tabs, settings forms, search filters, a modal over a list) and the anti-patterns behind remounts, skeleton flashes and slow clicks. Use when designing or reviewing a Rango app's routing, layout, loading or navigation UX, choosing between urls() and clientUrls(), placing a loader or a boundary, writing revalidate() rules, or debugging a remount, a re-shown fallback or a click that waits.
4
+ argument-hint: "[shop|dashboard|settings|search|modal]"
5
+ ---
6
+
7
+ # Navigation UX architecture with urls() and clientUrls()
8
+
9
+ The primitive skills tell you what each helper does. This one tells you where
10
+ each piece goes so a click shows the next page at once, nothing on screen
11
+ remounts, and no skeleton flashes over content that was already there. Read
12
+ the contract first, then the decision tree, then the recipe closest to your
13
+ app.
14
+
15
+ ## Not this skill if…
16
+
17
+ - You want to lay out the `urls()` files for an app type (a SaaS dashboard, a
18
+ multi-app host, a static site): that is the **app-structure** skill. This
19
+ skill only sketches the routes each UX recipe needs.
20
+ - You want a primitive's full API: `/client-urls`, `/loader`, `/layout`,
21
+ `/route`, `/links`, `/intercept`, `/view-transitions`, `/server-actions`,
22
+ `/hooks`. Section 6 maps each topic to its skill.
23
+
24
+ ## 1. The UX contract
25
+
26
+ Four promises. Rango keeps its half; the app keeps the other half by where it
27
+ puts things.
28
+
29
+ | Promise | What Rango does | What your app must do |
30
+ | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
31
+ | **A prefetched click shows the next page, or its skeleton, at the click.** | A click that adopts a complete prefetch commits from it with no request. Inside a `clientUrls()` group a click to another route of the group renders the destination in the browser before the server answers, prefetched or not. The router adds no wait of its own. | Let the links people click prefetch. Keep what a prefetch runs cheap (`prefetch: false` on expensive loaders). Give each slow read a boundary, so the destination can show before that read's data arrives. |
32
+ | **Deferred parts reveal by React's rules.** | Each loader streams on its own and each read gets one stable promise. The router never steers React's scheduling. | Place a `<Suspense>` wherever the page may show in parts. Expect React's timing: a fallback that appeared stays at least 300 ms (section 5). |
33
+ | **Nothing that stays on screen remounts.** | A layout stays mounted across navigations between its routes. A group's route segment is keyed by the group, so param and search changes reconcile. A search-only navigation reconciles everywhere. An action's revalidation renders into the mounted tree, so React state survives. | Put shared chrome in a layout, not in a route component. Never key a boundary or a wrapper by the URL. On a server route whose param changes should keep the page (`/product/a` to `/product/b`), add `transition()`. |
34
+ | **A fallback shows only for loading that is new.** | A navigation that mounts nothing new commits in a transition, so React keeps revealed content until the new data lands. A loader whose `revalidate()` holds is not re-run, so nothing waits for it. | Write `revalidate()` rules that hold what the navigation did not change. Return transient loader failures as data instead of throwing them. |
35
+
36
+ What is a bug and what is the design: a fallback for a boundary the navigation
37
+ mounts, or for data the navigation fetches, is the design. A remount of
38
+ something that stays on screen, or a fallback over content whose data was in
39
+ hand, is a router bug: report it.
40
+
41
+ **Judge it in a build, at a realistic distance.**
42
+
43
+ - Dev loads client references per render, so a boundary new to the page can
44
+ show its fallback for 300 ms in dev where a build shows none
45
+ (`docs/internal/suspense-contract.md`, "Dev is not production"). Dev also
46
+ prefetches nothing by default (`defaultPrefetch` is `"none"` in development
47
+ and `"viewport"` in production, `src/router/prefetch-default.ts`).
48
+ - Add a 300 to 500 ms delay to every server response (a middleware that
49
+ sleeps) before you compare clicks. At a local 10 ms, React's 300 ms throttle
50
+ is the slowest thing on the page and decides everything you see.
51
+
52
+ ## 2. The decision tree
53
+
54
+ ### 2.1 A server urls() page or a clientUrls() group?
55
+
56
+ | Pick | When | What you get |
57
+ | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
58
+ | A server `urls()` route with a handler | Pages people mostly reach by a document load (marketing, docs, articles, landing pages). Pages built from server components you don't want to ship as client code. Routes that need `cache()`, `Prerender()`, `parallel()`, `middleware()` or an `errorBoundary()` on the route itself. | The handler renders on the server, and `cache()` or a prerender can store its output. A click with no prefetch waits for the server before the page changes; a prefetched click does not. |
59
+ | A `clientUrls()` group | An area where people move a lot inside one layout: dashboards, admin, settings, tabs, filters, master and detail. Pages that are client components anyway. | The browser matches the group, so a click to another route of the group renders the destination at once, before the server answers. Param and search changes on one route reconcile and hold. There are no handlers: every read is a `useLoader` under its own boundary. A document load is still rendered on the server, and `loader(Def, { ssr: false })` puts a loader's data and handles in that HTML. |
60
+
61
+ Two limits of a group decide some cases:
62
+
63
+ - The destination renders before middleware has run. Its loader reads wait
64
+ for the server, but everything outside a loader read shows at the click. If
65
+ showing a page's shell before authorization is a problem, define that route
66
+ in `urls()` (`docs/client-urls.md`, "Security boundary").
67
+ - Inside the group there is no `middleware()`, `include()`, `parallel()`,
68
+ `cache()`, `errorBoundary()` or `notFoundBoundary()`. They go on the server
69
+ tree around the `include()`. For caching, put `"use cache"` on the function
70
+ a loader calls.
71
+
72
+ ### 2.2 Combine them: server structure around the group
73
+
74
+ The common shape: a server layout holds what every area shares, middleware
75
+ guards, an error boundary catches what loaders throw, and each area is a
76
+ group mounted with `include()`.
77
+
78
+ ```tsx
79
+ // src/urls.tsx (server)
80
+ import { urls } from "@rangojs/router";
81
+ import appUrls from "./app/app.client.js"; // clientUrls() group
82
+ import settingsUrls from "./settings/settings.client.js"; // clientUrls() group
83
+ import { AppShell, AppError, HomePage } from "./shell.js";
84
+ import { SessionLoader } from "./session.loader.js";
85
+ import * as AccountActions from "./account.actions.js";
86
+ import { requireSession } from "./middleware.js";
87
+
88
+ export const urlpatterns = urls(
89
+ ({
90
+ path,
91
+ layout,
92
+ include,
93
+ loader,
94
+ middleware,
95
+ errorBoundary,
96
+ revalidate,
97
+ }) => [
98
+ layout(AppShell, () => [
99
+ middleware(requireSession),
100
+ errorBoundary(<AppError />),
101
+ loader(SessionLoader, () => [
102
+ revalidate(({ isAction }) =>
103
+ isAction() ? isAction(AccountActions) : undefined,
104
+ ),
105
+ ]),
106
+ path("/", HomePage, { name: "home" }),
107
+ include("/app", appUrls, { name: "app" }),
108
+ include("/settings", settingsUrls, { name: "settings" }),
109
+ ]),
110
+ ],
111
+ );
112
+ ```
113
+
114
+ On a navigation inside one group:
115
+
116
+ | Piece | Runs? |
117
+ | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
118
+ | `requireSession` | Yes, on every request, a tab switch whose loaders all hold included. |
119
+ | The `AppShell` handler | No. Its segment is held. A `revalidate()` on the layout opts it in, and its handler time then adds to every navigation it runs on. |
120
+ | `SessionLoader` | Held on navigation (the default for a loader above the route). After an action, only an account action re-runs it. |
121
+ | The group's loaders | Each by its own `revalidate()`, run on the server. |
122
+
123
+ Pinned by `tests/vite-rsc-demo/e2e/client-shop-guards.test.ts` (dev and
124
+ production): the middleware stamps every request while the outer layout's run
125
+ count holds.
126
+
127
+ **Navigating between two groups** (`/app/...` to `/settings/...`) is a server
128
+ navigation: each group matches only below its own mount
129
+ (`src/client-urls/navigation.ts`, `stripMountPrefix`), so nothing renders
130
+ before the response, and the destination group mounts with its layouts (a
131
+ group's segment key is its mount, `src/segment-system.tsx`). The server shell
132
+ above both and its loaders stay as they are. That is the expected shape, not a
133
+ remount bug. Two consequences:
134
+
135
+ - Chrome that must survive moving between areas (the account menu, a cart
136
+ badge, global navigation) goes in the server layout above the groups.
137
+ Chrome that belongs to one area (its section nav, its tab bar) goes in that
138
+ group's `layout()`.
139
+ - Prefetch the links that cross areas: the adopted prefetch makes that click
140
+ commit at once like any prefetched click.
141
+
142
+ A large app follows the same shape: server layouts for the first page and
143
+ the chrome, loaders for user and app data, and one group per area (app, auth,
144
+ onboarding, settings). How to cut an app into areas and files is the
145
+ app-structure skill.
146
+
147
+ ### 2.3 Where a layout goes
148
+
149
+ | | A server `layout()` around the `include()` | A client `layout()` inside the group |
150
+ | -------------------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
151
+ | Stays mounted across | Every navigation below it, between groups too. | Navigations between the routes it wraps (#1124). The route component inside it mounts anew on a route change; it is a different page. |
152
+ | Its own code re-runs | Never on a navigation or an action by default. `revalidate()` opts in; the handler time adds to each such navigation. | It is a client component: it re-renders with React state, never re-fetched. |
153
+ | Can hold | Server components, `parallel()` slots, `middleware()`, `errorBoundary()`, `intercept()`. | Client components only. `useOutlet()` gives `content` and `pending`, which is true from the click to the commit of a navigation inside the group. |
154
+ | A `loader()` on it | Held on navigation, re-run after every action unless its `revalidate()` narrows that. | The same as on a server layout: held on navigation, whatever route of the layout or params change, and re-run after every action unless its `revalidate()` narrows that. |
155
+ | Use it for | App-wide chrome and its data: session, account menu, cart badge, notifications. | Area chrome: section nav, tabs, a busy indicator from `pending`. |
156
+
157
+ A server layout handler that reads request data (a cookie, the session,
158
+ `ctx.params`) renders it once and keeps it. Data that must refresh goes in a
159
+ loader read by a client component (section 2.4), or in middleware `ctx.set()`
160
+ consumed by a loader. See `/rango`, "Passing data down the tree".
161
+
162
+ ### 2.4 Where loaders go, and their revalidate() rules
163
+
164
+ Put a loader on the segment whose lifetime matches the data, and read it with
165
+ `useLoader` in the component that shows it, under its own boundary. Never
166
+ fetch in a handler with `await ctx.use(Loader)` when a read site can do it:
167
+ the handler then waits for the data, a route without `loading()` waits with
168
+ it, and under `cache()` or a `ppr` shell the value bakes in.
169
+
170
+ Defaults, when no `revalidate()` decides (`/loader`, "`revalidate()` return
171
+ shapes"):
172
+
173
+ | Declared on | On a navigation | After an action |
174
+ | ---------------------------------------- | ------------------------------------ | --------------- |
175
+ | A server `path()`, or anything inside it | Re-runs when params or search change | Re-runs |
176
+ | A server `layout()` above the route | Held | Re-runs |
177
+ | A `clientUrls()` `path()` | Re-runs when params or search change | Re-runs |
178
+ | A `clientUrls()` `layout()` | Held | Re-runs |
179
+ | Any segment the page does not hold yet | Runs; a predicate cannot stop it | Runs |
180
+
181
+ The four predicates most apps need (all run on the server, in `clientUrls()`
182
+ too):
183
+
184
+ ```ts
185
+ import type { RevalidateParams } from "@rangojs/router";
186
+ import * as CartActions from "./cart.actions.js";
187
+ import { saveProfile } from "./profile.actions.js";
188
+
189
+ // 1. Data keyed by a param: re-run when the param changes; hold on tab and
190
+ // search changes and on actions.
191
+ export function bySlug({
192
+ isAction,
193
+ currentParams,
194
+ nextParams,
195
+ defaultShouldRevalidate,
196
+ }: RevalidateParams): boolean {
197
+ if (isAction()) return false;
198
+ return currentParams.slug !== nextParams.slug
199
+ ? defaultShouldRevalidate
200
+ : false;
201
+ }
202
+
203
+ // 2. Data driven by the query string: re-run when the search changes.
204
+ export function bySearch({
205
+ isAction,
206
+ currentUrl,
207
+ nextUrl,
208
+ defaultShouldRevalidate,
209
+ }: RevalidateParams): boolean {
210
+ return !isAction() && currentUrl.search !== nextUrl.search
211
+ ? defaultShouldRevalidate
212
+ : false;
213
+ }
214
+
215
+ // 3. Data an action changes: after an action, only these actions re-run it;
216
+ // on a navigation, keep the default.
217
+ export const byCartAction = ({ isAction }: RevalidateParams) =>
218
+ isAction() ? isAction(CartActions) : undefined;
219
+
220
+ // 4. Data that depends on what the action returned.
221
+ export const bySavedProfile = ({ isAction, actionResult }: RevalidateParams) =>
222
+ isAction(saveProfile) ? actionResult?.ok === true : undefined;
223
+ ```
224
+
225
+ `bySlug` is the vite-rsc-demo's `productData`
226
+ (`tests/vite-rsc-demo/src/urls/client-shop.predicates.ts`). Rules that keep
227
+ them right:
228
+
229
+ - A boolean is a hard answer, `undefined` defers to the default. Pick by the
230
+ default you are changing: the ternary narrows a loader that re-runs after
231
+ every action; `isAction(X) || undefined` adds a re-run where the default
232
+ skips (a server layout's own handler).
233
+ - `revalidate(true)` and `revalidate(false)` are shorthands for `() => true`
234
+ and `() => false`.
235
+ - Never `revalidate(false)` on data keyed by a param: product to product then
236
+ keeps showing the first product. Never `revalidate(true)` as a habit: it re-runs on
237
+ every navigation and action, and where its read is on screen the navigation
238
+ either waits for it (a held commit) or shows that read's fallback again (an
239
+ urgent one).
240
+ - Chrome data that does not depend on the URL, on a group `layout()`: the
241
+ default already holds it on navigation, and a bare `isAction(CartActions)`
242
+ narrows the actions that re-run it to the cart's. It is a hard `false` on
243
+ navigation, which is what a cart badge wants, and `true` after a cart action. This is the
244
+ vite-rsc-demo's `CartLoader` rule.
245
+ - A predicate in a `clientUrls()` module may reference imports and globals
246
+ only. A predicate shared by several loaders goes in a module without
247
+ `"use client"` and is imported (`/client-urls`, "Revalidation runs on the
248
+ server").
249
+ - A prefetch runs the predicates too, so a loader that holds costs nothing in
250
+ the prefetch of a tab switch.
251
+ - Unit-test them with `runRevalidate` (`/testing`).
252
+
253
+ ### 2.5 loading() or an inline Suspense
254
+
255
+ Default to an inline `<Suspense>` directly above each `useLoader` read that
256
+ can be slow. What the URL already decides (a heading, the tabs, a breadcrumb
257
+ from params) paints at once, and each read fills on its own. A route-level
258
+ `loading()` is one skeleton for the whole route content.
259
+
260
+ | Use | When |
261
+ | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
262
+ | Inline `<Suspense>` per read | The default. Several reads with different latencies; a page whose frame is meaningful before the data. |
263
+ | `loading()` on a server route | The route's handler is async (with `loading()` it streams instead of holding the response; `docs/internal/execution-model.md`, "Limitation: a Response from an async handler under `loading()` is not a redirect"), or the content means nothing until its data lands. Keep inline boundaries inside it. |
264
+ | `loading()` on a group route | The route-level boundary around the destination the browser renders at the click. Same-route navigations hold whatever you declare. |
265
+ | No boundary at all | A read with no boundary suspends to the nearest one above: on a server route that is a layout's `loading()` or `<Suspense>`, so a larger area shows a fallback; with none, nothing of the new page shows until the data lands. In a group the destination is not shown at the click; React keeps the old page until the server's commit. |
266
+
267
+ Which fallbacks show: a boundary the navigation mounts shows its fallback; a
268
+ boundary already revealed holds its content on a navigation that mounts
269
+ nothing new; a fallback that appeared stays at least 300 ms.
270
+
271
+ `useLoader().isLoading` is true on a reader that still shows the old data
272
+ while a held navigation fetches the new one. Dim it instead of adding a
273
+ boundary (`/hooks`, `data.md`).
274
+
275
+ ### 2.6 Prefetch
276
+
277
+ A prefetch is a navigation sent early: the server renders the destination's
278
+ handlers and loaders for it, from the page you are on (`/links`, "What a
279
+ prefetch renders"). A click that adopts a complete prefetch commits with no
280
+ request. Entries live 300 s by default (`prefetchCacheTTL`) in the router's
281
+ memory only (prefetch responses carry no `Cache-Control`), and an action
282
+ drops them unless it calls `keepClientCache()`. A prefetch whose page failed
283
+ is never used: a 500 or 404, and a 200 whose render reported an error (a
284
+ loader that threw, a `loading()` handler that fell back to its
285
+ `errorBoundary()`, an async component that threw). The click sends its own
286
+ request, like a plain click.
287
+
288
+ The rule: **prefetch broadly, and flag what is expensive.**
289
+
290
+ - Links people click: leave the production default `viewport`, or set it
291
+ (`createRouter({ defaultPrefetch })`, `<Link prefetch>`). Long lists of
292
+ secondary links (related items): `hover`. Unbounded combinations (filter
293
+ chips, arbitrary query pages) and rarely followed links to expensive pages:
294
+ `none`, or `data-prefetch-scope="none"` on their container.
295
+ - `loader(Def, { prefetch: false })` for a loader that is expensive (a slow
296
+ query, a third-party API, a large payload), personalised (every visible link
297
+ runs it again for that user), or rarely needed (a reviews tab). Cheap
298
+ loaders stay prefetched, so their data is there at the click.
299
+ - `prefetch: false` only skips a loader that is new to the page. A loader
300
+ already on screen whose `revalidate()` re-runs it for the destination is
301
+ prefetched with it, so the click does not wait on a fill for data the page
302
+ is already showing.
303
+ - The trade-off, measured on a `clientUrls()` tab in a consumer app (dev, a
304
+ loaded machine): with the tab's loader flagged, a hover then a click 150 ms
305
+ later showed the content in 843 to 918 ms; with the tab prefetched whole, in
306
+ 77 to 157 ms. The flagged click pays the fill request and then React's
307
+ 300 ms on the tab's fallback.
308
+ - A click that adopts a prefetch with a flagged loader shows the page at once
309
+ and sends one fill request for the flagged loaders. Read a flagged loader
310
+ under its own `<Suspense>`: with no boundary, React keeps the page being
311
+ left until the fill answers.
312
+ - On a server route, `loading(fallback, { prefetch: false })` keeps the whole
313
+ entry behind that fallback out of the prefetch (`/route`, "`loading()`
314
+ options"). A group's `loading()` takes no options.
315
+ - When the target's `revalidate()` reads `currentUrl` or `currentParams`, give
316
+ the Link `prefetchKey=":source"`: the default prefetch entry is shared by
317
+ every source page (`src/browser/react/Link.tsx`, `prefetchKey`).
318
+
319
+ ### 2.7 transition() and view transitions
320
+
321
+ - On a server route, `transition()` makes its navigations commit in a
322
+ transition and drops params from the route's React key, so `/product/a` to
323
+ `/product/b` keeps the page on screen until the new data lands instead of
324
+ remounting it under a skeleton (`src/segment-system.tsx`,
325
+ `inTransitionScope`). `transition({ viewTransition: false })` gives the hold
326
+ without the animation.
327
+ - In a group the hold needs no `transition()`: group segments are keyed by the
328
+ group. There `transition()` adds the view-transition animation only, and
329
+ goes inside a `path()`.
330
+ - Put `transition()` at one level, not on every layout, and mount the
331
+ `<ParallelOutlet>` of a modal slot in a layout, not in a route component
332
+ that has a transition (`/view-transitions`, "Recommendations").
333
+ - A `transition({ when })` that returns `false` commits that navigation
334
+ urgently: no hold, the skeleton streams. Keep it to the navigations it names.
335
+ - `<Link revalidate={false}>` or `setSearchParams(next, { revalidate: false })`
336
+ changes the URL and the hooks at once with no server request, for view state
337
+ that needs no data (grid or list, an open panel). The pathname must stay the
338
+ same.
339
+
340
+ ### 2.8 intercept() modals and parallel() slots
341
+
342
+ - A modal over a list is an `intercept()` declared on the layout that wraps
343
+ both the list and the target route, rendered by
344
+ `<ParallelOutlet name="@modal" />` in that layout. A click opens the modal
345
+ and the list stays mounted under it; a document load of the URL renders the
346
+ full page; Back closes the modal. Recipe 3.5.
347
+ - An intercept's loaders each take their own `revalidate()`, and a flagged
348
+ `prefetch: false` loader opens the modal at once with its `loading()`. Its
349
+ errors render the declaring layout's `errorBoundary()`.
350
+ - Inside a group, `intercept()` takes a dot-local named target in the same
351
+ definition and `loader()`/`loading()` only, and only navigations from inside
352
+ the group open it.
353
+ - `parallel()` is server-only. A slot on a standalone layout is kept across
354
+ navigations and actions unless its `revalidate()` opts in; a slot with its
355
+ own `loading()` streams on its own. Use it for chrome that fills
356
+ separately, such as a sidebar (`/parallel`).
357
+
358
+ ### 2.9 Actions
359
+
360
+ - What re-runs by default: the route, its loaders and what is inside its
361
+ `path()`; loaders on layouts above. A layout's own handler and its parallel
362
+ slots do not. The server decides per loader with each `revalidate()`
363
+ (`actionResult` and `formData` included), in the action's own response.
364
+ - The commit renders into the mounted page: nothing remounts, a controlled
365
+ input keeps what was typed, and `useActionState` keeps its state.
366
+ - Return validation failures from the action as state for `useActionState`;
367
+ throw only for the unexpected (`/server-actions`, "Error Handling").
368
+ - Return a loader's transient failure (a flaky upstream, a rate limit) as
369
+ data, `{ ok: false, reason }`, and render it where the data goes. A throw on
370
+ the re-run after an action rejects a direct `await action()` call, or hands
371
+ the error to the nearest error boundary, which replaces the page and its
372
+ form (`/client-urls`, "A loader that fails on its rerun").
373
+ - `keepClientCache()` in an action that changed nothing keeps the prefetches
374
+ and the history cache.
375
+ - `useOptimistic` gives feedback before the action answers.
376
+
377
+ ### 2.10 Updates you did not make: three channels
378
+
379
+ | The change | The tool |
380
+ | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
381
+ | Your own mutation | A server action plus `revalidate()` rules (`isAction(ref)`, `actionResult`). The server re-runs what the action touched in the same response; nothing else to call. |
382
+ | Out of band: a push, polling, a REST call, a login | `useLoader(L).load()` (also `refetch`): a plain `load()` of a route-registered loader updates every reader of it. `refreshGroup` on reads plus `useRefreshLoaders()` refreshes several loaders at once. Both need a fetchable loader, `createLoader(fn, true)`; a plain one answers 403. `router.refresh()` re-renders the current route's server data and keeps client state. `invalidateClientCache()` marks the history cache stale and drops prefetches when the change makes them wrong. |
383
+ | Shared server caches (other users' data) | Tags: `cache({ tags })` or `cacheTag()`, then `updateTag()` in the action (awaitable, the action's response reads fresh) or `revalidateTag()` from a webhook. `revalidate()` is not cache invalidation (`/cache-guide`). |
384
+
385
+ ### 2.11 Handles: titles and breadcrumbs
386
+
387
+ - Push from the loader that has the data: `ctx.use(Meta)({ title })`,
388
+ `ctx.use(Breadcrumbs)({ label, href })`. This is how a group, which has no
389
+ handlers, sets them (`/breadcrumbs`, "Pushing from Loaders").
390
+ - Read them in chrome with `useHandle(Breadcrumbs)`, in a layout that stays.
391
+ - `loader(Def, { ssr: false })` puts its pushes in the document's HTML (a
392
+ title for search engines, a real 404 from `notFound()`). The document waits
393
+ for that loader; flag only the one that needs it.
394
+ - A loader an action skips keeps its title and crumbs
395
+ (`tests/shared-e2e/src/held-handles-scenario.ts`), and a link back to a page
396
+ you visited keeps that page's (#1115).
397
+
398
+ ### 2.12 Links by name, never by string
399
+
400
+ A hardcoded `"/dashboard/traffic"` keeps working until someone moves the
401
+ `include()` mount or renames the path, then breaks without a type error. A
402
+ name follows the move. Inside a module, link by local name, never by its
403
+ mount: `ctx.reverse(".x")` on the server and `useReverse(routes)` on the
404
+ client resolve against wherever the module is mounted and fill the mount's
405
+ params (`:locale`, `:accountId`, `:projectId`) from the current URL. The
406
+ module never knows its prefix, so it can be mounted twice. Pick the tool by
407
+ where the link is built (`/links`):
408
+
409
+ | Where the link is built | Use |
410
+ | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
411
+ | Server handler, layout, loader | `ctx.reverse(".local")` or `ctx.reverse("global.name")`; pass the string to client components as a prop. `scopedReverse` types the local names. |
412
+ | Client component linking inside one module or `clientUrls()` group | `useReverse(routes)` with `routes` from that module's generated `.gen.ts`. Mount-aware, fills params from `useParams()`, and only that module's names reach the client bundle. |
413
+ | Client component that needs a global name | Prefer a prop from the server. `router.named-routes.gen.ts` works with `useReverse` as an escape hatch, but is correct only at the root mount and pulls every route name into the client bundle. |
414
+ | A static path with no route behind it | `href("/about")`. |
415
+
416
+ The maps exist only if they are generated and committed: the Vite plugin
417
+ writes them on `vite dev` and `vite build`, `rango generate src` writes them
418
+ without Vite (`/typesafety`, "Generated Files and CLI Setup"). A group module
419
+ `dashboard.client.tsx` gets `dashboard.client.gen.ts` beside it. The recipes
420
+ below use these.
421
+
422
+ ```ts
423
+ // one shopUrls module at include("/:locale", ...) and include("/outlet", ...)
424
+ ctx.reverse(".product", { slug: "b" }); // /en/product/b, /outlet/product/b
425
+ ctx.reverse(".product", { slug: "b", locale: "de" }); // switch language
426
+ // /:locale? only: { locale: undefined } drops it; on a required /:locale it
427
+ // throws `Missing param "locale"`
428
+ ```
429
+
430
+ - Explicit params override the filled ones: `reverse(".issues", { projectId })`.
431
+ - Middleware `ctx.reverse` takes global names only and fills nothing, so
432
+ pass every param; a missing one throws `Missing param`.
433
+ `getRequestContext().reverse` in route middleware does fill (rebound after
434
+ the match); in global middleware only after `next()`.
435
+ - An action's `getRequestContext().reverse("global.name")` fills from the page
436
+ URL: `throw redirect(getRequestContext().reverse("account.settings"))`.
437
+ - Wrap each module's map once in a userland file
438
+ (`export const useAccountReverse = () => useReverse(accountRoutes)` in
439
+ `src/lib/links.ts`) and call that hook in components, under that module's
440
+ own mount (`/links`, "One hook per module").
441
+ - Not these: `useParams().accountId` in a template string, `ctx.params`
442
+ passed down as props to build links, or a "current account" context.
443
+ `useHref()` and relative `router.push()` fill the mount's params too, but
444
+ take unchecked path strings: prefer the typed `useReverse` map.
445
+ - `useReverse` with another module's map, or `useReverse(NamedRoutes)` under
446
+ a mount, gives a wrong URL with no error. Build cross-module links on the
447
+ server and pass them as props or loader data (dc-t57).
448
+ - A server layout stays held across navigations, its own `:projectId`
449
+ changing included: partial rendering by design. Links it rendered keep the
450
+ old params. Put param-dependent nav links in a client component with
451
+ `useReverse(routes)` (it re-renders from `useParams()`), or give the layout
452
+ a `revalidate()` that compares the param.
453
+ - A layout resolves `.name` in the module that declares it, on every page
454
+ below it: a parent module's layout links within its own module (`/links`,
455
+ "Local names").
456
+
457
+ Build cross-module links, and links a client component cannot reach with
458
+ its own map, on the server: `ctx.reverse` in the handler (passed as a
459
+ prop) or in a loader (returned as data, with a `revalidate()` when they
460
+ follow a param). The full example is in `/links`, "Client components:
461
+ receive URLs as props".
462
+
463
+ ### 2.13 Rewrite or redirect
464
+
465
+ Both send the visitor somewhere other than the matched route. Pick on what
466
+ the visitor should see.
467
+
468
+ | | `throw redirect(...)` | `throw rewrite(...)` |
469
+ | --------------- | --------------------- | ----------------------------------------------- |
470
+ | Cost | An extra round trip | None: the same response renders the other route |
471
+ | Address bar | Changes to the target | Keeps the visitor's URL |
472
+ | Reload or share | Lands on the target | Hits the original URL, which rewrites again |
473
+
474
+ - Rewrite fits a maintenance page, a flag or A/B variant, a legacy or alias
475
+ URL served by the canonical route, a geo split. Redirect fits a login gate
476
+ or a moved page, where the new address should show and be bookmarked.
477
+ - Middleware only: a loader or handler cannot rewrite. A global rewrite moves
478
+ a server action or form post to the target, where the action runs, so a
479
+ maintenance lock answers mutations with a `Response` (a 503) or throws
480
+ `rewrite("maintenance").disallowMutations()`, as in `/middleware`,
481
+ "Rewrite".
482
+ - The target can be a path or a route name (`rewrite("maintenance")`,
483
+ `rewrite("product", { slug })`). Global middleware and the target's own
484
+ route middleware run again for the target, so guards still apply.
485
+ - A loop answers 508 with `RewriteLoopError`: past the hop limit, or a rewrite
486
+ to the URL the request is already on (semantic-matrix row RW4; RW3 pins the
487
+ mutation's move and the `.disallowMutations()` error).
488
+
489
+ ## 3. Recipes
490
+
491
+ Each recipe is a sketch: the placements are the point. Imports of your own
492
+ modules are left out where they are obvious.
493
+
494
+ ### 3.1 A shop: catalog, product, cart badge, add to cart
495
+
496
+ Reference implementation: `tests/vite-rsc-demo/src/urls/client-shop.client.tsx`
497
+ (the group), `client-shop-guard.tsx` (the server layout and middleware around
498
+ it), `client-shop.predicates.ts`, and the mount in `src/urls/index.tsx`, in
499
+ the router repository (not shipped in the package). Its e2e suites
500
+ `tests/vite-rsc-demo/e2e/client-shop-*.test.ts` pin each behaviour in dev and
501
+ production. The sketch below differs in one place: the cart loader sits on the
502
+ server layout, so the badge also survives entering and leaving the group. The
503
+ demo keeps it on the group `layout()`, which holds it across the group's
504
+ routes as well.
505
+
506
+ ```tsx
507
+ // src/urls.tsx (server)
508
+ import { urls } from "@rangojs/router";
509
+ import shopUrls from "./shop/shop.client.js";
510
+ import { CartLoader } from "./shop/loaders.js";
511
+ import * as CartActions from "./shop/cart.actions.js";
512
+
513
+ export const urlpatterns = urls(
514
+ ({ layout, include, loader, revalidate, errorBoundary }) => [
515
+ layout(
516
+ (ctx) => <ShopShell shopHref={ctx.reverse("shop.index")} />,
517
+ () => [
518
+ errorBoundary(<ShopError />),
519
+ loader(CartLoader, () => [
520
+ revalidate(({ isAction }) =>
521
+ isAction() ? isAction(CartActions) : undefined,
522
+ ),
523
+ ]),
524
+ include("/shop", shopUrls, { name: "shop" }),
525
+ ],
526
+ ),
527
+ ],
528
+ );
529
+ ```
530
+
531
+ ```tsx
532
+ // src/shop/shell.tsx (server component) and cart-badge.tsx ("use client")
533
+ function ShopShell({ shopHref }: { shopHref: string }) {
534
+ return (
535
+ <>
536
+ <header>
537
+ <Link to={shopHref}>Shop</Link>
538
+ <Suspense fallback={<span>Cart</span>}>
539
+ <CartBadge />
540
+ </Suspense>
541
+ </header>
542
+ <Outlet />
543
+ </>
544
+ );
545
+ }
546
+
547
+ function CartBadge() {
548
+ const { data } = useLoader(CartLoader);
549
+ return <span>Cart ({data.count})</span>;
550
+ }
551
+ ```
552
+
553
+ ```tsx
554
+ // src/shop/shop.client.tsx
555
+ "use client";
556
+ import { Suspense } from "react";
557
+ import { clientUrls, Link, useLoader, useOutlet } from "@rangojs/router/client";
558
+ import { bySlug, bySearch } from "./predicates.js"; // no "use client" there
559
+
560
+ function ShopLayout() {
561
+ const { content, pending } = useOutlet();
562
+ return <main aria-busy={pending}>{content}</main>;
563
+ }
564
+
565
+ function ProductPage() {
566
+ return (
567
+ <>
568
+ <Tabs /> {/* from the URL: paints at the click */}
569
+ <Suspense fallback={<InfoSkeleton />}>
570
+ <ProductInfo /> {/* useLoader(ProductLoader) + <AddToCartForm> */}
571
+ </Suspense>
572
+ <Suspense fallback={<RelatedSkeleton />}>
573
+ <Related /> {/* <Link prefetch="hover"> per item */}
574
+ </Suspense>
575
+ <Suspense fallback={<ReviewsSkeleton />}>
576
+ <Reviews />
577
+ </Suspense>
578
+ </>
579
+ );
580
+ }
581
+
582
+ export default clientUrls(({ path, layout, loader, revalidate }) => [
583
+ layout(ShopLayout, () => [
584
+ path("/", Catalog, { name: "index" }, () => [
585
+ loader(ProductsLoader, () => [revalidate(bySearch)]),
586
+ ]),
587
+ path("/product/:slug", ProductPage, { name: "product" }, () => [
588
+ loader(ProductLoader, { ssr: false }, () => [revalidate(bySlug)]),
589
+ loader(RelatedLoader, () => [revalidate(bySlug)]),
590
+ loader(ReviewsLoader, { prefetch: false }, () => [revalidate(bySlug)]),
591
+ ]),
592
+ ]),
593
+ ]);
594
+ ```
595
+
596
+ Why each placement:
597
+
598
+ | Placement | Why |
599
+ | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
600
+ | Catalog and product in one group | A card click renders the product page at the click; the filters and tabs are same-route navigations that hold. |
601
+ | `CartLoader` on the server layout, ternary predicate | Held on every navigation (a server layout loader's default), re-run only after a cart action. It survives entering and leaving the group. The badge updates in the add-to-cart response itself. |
602
+ | `ProductLoader` with `ssr: false` | The product's title and a real 404 for an unknown slug are in the document's HTML. Client navigations still stream it. |
603
+ | `bySlug` on the product loaders | Product to product re-runs them; a tab switch and an add to cart hold them. One action, three outcomes: the badge refreshes, product and related keep their data (`client-shop-cart.test.ts`). |
604
+ | `ReviewsLoader` flagged `prefetch: false` | Every visible card prefetches; reviews are the expensive, rarely read part. The click still shows the product at once and the reviews fill behind their own boundary. A prefetch from another product page (the related links) still runs it: only a segment new to the page is deferred. |
605
+ | One `<Suspense>` per section | A slow product loader fills its section without holding the related list, and the other way round (the demo's product loader takes 2 s). |
606
+ | Cards `prefetch="viewport"`, filters `prefetch="none"` | A visible card is likely to be clicked; filter combinations are unbounded (the demo's `FilterLink`). |
607
+ | `pending` from `useOutlet()` on the layout | The progress signal for a filter change, whose grid holds instead of flashing (`client-shop-filters.test.ts`). |
608
+
609
+ The add-to-cart form uses `useActionState` and an action that returns its
610
+ result; nothing else is needed for the badge.
611
+
612
+ ### 3.2 Dashboard tabs
613
+
614
+ ```tsx
615
+ // src/dashboard/dashboard.client.tsx, mounted under the app's server shell
616
+ "use client";
617
+ import {
618
+ clientUrls,
619
+ Link,
620
+ useOutlet,
621
+ useReverse,
622
+ } from "@rangojs/router/client";
623
+ import { bySearch } from "../predicates.js";
624
+ import { routes } from "./dashboard.client.gen.js"; // generated, committed
625
+
626
+ function DashboardLayout() {
627
+ const { content, pending } = useOutlet();
628
+ const reverse = useReverse(routes);
629
+ return (
630
+ <section aria-busy={pending}>
631
+ <nav>
632
+ <Link to={reverse("overview")}>Overview</Link>
633
+ <Link to={reverse("traffic")}>Traffic</Link>
634
+ <Link to={reverse("reports")}>Reports</Link>
635
+ </nav>
636
+ {content}
637
+ </section>
638
+ );
639
+ }
640
+
641
+ export default clientUrls(({ path, layout, loader, revalidate }) => [
642
+ layout(DashboardLayout, () => [
643
+ path("/", Overview, { name: "overview" }, () => [loader(KpiLoader)]),
644
+ path(
645
+ "/traffic",
646
+ Traffic,
647
+ { name: "traffic", search: { range: "string?" } },
648
+ () => [loader(TrafficLoader, () => [revalidate(bySearch)])],
649
+ ),
650
+ path("/reports", Reports, { name: "reports" }, () => [
651
+ loader(ReportsLoader, { prefetch: false }),
652
+ ]),
653
+ ]),
654
+ ]);
655
+ ```
656
+
657
+ - **A tab with its own data is a route.** A click to another route of the
658
+ group renders it at the click, prefetched or not; the layout and its tab bar
659
+ keep their instance (#1124, pinned by "a layout() keeps its DOM across
660
+ navigations between its routes" in both apps' `e2e/client-urls.test.ts`).
661
+ - **A view option over the same data is a search param** (a date range, a
662
+ sort). Its navigation stays on the route, so the browser renders nothing
663
+ early (`src/client-urls/client-root.tsx`: a same-route intent from the
664
+ committed page never swaps); the page holds with `pending` until the server
665
+ answers, one round trip, unless the link was prefetched: then it commits at
666
+ the click. The prefetch is cheap when the loaders' rules hold them. Change the range with
667
+ `setSearchParams({ range }, { replace: true, scroll: false })`; a pure view
668
+ toggle takes `revalidate: false`.
669
+ - `ReportsLoader` is slow and rarely opened: flag it, read it under its own
670
+ `<Suspense>`. `KpiLoader` and `TrafficLoader` are cheap: leave them
671
+ prefetched so the tab is complete at the click.
672
+ - The org switcher, the user menu and their loaders go in the server layout
673
+ above the group (section 2.2): they stay when the user goes from the
674
+ dashboard to settings, which is a different group.
675
+
676
+ ### 3.3 A settings area with forms
677
+
678
+ ```tsx
679
+ // src/settings/settings.client.tsx
680
+ "use client";
681
+ import { clientUrls } from "@rangojs/router/client";
682
+ import { saveProfile } from "./profile.actions.js"; // "use server"
683
+
684
+ export default clientUrls(({ path, layout, loader, revalidate }) => [
685
+ layout(SettingsLayout, () => [
686
+ path("/profile", ProfilePage, { name: "profile" }, () => [
687
+ loader(ProfileLoader, () => [
688
+ revalidate(({ isAction, actionResult }) =>
689
+ isAction()
690
+ ? isAction(saveProfile) && actionResult?.ok === true
691
+ : undefined,
692
+ ),
693
+ ]),
694
+ ]),
695
+ path("/notifications", NotificationsPage, { name: "notifications" }, () => [
696
+ loader(NotificationPrefsLoader),
697
+ ]),
698
+ path("/billing", BillingPage, { name: "billing" }, () => [
699
+ loader(InvoicesLoader, { prefetch: false }),
700
+ ]),
701
+ ]),
702
+ ]);
703
+ ```
704
+
705
+ ```tsx
706
+ // the profile form
707
+ function ProfileForm() {
708
+ const { data } = useLoader(ProfileLoader);
709
+ const [state, formAction, isPending] = useActionState(saveProfile, null);
710
+ if (!data.ok) return <p role="status">{data.reason}</p>;
711
+ return (
712
+ <form action={formAction}>
713
+ <input name="name" defaultValue={data.profile.name} />
714
+ {state?.ok === false && <p role="alert">{state.error}</p>}
715
+ <button disabled={isPending}>Save</button>
716
+ </form>
717
+ );
718
+ }
719
+ ```
720
+
721
+ - `saveProfile` returns `{ ok: true }` or `{ ok: false, error }`; it never
722
+ throws for bad input, so the page stays and shows the error. The predicate
723
+ re-runs `ProfileLoader` only when the save succeeded: a refused save re-runs
724
+ nothing.
725
+ - `ProfileLoader` returns `{ ok: false, reason }` when its upstream fails,
726
+ instead of throwing: the page and the form stay mounted and the next re-run
727
+ replaces the message.
728
+ - The commit after the action renders into the mounted page: nothing
729
+ remounts and React state survives. React itself resets a `<form action>`'s
730
+ uncontrolled fields once the action function finishes without throwing; to
731
+ keep a refused value in its field, make the input controlled or return the
732
+ submitted value in the action state.
733
+ - The header's name and avatar come from the server shell's `SessionLoader`
734
+ with `isAction() ? isAction(saveProfile) : undefined` (or the module's
735
+ actions), so a saved name shows in the header in the same response.
736
+ - With JavaScript off the form still posts: a no-JS form post renders every
737
+ loader, whatever the predicates say.
738
+ - Each settings page is a route of one group so the section nav stays and
739
+ page changes render at the click. Billing's invoices are expensive: flagged.
740
+
741
+ ### 3.4 Search with filters
742
+
743
+ Search results are often a landing page from a link or a search engine, so
744
+ this one is a server route. The same holds in a group.
745
+
746
+ ```tsx
747
+ // src/search/urls.tsx (server)
748
+ export const searchPatterns = urls(({ path, loader, revalidate }) => [
749
+ path(
750
+ "/",
751
+ SearchPage,
752
+ {
753
+ name: "results",
754
+ search: { q: "string?", category: "string?", page: "number?" },
755
+ },
756
+ () => [
757
+ loader(ResultsLoader), // the route default: re-run on every search change
758
+ loader(FacetsLoader, () => [
759
+ revalidate(
760
+ ({ isAction, currentUrl, nextUrl, defaultShouldRevalidate }) => {
761
+ if (isAction()) return false;
762
+ const keys = ["q", "category"];
763
+ return keys.some(
764
+ (k) =>
765
+ currentUrl.searchParams.get(k) !== nextUrl.searchParams.get(k),
766
+ )
767
+ ? defaultShouldRevalidate
768
+ : false;
769
+ },
770
+ ),
771
+ ]),
772
+ ],
773
+ ),
774
+ ]);
775
+ ```
776
+
777
+ ```tsx
778
+ // SearchPage: a cheap, synchronous server handler
779
+ function SearchPage() {
780
+ return (
781
+ <>
782
+ <SearchBox /> {/* client: setSearchParams with replace, see below */}
783
+ <Suspense fallback={<FacetsSkeleton />}>
784
+ <Facets /> {/* <Link prefetch="none"> per filter chip */}
785
+ </Suspense>
786
+ <Suspense fallback={<ResultsSkeleton />}>
787
+ <Results /> {/* useLoader(ResultsLoader); dim on isLoading */}
788
+ </Suspense>
789
+ </>
790
+ );
791
+ }
792
+ ```
793
+
794
+ - A search-only navigation mounts nothing new, so it commits in a transition
795
+ and the results already on screen stay until the new ones land, in a server
796
+ route as in a group (`src/browser/partial-update.ts`, `isSameStructureNav`).
797
+ Show progress with `useLoader(ResultsLoader).isLoading` on the results, or
798
+ `useNavigation()`'s `isStreaming`.
799
+ - The route handler re-runs on every search change (route default), so it
800
+ must stay cheap and must not await data. The data lives in loaders.
801
+ - Facets depend on the query and category, not on the page number: their
802
+ rule holds them when only `page` changes.
803
+ - Filter chips: `prefetch="none"` (unbounded). The next-page link can take
804
+ `prefetch="hover"`.
805
+ - Typing: the setter replaces the whole query string, so merge, and replace
806
+ the history entry instead of pushing one per keystroke (debounce it):
807
+ `setSearchParams((prev) => { prev.set("q", q); prev.delete("page"); return prev; }, { replace: true })`.
808
+
809
+ ### 3.5 A modal over a list
810
+
811
+ ```tsx
812
+ // src/photos/urls.tsx (server), mounted with include("/photos", photoPatterns, { name: "photos" })
813
+ export const photoPatterns = urls(
814
+ ({ path, layout, intercept, loader, loading, errorBoundary, revalidate }) => [
815
+ layout(GalleryLayout, () => [
816
+ errorBoundary(<GalleryError />),
817
+ path("/", Gallery, { name: "gallery" }, () => [loader(PhotosLoader)]),
818
+ path("/:id", PhotoPage, { name: "photo" }, () => [loader(PhotoLoader)]),
819
+ intercept("@modal", ".photo", <PhotoModal />, () => [
820
+ layout(<ModalChrome />), // the overlay; closes with router.back()
821
+ loader(PhotoLoader, () => [revalidate(byId)]), // bySlug, on :id
822
+ loader(CommentsLoader, { prefetch: false }, () => [
823
+ revalidate(({ isAction }) =>
824
+ isAction() ? isAction(CommentActions) : undefined,
825
+ ),
826
+ ]),
827
+ loading(<ModalSkeleton />),
828
+ ]),
829
+ ]),
830
+ ],
831
+ );
832
+
833
+ function GalleryLayout() {
834
+ return (
835
+ <>
836
+ <Outlet />
837
+ <ParallelOutlet name="@modal" />
838
+ </>
839
+ );
840
+ }
841
+ ```
842
+
843
+ - The intercept is declared on the layout that wraps both routes, and the
844
+ slot is rendered by that layout, so the gallery stays mounted under the
845
+ modal and opening it fires no layout view transition.
846
+ - Grid thumbnails prefetch the modal from the gallery page (a target an
847
+ intercept claims is prefetched per source page). `CommentsLoader` is
848
+ flagged: the modal opens at the click with its skeleton and one fill brings
849
+ the comments.
850
+ - Posting a comment from the modal re-runs the modal's loaders whose rule
851
+ says so (`CommentsLoader`; `byId` holds the photo on actions), and the modal
852
+ keeps its React state.
853
+ - A document load of a photo URL renders `PhotoPage`, a shareable full page.
854
+ - Put an authorization guard in route `middleware()` on the target route or an
855
+ enclosing layout, or in the loaders. Route middleware runs for the modal on
856
+ every pass, a `cache()` hit and a `Prerender()` target included; `intercept()`
857
+ takes no `middleware()` of its own.
858
+ - Inside a `clientUrls()` group the same shape is
859
+ `intercept("@modal", ".photo", PhotoModal, () => [loader(PhotoLoader), loading(<ModalSkeleton />)])`,
860
+ with a named target in the same definition; only navigations from inside
861
+ the group open it.
862
+
863
+ ## 4. Anti-patterns
864
+
865
+ Each entry: what the user sees, why, the fix.
866
+
867
+ 1. **One big `<Suspense>` (or only a route `loading()`) around the page.** The
868
+ whole page is a skeleton until its slowest read lands; the heading and tabs
869
+ the URL already decided don't show. Fix: one boundary per read that can be
870
+ slow (2.5).
871
+ 2. **A boundary or wrapper keyed per navigation** (`<Suspense key={pathname}>`,
872
+ `key={params.id}` on a wrapper). Every navigation remounts the subtree: its
873
+ fallback shows again, inputs, scroll and child state reset, images reload.
874
+ A changed key always remounts in React. Fix: no URL-derived keys; let the
875
+ router hold and dim with `isLoading`.
876
+ 3. **A server layout that reads request data and is expected to refresh** (the
877
+ user's name from the session, a param-derived label). It stays as first
878
+ rendered: a login, an action or a param change does not update it. Fix: a
879
+ loader read by a client component in the layout, with a `revalidate()` rule;
880
+ or `revalidate()` on the layout, paying its handler time on every navigation
881
+ it re-runs on.
882
+ 4. **`revalidate(true)` everywhere.** Every navigation and action re-runs
883
+ the loader, prefetches carry it, and where its read is on screen the
884
+ navigation waits for it or shows its fallback again. Fix: rules that name
885
+ what changes the data (2.4).
886
+ 5. **`revalidate(false)` on data keyed by a param.** Product to product
887
+ keeps showing the first product. Fix: compare the identifying param.
888
+ 6. **A loader that throws a transient failure.** After an action, a direct
889
+ `await action()` rejects although the action ran, or an error boundary
890
+ replaces the page and the user's form. Fix: return `{ ok: false, reason }`
891
+ and render it at the read (2.9).
892
+ 7. **Reading a loader above the boundary meant to catch it.** The read
893
+ suspends to the next boundary up: a larger area shows a skeleton, or in a
894
+ group the destination is not shown at the click at all; an error escapes to
895
+ a higher boundary (the vite-rsc-demo's `SsrCategoryProbe` notes a 404 turned
896
+ into a 500 without its containment). Fix: call `useLoader` inside the
897
+ component the boundary wraps.
898
+ 8. **Shared chrome in a route component.** It remounts on every navigation to
899
+ another route: its state resets and its own fallbacks show again. Fix: move
900
+ it into a `layout()` (a group `layout()` keeps its instance across its
901
+ routes; a server layout across everything below it).
902
+ 9. **`useEffect` fetching.** A spinner after the page has shown, nothing
903
+ prefetched or server-rendered, no re-run after an action, races on fast
904
+ navigations. Fix: a loader read with `useLoader`; `useFetchLoader` for
905
+ on-demand reads (search-as-you-type, load more).
906
+ 10. **A server route with a param and no `transition()`, expected to hold.**
907
+ `/product/a` to `/product/b` remounts the route under its skeleton (its key
908
+ carries the params). Fix: `transition()` (or
909
+ `transition({ viewTransition: false })`), or make it a group route.
910
+ 11. **An area split into several groups, or a group per page.** Navigating
911
+ between groups is a server navigation: nothing shows before the response,
912
+ and the destination group's layout mounts. Fix: one group per area, with
913
+ the area's chrome in its `layout()` and app chrome on the server layout
914
+ above.
915
+ 12. **An async handler that awaits data, on a route without `loading()`.** The
916
+ response waits for it; with `loading()` the whole route waits behind one
917
+ skeleton. Fix: move the data to a loader read under its own boundary.
918
+ 13. **Prefetching everything with an expensive loader.** Forty visible cards
919
+ are forty renders of that loader for this user. Fix: `prefetch: false` on
920
+ the expensive loader, `prefetch="none"` on unbounded links (2.6).
921
+ 14. **Navigation status readers inside the destination's `loading()`.** In a
922
+ group, a `useNavigation()` or `useLinkStatus()` reader inside content the
923
+ click swaps out unmounts at the click. Fix: put status readers in chrome
924
+ that stays (`/client-urls`, "Pitfalls").
925
+ 15. **An effect keyed on a location-state object.** In a group the object is
926
+ the one the navigation passed until the commit, then the history entry's
927
+ copy: the effect runs twice. Fix: key it on a primitive field.
928
+
929
+ ## 5. Known gaps
930
+
931
+ These are open. Each says what you see and what to do meanwhile.
932
+
933
+ - **React's fallback throttle.** React commits a Suspense retry no sooner than
934
+ 300 ms after any boundary on the page last showed or hid its fallback. A
935
+ skeleton that appeared stays at least that long, and a read that resolves
936
+ after its parent was revealed shows its own fallback and starts the 300 ms
937
+ again. After a click that adopts a `prefetch: false` prefetch, a late read
938
+ can show a fallback the plain click never shows, and its content can come up
939
+ to 300 ms later than the plain click's (`/route`, "`loading()` options",
940
+ Limits; `docs/design/prefetch-false.md`, "When the holes resolve"). Rango
941
+ leaves this to React by design: no placeholders or re-anchored timers to
942
+ dodge it.
943
+ - **A view transition that changes nothing.** On a route with `loading()` and
944
+ `transition()` whose loaders take longer than about 320 ms, React's retry of
945
+ the boundary starts a view transition with the fallback still up, and the
946
+ content lands about 250 ms after its data (#1078). Escape hatches:
947
+ `/view-transitions`, "A view transition that changes nothing under
948
+ `loading()`".
949
+ - **A client component first used by the click.** In a build, a client
950
+ component at the top of a route's content whose module the document has not
951
+ used yet makes the Flight client wait for its `import()`, so the route's
952
+ `loading()` shows for 300 ms (`docs/internal/suspense-contract.md`, "What
953
+ the audit does not see").
954
+ - **A group navigation commits the destination 2 to 4 times.** The instance
955
+ is the same throughout (local state survives, mount effects run once), but
956
+ the one-commit target of #1079 is not met yet
957
+ (`docs/internal/suspense-contract.md`, rule 5).
958
+ - **The URL leads a held page.** `location` and the address bar move when the
959
+ response arrives; where React then holds the page being left, they are ahead
960
+ of the screen for that wait (#1046, `/hooks`, `navigation.md`).
961
+
962
+ ## 6. Where the details live
963
+
964
+ | Topic | Skill |
965
+ | ------------------------------------------------------------------ | ---------------------------------------- |
966
+ | How to lay out `urls()` files and areas for an app type | app-structure |
967
+ | `clientUrls()` surface, hooks inside a group, action failure table | `/client-urls` |
968
+ | Loader options, `revalidate()` return shapes, `ssr` and `prefetch` | `/loader` |
969
+ | Layout forms, outlets, layout revalidation, contracts | `/layout` |
970
+ | `path()`, `loading()` options, handler data ownership | `/route` |
971
+ | Link prefetch strategies, `data-prefetch-scope`, URL generation | `/links` |
972
+ | Middleware order, `throw rewrite()`, redirects | `/middleware` |
973
+ | Modals | `/intercept` |
974
+ | Slots | `/parallel` |
975
+ | `transition()`, view transitions and their costs | `/view-transitions` |
976
+ | Actions, `useActionState`, `keepClientCache()` | `/server-actions` |
977
+ | `useLoader`, `load()`, `refreshGroup`, `useNavigation` | `/hooks` |
978
+ | Titles and breadcrumbs | `/breadcrumbs` |
979
+ | Cache tags and invalidation | `/cache-guide`, `/caching`, `/use-cache` |
980
+ | `runRevalidate`, `serveShellRequest`, dev and production e2e | `/testing` |