@rangojs/router 0.5.2 → 0.7.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 (178) hide show
  1. package/dist/bin/rango.js +343 -125
  2. package/dist/types/browser/react/use-router.d.ts +10 -3
  3. package/dist/types/browser/react/use-search-params.d.ts +57 -10
  4. package/dist/types/browser/types.d.ts +22 -0
  5. package/dist/types/build/merge-full-manifests.d.ts +3 -0
  6. package/dist/types/build/route-trie.d.ts +4 -73
  7. package/dist/types/build/route-types/per-module-writer.d.ts +6 -4
  8. package/dist/types/build/route-types/router-processing.d.ts +2 -3
  9. package/dist/types/cache/cache-exec-scope.d.ts +31 -0
  10. package/dist/types/cache/taint.d.ts +12 -6
  11. package/dist/types/client-urls/client-root.d.ts +38 -0
  12. package/dist/types/client-urls/client-urls.d.ts +5 -0
  13. package/dist/types/client-urls/navigation.d.ts +38 -0
  14. package/dist/types/client-urls/revalidation-protocol.d.ts +25 -0
  15. package/dist/types/client-urls/server-projection.d.ts +71 -0
  16. package/dist/types/client-urls/types.d.ts +147 -0
  17. package/dist/types/client.d.ts +12 -4
  18. package/dist/types/client.rsc.d.ts +4 -1
  19. package/dist/types/decode-loader-results.d.ts +37 -0
  20. package/dist/types/errors.d.ts +1 -0
  21. package/dist/types/index.d.ts +1 -1
  22. package/dist/types/loader-redirect.d.ts +27 -0
  23. package/dist/types/outlet-context.d.ts +12 -0
  24. package/dist/types/outlet-provider.d.ts +3 -1
  25. package/dist/types/redirect-origin.d.ts +4 -0
  26. package/dist/types/route-content-wrapper.d.ts +42 -1
  27. package/dist/types/route-definition/helpers-types.d.ts +13 -2
  28. package/dist/types/router/error-handling.d.ts +35 -1
  29. package/dist/types/router/intercept-resolution.d.ts +12 -0
  30. package/dist/types/router/loader-resolution.d.ts +24 -2
  31. package/dist/types/router/revalidation.d.ts +7 -0
  32. package/dist/types/router/route-trie-builder.d.ts +77 -0
  33. package/dist/types/router/router-interfaces.d.ts +20 -0
  34. package/dist/types/router/segment-resolution/helpers.d.ts +1 -1
  35. package/dist/types/router/segment-resolution/loader-mask.d.ts +9 -9
  36. package/dist/types/router/trie-matching.d.ts +1 -1
  37. package/dist/types/rsc/manifest-init.d.ts +5 -5
  38. package/dist/types/rsc/shell-capture.d.ts +9 -0
  39. package/dist/types/rsc/shell-serve.d.ts +11 -0
  40. package/dist/types/rsc/types.d.ts +30 -0
  41. package/dist/types/segment-system.d.ts +2 -0
  42. package/dist/types/server/context.d.ts +10 -0
  43. package/dist/types/server/handle-store.d.ts +34 -3
  44. package/dist/types/server/request-context.d.ts +11 -1
  45. package/dist/types/server.d.ts +1 -0
  46. package/dist/types/ssr/index.d.ts +22 -0
  47. package/dist/types/ssr/ssr-root.d.ts +10 -0
  48. package/dist/types/testing/dom.entry.d.ts +1 -1
  49. package/dist/types/testing/render-route.d.ts +16 -6
  50. package/dist/types/testing/run-loader.d.ts +9 -0
  51. package/dist/types/types/boundaries.d.ts +22 -0
  52. package/dist/types/types/index.d.ts +1 -1
  53. package/dist/types/types/loader-types.d.ts +57 -5
  54. package/dist/types/types/segments.d.ts +7 -0
  55. package/dist/types/urls/path-helper-types.d.ts +13 -4
  56. package/dist/types/vite/discovery/client-urls-projection.d.ts +53 -0
  57. package/dist/types/vite/discovery/discover-routers.d.ts +1 -1
  58. package/dist/types/vite/discovery/state.d.ts +8 -1
  59. package/dist/types/vite/plugins/client-ref-dedup.d.ts +0 -11
  60. package/dist/vite/index.js +6159 -2959
  61. package/package.json +6 -5
  62. package/skills/breadcrumbs/SKILL.md +39 -9
  63. package/skills/catalog.json +7 -1
  64. package/skills/client-urls/SKILL.md +338 -0
  65. package/skills/comparison/references/framework-comparison.md +23 -9
  66. package/skills/hooks/SKILL.md +2 -2
  67. package/skills/hooks/data.md +11 -2
  68. package/skills/hooks/handle-and-actions.md +7 -0
  69. package/skills/hooks/outlets.md +26 -5
  70. package/skills/hooks/urls.md +40 -3
  71. package/skills/loader/SKILL.md +132 -20
  72. package/skills/migrate-nextjs/SKILL.md +70 -10
  73. package/skills/migrate-react-router/SKILL.md +49 -13
  74. package/skills/migrate-react-router/component-migration.md +18 -13
  75. package/skills/migrate-react-router/data-and-actions.md +14 -3
  76. package/skills/migrate-react-router/route-mapping.md +15 -2
  77. package/skills/parallel/SKILL.md +32 -1
  78. package/skills/ppr/SKILL.md +16 -6
  79. package/skills/prerender/SKILL.md +8 -4
  80. package/skills/rango/SKILL.md +21 -17
  81. package/skills/react-compiler/SKILL.md +3 -3
  82. package/skills/route/SKILL.md +5 -2
  83. package/skills/router-setup/SKILL.md +16 -2
  84. package/skills/scripts/SKILL.md +16 -6
  85. package/skills/shell-manifest/SKILL.md +16 -7
  86. package/skills/testing/SKILL.md +2 -2
  87. package/skills/testing/client-components.md +6 -0
  88. package/skills/testing/handles.md +30 -8
  89. package/skills/testing/loader.md +51 -49
  90. package/skills/testing/middleware.md +1 -1
  91. package/skills/theme/SKILL.md +8 -5
  92. package/src/bin/rango.ts +7 -3
  93. package/src/browser/navigation-bridge.ts +6 -0
  94. package/src/browser/navigation-client.ts +5 -0
  95. package/src/browser/partial-update.ts +65 -13
  96. package/src/browser/react/use-router.ts +40 -11
  97. package/src/browser/react/use-search-params.ts +140 -17
  98. package/src/browser/rsc-router.tsx +59 -0
  99. package/src/browser/server-action-bridge.ts +26 -0
  100. package/src/browser/types.ts +22 -0
  101. package/src/build/merge-full-manifests.ts +161 -0
  102. package/src/build/route-trie.ts +9 -332
  103. package/src/build/route-types/include-resolution.ts +66 -11
  104. package/src/build/route-types/per-module-writer.ts +11 -6
  105. package/src/build/route-types/router-processing.ts +184 -153
  106. package/src/build/runtime-discovery.ts +23 -12
  107. package/src/cache/cache-exec-scope.ts +47 -0
  108. package/src/cache/cache-runtime.ts +24 -25
  109. package/src/cache/taint.ts +28 -9
  110. package/src/client-urls/client-root.tsx +168 -0
  111. package/src/client-urls/client-urls.ts +776 -0
  112. package/src/client-urls/navigation.ts +237 -0
  113. package/src/client-urls/revalidation-protocol.ts +56 -0
  114. package/src/client-urls/server-projection.ts +670 -0
  115. package/src/client-urls/types.ts +201 -0
  116. package/src/client.rsc.tsx +12 -0
  117. package/src/client.tsx +49 -6
  118. package/src/decode-loader-results.ts +113 -0
  119. package/src/errors.ts +14 -0
  120. package/src/handles/deferred-resolution.ts +14 -7
  121. package/src/index.ts +1 -0
  122. package/src/loader-redirect.tsx +64 -0
  123. package/src/outlet-context.ts +12 -0
  124. package/src/outlet-provider.tsx +15 -1
  125. package/src/redirect-origin.ts +29 -0
  126. package/src/route-content-wrapper.tsx +96 -3
  127. package/src/route-definition/dsl-helpers.ts +28 -3
  128. package/src/route-definition/helpers-types.ts +13 -0
  129. package/src/route-definition/redirect.ts +17 -18
  130. package/src/router/error-handling.ts +65 -11
  131. package/src/router/intercept-resolution.ts +29 -0
  132. package/src/router/loader-resolution.ts +261 -28
  133. package/src/router/match-result.ts +7 -0
  134. package/src/router/revalidation.ts +24 -11
  135. package/src/router/route-trie-builder.ts +334 -0
  136. package/src/router/router-interfaces.ts +38 -0
  137. package/src/router/segment-resolution/fresh.ts +55 -2
  138. package/src/router/segment-resolution/helpers.ts +9 -11
  139. package/src/router/segment-resolution/loader-cache.ts +34 -23
  140. package/src/router/segment-resolution/loader-mask.ts +9 -9
  141. package/src/router/segment-resolution/revalidation.ts +23 -2
  142. package/src/router/trie-matching.ts +3 -3
  143. package/src/router.ts +46 -1
  144. package/src/rsc/full-payload.ts +6 -0
  145. package/src/rsc/handler.ts +10 -7
  146. package/src/rsc/loader-fetch.ts +2 -2
  147. package/src/rsc/manifest-init.ts +28 -9
  148. package/src/rsc/rsc-rendering.ts +15 -1
  149. package/src/rsc/shell-capture.ts +12 -0
  150. package/src/rsc/shell-serve.ts +15 -2
  151. package/src/rsc/ssr-setup.ts +10 -1
  152. package/src/rsc/types.ts +31 -2
  153. package/src/segment-system.tsx +83 -26
  154. package/src/server/context.ts +10 -0
  155. package/src/server/cookie-store.ts +19 -19
  156. package/src/server/handle-store.ts +185 -48
  157. package/src/server/request-context.ts +30 -6
  158. package/src/server.ts +7 -0
  159. package/src/ssr/index.tsx +37 -2
  160. package/src/ssr/ssr-root.tsx +29 -2
  161. package/src/testing/dom.entry.ts +1 -1
  162. package/src/testing/render-route.tsx +22 -8
  163. package/src/testing/run-loader.ts +51 -13
  164. package/src/types/boundaries.ts +19 -0
  165. package/src/types/index.ts +1 -0
  166. package/src/types/loader-types.ts +60 -5
  167. package/src/types/segments.ts +7 -0
  168. package/src/urls/include-helper.ts +22 -4
  169. package/src/urls/path-helper-types.ts +17 -1
  170. package/src/use-loader.tsx +67 -6
  171. package/src/vite/discovery/client-urls-projection.ts +322 -0
  172. package/src/vite/discovery/discover-routers.ts +43 -17
  173. package/src/vite/discovery/state.ts +11 -1
  174. package/src/vite/discovery/virtual-module-codegen.ts +20 -0
  175. package/src/vite/plugins/client-ref-dedup.ts +281 -19
  176. package/src/vite/plugins/expose-action-id.ts +45 -23
  177. package/src/vite/plugins/virtual-entries.ts +12 -3
  178. package/src/vite/router-discovery.ts +163 -12
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.5.2",
3
+ "version": "0.7.0",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -182,6 +182,7 @@
182
182
  "prepublishOnly": "pnpm build",
