@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,280 @@
1
+ # Several apps under createHostRouter()
2
+
3
+ One deployment, several independently routed apps: marketing on the apex
4
+ (`example.com`, `www.example.com`), the product on `app.`, the staff tools
5
+ on `admin.`, the docs on `docs.`. Each app is an ordinary `createRouter()`
6
+ app with its own `urls()`, Document and generated route map; the host router
7
+ only picks which one handles a request.
8
+
9
+ Reach for this when parts of the product want different documents, chrome,
10
+ deploy cadence or hostnames. Sections that share one document belong in one
11
+ router as `include()`s ([`./large-app.md`](./large-app.md)): a navigation
12
+ between apps is always a document load.
13
+
14
+ References in the router repository: `examples/cloudflare-multi-router` (a
15
+ Cloudflare host entry, path-mounted and host-mounted apps, a shared cache)
16
+ and `packages/rangojs-router/e2e/test-app/.host-fixture` (the Node preset,
17
+ the host-override cookie, two apps on one store).
18
+
19
+ ## File tree
20
+
21
+ ```
22
+ src/
23
+ worker.rsc.tsx the host router (Cloudflare: you own the entry)
24
+ env.ts bindings shared by every app
25
+ shared/
26
+ document.tsx "use client" Document the apps pass to createRouter()
27
+ styles.css one stylesheet (e.g. @import "tailwindcss")
28
+ theme.ts one ThemeConfig
29
+ cache.ts one cache store factory
30
+ origins.ts each app's origin, per environment
31
+ top-nav.tsx components every app renders
32
+ apps/
33
+ site/ router.tsx handler.ts urls.tsx router.named-routes.gen.ts tsconfig.json
34
+ app/ router.tsx handler.ts urls.tsx router.named-routes.gen.ts tsconfig.json …
35
+ admin/ …
36
+ docs/ …
37
+ tsconfig.json the host entry's program only
38
+ ```
39
+
40
+ ## The host router
41
+
42
+ ```tsx
43
+ // src/worker.rsc.tsx
44
+ import {
45
+ createHostRouter,
46
+ defineHosts,
47
+ isNoRouteMatchError,
48
+ } from "@rangojs/router/host";
49
+ import type { AppBindings } from "./env";
50
+
51
+ export const hosts = defineHosts({
52
+ site: [".", "www.*"],
53
+ app: "app.*",
54
+ admin: "admin.*",
55
+ docs: "docs.*",
56
+ });
57
+
58
+ // Dev: every app answers on localhost, and the x-rango-host cookie picks one
59
+ // (x-rango-host=admin.example.com). The request is forwarded unchanged.
60
+ const hostRouter = createHostRouter({
61
+ hostOverride: { cookieName: "x-rango-host", allowedHosts: ["localhost"] },
62
+ });
63
+
64
+ // Lazy mounts: an app's module graph loads on its first request, and each app
65
+ // keeps its own cache version.
66
+ hostRouter.host(hosts.admin).lazy(() => import("./apps/admin/handler"));
67
+ hostRouter.host(hosts.app).lazy(() => import("./apps/app/handler"));
68
+ hostRouter.host(hosts.docs).lazy(() => import("./apps/docs/handler"));
69
+ hostRouter.host(hosts.site).lazy(() => import("./apps/site/handler"));
70
+ // localhost has one label: neither "." nor "*." matches it.
71
+ hostRouter.host(["localhost"]).lazy(() => import("./apps/site/handler"));
72
+
73
+ export default {
74
+ async fetch(request, env, ctx) {
75
+ try {
76
+ return await hostRouter.match(request, { env, ctx });
77
+ } catch (err) {
78
+ if (isNoRouteMatchError(err))
79
+ return new Response("Not Found", { status: 404 });
80
+ throw err;
81
+ }
82
+ },
83
+ } satisfies ExportedHandler<AppBindings>;
84
+ ```
85
+
86
+ Patterns are tried in registration order, first match wins; "apex" means
87
+ exactly two labels (no public-suffix list), so `example.co.uk` needs its own
88
+ pattern (`/host-router` → "Pattern Syntax").
89
+
90
+ On the `node` and `vercel` presets rango owns the entry: export the instance
91
+ (`export default hostRouter`), drop the `fetch` wrapper, and an unmatched host
92
+ answers 404 on its own. Point `rango({ hostRouter: "./src/worker.rsc.tsx" })`
93
+ at the module when auto-detection cannot pick it.
94
+
95
+ ## Each app
96
+
97
+ ```ts
98
+ // src/apps/app/handler.ts: what the host router's lazy mount imports
99
+ import { router } from "./router";
100
+
101
+ export default (request: Request, input: any) => router.fetch(request, input);
102
+ ```
103
+
104
+ ```tsx
105
+ // src/apps/app/router.tsx
106
+ import { createRouter } from "@rangojs/router";
107
+ import { Document } from "../../shared/document";
108
+ import { appCache } from "../../shared/cache";
109
+ import { theme } from "../../shared/theme";
110
+ import type { AppBindings } from "../../env";
111
+ import { loadSession } from "./session";
112
+ import { appPatterns } from "./urls";
113
+
114
+ export const router = createRouter<AppBindings>({
115
+ document: Document,
116
+ cache: appCache,
117
+ theme,
118
+ })
119
+ .use(loadSession)
120
+ .routes(appPatterns);
121
+ ```
122
+
123
+ ```tsx
124
+ // src/apps/app/urls.tsx
125
+ import { urls } from "@rangojs/router";
126
+ import { Outlet } from "@rangojs/router/client";
127
+ import { TopNav } from "../../shared/top-nav";
128
+ import { DashboardPage, LoginPage } from "./pages";
129
+ import { requireUser } from "./session";
130
+
131
+ function Shell() {
132
+ return (
133
+ <>
134
+ <TopNav />
135
+ <Outlet />
136
+ </>
137
+ );
138
+ }
139
+
140
+ export const appPatterns = urls(({ path, layout, middleware }) => [
141
+ path("/login", LoginPage, { name: "login" }),
142
+ layout(Shell, () => [
143
+ middleware(requireUser),
144
+ path("/", DashboardPage, { name: "dashboard" }),
145
+ // … the app's sections as includes, as in ./large-app.md
146
+ ]),
147
+ ]);
148
+ ```
149
+
150
+ Inside an app, everything in the other recipes applies unchanged: the `app.`
151
+ app is [`./large-app.md`](./large-app.md) without its marketing pages, the
152
+ `docs.` app is [`./content-site.md`](./content-site.md), and `site` is a
153
+ handful of `ppr` pages:
154
+
155
+ ```tsx
156
+ // src/apps/site/urls.tsx (excerpt)
157
+ export const sitePatterns = urls(({ path, layout }) => [
158
+ layout(SiteLayout, () => [
159
+ path("/", Home, { name: "home", ppr: true }),
160
+ path("/pricing", Pricing, { name: "pricing", ppr: true }),
161
+ ]),
162
+ ]);
163
+ ```
164
+
165
+ ## What is shared, and how
166
+
167
+ | Shared | How | Notes |
168
+ | ------------- | ---------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
169
+ | Components | ordinary imports from `src/shared/` | One Vite build and one client manifest: a shared client component ships as the same hashed asset to every app. Keep route-name-typed calls (`ctx.reverse("…")`, `href()`) out of shared code; each app's names are its own. |
170
+ | The Document | one `"use client"` component every `createRouter({ document })` passes, or one per app | Each document load is that app's own; apps may differ freely |
171
+ | CSS | `import styles from "../../shared/styles.css?url"` in the Document, `<link precedence>` | Compiles to one hashed asset, cached across apps. Apps need not agree on `precedence`: a cross-app navigation is a document load (`/css`) |
172
+ | Theme | one `ThemeConfig` object passed as `theme` to every router | The stored choice is a host-only cookie (`src/theme/ThemeProvider.tsx`, no `Domain`), so a theme picked on `app.` does not follow the user to `docs.` |
173
+ | Cache store | one factory (`cache: appCache`), one KV namespace | Keys holding one router's output start with that router's id, so two apps never read each other's shells, records or loader entries. Keep `createRouter({ ... })` an inline object literal (or pass `id`) so ids are stable |
174
+ | Session | a cookie on the parent domain, `cookies().set("session", token, { domain: "example.com", … })` | Set by the app that signs users in; every app resolves it in its own `router.use()` |
175
+ | Client caches | not shared | The state cookie is `rango-state_{routerId}`, one per app, so sibling apps on one origin do not collide |
176
+
177
+ ## TypeScript: one program per app
178
+
179
+ Every router's `router.named-routes.gen.ts` augments the one global
180
+ `Rango.GeneratedRouteMap`, so two apps with overlapping route names
181
+ (`home`, `login`) collide in one program. Give each app its own
182
+ `tsconfig.json`, and keep the root program to the host entry:
183
+
184
+ ```jsonc
185
+ // tsconfig.json: the host entry only
186
+ { "extends": "./tsconfig.base.json", "include": ["src/env.ts", "src/worker.rsc.tsx"] }
187
+
188
+ // src/apps/app/tsconfig.json: one app, its generated map, the shared code
189
+ { "extends": "../../../tsconfig.base.json", "include": ["**/*", "../../shared/**/*", "../../env.ts"] }
190
+ ```
191
+
192
+ The root program still type-checks the apps' source through the lazy
193
+ `import()`s, but not their generated maps, as long as no app source imports
194
+ its own `*.named-routes.gen.ts`. Run `tsc -p` for each program in CI.
195
+ Generated and committed: one `router.named-routes.gen.ts` per app (plus
196
+ per-module `*.gen.ts` where an app uses `useReverse(routes)`). See
197
+ `/typesafety` → multi-project setup.
198
+
199
+ ## Dev: the host-override cookie
200
+
201
+ `vite dev` serves every app on one origin, `localhost`. The
202
+ `hostOverride` option picks the app from a cookie when the request's real
203
+ host matches `allowedHosts`:
204
+
205
+ ```js
206
+ // browser devtools, on http://localhost:5173
207
+ document.cookie = "x-rango-host=admin.example.com; path=/";
208
+ ```
209
+
210
+ Reload and the admin app answers; delete the cookie for the site. Without a
211
+ `fallback()`, a cookie the router refuses (a malformed hostname, a host not
212
+ in `allowedHosts`) answers 400 and clears the cookie. Playwright sets it with
213
+ `context.addCookies([{ name: "x-rango-host", value: "admin.example.com", url }])`,
214
+ which is how both reference apps run their dev and production e2e.
215
+
216
+ All apps answer under one origin in this mode, so links to another app's
217
+ production host leave the dev server; switch apps by changing the cookie.
218
+
219
+ ## Links between apps
220
+
221
+ A navigation from one app to another is a full document load, by design. A
222
+ soft in-place swap between apps shipped once (#476) and was replaced by a
223
+ document navigation (#555): a stylesheet two apps share by `href` could drop
224
+ on the swap (React dedups stylesheets by `href`), and the theme, warmup and
225
+ prefetch settings, which are document-lifetime, would remount or never take
226
+ effect. A document load re-establishes the target app's whole document.
227
+
228
+ | The other app is | Link with | What happens |
229
+ | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
230
+ | On another host (`app.example.com`) | an absolute URL: `<Link to="https://app.example.com/">` or `<a href>` | Another origin is external to `<Link>` (`src/browser/react/Link.tsx`) and to the plain-anchor interceptor: the browser navigates, nothing is prefetched |
231
+ | On the same origin under a path (`host(["example.com/docs"])`) | `<Link to="/docs" reloadDocument prefetch="none">` or `<a href="/docs" data-no-intercept="true">` | A document navigation at the click. A plain same-origin `<Link>` also ends in a document load, after a wasted round trip: the server answers the partial request with `X-RSC-Reload` |
232
+
233
+ ```tsx
234
+ // src/shared/origins.ts
235
+ export const ORIGINS = {
236
+ site: import.meta.env.VITE_SITE_ORIGIN ?? "https://example.com",
237
+ app: import.meta.env.VITE_APP_ORIGIN ?? "https://app.example.com",
238
+ docs: import.meta.env.VITE_DOCS_ORIGIN ?? "https://docs.example.com",
239
+ } as const;
240
+
241
+ // src/shared/top-nav.tsx: a server component every app renders
242
+ import { Link } from "@rangojs/router/client";
243
+ import { ORIGINS } from "./origins";
244
+
245
+ export function TopNav() {
246
+ return (
247
+ <nav>
248
+ <Link to={`${ORIGINS.site}/`}>Home</Link>
249
+ <Link to={`${ORIGINS.docs}/`}>Docs</Link>
250
+ <Link to={`${ORIGINS.app}/`}>Open the app</Link>
251
+ </nav>
252
+ );
253
+ }
254
+ ```
255
+
256
+ `reloadDocument` alone does not stop the prefetch: without
257
+ `prefetch="none"`, a visible link still sends one, which the other app
258
+ answers with `X-RSC-Reload` and the client drops. Make each app's entry pages
259
+ fast as documents (`ppr`, or `Prerender`), since every cross-app click lands
260
+ on one.
261
+
262
+ ## Deploys
263
+
264
+ Each `createRouter()` app has its own cache version, computed from the code
265
+ its router can run up to a lazy mount. A deploy that changes the admin app's
266
+ server code clears the admin app's cached data and stored HTML and reloads
267
+ its open tabs; the other apps keep theirs. Code shared by statically
268
+ importing modules moves every app that imports it (`/host-router` →
269
+ "Deploys and the cache").
270
+
271
+ ## Why
272
+
273
+ | Choice | Why |
274
+ | ---------------------------- | --------------------------------------------------------------------------------------------- |
275
+ | One `createRouter()` per app | Own document, routes, names, middleware, cache version |
276
+ | `.lazy()` mounts | An app's graph loads on its first request, and the mount is where its cache version stops |
277
+ | `defineHosts` | One typed table of patterns, reused by tests |
278
+ | The cookie override in dev | One dev origin, hydration same-origin; the reference apps' e2e drive every app through it |
279
+ | Absolute URLs between hosts | Another origin is another document; no request is wasted on a prefetch or a reload round trip |
280
+ | A program per app | Route-name augmentations of different apps cannot collide |
@@ -137,6 +137,11 @@ path("/product/:id", async (ctx) => {
137
137
  Async content is a `Promise<ReactNode>`. Resolve it in your component
138
138
  with React's `use()` hook wrapped in `<Suspense>`.
139
139
 
140
+ A server component that pushes a crumb after an `await` throws
141
+ `LateHandlePushError`. Reserve the crumb in the handler with `.defer()` and
142
+ resolve it from the component (see "Deferred content" below); the same works
143
+ for a `Meta` title.
144
+
140
145
  A crumb the HANDLER pushes is handler output. In a route `cache()` entry and
141
146
  in a `ppr` shell it is stored resolved: the cache write, or the shell capture
142
147
  (within `ppr.captureTimeout`), waits for a `content` promise and for a
@@ -202,7 +207,7 @@ consumer sees it — `useHandle(Breadcrumbs)` returns the resolved item, never a
202
207
  `Promise`, so you read it like any sync crumb (no `use()`, no thenable narrowing).
203
208
  On a full/SSR load the value is resolved server-side; on a soft navigation the
204
209
  breadcrumbs HOLD the previous resolved value until the deferred value lands, then
205
- swap in — no blank, no pending entry. If the slot times out to `else: null`/
210
+ swap in — no blank, no pending entry. The hold-then-swap is deliberate: a handle never holds the page and never shows the next page's values over the old one (see [hooks](../hooks/handle-and-actions.md#when-handle-values-change-by-design)). If the slot times out to `else: null`/
206
211
  undefined, the entry is simply dropped. Use `.defer()` only when even
207
212
  `label`/`href` are unknown at handler time AND the producer is a component,
208
213
  not a loader — a loader-produced crumb pushes directly from the loader body
@@ -894,7 +894,9 @@ and a read that worked only on direct visits would break the first time a
894
894
  rewritten request reached it. A PPR shell capture and a render the document
895
895
  cache stores read it as `undefined` instead of throwing: those renders are
896
896
  the target page as a direct visit renders it, and the rewritten visitor's
897
- own render (outside them) still sees its URL. A response route under
897
+ own render (outside them) still sees its URL. A shell keyed by the visitor's
898
+ URL (`rewrite(...).shell("visitor")`, `/ppr`) reads it, since its key covers
899
+ that URL. A response route under
898
900
  `cache()` whose handler read it is not stored.
899
901
 
900
902
  A loader body invoked from a handler with `await ctx.use(Loader)` may read
@@ -9,6 +9,12 @@
9
9
  "argumentHint": "",
10
10
  "path": "skills/api-client/SKILL.md"
11
11
  },
12
+ {
13
+ "name": "app-structure",
14
+ "description": "Concrete urls() layouts for whole Rango apps, with the file tree, router.tsx, the root urls.tsx with sync include() of section modules, and where middleware, server layouts, clientUrls() groups, response routes, caching, prerender, PPR and boundaries sit. Recipes for a large signed-in app with a protected admin area (auth, onboarding, app, settings and admin as clientUrls() groups under one server shell), several apps under createHostRouter(), a docs site or blog, and a shop. Use when starting or restructuring a Rango app, laying out urls() files, splitting an app into sections or separate apps, or setting up a dashboard, SaaS, multi-app, static or content site, or e-commerce app.",
15
+ "argumentHint": "[large-app|multi-app|content-site|e-commerce]",
16
+ "path": "skills/app-structure/SKILL.md"
17
+ },
12
18
  {
13
19
  "name": "breadcrumbs",
14
20
  "description": "Built-in Breadcrumbs handle for accumulating breadcrumb navigation across route segments. Use when building a breadcrumb trail for nested routes, or asking how to show the current navigation path in a layout.",
@@ -273,6 +279,12 @@
273
279
  "argumentHint": "[profile-name]",
274
280
  "path": "skills/use-cache/SKILL.md"
275
281
  },
282
+ {
283
+ "name": "ux-architecture",
284
+ "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.",
285
+ "argumentHint": "[shop|dashboard|settings|search|modal]",
286
+ "path": "skills/ux-architecture/SKILL.md"
287
+ },
276
288
  {
277
289
  "name": "vercel",
278
290
  "description": "Deploy a Rango app to Vercel Functions (Build Output API v3). Use when deploying a Rango app to Vercel, or asking how the router works with Vercel Functions and the Build Output API.",
@@ -142,6 +142,85 @@ At the top level of the builder only `path()`, `layout()` and `intercept()`
142
142
  are accepted, and the builder must define at least one `path()`. Any helper
143
143
  name not in this table throws (`clientUrls() has no route() helper`).
144
144
 
145
+ `loader`, `loading`, `revalidate` and `transition` are also exported from
146
+ `@rangojs/router/client`, the same functions the builder hands out, for a
147
+ component's own items (next section).
148
+
149
+ ## A component's own items: `Component.use`
150
+
151
+ A page, layout or intercept component can carry its own items, as a server
152
+ handler does with `.use` (`/handler-use`): its loaders, its skeleton and its
153
+ revalidation rules travel with it instead of being repeated at every `path()`
154
+ that mounts it. Build them with the helpers from `@rangojs/router/client`. The
155
+ component's module needs no `clientUrls()`, and no `"use client"` of its own
156
+ when a group imports it.
157
+
158
+ ```tsx
159
+ // src/dash/Dashboard.tsx
160
+ "use client";
161
+ import { loader, loading, revalidate, useLoader } from "@rangojs/router/client";
162
+ import * as StatsActions from "./stats.actions";
163
+ import { StatsLoader } from "./stats.loader";
164
+
165
+ export function Dashboard() {
166
+ const { data } = useLoader(StatsLoader);
167
+ return <StatsView stats={data} />;
168
+ }
169
+ Dashboard.use = () => [
170
+ loader(StatsLoader, () => [
171
+ revalidate(({ isAction }) => isAction(StatsActions)),
172
+ ]),
173
+ loading(<DashboardSkeleton />),
174
+ ];
175
+ ```
176
+
177
+ ```tsx
178
+ // src/urls/app.client.tsx
179
+ "use client";
180
+ import { clientUrls } from "@rangojs/router/client";
181
+ import { Dashboard } from "../dash/Dashboard";
182
+
183
+ export default clientUrls(({ path }) => [
184
+ path("/dash", Dashboard, { name: "dash" }),
185
+ ]);
186
+ ```
187
+
188
+ | Component mounted by | Its `.use` may return |
189
+ | -------------------- | ---------------------------------------------- |
190
+ | `path()` | `loader()`, `loading()`, `transition()` |
191
+ | `layout()` | `loader()`, `loading()` |
192
+ | `intercept()` | `loader()` (without `ssr: false`), `loading()` |
193
+
194
+ - **Merge order is the server's.** The component's items come first and the
195
+ mount site's own items second: an explicit `loading()` or `transition()`
196
+ wins, loaders accumulate (the same definition in both gives two loaders, as
197
+ `loader(Def)` twice does). A layout component's loaders are layout loaders,
198
+ shared by every route below it.
199
+ - **No routes inside `.use`.** `path()`, `layout()` and `intercept()` there
200
+ throw (`clientUrls() does not support path() inside Dashboard.use`): route
201
+ types and the generated `.gen.ts` are read from the builder's own items.
202
+ Unlike a server layout handler's `.use`, a client layout's cannot add
203
+ routes.
204
+ - **`.use` runs when the group is defined**, once per mount site (in the
205
+ browser, during SSR and in discovery), never at render. The error for an
206
+ item the site does not take names the component.
207
+ - **A predicate in `.use` runs on the server,** as one in the builder does:
208
+ the build hoists it out of the component's module with the rules below,
209
+ and it registers when a group whose routes use the component loads on the
210
+ server, wherever the module sits: behind barrels or in another workspace
211
+ package. A predicate in `node_modules` is not hoisted,
212
+ and route discovery fails naming its loader.
213
+ - **Import the helpers from `@rangojs/router/client`.** The same names from
214
+ `@rangojs/router` are the server DSL's; called in a client module they
215
+ throw, pointing to `@rangojs/router/client`.
216
+ - In dev, an edit of a component's `.use` refreshes its groups' projections
217
+ and predicates, as an edit of the group does.
218
+ - Under `isolatedDeclarations` (a library's sources), an exported function
219
+ cannot take an assigned property: declare the component unexported,
220
+ assign `.use`, and export it through a const typed `ClientUrlComponent`.
221
+ - A `createLoader()` definition's own `.use` (`Def.use`) is not read inside
222
+ `clientUrls()` yet: put its `revalidate()` on the `loader()` call.
223
+
145
224
  `include`, `parallel`, `cache`, `middleware`, `errorBoundary`,
146
225
  `notFoundBoundary` **throw** — and that is a design position, not a gap.
147
226
  Client route groups are a PERFORMANCE surface (instant presentation, held
@@ -201,10 +280,11 @@ the arguments and type of a `urls()` loader's (`ShouldRevalidateFn` from
201
280
  browser before the request goes out; there is no client-computed decision and
202
281
  no hybrid.
203
282
 
204
- You still write the predicate in the `"use client"` module. The rango Vite
205
- plugin hoists each `revalidate()` argument into a server-only module, leaves a
206
- key in the browser and SSR copies, and registers the predicate when the
207
- group's module loads on the server (a lazy `include()` stays lazy). The cost
283
+ You still write the predicate in the `"use client"` module, or in a
284
+ component's `.use`. The rango Vite plugin hoists each `revalidate()` argument
285
+ into a server-only module, leaves a key in the browser and SSR copies, and
286
+ registers the predicate when the group's module loads on the server, with
287
+ those of the modules it imports (a lazy `include()` stays lazy). The cost
208
288
  is a rule: **the argument may reference imports and globals only.** Anything
209
289
  else is a build error naming `file:line:column`, starting
210
290
  `[rango] clientUrls() revalidate() at ...: revalidate() runs on the server; ...`.
@@ -213,6 +293,14 @@ else is a build error naming `file:line:column`, starting
213
293
  (`revalidate(isCartAction)`), a namespace member (`P.cart`), a call of an
214
294
  imported factory (`makePredicate("cart")`), and globals such as `URL`,
215
295
  `Boolean` and `import.meta.env`.
296
+ - `revalidate(true)` / `revalidate(false)`, shorthand for `() => true` /
297
+ `() => false`: a boolean LITERAL stays in place (nothing to hoist). Any
298
+ other argument is hoisted whatever it evaluates to, because the build cannot
299
+ tell an imported boolean from an imported predicate: the server evaluates
300
+ it, and a boolean result is the same constant answer
301
+ (`revalidate(HOLD_SESSION)` with an imported flag works). A value that is
302
+ neither fails when the server mounts the group, naming the route and
303
+ loader.
216
304
  - A binding declared in the same `"use client"` module is rejected: move the
217
305
  predicate to its own module without `"use client"` and import it, or write it
218
306
  inline. A binding of an enclosing function is rejected: read it from the
@@ -223,7 +311,8 @@ else is a build error naming `file:line:column`, starting
223
311
  request (a cookie or a search param) and read it from the argument, or
224
312
  refresh with `useRefreshLoaders`.
225
313
  - An `async` or generator function, a spread, a second argument, no argument,
226
- or a non-function value (object, array or string literal) is rejected.
314
+ or a literal that is neither a function nor a boolean (object, array,
315
+ string, number or `null`) is rejected.
227
316
  - An import of `client-only`, or of a module whose directive prologue is
228
317
  `"use client"`, is rejected (on the server its exports are client
229
318
  references). A `"use server"` import is fine: on the server it is the real
@@ -273,10 +362,10 @@ with the ternary above instead (same rule as a server-tree loader). A bare
273
362
  `isAction(refs)` is a hard `false` on navigation too, so the loader stops
274
363
  refetching when params change.
275
364
 
276
- `intercept()` loaders inside `clientUrls()` do not apply their `revalidate()`
277
- yet (a known gap, left for a follow-up). The build still checks those
278
- predicates like any other, so one that captures a binding of the `"use client"`
279
- module fails the build although nothing runs it: import it, or drop it.
365
+ `intercept()` loaders inside `clientUrls()` take `revalidate()` the same way:
366
+ the build moves the predicate to the server, and it runs there with the same
367
+ arguments, `actionResult` included, so an action from the modal re-runs only
368
+ the loaders whose predicate says so.
280
369
 
281
370
  A group `layout()` loader revalidates as a server layout's loader does. With no
282
371
  `revalidate()` a navigation keeps it, whichever route of the layout it goes to
@@ -292,7 +381,7 @@ currentParams.slug !== nextParams.slug ? true : defaultShouldRevalidate)`.
292
381
 
293
382
  Two scars worth copying:
294
383
 
295
- - A blunt `() => false` keeps serving the OLD product on product→product
384
+ - A blunt `revalidate(false)` keeps serving the OLD product on product→product
296
385
  navigations (same route, new param). Make predicates param-sensitive:
297
386
  return `defaultShouldRevalidate` when the identifying param changed.
298
387
  - One action, per-loader outcomes: a cart badge loader revalidates on cart
@@ -312,8 +401,8 @@ Everything after that is decided on the server, while it handles that request:
312
401
  - **The browser's caches.** When the response arrives the browser marks its
313
402
  history cache stale and drops its prefetches, unless the action called
314
403
  `keepClientCache()` (`/server-actions` → "Client Cache After an Action").
315
- - **A loader that fails on its rerun** reaches your `onError` on the server and
316
- the page as an error, below.
404
+ - **A loader that fails on its rerun** reaches your `onError` on the server,
405
+ and in the browser it throws at its read, below.
317
406
 
318
407
  A form's React state survives an action's revalidation in a group, as on a
319
408
  `urls()` route: the commit renders into the mounted page instead of remounting
@@ -322,17 +411,79 @@ it, so a controlled input keeps what the user typed.
322
411
  ### A loader that fails on its rerun
323
412
 
324
413
  The action has already run when its loaders rerun, so a loader that throws
325
- there does not undo it. Where the error lands decides what the user sees:
414
+ there does not undo it. It behaves as on a navigation, on every route (a group
415
+ route or a `urls()` route, with or without `loading()`), and whether the
416
+ action was called directly or from `<form action>`, `useActionState` or
417
+ `useTransition`:
418
+
419
+ - **The call resolves** with the action's return value.
420
+ - **The loader throws in place,** at its `useLoader` read. The nearest
421
+ boundary to the read catches it: your own React error boundary around the
422
+ read, else the `errorBoundary()` around the group's `include()` mount (or
423
+ the route's, in `urls()`), else the router's root error boundary.
424
+ - **Everything outside that boundary stays,** with its React state: a form
425
+ next to the failed list keeps what the user typed. Loaders that reran
426
+ without error commit their new data in the same commit.
427
+ - **The error is reported**: to your `onError` on the server (phase
428
+ `"loader"`), and in the browser as `revalidationError` on the action's
429
+ `useAction()` state. `error` stays `null` there and `result` holds the
430
+ action's return value, so "saved, but the list could not refresh" reads
431
+ apart from "the save failed".
432
+ - **A later rerun that succeeds clears it.** An `errorBoundary()` fallback
433
+ clears on the next action or navigation that reruns the loader without
434
+ error. Your own boundary resets by its own rule: key the reset on data that
435
+ reruns with the loader and is read outside the boundary, so the reset lands
436
+ in the same render as the new data. A reset when the action settles can run
437
+ before React rendered the commit and catch the old failure again.
438
+
439
+ ```tsx
440
+ // in the group's "use client" module
441
+ function StockPanel() {
442
+ // Reruns with every save and never throws.
443
+ const { data: status } = useLoader(SaveStatusLoader);
444
+ return (
445
+ <StockBoundary resetKey={status.version}>
446
+ <StockList /> {/* useLoader(StockLoader) throws here on a failed rerun */}
447
+ </StockBoundary>
448
+ );
449
+ }
326
450
 
327
- | The route | Called directly (`await action()`) | From `<form action>`, `useActionState` or `useTransition` |
328
- | ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
329
- | A group route, or a `urls()` route without `loading()` | The call rejects with the loader's error (`"An error occurred"` in a build), although the action returned. Nothing of that revalidation commits: the page keeps its data and its React state. | React hands the rejection to the nearest React error boundary above the form. With none of yours, the router's root error boundary replaces the page. |
330
- | A `urls()` route with `loading()` | The call resolves with the action's result. The commit renders, the loader's read throws, and the nearest React error boundary catches it; with none of yours, the root one replaces the page. | The same. |
331
- | Under an `errorBoundary()` (for a group, around the mount) | Its fallback replaces the route: the page and its form state unmount. | The same. |
451
+ function SaveStock({ sku }: { sku: string }) {
452
+ const { revalidationError } = useAction(saveStock);
453
+ const [stale, setStale] = useState(false);
454
+ // useAction resets to idle once the action has settled: keep the notice.
455
+ useEffect(() => {
456
+ if (revalidationError) setStale(true);
457
+ }, [revalidationError]);
458
+ return (
459
+ <form action={saveStock.bind(null, sku)}>
460
+ <button>Save</button>
461
+ {stale && (
462
+ <p role="status">Saved. The stock count could not be refreshed.</p>
463
+ )}
464
+ </form>
465
+ );
466
+ }
467
+ ```
332
468
 
333
- A group route's `loading()` does not change the first row. For a failure the
334
- user can retry (a flaky upstream, a rate limit), return it as data instead of
335
- throwing, and render it where the data goes:
469
+ `StockBoundary` is an ordinary React error boundary whose
470
+ `getDerivedStateFromProps` clears its error when `resetKey` changes. A loader
471
+ that throws `notFound()` or `redirect()` is not a failure and keeps its path:
472
+ a `redirect()` (a deleted record's page) replaces the route's content and
473
+ navigates, and the layouts above keep their instance. A no-JS form post
474
+ renders the whole document after the action, so a loader that throws there
475
+ behaves as on a page load. Every refetch the router runs for an action
476
+ follows the same rule (after several actions at once, after a navigation
477
+ during it), and the action that ran it reports the error as its
478
+ `revalidationError`; with several actions at once, that is the one whose
479
+ refetch covers them all. Not covered yet: an `intercept()`'s loaders, and a
480
+ parallel slot's loaders under its own `loading()`, decoded above the slot's
481
+ content, so a boundary inside the slot does not see the error.
482
+
483
+ For a failure the user should see in place of the data without an error
484
+ boundary (a flaky upstream, a
485
+ rate limit), return it as data instead of throwing, and render it where the
486
+ data goes:
336
487
 
337
488
  ```tsx
338
489
  // stock.loader.ts
@@ -355,10 +506,7 @@ function Stock() {
355
506
  }
356
507
  ```
357
508
 
358
- The action resolves with its result, the page and its form state stay, and the
359
- next action or navigation that reruns the loader replaces the message. Keep
360
- throwing for a failure that should stop the page, with an `errorBoundary()`
361
- around the mount for it.
509
+ The next action or navigation that reruns the loader replaces the message.
362
510
 
363
511
  A failure returned as data is an ordinary value, so whatever stores the
364
512
  loader's value stores it too: a `cache()` on the loader serves it for its TTL,
@@ -370,8 +518,9 @@ data in a loader that is not cached itself.
370
518
 
371
519
  Pinned by dev + production e2e in the router repository (a group, its `urls()`
372
520
  twin, each with and without `loading()`, and the group under an
373
- `errorBoundary()`), and by a `serveShellRequest({ action })` test of the
374
- response.
521
+ `errorBoundary()`), and by `serveShellRequest({ action })` tests whose
522
+ `readAction()` reads the call's answer as the browser does: the result and
523
+ the `revalidationError`.
375
524
 
376
525
  ## Loaders are full citizens: signals and handles
377
526
 
@@ -386,7 +535,7 @@ import { createLoader, notFound, redirect } from "@rangojs/router";
386
535
 
387
536
  export const ProductLoader = createLoader(async (ctx) => {
388
537
  const moved = LEGACY_SLUGS[ctx.params.slug];
389
- if (moved) throw redirect(`/shop/product/${moved}`);
538
+ if (moved) throw redirect(ctx.reverse("shop.product", { slug: moved }));
390
539
  if (!(await exists(ctx.params.slug))) notFound(`No "${ctx.params.slug}"`);
391
540
  return getProduct(ctx.params.slug);
392
541
  });
@@ -507,7 +656,9 @@ rest of the destination; otherwise it is held. A same-route click
507
656
  that layout is new to the page (entering the group from outside).
508
657
  - `loading()` in `clientUrls()` takes no options, and a flagged `loading()`
509
658
  on a server layout above the group defers nothing for the group's routes.
510
- - `intercept()` loaders accept the flag and ignore it, as in `urls()`.
659
+ - On an `intercept()` loader the flag works as in `urls()`: an in-group
660
+ prefetch of the intercept's target defers it, and the click opens the modal
661
+ at once with one fill for it.
511
662
  - With `ssr: false` on a `ppr` group route the flag is ignored (the bake
512
663
  lane), as on a server route.
513
664
  - Test it with `serveShellRequest` in the Flight project: mount the module
@@ -573,9 +724,11 @@ a hard load of the target URL renders the full route.
573
724
  in the HTML once" as a guarantee — only the flagged loader is guaranteed.
574
725
  - **Hook semantics shift inside a group.** `usePathname` is ABSOLUTE (mount
575
726
  included) — never compare it to your own `path()` patterns; build local
576
- links with `useHref()` (`groupHref("/items/1")` composes the mount).
577
- `useRouter().push("cart")` (RELATIVE,
578
- no leading slash) resolves against the mount; `push("/x")` stays
727
+ links with `useReverse(routes)` from the group's `.gen.ts`: it composes the
728
+ mount and fills its params. `useHref()` (`groupHref("/items/1")`) and a
729
+ relative `useRouter().push("cart")` also fill `:param` mount segments from
730
+ the current params (a missing one throws, or rejects the push), but append
731
+ the rest unchecked. `push("/x")` stays
579
732
  app-absolute by design (never auto-prefixed). `useSearchParams` carries REAL
580
733
  values during SSR (live request seeds the SSR store; on ppr routes the
581
734
  capture/resume renders seed the shell key's own sorted search — search is