@rangojs/router 0.22.0 → 0.23.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 (180) hide show
  1. package/dist/bin/rango.js +8 -3
  2. package/dist/testing/vitest.js +31 -1
  3. package/dist/types/browser/defer.d.ts +48 -0
  4. package/dist/types/browser/document-revealed.d.ts +17 -0
  5. package/dist/types/browser/event-controller.d.ts +7 -1
  6. package/dist/types/browser/navigation-transaction.d.ts +21 -7
  7. package/dist/types/browser/partial-update.d.ts +34 -5
  8. package/dist/types/browser/react/NavigationProvider.d.ts +13 -3
  9. package/dist/types/browser/react/filter-segment-order.d.ts +7 -0
  10. package/dist/types/browser/response-adapter.d.ts +7 -0
  11. package/dist/types/browser/types.d.ts +19 -9
  12. package/dist/types/browser/validate-redirect-origin.d.ts +11 -0
  13. package/dist/types/build/generate-route-types.d.ts +1 -1
  14. package/dist/types/build/route-types/router-processing.d.ts +1 -1
  15. package/dist/types/build/route-types/scan-filter.d.ts +16 -2
  16. package/dist/types/cache/cf/cf-cache-store.d.ts +16 -1
  17. package/dist/types/cache/cf/cf-cache-types.d.ts +23 -2
  18. package/dist/types/client-urls/client-urls.d.ts +1 -1
  19. package/dist/types/client-urls/hoisted-revalidate.d.ts +13 -0
  20. package/dist/types/client-urls/navigation.d.ts +0 -37
  21. package/dist/types/client-urls/revalidate-registry.d.ts +4 -0
  22. package/dist/types/client-urls/server-projection.d.ts +29 -1
  23. package/dist/types/client-urls/types.d.ts +34 -69
  24. package/dist/types/client.d.ts +0 -1
  25. package/dist/types/errors.d.ts +5 -0
  26. package/dist/types/handles/deferred-resolution.d.ts +2 -1
  27. package/dist/types/handles/pushed-by.d.ts +23 -0
  28. package/dist/types/loader-store.d.ts +8 -0
  29. package/dist/types/prefetch-markers.d.ts +11 -0
  30. package/dist/types/route-definition/helpers-types.d.ts +27 -0
  31. package/dist/types/router/is-action.d.ts +2 -1
  32. package/dist/types/router/segment-resolution/loader-mask.d.ts +3 -2
  33. package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +241 -0
  34. package/dist/types/server/context.d.ts +21 -1
  35. package/dist/types/server/handle-store.d.ts +9 -1
  36. package/dist/types/server/request-context.d.ts +14 -1
  37. package/dist/types/server.d.ts +1 -0
  38. package/dist/types/testing/flight.entry.d.ts +1 -1
  39. package/dist/types/testing/index.d.ts +3 -3
  40. package/dist/types/testing/internal/action-id.d.ts +10 -1
  41. package/dist/types/testing/internal/client-urls-projection.d.ts +16 -0
  42. package/dist/types/testing/internal/test-action.d.ts +8 -0
  43. package/dist/types/testing/run-loader.d.ts +2 -1
  44. package/dist/types/testing/run-revalidate.d.ts +74 -0
  45. package/dist/types/testing/run-transition-when.d.ts +2 -3
  46. package/dist/types/testing/serve-shell-request.d.ts +38 -0
  47. package/dist/types/testing/vitest.d.ts +11 -0
  48. package/dist/types/types/boundaries.d.ts +9 -0
  49. package/dist/types/types/handler-context.d.ts +14 -0
  50. package/dist/types/types/loader-types.d.ts +30 -0
  51. package/dist/types/types/segments.d.ts +21 -4
  52. package/dist/types/urls/path-helper-types.d.ts +25 -4
  53. package/dist/types/urls/type-extraction.d.ts +6 -2
  54. package/dist/types/vite/discovery/discover-routers.d.ts +9 -0
  55. package/dist/types/vite/discovery/state.d.ts +11 -4
  56. package/dist/types/vite/plugins/client-urls-revalidate-hoist.d.ts +54 -0
  57. package/dist/types/vite/plugins/hoist/literal.d.ts +83 -0
  58. package/dist/types/vite/plugins/hoist/scope.d.ts +40 -0
  59. package/dist/types/vite/plugins/hoist/source.d.ts +41 -0
  60. package/dist/types/vite/plugins/transition-when-hoist.d.ts +8 -11
  61. package/dist/types/vite/router-discovery.d.ts +10 -0
  62. package/dist/vite/index.js +843 -309
  63. package/package.json +2 -2
  64. package/skills/caching/SKILL.md +42 -5
  65. package/skills/catalog.json +2 -2
  66. package/skills/client-urls/SKILL.md +113 -24
  67. package/skills/cloudflare/SKILL.md +14 -9
  68. package/skills/hooks/SKILL.md +11 -3
  69. package/skills/links/SKILL.md +24 -0
  70. package/skills/loader/SKILL.md +192 -1
  71. package/skills/migrate-nextjs/SKILL.md +2 -2
  72. package/skills/migrate-react-router/SKILL.md +10 -7
  73. package/skills/migrate-react-router/data-and-actions.md +1 -1
  74. package/skills/ppr/SKILL.md +13 -0
  75. package/skills/prerender/SKILL.md +22 -8
  76. package/skills/rango/SKILL.md +12 -6
  77. package/skills/route/SKILL.md +139 -0
  78. package/skills/testing/SKILL.md +3 -2
  79. package/skills/testing/client-components.md +1 -1
  80. package/skills/testing/predicates.md +67 -32
  81. package/skills/testing/serve-shell-request.md +88 -18
  82. package/skills/testing/setup.md +2 -2
  83. package/skills/typesafety/route-types.md +2 -2
  84. package/skills/view-transitions/SKILL.md +26 -0
  85. package/src/browser/defer.ts +82 -0
  86. package/src/browser/document-revealed.ts +52 -0
  87. package/src/browser/event-controller.ts +43 -32
  88. package/src/browser/navigation-bridge.ts +64 -14
  89. package/src/browser/navigation-client.ts +48 -36
  90. package/src/browser/navigation-store.ts +13 -0
  91. package/src/browser/navigation-transaction.ts +25 -8
  92. package/src/browser/partial-update.ts +528 -135
  93. package/src/browser/prefetch/fetch.ts +2 -1
  94. package/src/browser/react/NavigationProvider.tsx +47 -28
  95. package/src/browser/react/filter-segment-order.ts +13 -2
  96. package/src/browser/response-adapter.ts +20 -0
  97. package/src/browser/rsc-router.tsx +17 -45
  98. package/src/browser/server-action-bridge.ts +19 -63
  99. package/src/browser/types.ts +19 -9
  100. package/src/browser/validate-redirect-origin.ts +16 -0
  101. package/src/build/generate-route-types.ts +1 -0
  102. package/src/build/route-types/router-processing.ts +3 -6
  103. package/src/build/route-types/scan-filter.ts +37 -10
  104. package/src/cache/cf/cf-cache-store.ts +59 -21
  105. package/src/cache/cf/cf-cache-types.ts +24 -2
  106. package/src/cache/document-cache.ts +22 -1
  107. package/src/client-urls/client-urls.ts +22 -6
  108. package/src/client-urls/hoisted-revalidate.ts +25 -0
  109. package/src/client-urls/navigation.ts +0 -96
  110. package/src/client-urls/revalidate-registry.ts +30 -0
  111. package/src/client-urls/server-projection.ts +151 -65
  112. package/src/client-urls/types.ts +39 -71
  113. package/src/client.tsx +9 -5
  114. package/src/errors.ts +15 -0
  115. package/src/handles/deferred-resolution.ts +28 -5
  116. package/src/handles/pushed-by.ts +122 -0
  117. package/src/loader-store.ts +18 -7
  118. package/src/prefetch-markers.ts +13 -0
  119. package/src/route-content-wrapper.tsx +18 -4
  120. package/src/route-definition/dsl-helpers.ts +27 -4
  121. package/src/route-definition/helpers-types.ts +27 -1
  122. package/src/router/intercept-resolution.ts +6 -4
  123. package/src/router/is-action.ts +2 -1
  124. package/src/router/loader-resolution.ts +83 -4
  125. package/src/router/match-api.ts +36 -1
  126. package/src/router/match-middleware/background-revalidation.ts +11 -1
  127. package/src/router/match-middleware/cache-lookup.ts +29 -6
  128. package/src/router/match-middleware/cache-store.ts +17 -1
  129. package/src/router/match-result.ts +14 -2
  130. package/src/router/navigation-snapshot.ts +2 -1
  131. package/src/router/segment-resolution/fresh.ts +22 -18
  132. package/src/router/segment-resolution/loader-cache.ts +2 -1
  133. package/src/router/segment-resolution/loader-mask.ts +5 -3
  134. package/src/router/segment-resolution/prefetch-deferral.ts +543 -0
  135. package/src/router/segment-resolution/revalidation.ts +169 -60
  136. package/src/rsc/rsc-rendering.ts +50 -7
  137. package/src/rsc/shell-capture.ts +2 -1
  138. package/src/segment-loader-promise.ts +23 -20
  139. package/src/segment-system.tsx +60 -26
  140. package/src/server/context.ts +39 -5
  141. package/src/server/handle-store.ts +104 -8
  142. package/src/server/loader-registry.ts +6 -1
  143. package/src/server/request-context.ts +21 -1
  144. package/src/server.ts +4 -0
  145. package/src/testing/action-form.ts +2 -11
  146. package/src/testing/e2e/page-helpers.ts +3 -1
  147. package/src/testing/flight.entry.ts +1 -0
  148. package/src/testing/index.ts +6 -3
  149. package/src/testing/internal/action-id.ts +16 -1
  150. package/src/testing/internal/client-urls-projection.ts +70 -0
  151. package/src/testing/internal/test-action.ts +25 -0
  152. package/src/testing/run-loader.ts +2 -1
  153. package/src/testing/run-revalidate.ts +142 -0
  154. package/src/testing/run-transition-when.ts +7 -8
  155. package/src/testing/serve-shell-request.ts +116 -6
  156. package/src/testing/vitest.ts +67 -1
  157. package/src/types/boundaries.ts +12 -0
  158. package/src/types/handler-context.ts +14 -0
  159. package/src/types/loader-types.ts +30 -0
  160. package/src/types/segments.ts +26 -4
  161. package/src/urls/path-helper-types.ts +27 -5
  162. package/src/urls/type-extraction.ts +30 -10
  163. package/src/use-loader.tsx +18 -13
  164. package/src/vite/discovery/client-urls-projection.ts +20 -21
  165. package/src/vite/discovery/discover-routers.ts +94 -26
  166. package/src/vite/discovery/state.ts +20 -4
  167. package/src/vite/discovery/virtual-module-codegen.ts +22 -17
  168. package/src/vite/plugins/client-urls-revalidate-hoist.ts +564 -0
  169. package/src/vite/plugins/hoist/literal.ts +417 -0
  170. package/src/vite/plugins/hoist/scope.ts +370 -0
  171. package/src/vite/plugins/hoist/source.ts +84 -0
  172. package/src/vite/plugins/transition-when-hoist.ts +113 -779
  173. package/src/vite/rango.ts +2 -0
  174. package/src/vite/router-discovery.ts +93 -32
  175. package/dist/types/client-urls/revalidate-chain.d.ts +0 -33
  176. package/dist/types/client-urls/revalidation-protocol.d.ts +0 -25
  177. package/dist/types/testing/run-client-revalidate.d.ts +0 -43
  178. package/src/client-urls/revalidate-chain.ts +0 -83
  179. package/src/client-urls/revalidation-protocol.ts +0 -56
  180. package/src/testing/run-client-revalidate.ts +0 -92
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -194,7 +194,7 @@
194
194
  },