183
183
  "typecheck": "tsc --noEmit && tsc -p tsconfig.strict-check.json --noEmit && tsc -p tsconfig.augment-check.json --noEmit",
184
184
  "test": "playwright test",
185
+ "test:preload": "playwright test --config=playwright.preload.config.ts",
185
186
  "test:ui": "playwright test --ui",
186
187
  "test:hmr-local": "playwright test --project=dev-warmup --project=hmr-basename --project=hmr-prerender --no-deps --workers=1 && RANGO_E2E_ROUTE_HMR_ONLY=1 playwright test --project=hmr-routes --no-deps --workers=1",
187
188
  "test:hmr-routes-local": "RANGO_E2E_ROUTE_HMR_ONLY=1 playwright test --project=hmr-routes --no-deps --workers=1",
@@ -191,7 +192,7 @@
191
192
  },
192
193
  "dependencies": {
193
194
  "@types/debug": "^4.1.12",
194
- "@vitejs/plugin-rsc": "^0.5.30",
195
+ "@vitejs/plugin-rsc": "^0.5.31",
195
196
  "debug": "^4.4.1",
196
197
  "magic-string": "^0.30.17",
197
198
  "picomatch": "^4.0.4",
@@ -224,9 +225,9 @@
224
225
  "@playwright/test": "^1.49.1",
225
226
  "@testing-library/react": ">=16",
226
227
  "@vercel/functions": "^3.0.0",
227
- "@vitejs/plugin-rsc": "^0.5.30",
228
- "react": ">=19.2.6 <20",
229
- "react-dom": ">=19.2.6 <20",
228
+ "@vitejs/plugin-rsc": "^0.5.31",
229
+ "react": ">=19.2.8 <20",
230
+ "react-dom": ">=19.2.8 <20",
230
231
  "vite": "^8.0.16",
231
232
  "vitest": ">=3"
232
233
  },