195
195
  "dependencies": {
196
196
  "@types/debug": "^4.1.12",
197
- "@vitejs/plugin-rsc": "^0.5.35",
197
+ "@vitejs/plugin-rsc": "^0.5.36",
198
198
  "debug": "^4.4.1",
199
199
  "magic-string": "^0.30.17",
200
200
  "picomatch": "^4.0.4",
@@ -453,6 +453,28 @@ or a non-cacheable variable fails on a miss unless the loader `cache()` has a
453
453
  a `keyGenerator`. Either one switches the check off, so it must itself include
454
454
  the value (`/loader` → "Cache Key").
455
455
 
456
+ ### Loaders and `prefetch: false`
457
+
458
+ A loader registered with `{ prefetch: false }` is skipped in a `<Link>`
459
+ prefetch, and its own `cache()` is not read either, even when it would hit:
460
+ the fill request that the click sends afterwards reads the loader cache and
461
+ runs the loader on a miss. A route-level `cache()` boundary is unaffected: its
462
+ stored handler output is served and written as before, hit or miss, and
463
+ `loading(fallback, { prefetch: false })` under it defers only the loaders
464
+ behind the fallback, never the cached handler. A `cache()` route below a
465
+ layout with a flagged `loading()` keeps using its record whenever the browser
466
+ already holds that layout; only a prefetch that defers the layout itself (it
467
+ is new to the page) leaves the record unread and unwritten. A response that
468
+ carries deferred work is never reused: the document cache refuses it, and it
469
+ is sent `cache-control: private, no-cache` with `X-Rango-Prefetch` added to
470
+ `Vary`, whatever `Cache-Control` the route set, so no CDN keeps it and it
471
+ never answers a navigation. A browser may keep a copy and answer the same
472
+ prefetch from it while it reloads the page for back/forward; the click
473
+ completes it like any other. A fill is never answered from a stored body.
474
+ Every body the document cache does hold is complete, so one entry answers a
475
+ prefetch and a navigation, as without the flag. See `/loader` →
476
+ "`prefetch: false`".
477
+
456
478
  ## Global Cache Configuration
457
479
 
458
480
  Configure the app-level store on the router. `cache()` boundaries, cached
@@ -621,11 +643,26 @@ const router = createRouter<AppBindings>({
621
643
 
622
644
  **How the two layers work:**
623
645
 
624
- | Scenario | L1 (Cache API) | L2 (KV) | Result |
625
- | ------------ | -------------- | ------- | ----------------------------- |
626
- | Hot request | HIT | — | Serve from L1 (fast) |
627
- | Cold colo | MISS | HIT | Serve from KV, promote to L1 |
628
- | First render | MISS | MISS | Render, write to both L1 + KV |
646
+ | Scenario | L1 (Cache API) | L2 (KV) | Result |
647
+ | ------------------------------------ | ---------------- | --------------------------- | ----------------------------- |
648
+ | Hot request | HIT | — | Serve from L1 (fast) |
649
+ | Cold colo | MISS | HIT | Serve from KV, promote to L1 |
650
+ | First render | MISS | MISS | Render, write to both L1 + KV |
651
+ | After `updateTag`, `kvFallThrough` | copy invalidated | HIT, written after the call | Serve from KV, promote to L1 |
652
+ | After `updateTag`, `kvFallThrough` | copy invalidated | MISS, or the old entry | Render, write to both L1 + KV |
653
+ | After `updateTag`, the default (off) | copy invalidated | not read | Render, write to both L1 + KV |
654
+
655
+ By default a copy a tag invalidation rejected is a miss and the colo renders.
656
+ `new CFCacheStore({ ctx, kv, kvFallThrough: true })` makes it read KV like a
657
+ cold colo, so the colo serves the entry another colo already rebuilt (a
658
+ visitor's render there, or a `router.prerender()` warm) instead of rebuilding
659
+ it. The KV entry passes the same tag check: one written before the invalidation
660
+ is never served. The cost is one KV get per rejected copy per colo after each
661
+ invalidation (a `ppr` document reads a shell, a record and each `"use cache"`
662
+ result, every copy rejected and the newer entry in KV: 150 ms added, measured
663
+ in #1069 at 50 ms per KV read locally, not on a deployed worker).
664
+ Turn it on for several locations where a render costs more than a KV read;
665
+ leave it off otherwise. It needs `kv`.
629
666
 
630
667
  KV entries require `expirationTtl >= 60s`. Short-lived data entries (< 60s total
631
668
  TTL) are only cached in L1. A PPR shell is always written to KV, a short one
@@ -35,7 +35,7 @@
35
35
  },
36
36
  {
37
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 ssr:false 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.",
38
+ "description": "Define client-component route groups with clientUrls() in a \"use client\" module — no handlers, useLoader at read sites, server-run revalidate predicates, loader-thrown notFound/redirect, loader handle writes, and the ssr:false 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
39
  "argumentHint": "[setup]",
40
40
  "path": "skills/client-urls/SKILL.md"
41
41
  },
@@ -137,7 +137,7 @@
137
137
  },
138
138
  {
139
139
  "name": "loader",
140
- "description": "Define data loaders with createLoader and register them on routes with loader(). Use when a route needs per-request data that stays fresh and streams while the page renders, a client component reads server data with useLoader, a loader should re-run after specific actions (revalidate), be cached (cache), be callable from the client (fetchable loaders), throw notFound()/redirect(), write page meta/breadcrumbs handles, or must be settled in the SSR'd document (ssr:false).",
140
+ "description": "Define data loaders with createLoader and register them on routes with loader(). Use when a route needs per-request data that stays fresh and streams while the page renders, a client component reads server data with useLoader, a loader should re-run after specific actions (revalidate), be cached (cache), be callable from the client (fetchable loaders), throw notFound()/redirect(), write page meta/breadcrumbs handles, must be settled in the SSR'd document (ssr:false), or must stay out of link prefetches (prefetch:false).",
141
141
  "argumentHint": "[loader]",
142
142
  "path": "skills/loader/SKILL.md"
143
143
  },
@@ -1,6 +1,6 @@
1
1
  ---
2
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 ssr:false 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.
3
+ description: Define client-component route groups with clientUrls() in a "use client" module — no handlers, useLoader at read sites, server-run revalidate predicates, loader-thrown notFound/redirect, loader handle writes, and the ssr:false 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
4
  argument-hint: "[setup]"
5
5
  ---
6
6
 
@@ -10,7 +10,8 @@ argument-hint: "[setup]"
10
10
  components are client components, and there are **no handlers at all** — every
11
11
  data read is `useLoader` at the consumption site, waiting is scoped by the
12
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
13
+ run on the server (the build moves them out of the `"use client"` module).
14
+ The server shop pattern — `await ctx.use(ProductLoader)` in
14
15
  a handler — is pipeline-blocking by contract: no pending UI can ever cover that
15
16
  latency. `clientUrls()` makes that shape unrepresentable.
16
17
 
@@ -20,9 +21,9 @@ layout, mostly tab/param/filter switches. Three things compound: the
20
21
  definition also matches in the browser, so a soft navigation renders the
21
22
  destination component immediately (loader reads suspend into `loading()` or
22
23
  an inline `<Suspense>`; `useOutlet().pending` flips for chrome) with no server
23
- round-trip to start; browser-run `revalidate()` predicates HOLD data across
24
- navigations that don't invalidate it (a tab switch re-runs nothing — only the
25
- decision crosses the wire); and any read that does refresh streams behind its
24
+ round-trip to start; per-loader `revalidate()` predicates, run on the server
25
+ with the same arguments as in `urls()`, HOLD data across navigations that
26
+ don't invalidate it (a tab switch re-runs nothing); and any read that does refresh streams behind its
26
27
  own inline boundary instead of gating the commit. Add per-Link prefetch
27
28
  (`/links`) and a warmed click lands the complete page instantly.
28
29
 
@@ -131,9 +132,9 @@ flow into the generated route map, so `href()`, `ctx.reverse()` and
131
132
  | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
132
133
  | `path()` | Options are `name`, `search`, `trailingSlash`, `ppr` (shell caching — see `/ppr` → The loader lane rule); no response variants. Use callback may contain `loader()`, `loading()`, `transition()` |
133
134
  | `layout()` | `layout(Component, () => [...])` — children are required and must contain at least one `path()`; may also hold `loader()`, `loading()`, `intercept()`, nested `layout()` |
134
- | `loader()` | `loader(Def, use?)` or `loader(Def, { ssr: false }, use?)` — see below |
135
+ | `loader()` | `loader(Def, use?)` or `loader(Def, { ssr: false, prefetch: false }, use?)` — see below |
135
136
  | `loading()` | Route-level boundary around the optimistic render; inline `<Suspense>` at read sites keeps the destination's chrome visible while only the reads wait |
136
- | `revalidate()` | Valid **inside a loader() use callback only**; runs in the browser |
137
+ | `revalidate()` | Valid **inside a loader() use callback only**; runs on the server (the build hoists it out of the `"use client"` module) |
137
138
  | `transition()` | Inside a `path()` use callback only (at most one). ViewTransition config plus a browser-run `when` predicate (see `/view-transitions`); same-route navs in a group already hold without it |
138
139
  | `intercept()` | Dot-local named target in the SAME definition; use may contain `loader()`/`loading()` |
139
140
 
@@ -170,7 +171,8 @@ DIFFERENT schedules:
170
171
  - **Middleware runs on EVERY canonical request**, not just the first
171
172
  encounter. Every navigation inside the group — including a tab switch whose
172
173
  loaders all hold — still sends the canonical partial request (it commits
173
- URL/history and carries the revalidation decisions), and middleware wraps
174
+ URL/history, and the server runs each loader's `revalidate()` while it
175
+ handles it), and middleware wraps
174
176
  each one: `requireAdmin` re-authorizes every navigation, and its
175
177
  `ctx.set()` variables are fresh for the group's loaders on every pass. The
176
178
  instant feel comes from held data and optimistic presentation, not from
@@ -187,22 +189,62 @@ All three pinned by dev + production e2e in the router repository (a
187
189
  middleware-stamped header per request, a layout run-count stamp held across
188
190
  navigations).
189
191
 
190
- ## Revalidation runs in the browser
191
-
192
- A server-tree `revalidate()` predicate executes on the server. A `clientUrls()`
193
- predicate is client-module code, so it executes **in the browser** with
194
- client-computable args — `currentUrl`, `nextUrl`, `currentParams`,
195
- `nextParams`, `defaultShouldRevalidate`, `stale`, `isAction`, `actionId` — and
196
- only its _decision_ crosses the wire with the navigation request. Requests that
197
- carry no decisions (no-JS, progressive enhancement, prefetch, document loads)
198
- follow the locked server defaults.
199
-
200
- `isAction` is the same callable matcher as the server predicate, not a
201
- boolean. Action identity is `actionId`, whose FORM differs per environment:
202
- file-path `$id` (`path#export`) in the RSC env, hashed `$$id` in the
203
- browser — which is why the matcher (resolving an imported reference's
204
- `$id ?? $$id`) is the supported surface and a file-path substring on
205
- `actionId` is not:
192
+ ## Revalidation runs on the server
193
+
194
+ A `clientUrls()` loader's `revalidate()` runs only on the server, with exactly
195
+ the arguments and type of a `urls()` loader's (`ShouldRevalidateFn` from
196
+ `@rangojs/router`): `currentParams`, `currentUrl`, `nextParams`, `nextUrl`,
197
+ `defaultShouldRevalidate`, `context` (the handler context, so
198
+ `context.get(var)` works), `segmentType`, `actionId`, `isAction(...refs)`,
199
+ `actionUrl`, `actionResult`, `formData`, `method`,
200
+ `routeName`/`fromRouteName`/`toRouteName`, and `stale`. Nothing runs in the
201
+ browser before the request goes out; there is no client-computed decision and
202
+ no hybrid.
203
+
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
208
+ is a rule: **the argument may reference imports and globals only.** Anything
209
+ else is a build error naming `file:line:column`, starting
210
+ `[rango] clientUrls() revalidate() at ...: revalidate() runs on the server; ...`.
211
+
212
+ - Accepted: an inline arrow or function expression, an imported predicate
213
+ (`revalidate(isCartAction)`), a namespace member (`P.cart`), a call of an
214
+ imported factory (`makePredicate("cart")`), and globals such as `URL`,
215
+ `Boolean` and `import.meta.env`.
216
+ - A binding declared in the same `"use client"` module is rejected: move the
217
+ predicate to its own module without `"use client"` and import it, or write it
218
+ inline. A binding of an enclosing function is rejected: read it from the
219
+ predicate's argument. So are `this`, `arguments`, `super`, `new.target`,
220
+ `import()` and `import.meta` other than `import.meta.env`.
221
+ - A browser-only global (`window`, `document`, `navigator`, `localStorage`,
222
+ `sessionStorage`, `location`, `history`) is rejected: put the input on the
223
+ request (a cookie or a search param) and read it from the argument, or
224
+ refresh with `useRefreshLoaders`.
225
+ - 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.
227
+ - An import of `client-only`, or of a module whose directive prologue is
228
+ `"use client"`, is rejected (on the server its exports are client
229
+ references). A `"use server"` import is fine: on the server it is the real
230
+ action, which `isAction(ref)` matches. `server-only` and `node:` imports work
231
+ on the server, but the import also stays in the `"use client"` module
232
+ (imports are not pruned from the browser bundle), so it must still load in
233
+ the browser.
234
+
235
+ Per request: a JS action POST runs the predicate with `method: "POST"`,
236
+ `actionId`, `actionResult` (the action's return value), `formData` (the last
237
+ `FormData` argument) and `actionUrl`; the default is true. An in-group
238
+ navigation GET defaults to true when params or search changed. A prefetch
239
+ partial runs the predicate, so a prefetched in-group navigation honours it. A
240
+ stale back/forward revalidation sees `stale: true`. Document requests and
241
+ no-JS form posts hold no segments, so every loader renders whatever a predicate
242
+ says. On the action-triggered refetch GET (consolidation) `isAction()` is false,
243
+ as for `urls()`. `currentParams`/`nextParams` are the server's route params,
244
+ including the `include()` mount's params, and `actionId` is the server's
245
+ `src/...#export` form; match actions with `isAction(ref)` rather than a
246
+ substring. Vitest does not run the plugin, so in unit tests the predicate stays
247
+ a plain function: test it with `runRevalidate` (`/testing`).
206
248
 
207
249
  ```ts
208
250
  import { addToCart, removeFromCart } from "./actions/cart";
@@ -211,6 +253,11 @@ import * as CartActions from "./actions/cart";
211
253
  // After an action, re-run only for cart actions; on navigation, keep the default.
212
254
  revalidate(({ isAction }) => (isAction() ? isAction(CartActions) : undefined));
213
255
 
256
+ // Decide on what the action returned (actionResult is visible on the server):
257
+ revalidate((ctx) =>
258
+ ctx.isAction(checkout) ? ctx.actionResult?.ok === true : undefined,
259
+ );
260
+
214
261
  // Matcher forms (each returns a boolean):
215
262
  isAction(); // any action
216
263
  isAction(addToCart); // one action
@@ -226,6 +273,11 @@ with the ternary above instead (same rule as a server-tree loader). A bare
226
273
  `isAction(refs)` is a hard `false` on navigation too, so the loader stops
227
274
  refetching when params change.
228
275
 
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.
280
+
229
281
  Two scars worth copying:
230
282
 
231
283
  - A blunt `() => false` keeps serving the OLD product on product→product
@@ -321,6 +373,43 @@ need it, not the route.
321
373
 
322
374
  Pinned end to end by dev + production e2e in the router repository.
323
375
 
376
+ ## `prefetch: false` — keep a loader out of prefetches
377
+
378
+ A `<Link>` prefetch of a group route runs its loaders. Flag the expensive one
379
+ and the prefetch skips it:
380
+
381
+ ```tsx
382
+ path("/product/:slug", ProductPage, { name: "product" }, () => [
383
+ loader(PriceLoader),
384
+ loader(ReviewsLoader, { prefetch: false }), // kept out of prefetches
385
+ loading(<ProductSkeleton />),
386
+ ]),
387
+ ```
388
+
389
+ A click inside the group still shows the destination at once. With the
390
+ prefetch received, the URL and the other loaders' data move with the click
391
+ too, `useOutlet().pending` ends with that commit, and one follow-up request
392
+ (the fill) runs only the flagged loaders. Read the flagged loader inside its
393
+ own `<Suspense>` to show the page and that fallback at once; with no boundary
394
+ React holds the page being left until the fill answers, as it would with no
395
+ prefetch. Same rules as on a server route (`/loader` → "`prefetch: false`"):
396
+ only a segment new to the page is deferred, so a same-route click
397
+ (`/product/a` to `/product/b`) prefetches the loader as usual.
398
+
399
+ - On a `layout()` of the group the flag applies to every route below it.
400
+ - A flagged `loader()` on a server layout above the group is deferred while
401
+ that layout is new to the page (entering the group from outside).
402
+ - `loading()` in `clientUrls()` takes no options, and a flagged `loading()`
403
+ on a server layout above the group defers nothing for the group's routes.
404
+ - `intercept()` loaders accept the flag and ignore it, as in `urls()`.
405
+ - With `ssr: false` on a `ppr` group route the flag is ignored (the bake
406
+ lane), as on a server route.
407
+ - Test it with `serveShellRequest` in the Flight project: mount the module
408
+ itself, and `rangoUseClientTransform()` installs the group's projection
409
+ (`/testing` → `serve-shell-request.md`, "`clientUrls()` routes"). It also
410
+ registers the group's `revalidate()` predicates, so the server runs them
411
+ there as in your app.
412
+
324
413
  ## Intercepts
325
414
 
326
415
  ```tsx