@@ -7,8 +7,9 @@ argument-hint: "[setup]"
7
7
  # Breadcrumbs
8
8
 
9
9
  Built-in handle for accumulating breadcrumb items across route segments.
10
- Each layout/route pushes items via `ctx.use(Breadcrumbs)`, and they are
11
- collected in parent-to-child order with automatic deduplication by `href`.
10
+ Handlers and loader bodies push items via `ctx.use(Breadcrumbs)`, and they
11
+ are collected in parent-to-child order with automatic deduplication by
12
+ `href`.
12
13
 
13
14
  ## BreadcrumbItem Type
14
15
 
@@ -58,6 +59,32 @@ export const urlpatterns = urls(({ path, layout }) => [
58
59
 
59
60
  On `/blog/my-post`, breadcrumbs accumulate: `Home > Blog > my-post`.
60
61
 
62
+ ## Pushing from Loaders
63
+
64
+ A data-derived crumb belongs where the data lives — the loader body pushes
65
+ with the same API (handler parity), so routes without handlers (`useLoader`
66
+ consumption, `clientUrls()` groups) still build trails:
67
+
68
+ ```typescript
69
+ export const ProductLoader = createLoader(async (ctx) => {
70
+ "use server";
71
+ const product = await getProduct(ctx.params.slug);
72
+
73
+ const pushCrumb = ctx.use(Breadcrumbs);
74
+ pushCrumb({ label: "Shop", href: "/shop" });
75
+ pushCrumb({ label: product.name, href: `/shop/product/${product.slug}` });
76
+
77
+ return product;
78
+ });
79
+ ```
80
+
81
+ Two pushes from one segment accumulate in push order (`Shop › product`).
82
+ Delivery follows the loader race model: a push that beats the handler barrier
83
+ is in the SSR'd document; a push after a slow fetch streams and applies
84
+ client-side (`useHandle` re-renders when it lands). To guarantee document
85
+ delivery, register the loader as `loader(Def, { stream: "navigation" })` —
86
+ see `/loader` → "Writing Handles from Loaders".
87
+
61
88
  ## Async Content
62
89
 
63
90
  The `content` field supports `Promise<ReactNode>` for streaming:
@@ -83,9 +110,10 @@ with React's `use()` hook wrapped in `<Suspense>`.
83
110
 
84
111
  ### Deferred content (decide now, resolve from a deep component)
85
112
 
86
- When the handler should DECIDE to push a crumb (it holds `ctx`, so the decision
87
- must land before the handles stream seals) but the value is produced far away — by
88
- a deep async component, not the handler call `.defer()` on the push function.
113
+ When the handler should DECIDE to push a crumb early but the value is produced
114
+ far away by a deep async component, not the handler call `.defer()` on the
115
+ push function. (If the value comes from a LOADER, skip `.defer()` entirely and
116
+ push from the loader body — see "Pushing from Loaders" above.)
89
117
  `ctx.use(Handle)` returns the push function; `.defer(options)` reserves the crumb's
90
118
  slot synchronously and returns a **resolver that is push-equal** — you call it
91
119
  later, anywhere in the render, with the same argument you'd have passed to the
@@ -140,10 +168,12 @@ On a full/SSR load the value is resolved server-side; on a soft navigation the
140
168
  breadcrumbs HOLD the previous resolved value until the deferred value lands, then
141
169
  swap in — no blank, no pending entry. If the slot times out to `else: null`/
142
170
  undefined, the entry is simply dropped. Use `.defer()` only when even
143
- `label`/`href` are unknown at handler time if you know them and only the
144
- `content` is async, push a concrete item with a `Promise` `content` field instead
145
- (the `content` field is a nested promise you resolve with `use()` in your
146
- component; no `.defer()` needed).
171
+ `label`/`href` are unknown at handler time AND the producer is a component,
172
+ not a loader a loader-produced crumb pushes directly from the loader body
173
+ (add `{ stream: "navigation" }` if it must be in the SSR'd document), and if
174
+ you know `label`/`href` and only the `content` is async, push a concrete item
175
+ with a `Promise` `content` field instead (the `content` field is a nested
176
+ promise you resolve with `use()` in your component; no `.defer()` needed).
147
177
 
148
178
  ## Consuming Breadcrumbs (Client)
149
179
 
@@ -33,6 +33,12 @@
33
33
  "argumentHint": "[setup]",
34
34
  "path": "skills/caching/SKILL.md"
35
35
  },
36
+ {
37
+ "name": "client-urls",
38
+ "description": "Define client-component route groups with clientUrls() in a \"use client\" module — no handlers, useLoader at read sites, client-run revalidate predicates, loader-thrown notFound/redirect, loader handle writes, and the stream:\"navigation\" SSR-completeness opt-in. Use when a route group's pages are client components, when building high-navigation-speed UIs (dashboards, admin panels, settings) where transitions must feel instant, when moving data reads from handler ctx.use() to useLoader at consumption sites, or when asking how routes defined in a \"use client\" file work.",
39
+ "argumentHint": "[setup]",
40
+ "path": "skills/client-urls/SKILL.md"
41
+ },
36
42
  {
37
43
  "name": "cloudflare",
38
44
  "description": "Deploy and operate @rangojs/router on Cloudflare Workers with the Cloudflare Vite plugin, typed D1/KV bindings, local migrations, secrets, streaming, preview parity, and built-worker deployment. Use when creating a Cloudflare Rango app, moving a Rango app to Workers, wiring D1 or KV, debugging dev/preview differences, or preparing a Worker deployment.",
@@ -131,7 +137,7 @@
131
137
  },
132
138
  {
133
139
  "name": "loader",
134
- "description": "Define data loaders for fetching data in routes with createLoader. Use when pages need per-request data that stays fresh, data should stream while the page renders, or client components need reactive server data.",
140
+ "description": "Define data loaders for fetching data in routes with createLoader. Use when pages need per-request data that stays fresh, data should stream while the page renders, client components need reactive server data, a loader should throw notFound()/redirect(), set page meta/breadcrumbs from loader data (handle writes), or loader data must be guaranteed in the SSR'd document (stream:\"navigation\").",
135
141
  "argumentHint": "[loader]",
136
142
  "path": "skills/loader/SKILL.md"
137
143
  },
@@ -0,0 +1,338 @@
1
+ ---
2
+ name: client-urls
3
+ description: Define client-component route groups with clientUrls() in a "use client" module — no handlers, useLoader at read sites, client-run revalidate predicates, loader-thrown notFound/redirect, loader handle writes, and the stream:"navigation" SSR-completeness opt-in. Use when a route group's pages are client components, when building high-navigation-speed UIs (dashboards, admin panels, settings) where transitions must feel instant, when moving data reads from handler ctx.use() to useLoader at consumption sites, or when asking how routes defined in a "use client" file work.
4
+ argument-hint: "[setup]"
5
+ ---
6
+
7
+ # Client route groups with clientUrls()
8
+
9
+ `clientUrls()` defines a route group inside a `"use client"` module. The
10
+ components are client components, and there are **no handlers at all** — every
11
+ data read is `useLoader` at the consumption site, waiting is scoped by the
12
+ inline `<Suspense>` above each read, and per-loader `revalidate()` predicates
13
+ run in the browser. The server shop pattern — `await ctx.use(ProductLoader)` in
14
+ a handler — is pipeline-blocking by contract: no pending UI can ever cover that
15
+ latency. `clientUrls()` makes that shape unrepresentable.
16
+
17
+ This is the fastest-transition shape Rango has, and the natural fit for
18
+ dashboard / admin / settings-style apps — high navigation frequency inside one
19
+ layout, mostly tab/param/filter switches. Three things compound: the
20
+ definition also matches in the browser, so a soft navigation presents
21
+ optimistic pending UI immediately (`useOutlet().pending`) with no server
22
+ round-trip to start; browser-run `revalidate()` predicates HOLD data across
23
+ navigations that don't invalidate it (a tab switch re-runs nothing — only the
24
+ decision crosses the wire); and any read that does refresh streams behind its
25
+ own inline boundary instead of gating the commit. Add per-Link prefetch
26
+ (`/links`) and a warmed click lands the complete page instantly.
27
+
28
+ ## Not this skill if…
29
+
30
+ - You are defining server routes with handlers — that is `urls()`: see
31
+ `/route`.
32
+ - You want loader mechanics (createLoader, caching a loader, fetchable
33
+ loaders) — see `/loader`. This skill covers what is _different_ inside
34
+ `clientUrls()`.
35
+ - You want `useOutlet`/`useLoader`/`useHandle` reference — see `/hooks`.
36
+
37
+ ## The shape
38
+
39
+ A `clientUrls()` module never ships route-definition code to the server as
40
+ code. The Vite plugin discovers the module (it must be `"use client"`, contain
41
+ `clientUrls(`, and **default-export** the result), evaluates it, and serializes
42
+ a JSON **projection**: patterns, names, options, loader ids, which loaders are
43
+ flagged, loading/transition presence. The server tree materializes real routes
44
+ from that projection — loader stubs resolved by id from the server loader
45
+ registry, your client components referenced but never executed server-side as
46
+ route code. In the browser the same definition matches locally, so soft
47
+ navigations inside the group present optimistic pending UI
48
+ (`useOutlet().pending`) while the canonical partial Flight settles.
49
+
50
+ Consequences you will hit:
51
+
52
+ - **Components must be named function values.** `path("/x", HomePage)`, not
53
+ `path("/x", <HomePage />)` and not an inline arrow — the projection stores a
54
+ component reference, and an anonymous value throws
55
+ (`clientUrls() path() expects a named client component value`).
56
+ - **Loaders must be `createLoader()` definitions** whose bodies are
57
+ `"use server"` — they execute on the server, addressed by id; the client
58
+ module only imports the definition object.
59
+ - **Everything in the definition must be JSON-projectable.** That is why the
60
+ unsupported helpers below are rejected rather than silently dropped.
61
+
62
+ ## Defining and mounting
63
+
64
+ ```tsx
65
+ // src/urls/shop.client.tsx
66
+ "use client";
67
+
68
+ import { Suspense } from "react";
69
+ import { clientUrls, useLoader, Link } from "@rangojs/router/client";
70
+ import { ProductsLoader, ProductLoader } from "@/loaders/products.js";
71
+
72
+ function ShopLayout() {
73
+ /* useOutlet().content + chrome */
74
+ }
75
+ function ShopIndex() {
76
+ /* <Suspense><Grid/></Suspense> */
77
+ }
78
+ function ProductPage() {
79
+ /* useLoader(ProductLoader) under Suspense */
80
+ }
81
+
82
+ export default clientUrls(({ path, layout, loader, revalidate }) => [
83
+ layout(ShopLayout, () => [
84
+ path("/", ShopIndex, { name: "index" }, () => [loader(ProductsLoader)]),
85
+ path("/product/:slug", ProductPage, { name: "product" }, () => [
86
+ loader(ProductLoader, () => [
87
+ revalidate(
88
+ ({
89
+ isAction,
90
+ currentParams,
91
+ nextParams,
92
+ defaultShouldRevalidate,
93
+ }) => {
94
+ if (isAction) return false;
95
+ return currentParams.slug !== nextParams.slug
96
+ ? defaultShouldRevalidate
97
+ : false;
98
+ },
99
+ ),
100
+ ]),
101
+ ]),
102
+ ]),
103
+ ]);
104
+ ```
105
+
106
+ Mount it from the server tree like any module:
107
+
108
+ ```tsx
109
+ // src/urls/index.tsx (server)
110
+ import shopUrls from "./shop.client.js";
111
+
112
+ include("/shop", shopUrls, { name: "shop" });
113
+ ```
114
+
115
+ Route names compose through the include (`shop.index`, `shop.product`) and
116
+ flow into the generated route map, so `href`/`reverse` and `Handler<"...">`
117
+ typing work exactly as for server routes (`/typesafety`).
118
+
119
+ ## Helpers: what exists inside clientUrls()
120
+
121
+ | Helper | Notes |
122
+ | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
123
+ | `path()` | Options are `name`, `search`, `trailingSlash`, `ppr` (shell caching — see /ppr skill; loader routes need `loading()` or capture refuses); no response variants |
124
+ | `layout()` | Must contain at least one `path()` |
125
+ | `loader()` | `loader(Def, use?)` or `loader(Def, { stream: "navigation" }, use?)` — see below |
126
+ | `loading()` | Route/layout-level pending UI; inline `<Suspense>` at read sites is usually better |
127
+ | `revalidate()` | Valid **inside a loader() use callback only**; runs in the browser |
128
+ | `transition()` | Data-only ViewTransition config — no `when` (that is a server-executed predicate) |
129
+ | `intercept()` | Dot-local named target in the SAME definition; use may contain `loader()`/`loading()` |
130
+
131
+ `include`, `parallel`, `cache`, `middleware`, `errorBoundary`,
132
+ `notFoundBoundary` **throw** — and that is a design position, not a gap.
133
+ Client route groups are a PERFORMANCE surface (instant presentation, held
134
+ data, streaming reads), not full-feature routing; the server tree around the
135
+ mount is the full-feature router. Put those helpers at or around the
136
+ `include()` mount in the server `urls()` tree: middleware wrapping the mount
137
+ runs for every route in the group; a `notFoundBoundary()` on an enclosing
138
+ server layout catches loader-thrown `notFound()` from the group. (Caching
139
+ still reaches the group's data the right way: `"use cache"` inside a loader
140
+ body — the loaders stay the live layer, the expensive function is what
141
+ caches.)
142
+
143
+ Boundaries are a deliberate exclusion, not a projection limitation: the
144
+ server-tree boundary around the mount gives group-scoped custom UI with the
145
+ uniform server-resolved envelope (PE/no-JS correct), and INSIDE the group
146
+ plain React error boundaries just work — every component is a client
147
+ component, and a streamed loader rejection throws to the boundary above its
148
+ `useLoader` read (siblings keep reading it from `error`). Per-read boundary +
149
+ inline Suspense is the clientUrls idiom; a route-level DSL boundary would be
150
+ the same regression `loading()` is relative to inline Suspense.
151
+
152
+ ## Outer layouts and middleware across group navigations
153
+
154
+ With the common shape — `layout(<AdminRscLayout />, () => [middleware(requireAdmin), include("/admin", adminUrls)])` — middleware and layout handlers ride
155
+ DIFFERENT schedules:
156
+
157
+ - **Middleware runs on EVERY canonical request**, not just the first
158
+ encounter. Every navigation inside the group — including a tab switch whose
159
+ loaders all hold — still sends the canonical partial request (it commits
160
+ URL/history and carries the revalidation decisions), and middleware wraps
161
+ each one: `requireAdmin` re-authorizes every navigation, and its
162
+ `ctx.set()` variables are fresh for the group's loaders on every pass. The
163
+ instant feel comes from held data and optimistic presentation, not from
164
+ skipping the server.
165
+ - **The outer layout handler does NOT re-run on within-group navigations** —
166
+ partial rendering holds its segment (and the parent chain skips on actions
167
+ by default). Layout-rendered data that must track the group belongs in
168
+ middleware `ctx.set()`, not the layout body.
169
+ - The only thing that precedes middleware is the optimistic branch
170
+ (destination `loading()` / `useOutlet().pending`) — presentation, never
171
+ authorization.
172
+
173
+ All three pinned by dev + production e2e in the router repository (a
174
+ middleware-stamped header per request, a layout run-count stamp held across
175
+ navigations).
176
+
177
+ ## Revalidation runs in the browser
178
+
179
+ A server-tree `revalidate()` predicate executes on the server. A `clientUrls()`
180
+ predicate is client-module code, so it executes **in the browser** with
181
+ client-computable args — `currentUrl`, `nextUrl`, `currentParams`,
182
+ `nextParams`, `defaultShouldRevalidate`, `stale`, `isAction`, `actionId` — and
183
+ only its _decision_ crosses the wire with the navigation request. Requests that
184
+ carry no decisions (no-JS, progressive enhancement, prefetch, document loads)
185
+ follow the locked server defaults.
186
+
187
+ Two scars worth copying:
188
+
189
+ - A blunt `() => false` keeps serving the OLD product on product→product
190
+ navigations (same route, new param). Make predicates param-sensitive:
191
+ return `defaultShouldRevalidate` when the identifying param changed.
192
+ - One action, per-loader outcomes: a cart badge loader revalidates on actions
193
+ (`isAction ? defaultShouldRevalidate : false`) while product/related loaders
194
+ hold — three freshness outcomes in a single commit, decided per loader.
195
+
196
+ ## Loaders are full citizens: signals and handles
197
+
198
+ Everything a DSL loader can do in the server tree works here — the loader body
199
+ is server code either way.
200
+
201
+ **Authority signals.** A loader may `throw notFound()` or
202
+ `throw redirect(url)`:
203
+
204
+ ```ts
205
+ export const ProductLoader = createLoader(async (ctx) => {
206
+ "use server";
207
+ const moved = LEGACY_SLUGS[ctx.params.slug];
208
+ if (moved) throw redirect(`/shop/product/${moved}`);
209
+ if (!(await exists(ctx.params.slug))) notFound(`No "${ctx.params.slug}"`);
210
+ return getProduct(ctx.params.slug);
211
+ });
212
+ ```
213
+
214
+ On a document load, `notFound()` streams the resolved not-found UI in the
215
+ envelope and _opportunistically_ sets a real 404 status — the status write only
216
+ wins if the rejection settles before the document Response is constructed (a
217
+ fast, pre-fetch existence check usually wins; see `stream: "navigation"` below
218
+ for the deterministic version). On navigations the 404 UI swaps in with the
219
+ URL preserved. A loader `redirect()` is always a client-side navigate — there
220
+ is no document-lane 302 from loaders; pre-stream redirect authority belongs to
221
+ middleware.
222
+
223
+ **Handle writes.** `ctx.use(Handle)` from the loader body is the WRITE, with
224
+ handler parity — `ctx.use(Meta)({ title })` pushes exactly like a handler
225
+ push. Reads moved to `ctx.get(handle)`, gated behind `await ctx.rendered()`.
226
+ Delivery follows the race model: pushes that settle before the handler barrier
227
+ ride the SSR handle snapshot; later ones stream and apply post-hydration
228
+ (document loads) or progressively (navigations). See `/loader` for the full
229
+ contract.
230
+
231
+ ## `stream: "navigation"` — the SSR-completeness opt-in
232
+
233
+ By default every loader streams on every render, so nothing a slow loader
234
+ produces is _guaranteed_ to be in the SSR'd HTML — data SSRs as the Suspense
235
+ fallback, a late handle push applies post-hydration, a late `notFound()` loses
236
+ the status race. When the loader feeds something that must exist in the
237
+ document — `<head>` meta via a handle, or a real 404 status — flag it:
238
+
239
+ ```ts
240
+ path("/product/:slug", ProductPage, { name: "product" }, () => [
241
+ loader(ProductLoader, { stream: "navigation" }, () => [revalidate(productData)]),
242
+ loader(RelatedLoader), // untouched: still streams behind its boundary
243
+ ]),
244
+ ```
245
+
246
+ The name says WHERE streaming still applies, not that it is disabled: document
247
+ renders await this loader before first flush (data settled — no fallback
248
+ paints; handle pushes beat the barrier snapshot; a thrown `notFound()` is a
249
+ deterministic real 404, no warm-up race); client navigations stream exactly as
250
+ before. Scoped per loader — the flagged loader awaits only itself, siblings
251
+ keep streaming.
252
+
253
+ Constraints:
254
+
255
+ - A flagged loader must not `await ctx.rendered()` (or read handles via
256
+ `ctx.get(handle)`, which rendered() gates) — the document render awaits the
257
+ loader _before_ the render barrier resolves, so that wait is a cycle by
258
+ construction. It throws a deadlock error naming the fix.
259
+ - `intercept()` loaders reject the flag — intercepts render on client
260
+ navigations only, so a document-render await can never apply.
261
+ - It does not change PPR capture behavior: capture renders mask loaders and
262
+ skip the await.
263
+
264
+ Every document load pays the flagged loader's latency before first byte —
265
+ that is the point, but it is a real cost. Keep flagged loaders fast (existence
266
+ checks before expensive fetches) and flag the loaders that need it, not the
267
+ route.
268
+
269
+ Pinned end to end by dev + production e2e in the router repository.
270
+
271
+ ## Intercepts
272
+
273
+ ```tsx
274
+ export default clientUrls(({ path, intercept, loader, loading }) => [
275
+ path("/items", ItemsIndex, { name: "index" }),
276
+ path("/items/:id", ItemDetail, { name: "detail" }),
277
+ intercept("@modal", ".detail", ItemModal, () => [
278
+ loader(ItemLoader),
279
+ loading(<ModalSkeleton />),
280
+ ]),
281
+ ]);
282
+ ```
283
+
284
+ Compared to the server `intercept()` (`/intercept`): the target must be a
285
+ dot-local NAMED route in the same definition, there is no `when` selector and
286
+ no middleware, and the use callback accepts `loader()`/`loading()` only —
287
+ every field must be JSON-projectable. Scoping is module-local: only
288
+ navigations originating inside this `clientUrls()` group render the intercept;
289
+ a hard load of the target URL renders the full route.
290
+
291
+ ## Pitfalls
292
+
293
+ - **Duplicate patterns or names throw** at definition time, per definition.
294
+ - **`layout()` with no `path()` inside throws** — a client layout exists only
295
+ to wrap routes.
296
+ - **`loading()` vs inline Suspense:** same-route SEARCH navigations
297
+ (filters, tabs) hold previous content by default — the canonical commit
298
+ runs in a `startTransition` when no new segments mount, and the wrapping
299
+ layout sees `useOutlet().pending === true` for the held window; a
300
+ route-level `loading()` no longer re-flashes there. PARAM navigations
301
+ still remount (fresh skeleton, fresh state) unless the route declares
302
+ `transition()` — that opt-in drops the param from the segment key. Inline
303
+ `<Suspense>` above each `useLoader` read is still the finer-grained tool
304
+ when different reads on one route should wait independently.
305
+ - **Two parallel loaders with equal latency look "SSR'd" together.** Loaders
306
+ kick off in parallel, so awaiting one (`stream: "navigation"`) gives
307
+ same-or-faster siblings time to settle coincidentally. Do not read "it was
308
+ in the HTML once" as a guarantee — only the flagged loader is guaranteed.
309
+ - **Hook semantics shift inside a group.** `usePathname` is ABSOLUTE (mount
310
+ included) — never compare it to your own `path()` patterns; build local
311
+ links with `useHref()` (`groupHref("/items/1")` composes the mount).
312
+ `useRouter().push("cart")` (RELATIVE,
313
+ no leading slash) resolves against the mount; `push("/x")` stays
314
+ app-absolute by design (never auto-prefixed). `useSearchParams` carries REAL
315
+ values during SSR (live request seeds the SSR store; on ppr routes the
316
+ capture/resume renders seed the shell key's own sorted search — search is
317
+ part of shell identity, so static-part reads are legal).
318
+ `useNavigation`/`useLinkStatus` report the canonical nav, but a reader
319
+ inside optimistically-swapped content (destination WITH `loading()`)
320
+ unmounts at click — put status readers in surviving chrome. Errors: wrap
321
+ throwers in the client `ErrorBoundary`; `useFetchLoader` works unchanged
322
+ (route-independent lane; `revalidate()` deliberately not consulted).
323
+ Pinned in the router repo's client-urls hook-probe e2e.
324
+ - **Editing the module in dev:** projections refresh via HMR discovery; if a
325
+ route-shape edit ever serves stale routes, restart dev — and if you are
326
+ developing the router itself, rebuild the router dist before `pnpm dev`
327
+ (the discovery machinery ships compiled).
328
+
329
+ ## Reference app
330
+
331
+ The router repository (not shipped in this package) carries a canonical
332
+ consumer at `tests/vite-rsc-demo/src/urls/client-shop.client.tsx`: layout +
333
+ index + product routes, param-sensitive predicates, action-scoped cart
334
+ revalidation, loader-thrown `notFound()`/`redirect()`, loader-written
335
+ Meta/Breadcrumbs, viewport prefetch on cards, and the `stream: "navigation"`
336
+ fixtures — with e2e suites next to it pinning each contract in dev and
337
+ production. The sections above are self-contained; the app is corroboration,
338
+ not required reading.
@@ -601,8 +601,12 @@ the current state for a known no-op.
601
601
  TanStack Router is the real peer here (`preload="intent|viewport|render"`,
602
602
  `preloadDelay`, `preloadStaleTime`); Rango's edge is the resource-aware
603
603
  idle + image-ready gating and RSC-payload reuse. Next has an internal prefetch
604
- scheduler, but its public `<Link prefetch>` surface does not expose Rango's choice
605
- of trigger and resource gates. Waku has manual route prefetching plus experimental
604
+ scheduler the machinery behind its "instant navigations" pitch but its
605
+ public `<Link prefetch>` surface does not expose Rango's choice of trigger and
606
+ resource gates. Rango's counterpart to instant navigations is the same
607
+ outcome, spelled explicitly: a viewport/hover-warmed Link commits its
608
+ prefetched payload as a whole on click, so the complete page lands with no
609
+ fetch waterfall. Waku has manual route prefetching plus experimental
606
610
  `unstable_prefetchOnEnter` / `unstable_prefetchOnView` Link triggers, but without
607
611
  this resource-aware policy layer.
608
612
 
@@ -613,10 +617,14 @@ excluded from an enclosing segment cache and resolve on every request unless the
613
617
  loader itself explicitly opts into `cache()`. They may safely read `cookies()`,
614
618
  `headers()`, request context, and `env` because loader execution is outside the
615
619
  cached shell. Loaders run in parallel, stream independently under `loading()`
616
- boundaries, compose server-side via `ctx.use(OtherLoader)`, and can
617
- `await ctx.rendered()` to read handle data after the render settles. Reads happen
618
- through `useLoader` in a client component (including its SSR pass) or
619
- `useFetchLoader` for standalone client fetches. "Fetchable" loaders are callable
620
+ boundaries, compose server-side via `ctx.use(OtherLoader)`, and carry
621
+ route-level authority: they can throw `notFound()`/`redirect()`, WRITE handles
622
+ (`ctx.use(Meta)({ title })` data-derived page metadata pushed from the data's
623
+ producer), read handle data after the render settles (`ctx.get(handle)` behind
624
+ `await ctx.rendered()`), and opt into `loader(Def, { stream: "navigation" })`
625
+ so a document render awaits them before first flush (deterministic SSR'd data,
626
+ meta, and 404 status). Reads happen through `useLoader` in a client component
627
+ (including its SSR pass) or `useFetchLoader` for standalone client fetches. "Fetchable" loaders are callable
620
628
  endpoints with their own middleware and GET/POST/PUT/PATCH/DELETE bodies.
621
629
 
622
630
  One sharp edge is worth stating because the distinction matters: a cached handler
@@ -682,12 +690,18 @@ that no loader-before-render has:
682
690
  - it is **standalone and composable**, not route-coupled — one `createLoader()`
683
691
  read by many segments, composed via `ctx.use`, or exposed as a fetchable endpoint;
684
692
  - it **streams as a hole, not a gate** — concurrent, Suspense-resolved under
685
- `loading()`, so the shell never blocks on it;
693
+ `loading()`, so the shell never blocks on it (and gating is a deliberate
694
+ per-loader opt-in — `{ stream: "navigation" }` awaits ONE loader on document
695
+ renders while the rest keep streaming — not the model's default);
686
696
  - it is **client-addressable and refreshable** independent of navigation (`key`,
687
697
  `refreshGroup`, `useRefreshLoaders`), behaving like a built-in, server-defined
688
698
  data cell with zero loader logic in the client bundle;
689
- - it can **read render output** via `await ctx.rendered()` a loader that depends
690
- on what the render produced, which a strictly before-render model cannot express.
699
+ - it carries **route authority and document side-effects**thrown
700
+ `notFound()`/`redirect()` and handle writes (page meta, breadcrumbs) travel
701
+ with the data unit itself, not with a route-coupled function beside it;
702
+ - it can **read render output** via `ctx.get(handle)` behind
703
+ `await ctx.rendered()` — a loader that depends on what the render produced,
704
+ which a strictly before-render model cannot express.
691
705
 
692
706
  The fair objections, and why they do not collapse the distinction:
693
707
 
@@ -28,7 +28,7 @@ the one for your case.
28
28
  | Stable router actions (push/replace/refresh/…) | `useRouter()` | [`./navigation.md`](./navigation.md) |
29
29
  | Current URL path & matched segments | `useSegments()` | [`./navigation.md`](./navigation.md) |
30
30
  | Pending state inside a `<Link>` | `useLinkStatus()` | [`./navigation.md`](./navigation.md) |
31
- | Loader data (strict, guaranteed) | `useLoader()` | [`./data.md`](./data.md) |
31
+ | Loader data (streams; suspends until it lands) | `useLoader()` | [`./data.md`](./data.md) |
32
32
  | Loader data with on-demand fetch | `useFetchLoader()` | [`./data.md`](./data.md) |
33
33
  | Refresh multiple loaders across groups | `useRefreshLoaders()` | [`./data.md`](./data.md) |
34
34
  | Accumulated handle data from route segments | `useHandle()` | [`./handle-and-actions.md`](./handle-and-actions.md) |
@@ -69,7 +69,7 @@ the one for your case.
69
69
  | ------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------ |
70
70
  | `useParams()` | Route params | `Readonly<T>` (default `Record<string, string>`) or selected value |
71
71
  | `usePathname()` | Current pathname | `string` |
72
- | `useSearchParams()` | URL search params | `ReadonlyURLSearchParams` |
72
+ | `useSearchParams()` | URL search params (read + write) | `[ReadonlyURLSearchParams, SetSearchParams]` (RR-style tuple) |
73
73
  | `useHref()` | Mount-aware href | `(path) => string` |
74
74
  | `useMount()` | Current include() mount path | `string` |
75
75
  | `useReverse()` | Local reverse for imported routes | `(name, params?, search?) => string` |
@@ -2,7 +2,7 @@
2
2
 
3
3
  ### useLoader()
4
4
 
5
- Access loader data (strict - data guaranteed):
5
+ Access loader data (strict data guaranteed once the read returns):
6
6
 
7
7
  ```tsx
8
8
  "use client";
@@ -13,13 +13,22 @@ function ProductPrice() {
13
13
  const { data, isLoading, error } = useLoader(ProductLoader);
14
14
 
15
15
  // data: T (guaranteed - throws if not in context)
16
- // isLoading: boolean
16
+ // isLoading: boolean (refetch/load() states — NOT the initial streamed read)
17
17
  // error: Error | null
18
18
 
19
19
  return <span>${data.price}</span>;
20
20
  }
21
21
  ```
22
22
 
23
+ **Loaders stream, and `useLoader` implicitly suspends.** A first read whose
24
+ loader data has not streamed in yet suspends to the nearest `<Suspense>`
25
+ boundary (or the route's `loading()`) — it does NOT render with
26
+ `isLoading: true`. Put a boundary above every read whose loader can be slow;
27
+ `isLoading` covers later refetches (`load()`, key/group refreshes). Once the
28
+ component renders, `data` is present. (On document loads a loader registered
29
+ with `{ stream: "navigation" }` is already settled at first paint, so its
30
+ reads never suspend there — see `/loader`.)
31
+
23
32
  **Precondition**: Loader must be registered on route via `loader()` helper.
24
33
 
25
34
  Loaders can also be passed as props from server to client components:
@@ -30,6 +30,13 @@ function BreadcrumbNav() {
30
30
  const lastCrumb = useHandle(Breadcrumbs, (data) => data.at(-1));
31
31
  ```
32
32
 
33
+ Writers: handlers AND loader bodies — `ctx.use(Handle)` returns the push
34
+ function in each (middleware has no handle APIs). Loader pushes follow a delivery race (settle before
35
+ the handler barrier → in the SSR'd document; later → applied client-side), and
36
+ `loader(Def, { stream: "navigation" })` makes them deterministic in the
37
+ document. See `/loader` → "Writing Handles from Loaders". `useHandle`
38
+ re-renders on every handle update, late loader pushes included.
39
+
33
40
  Handles can be passed as props from server to client components:
34
41
 
35
42
  ```tsx
@@ -22,20 +22,41 @@ function DashboardLayout({ children }: { children?: React.ReactNode }) {
22
22
 
23
23
  ### useOutlet()
24
24
 
25
- Access outlet content programmatically:
25
+ Access outlet content and its client-route presentation state programmatically:
26
+
27
+ ```ts
28
+ interface OutletState {
29
+ readonly content: ReactNode;
30
+ readonly pending: boolean;
31
+ }
32
+ ```
26
33
 
27
34
  ```tsx
28
35
  "use client";
29
36
  import { useOutlet } from "@rangojs/router/client";
30
37
 
31
38
  function ConditionalLayout() {
32
- const outlet = useOutlet();
33
- // ReactNode | null
39
+ const { content, pending } = useOutlet();
34
40
 
35
- return outlet ? (
36
- <div className="with-content">{outlet}</div>
41
+ return content ? (
42
+ <div className="with-content" aria-busy={pending}>
43
+ {content}
44
+ </div>
37
45
  ) : (
38
46
  <div className="empty">No content</div>
39
47
  );
40
48
  }
41
49
  ```
50
+
51
+ Migration from the old bare-node return is `const { content, pending } =
52
+ useOutlet()`, then render `content` where you previously rendered the hook result.
53
+ `<Outlet />` itself is unchanged.
54
+
55
+ `pending` is narrow. After hydration, a `clientUrls()` layout receives `true`
56
+ while a browser-local match to a different client route beneath it is presenting
57
+ optimistic loading or retaining the current branch until canonical partial Flight
58
+ settles. It clears on commit, error, redirect, cancellation, or supersession. It
59
+ is `false` during SSR and does not describe ordinary server-route navigation,
60
+ prefetch, generic Suspense, unrelated actions, or a params/search change that
61
+ keeps the same client route record. Use `useNavigation()`, `useLinkStatus()`, or
62
+ loader state for those scopes.