@@ -292,20 +292,25 @@ traffic"). On Workers:
292
292
  the request it is called from.
293
293
 
294
294
  - **A warm fills the colo it runs in, and KV.** Another colo that already
295
- holds its own Cache API copy keeps serving it until it expires; an L1 hit
296
- never consults KV. After a deploy no colo holds a copy, so the warm's KV
297
- entry is what each one reads next. For a content change, invalidate and then
298
- warm, in this order:
295
+ holds its own Cache API copy keeps serving it until it expires or a tag
296
+ invalidation rejects it; a hit on a valid copy never consults KV. After a
297
+ deploy no colo holds a copy, so the warm's KV entry is what each one reads
298
+ next. For a content change, invalidate and then warm, in this order:
299
299
 
300
300
  ```typescript
301
- await updateTag(`product:${id}`); // no colo serves the old copy any more
301
+ await updateTag(`product:${id}`); // a colo the marker has reached refuses its old copy
302
302
  await router.prerender({ env, ctx })(`/products/${id}`); // writes the new one
303
303
  ```
304
304
 
305
- A colo with no copy of its own reads the warmed entry from KV. A colo that
306
- was still holding the old copy treats it as a miss and renders once for
307
- itself (it does not read the fresher KV entry). With `tagPurge` the purge
308
- evicts every colo's copy, so they all read the warmed entry.
305
+ A colo with no copy of its own then reads the warmed entry from KV and
306
+ renders nothing. A colo that was still holding the old copy renders once for
307
+ itself by default; with `kvFallThrough: true` on the store it reads KV as if
308
+ it held none and renders nothing (with `tagPurge` the purge evicts the copy
309
+ first). That option costs one KV get per rejected copy per colo after each
310
+ invalidation, so turn it on where a render costs more than that. The KV
311
+ entry is checked against the same tag markers as
312
+ the colo's own copy, so a colo that KV still hands the entry from before
313
+ the invalidation refuses it and renders for itself.
309
314
 
310
315
  - **Entries under 60 seconds stay in one colo.** KV's minimum `expirationTtl`
311
316
  is 60 s, so a segment, item or response entry whose `ttl + swr` is shorter
@@ -111,11 +111,19 @@ cannot mismatch its server HTML.
111
111
 
112
112
  - Navigation state, params and action state: the component shows the live
113
113
  value right after it hydrates.
114
- - Handle data a loader pushed late, and the entry's location state: every
115
- reader gets them once the whole document has streamed and hydrated, in one
116
- transition. A reader re-renders only when its selection changes.
114
+ - Handle data a loader pushed late, and the entry's location state: both wait
115
+ until the whole document has streamed and been revealed. Location state
116
+ lands in the barrier commit (one transition that also hydrates every
117
+ boundary still dehydrated); handle data lands in the release notification
118
+ right after that commit. `useLocationState` readers re-render when the
119
+ entry's state object changes; `useHandle` readers when their selection
120
+ changes.
117
121
  - A navigation that commits before then mounts its page with the live values
118
122
  from its first render. A boundary of the document that persists across that
119
123
  navigation and has not hydrated yet then hydrates against the live values;
120
124
  where they differ from its HTML, React reports the mismatch and renders that
121
125
  boundary on the client.
126
+ - A held navigation whose history entry commits during that window releases
127
+ the store for the whole hold. A boundary of the old page that hydrates during
128
+ the hold reads the destination's values and can mismatch its HTML; React
129
+ then renders that boundary on the client.
@@ -479,6 +479,30 @@ mounted viewport/render Links. Links share one document-level scope observer,
479
479
  not one observer per Link or container. Adding a scope cannot recall work already
480
480
  queued or in flight; removing it re-arms both Link types.
481
481
 
482
+ ### What a prefetch renders
483
+
484
+ A prefetch is a navigation sent early (a partial request with an
485
+ `X-Rango-Prefetch` header), and by default the server renders everything for
486
+ it: every handler and every loader. That is the point, a click then commits
487
+ without a request, but it is also forty renders for a grid of forty links.
488
+ Keep the expensive part out of the prefetch on the route, not on the link:
489
+
490
+ ```tsx
491
+ // skip one loader
492
+ loader(ReviewsLoader, { prefetch: false }),
493
+ // skip what the fallback covers
494
+ loading(<OrdersSkeleton />, { prefetch: false }),
495
+ ```
496
+
497
+ A click that adopts such a prefetch commits what was prefetched, shows the
498
+ fallback for the deferred part, and sends one extra request for it. Only what
499
+ is new to the page is deferred: a segment already on screen (the layout of the
500
+ section you are in, the route itself on `/product/a` to `/product/b`) is
501
+ prefetched as usual. See
502
+ `/loader` → "`prefetch: false`" and `/route` → "`loading()` options". To stop
503
+ a link or a whole section from prefetching at all, use the `prefetch` prop or
504
+ `data-prefetch-scope` above.
505
+
482
506
  ## When to use what
483
507
 
484
508
  | Context | API | Resolves | Use for |
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: loader
3
- description: Define data loaders with createLoader and register them on routes with loader(). Use when a route needs per-request data that stays fresh and streams while the page renders, a client component reads server data with useLoader, a loader should re-run after specific actions (revalidate), be cached (cache), be callable from the client (fetchable loaders), throw notFound()/redirect(), write page meta/breadcrumbs handles, or must be settled in the SSR'd document (ssr:false).
3
+ description: Define data loaders with createLoader and register them on routes with loader(). Use when a route needs per-request data that stays fresh and streams while the page renders, a client component reads server data with useLoader, a loader should re-run after specific actions (revalidate), be cached (cache), be callable from the client (fetchable loaders), throw notFound()/redirect(), write page meta/breadcrumbs handles, must be settled in the SSR'd document (ssr:false), or must stay out of link prefetches (prefetch:false).
4
4
  argument-hint: "[loader]"
5
5
  ---
6
6
 
@@ -936,6 +936,8 @@ function ProductPage() {
936
936
  }
937
937
  ```
938
938
 
939
+ A skeleton that has appeared stays on screen for at least about 300 ms before React replaces it with what it was waiting for: React does not replace a fallback sooner than 300 ms after the last fallback committed ([#1078](https://github.com/rangojs/rango/issues/1078)). On a cold click that mounts the `loading()` boundary, a loader that finishes sooner than that still shows its skeleton for about 300 ms, while a slower loader is not delayed. The router leaves this to React: an early skeleton is preferred to holding the click, and a fully prefetched click commits complete and shows none. A nested `loading()` whose data lands after the outer one has been revealed shows its own fallback, and the 300 ms starts again. The loaders feature page on the docs site has the measured figures. Under `transition()` a related cost applies; see [view-transitions](../view-transitions/SKILL.md).
940
+
939
941
  ## Loader Authority: notFound() and redirect()
940
942
 
941
943
  A loader may **throw** `notFound()` and `redirect()` — data-dependent
@@ -1094,6 +1096,195 @@ The costs and constraints:
1094
1096
  Also available in `clientUrls()` route groups (`/client-urls`), where the
1095
1097
  loader-heavy shape makes it most useful.
1096
1098
 
1099
+ ## `prefetch: false` — Keep a Loader Out of Prefetches
1100
+
1101
+ A `<Link>` prefetch is a navigation sent early: the server renders every
1102
+ handler and every loader for it. With the default `"viewport"` strategy that
1103
+ means a grid of forty product links runs forty copies of your slowest loader
1104
+ for pages most visitors never open. Pass `prefetch: false` to keep one loader
1105
+ out:
1106
+
1107
+ ```tsx
1108
+ import { Suspense } from "react";
1109
+
1110
+ path("/product/:slug", ProductPage, { name: "product" }, () => [
1111
+ loader(PriceLoader), // cheap: still runs in the prefetch
1112
+ loader(ReviewsLoader, { prefetch: false }), // expensive: skipped in a prefetch
1113
+ loading(<ProductSkeleton />),
1114
+ ]),
1115
+ ```
1116
+
1117
+ (`prefetch` sits beside `ssr` in `LoaderOptions`; combine them as
1118
+ `{ ssr: false, prefetch: false }` if you need both.)
1119
+
1120
+ What happens:
1121
+
1122
+ - A **prefetch** of the route does not run the loader and does not read its
1123
+ `cache()`; the payload marks it deferred. The rest of the page prefetches as
1124
+ usual.
1125
+ - Only on a route the browser does not show yet. A loader the page already
1126
+ holds, on a same-route navigation such as `/product/a` to `/product/b`, runs
1127
+ in the prefetch when it revalidates, exactly as without the flag, and the
1128
+ click sends no second request. The rule: a click that adopts a prefetch is
1129
+ never worse than the same click with no prefetch at all (one exception: a
1130
+ click before its prefetch has answered, under "The limits"), and content on
1131
+ screen is never swapped for a fallback because of the flag. For the same
1132
+ reason a prefetch that deferred something is used only on the page that
1133
+ made it: a page that already shows the segment makes its own prefetch.
1134
+ - A **click** that adopts that prefetch is never blocked by the loader. A
1135
+ deferred loader behaves like a loader that is still streaming, and where
1136
+ the page reads it (`useLoader(ReviewsLoader)`) React does what it does for
1137
+ one:
1138
+ - the read has its own `<Suspense>`: the page shows at once with that
1139
+ fallback in it. This can be a finer fallback than the same click shows
1140
+ with no prefetch, where the whole route is still streaming behind its
1141
+ `loading()`;
1142
+ - the nearest boundary is the route's `loading()`: that fallback shows;
1143
+ - no boundary at all: React holds the page being left until the value
1144
+ arrives.
1145
+
1146
+ Meanwhile the browser sends one second request, the fill, that runs only
1147
+ the deferred loader. Its value arrives in the page that is already on
1148
+ screen, as a streamed loader's does; nothing remounts.
1149
+
1150
+ - A **document request** (hard load), a **navigation with no prefetch to
1151
+ adopt**, an action, back/forward and a no-JS form post run the loader as
1152
+ they always did. Leaving the option out is the old behavior.
1153
+
1154
+ Read the loader behind a boundary, as you would any streamed loader:
1155
+
1156
+ ```tsx
1157
+ "use client";
1158
+ function Reviews() {
1159
+ const { data } = useLoader(ReviewsLoader);
1160
+ return <ReviewList items={data} />;
1161
+ }
1162
+
1163
+ // in the server component:
1164
+ <Suspense fallback={<ReviewsSkeleton />}>
1165
+ <Reviews />
1166
+ </Suspense>;
1167
+ ```
1168
+
1169
+ The limits:
1170
+
1171
+ - **A click before its prefetch has answered waits for it, then fetches the
1172
+ deferred part.** The flag is there to keep expensive or slow work out of
1173
+ every prefetch, so that an API that is costly to call is not called for
1174
+ every link a visitor might click. A click that comes while the link's
1175
+ prefetch is still in flight reuses that prefetch, as it does without the
1176
+ flag, and sends the fill when the prefetch answers. The page shows no later
1177
+ than with no prefetch; the deferred part shows its fallback until the fill
1178
+ lands, and can complete later than a click with no prefetch, by up to what
1179
+ was left of the prefetch. That is the price of keeping the work out of the
1180
+ prefetch.
1181
+ - **A navigation started before the fill answers cancels it.** It is
1182
+ cancelled when the newer navigation starts, not when that one commits. If
1183
+ the newer navigation then ends without replacing the page (a redirect the
1184
+ client refuses, an action refetch that drops its response), the deferred
1185
+ part keeps its fallback until the next navigation. Every other ending
1186
+ replaces the page anyway.
1187
+ - **`navigate()` resolves after the fill has answered.** The click's
1188
+ navigation fills the deferred part itself, so `await navigate(url)` (and a
1189
+ `router.push()` you await) settles a round trip after the page committed.
1190
+ `useNavigation()` reads `idle` from the commit, with `isStreaming` true
1191
+ until the fill has streamed in.
1192
+ - **A boundary is required for a fallback.** With no `loading()` on the entry
1193
+ and no `<Suspense>` around the read, nothing can show a fallback, so React
1194
+ holds the page being left on screen until the fill returns.
1195
+ `useNavigation()` reads `state: "loading"` for that time, as on any
1196
+ navigation that is still streaming, so a progress bar shows, and the URL
1197
+ hooks (`useNavigation().location`) change with the page. The address bar
1198
+ has already changed: it moves with the click, where a click with no
1199
+ prefetch moves it when its response starts. There is no definition-time
1200
+ error.
1201
+ - **`ctx.rendered()` is refused.** A loader that can be deferred throws on
1202
+ every request kind (a document load included, so you see it the first time
1203
+ you open the page in development) if it calls `await ctx.rendered()`. The
1204
+ fill does not re-run the entry's handler, so there is no render to wait for
1205
+ and no handle data to read. Remove `{ prefetch: false }` from the loader, or
1206
+ stop calling `ctx.rendered()`. Pushing a handle from the loader is fine.
1207
+ - **`ctx.set()` values from held handlers are not visible.** The fill runs the
1208
+ deferred work and middleware only. A handler or layout the client already
1209
+ holds does not run again, so a variable it set with `ctx.set()` is missing
1210
+ when the loader reads it with `ctx.get()`. Variables set by middleware are
1211
+ there, as middleware runs on every request; so are `ctx.params` and
1212
+ `ctx.request`.
1213
+ - **`redirect()`, `notFound()` and handle pushes arrive late.** They behave as
1214
+ for a loader that streams behind `loading()`, but they come with the fill,
1215
+ after the page is already on screen: a redirect becomes a replace
1216
+ navigation, `notFound()` renders the nearest not-found boundary, an error
1217
+ reaches the error boundary.
1218
+ - **The fill reads through the loader's own caching; a prefetch reads none
1219
+ of it, by intent.** A loader with `cache()` that nothing in the prefetch
1220
+ reads is skipped even when its entry is warm, and so is a `"use cache"`
1221
+ function it calls: a prefetch that looked would have to run the loader on
1222
+ a miss, which is the work the flag keeps out. The fill is where the loader
1223
+ runs. It serves a warm `cache()` entry without running the loader, runs
1224
+ the loader and stores on a cold one, and a `"use cache"` function inside
1225
+ the loader hits or stores as on any request.
1226
+ - **A loader something else reads with `ctx.use()` runs in the prefetch.**
1227
+ The reader needs the value, so the flag cannot keep the loader out. When
1228
+ the read is in time, the loader is an ordinary loader for that prefetch,
1229
+ its own `cache()` included: one run (none on a warm entry), the reader and
1230
+ the loader's data agree, nothing deferred, no fill for it. In time means:
1231
+ inside a handler that has no `loading()` (the router waits for it), or
1232
+ before the reader's first `await`.
1233
+ - **Two late readers make it run twice.** The loader runs in the prefetch for
1234
+ its reader and again in the fill, because by the time the read happens the
1235
+ loader's own segment has already been sent as deferred:
1236
+
1237
+ ```tsx
1238
+ // 1. A handler under loading() is streamed: the router does not wait for
1239
+ // it, so a read after its first await comes late.
1240
+ path(
1241
+ "/product/:id",
1242
+ async (ctx) => {
1243
+ const product = await getProduct(ctx.params.id);
1244
+ const reviews = await ctx.use(ReviewsLoader); // late: runs it here
1245
+ return <Product product={product} reviews={reviews} />;
1246
+ },
1247
+ { name: "product" },
1248
+ () => [
1249
+ loader(ReviewsLoader, { prefetch: false }), // and the fill runs it again
1250
+ loading(<ProductSkeleton />),
1251
+ ],
1252
+ );
1253
+
1254
+ // 2. A loader that is not deferred itself and reads it after an await.
1255
+ const SummaryLoader = createLoader(async (ctx) => {
1256
+ const product = await getProduct(ctx.params.id);
1257
+ const reviews = await ctx.use(ReviewsLoader); // late: two runs
1258
+ return summarize(product, reviews);
1259
+ });
1260
+ ```
1261
+
1262
+ Data is never missing, only fetched twice, and the page shows the fill's
1263
+ value. To get one run, start the read before the first `await`
1264
+ (`const reviews = ctx.use(ReviewsLoader)` at the top, awaited where you
1265
+ need it), or drop the flag from a loader the page cannot render without.
1266
+
1267
+ - **`clientUrls()` routes take it on `loader()`.** In a `clientUrls()`
1268
+ definition `loader(Def, { prefetch: false })` works as here: a click
1269
+ inside the group shows the destination at once, commits the prefetched
1270
+ data with the URL, and one fill runs the flagged loaders (see
1271
+ `/client-urls` → "`prefetch: false`"). A flagged `loading()` defers nothing
1272
+ on a group route, from the group or from a server layout above it.
1273
+ - **No effect inside `intercept()`.** Its loaders accept the flag and ignore
1274
+ it, in `urls()` and in `clientUrls()`: a prefetch that resolves an
1275
+ intercept defers nothing.
1276
+ - **`ppr` and `ssr: false`.** On a `ppr` route an `ssr: false` loader is the
1277
+ bake lane and is served from the shell, so `prefetch: false` is ignored for
1278
+ it and development logs a warning. Under `cache()` and `Prerender` the flag
1279
+ works as above (see `/caching`, `/prerender`, `/ppr`).
1280
+
1281
+ To skip a handler along with its loaders, flag the `loading()` instead (see
1282
+ `/route` → "`loading()` options"). `loader(Def, { prefetch: false })` accepts
1283
+ only a boolean; anything else throws `loader() prefetch must be a boolean`.
1284
+
1285
+ Test it with `serveShellRequest(router, url, { partial: { prefetch: true } })`
1286
+ and `result.readDeferred()` (see `/testing`).
1287
+
1097
1288
  ## Fetchable Loaders
1098
1289
 
1099
1290
  By default, loaders only run as part of a render (document, navigation, or
@@ -850,8 +850,8 @@ For **dashboard / admin / settings-shaped sections** — high navigation
850
850
  frequency inside one layout, mostly tab/param/filter switches — also consider
851
851
  porting that route group to `clientUrls()` (`/client-urls`): the definition
852
852
  matches in the browser (instant optimistic pending, no server round-trip to
853
- start a transition) and browser-run `revalidate()` predicates hold data across
854
- switches that don't invalidate it, which is the fastest transition shape Rango
853
+ start a transition) and per-loader `revalidate()` predicates (run on the
854
+ server) hold data across switches that don't invalidate it, which is the fastest transition shape Rango
855
855
  has. Server-component routes and `clientUrls()` groups compose in one tree.
856
856
 
857
857
  ## 7. Server Actions