@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
@@ -84,21 +84,24 @@ per app (both compose in one `urls()` tree via `include()`):
84
84
  | component (client, hooks) | stays a client component — no conversion |
85
85
  | `loader` (throws `redirect`/404) | `createLoader()` — thrown `redirect()`/`notFound()` kept |
86
86
  | `useLoaderData()` | `useLoader(Loader)` at the read site, under `<Suspense>` |
87
- | `shouldRevalidate` (runs client) | `revalidate()` predicate — ALSO runs in the browser |
87
+ | `shouldRevalidate` (runs client) | `revalidate()` predicate — runs on the server |
88
88
  | `meta({ data })` | `ctx.use(Meta)` push from the loader body |
89
89
  | `defer` / `Await` | loaders stream; `<Suspense>` above each read |
90
90
 
91
- Note the `shouldRevalidate` row: a server-tree `revalidate()` runs on the
92
- server, but a `clientUrls()` predicate runs in the browser with
93
- client-computable args — the exact RR semantics. A group ported this way can
94
- still be re-migrated to server handlers later, route by route.
91
+ Note the `shouldRevalidate` row: RR runs it in the browser, but a
92
+ `clientUrls()` loader's predicate runs on the server like a server-tree
93
+ `revalidate()`, with the same arguments (`actionResult` and `formData`
94
+ included). The build moves it out of the `"use client"` module, so it may
95
+ reference imports and globals only: move a predicate that captures
96
+ module-local values into its own module and import it. A group ported this
97
+ way can still be re-migrated to server handlers later, route by route.
95
98
 
96
99
  Start with server handlers; reach for `clientUrls()` when a route group's
97
100
  conversion cost is dominated by rewriting interactive components rather than
98
101
  by moving data fetching — or when the group is a **high-navigation-speed
99
102
  surface** (dashboard, admin panel, settings): browser-local matching gives
100
- instant optimistic pending, and browser-run predicates hold data across
101
- tab/param switches, so transitions are the fastest Rango offers. See
103
+ instant optimistic pending, and per-loader `revalidate()` predicates (run on
104
+ the server) hold data across tab/param switches, so transitions are the fastest Rango offers. See
102
105
  `/client-urls`.
103
106
 
104
107
  ## Migration Strategy
@@ -55,7 +55,7 @@ Use it only when you need capabilities beyond what the handler provides:
55
55
  If the React Router loader just fetches data for its page component AND the
56
56
  component can become a server component, merge it into the handler. If the
57
57
  component stays a client component, port the whole group with `clientUrls()`
58
- instead — loader, `useLoader` read, and browser-run `revalidate()` keep the RR
58
+ instead — loader, `useLoader` read, and per-loader `revalidate()` keep the RR
59
59
  route-module shape (see the "Two target shapes" section in the main skill and
60
60
  `/client-urls`). See `/loader` for when the live data layer is useful.
61
61
 
@@ -1069,6 +1069,19 @@ Four hard edges (each e2e/unit-pinned):
1069
1069
  must be fresh on every serve, it belongs on the live lane (no `ssr: false`) or
1070
1070
  in a nested promise.
1071
1071
 
1072
+ ### `prefetch: false` on a `ppr` route
1073
+
1074
+ A `ppr` route's handler output is stored, so a `loading()` flagged with
1075
+ `prefetch: false` does not skip the handler in a `<Link>` prefetch: the shell
1076
+ and its partial replay are served as usual and only the live loaders behind
1077
+ the fallback are deferred. A live loader registered with `{ prefetch: false }` is
1078
+ skipped the same way, and the click that adopts the prefetch sends one fill
1079
+ request for it. An `ssr: false` loader is the bake lane and is served from the
1080
+ shell, so on a `ppr` route `prefetch: false` is ignored for it, and
1081
+ development logs a warning that the loader "is registered with ssr: false
1082
+ and prefetch: false on a ppr route"; drop one of the two options. See
1083
+ `/loader` → "`prefetch: false`".
1084
+
1072
1085
  ### The layout-with-loaders playbook (the storefront case)
1073
1086
 
1074
1087
  The most common real-app shape: an app-wide layout registers per-user loaders
@@ -666,6 +666,16 @@ build-time shell capture runs it and bakes its settled data into the shell,
666
666
  and a shell HIT runs it only when its return carries promises (`/ppr` → "The
667
667
  bake lane"). The Flight payload collection itself still skips loaders.
668
668
 
669
+ ### `prefetch: false` skips only the loaders
670
+
671
+ A `Prerender` handler's output is stored, so a `loading()` flagged with
672
+ `prefetch: false` on the route does not skip the handler in a `<Link>` prefetch: the
673
+ stored output is served as usual, and only the loaders behind the fallback are
674
+ deferred (a loader registered with `{ prefetch: false }` likewise). The click
675
+ that adopts the prefetch shows the fallback and sends one fill request for
676
+ them. This holds in development, where there is no build artifact, as in
677
+ production. See `/loader` → "`prefetch: false`".
678
+
669
679
  ### Build-time handle data is frozen
670
680
 
671
681
  Handle values pushed via `ctx.use()` DURING pre-rendering (handler pushes at
@@ -1283,8 +1293,8 @@ fresh.
1283
1293
  A warm replaces the entry where it runs, and on a shared store that is the
1284
1294
  entry other locations read on their next miss. It does not reach a copy
1285
1295
  another location already holds: on `CFCacheStore`, each colo keeps serving its
1286
- own Cache API copy until it expires, and an entry whose `ttl + swr` is under
1287
- 60 seconds never reaches KV at all.
1296
+ own Cache API copy until it expires or a tag invalidation rejects it, and an
1297
+ entry whose `ttl + swr` is under 60 seconds never reaches KV at all.
1288
1298
 
1289
1299
  So after a deploy (new keys: no location holds a copy) a warm alone makes
1290
1300
  the route ready everywhere. For a content change, invalidate first, then
@@ -1306,12 +1316,16 @@ path.json("/hooks/product-changed", async (ctx) => {
1306
1316
 
1307
1317
  The invalidation makes every location's copy unservable, so no visitor is
1308
1318
  served the old content, and the warm writes the new entry where it runs and
1309
- to the shared store. A location with no copy of its own reads that entry on
1310
- its next request and renders nothing. On `CFCacheStore`, a colo that was
1311
- still holding its own (now invalidated) copy renders once for itself instead
1312
- of reading the warmed entry, unless the store uses `tagPurge`, which evicts
1313
- those copies. Await `updateTag()` before the warm: a write that started
1314
- before an invalidation of one of its tags is refused.
1319
+ to the shared store. Every other location reads that entry on its next
1320
+ request and renders nothing: a location with no copy of its own, and, on
1321
+ `CFCacheStore` with `kvFallThrough: true` (default false), a colo that was
1322
+ still holding its own (now invalidated) copy, which reads KV as if it held
1323
+ none. Without the option such a colo renders once for itself, unless the store
1324
+ uses `tagPurge`, which evicts those copies. With the option, a colo renders
1325
+ for itself only while KV still hands it the entry from before the invalidation,
1326
+ which it refuses: Cloudflare documents up to 60
1327
+ seconds for a KV write to spread. Await `updateTag()` before the warm: a
1328
+ write that started before an invalidation of one of its tags is refused.
1315
1329
 
1316
1330
  ### Limits worth knowing
1317
1331
 
@@ -168,13 +168,16 @@ stated, greppable contract.
168
168
  only applies when the enclosing cache recomputes; put a value in a loader if it
169
169
  must be fresher. See `/cache-guide` → "Combining Both".
170
170
  - **A predicate runs where its effect lands.** A server-tree `revalidate()`
171
- decides what the server re-sends, so it runs on the server; an `intercept({
171
+ and a `clientUrls()` loader's `revalidate()` decide what the server re-sends,
172
+ so they run on the server (the build hoists a group's predicate out of its
173
+ `"use client"` module and passes it the `urls()` arguments: `/client-urls`);
174
+ an `intercept({
172
175
  when })` selector decides what the server renders, so it runs on the server;
173
- a `clientUrls()` `revalidate()` and `transition({ when })` decide a browser
174
- commit, so they run in the browser. A browser predicate sees URLs, params,
175
- route names, history state and action data, never `get()`, `env` or handler
176
- state; in a server `urls()` it is written inline (the build hoists it into a
177
- client module) or imported from a `"use client"` module (`/view-transitions`).
176
+ `transition({ when })` decides a browser commit, so it runs in the browser.
177
+ A `transition({ when })` predicate sees URLs, params, route names, history
178
+ state and action data, never `get()`, `env` or handler state; in a server
179
+ `urls()` it is written inline (the build hoists it into a client module) or
180
+ imported from a `"use client"` module (`/view-transitions`).
178
181
  - **Vocabulary.** `revalidate()` keeps React Router's `shouldRevalidate`
179
182
  dialect (`currentUrl`/`nextUrl`, `currentParams`/`nextParams`). Navigation
180
183
  selectors (`intercept({ when })`, `transition({ when })`) use `from`/`to`
@@ -270,6 +273,9 @@ the `path()` re-run; layouts and parallels above the route are skipped.
270
273
  revalidate((ctx) => (ctx.isAction(checkout) && ctx.actionResult?.ok) || undefined),
271
274
  ```
272
275
 
276
+ The same `actionResult` idiom works in a `clientUrls()` group's loader
277
+ `revalidate()`, which runs on the server with these arguments.
278
+
273
279
  **The source is the source of truth.** Structure, types, and update policy are
274
280
  visible and local in the tree — read top-down, no hidden global model to hold in
275
281
  your head. A snippet earns its place only if, from the code alone, you can answer:
@@ -238,6 +238,145 @@ path("/product/:slug", ProductPage, { name: "product" }, () => [
238
238
  ])
239
239
  ```
240
240
 
241
+ ### `loading()` options
242
+
243
+ `loading(fallback, options?)` takes two options:
244
+
245
+ | Option | Effect |
246
+ | ----------------- | --------------------------------------------------------------------------------------------------------------------------- |
247
+ | `ssr: false` | Skip the fallback on document requests; it shows on client navigations only. |
248
+ | `prefetch: false` | Keep everything behind this fallback out of `<Link>` prefetches; the click shows the fallback and fetches that part itself. |
249
+
250
+ ```tsx
251
+ path("/orders", OrdersPage, { name: "orders" }, () => [
252
+ loader(OrdersLoader),
253
+ loading(<OrdersSkeleton />, { prefetch: false }),
254
+ ]),
255
+ ```
256
+
257
+ **What `prefetch: false` defers.** The flag applies when the entry is new to
258
+ the browser: the page the prefetch is sent from does not show it. A prefetch
259
+ then skips the entry's handler, its loaders, its orphan layouts and its
260
+ parallel slots. On a `layout()` the fallback covers the whole outlet, so every
261
+ deeper entry is skipped with it. A parallel slot with its own flagged
262
+ `loading()` is a separate unit: the route beside it still renders in the
263
+ prefetch. The prefetch payload carries a placeholder for each skipped piece,
264
+ marked deferred, with its fallback: the page's skeleton.
265
+
266
+ **An entry the browser already holds is never deferred.** Inside a section
267
+ whose `layout()` has a flagged `loading()`, the layout is on screen, so its
268
+ flag has no say: a prefetch of a sibling route renders that route's handler
269
+ and loaders as usual, and the click sends no second request. The same goes for
270
+ a same-route navigation (`/product/a` to `/product/b`). The rule behind it:
271
+ a click that adopts a prefetch is never worse than the same click with no
272
+ prefetch at all (two exceptions, under "Limits": a click before its
273
+ prefetch has answered, and a flagged `layout()` entered from outside). It
274
+ shows the next page, or its skeleton, at the click and never later than that
275
+ plain navigation, the router adds no wait of its own, and it never replaces
276
+ content that is on screen with a fallback. The deferred part arrives after
277
+ the click and React reveals it as it reveals any streaming content: a read
278
+ inside it can show its own fallback where the plain navigation showed the
279
+ whole page a little later. For
280
+ the same reason a prefetch that deferred something is used only on the page
281
+ that made it: a page that already shows the entry makes its own prefetch,
282
+ which defers nothing for it. To keep navigation _inside_ a section out of
283
+ prefetches, flag the `loading()` of the routes in it:
284
+
285
+ ```tsx
286
+ layout(AccountLayout, () => [
287
+ // From outside /account: the layout and the route below wait together.
288
+ loading(<AccountSkeleton />, { prefetch: false }),
289
+ // From another /account page: prefetched whole, the layout is on screen.
290
+ path("/account/profile", ProfilePage, { name: "account.profile" }),
291
+ // From another /account page: still deferred, by its own flag.
292
+ path("/account/orders", OrdersPage, { name: "account.orders" }, () => [
293
+ loader(OrdersLoader),
294
+ loading(<OrdersSkeleton />, { prefetch: false }),
295
+ ]),
296
+ ]),
297
+ ```
298
+
299
+ **What the click looks like.** The click that adopts the prefetch commits at
300
+ once: the URL changes, the prefetched content shows, and the fallback shows
301
+ where the deferred part goes. The browser sends one second request (the fill)
302
+ that runs only the deferred work. The page is built once, from the skeleton:
303
+ the fill resolves what it is waiting for, and nothing on it is mounted a
304
+ second time. The fill reads `cache()` and `"use cache"`
305
+ as any request does; the prefetch read neither for the work it skipped. A hard
306
+ load, a navigation with nothing prefetched, an action and back/forward run
307
+ everything as before.
308
+
309
+ **Under `transition()` the click commits with the fill.** A route with
310
+ `transition()` commits in a transition: with no prefetch, React keeps the page
311
+ being left until the response starts and then shows the new page with its
312
+ fallback. A prefetched click to a flagged `loading()` on such a route does the
313
+ same with its fill: the fallback shows with the fill's first chunk, as a plain
314
+ click's shows with its response, not at the click. The same holds when the
315
+ `transition()` is on something the fallback covers, such as a route under a
316
+ flagged layout. Nothing changes at the click: `useNavigation()` reads
317
+ `loading`, and the URL, the URL hooks and the page change together, fallback
318
+ included, when the fill starts answering. So the click is the plain click's equal
319
+ there, not ahead of it; what the prefetch saves is the rest of the page,
320
+ which is not fetched again.
321
+ (Shown with the click, the content could only arrive in a second commit,
322
+ which under a `<ViewTransition>` runs a view transition of its own and delays
323
+ the reveal.) `loader(Def, { prefetch: false })` on a `transition()` route is
324
+ not affected: the page shows with the click.
325
+
326
+ **Stored output is still served.** Under `cache()`, on a `ppr` route and for a
327
+ `Prerender`/`Static` handler, the handler output is stored, so the prefetch
328
+ looks it up, renders it and writes it exactly as before, hit or miss. Only the
329
+ loaders behind the fallback are deferred: for a flagged `layout()` that is
330
+ every loader below it, although the stored layout is served. The flag keeps
331
+ the block's work, its loaders included, out of prefetches. See `/caching`,
332
+ `/ppr` and `/prerender`.
333
+
334
+ **Limits.**
335
+
336
+ - A click before its prefetch has answered reuses that prefetch, waits for
337
+ it and then fetches the deferred part. The page shows no later than with no
338
+ prefetch; the deferred part can complete later, by up to what was left of
339
+ the prefetch. That is the price of keeping expensive or slow work out of
340
+ every prefetch, which is what the flag is for. See `/loader` →
341
+ "`prefetch: false`".
342
+ - A navigation started before the fill answers cancels it, and `navigate()`
343
+ resolves only once the fill has answered. A newer navigation that ends
344
+ without replacing the page leaves the fallback up until the next one. See
345
+ `/loader` → "`prefetch: false`".
346
+ - It needs a renderable fallback. `loading(false)` has nothing to show, so the
347
+ flag does nothing there. The last `loading()` on an entry wins, flag
348
+ included.
349
+ - A loader behind the fallback cannot call `ctx.rendered()`; it throws on every
350
+ request kind. See `/loader` → "`prefetch: false`" for this and the other
351
+ loader limits (`ctx.set()` values, late `redirect()`/`notFound()`, a loader a
352
+ handler awaits).
353
+ - A layout the client already holds is not deferred (its fallback would not
354
+ show). To keep navigation inside it out of prefetches, flag the route's own
355
+ `loading()`.
356
+ - On a `layout()` entered from a page without it, the prefetch skips the
357
+ layout's own handler too. This is the one place where a prefetched click
358
+ shows a different page from a click with no prefetch, by design: it shows
359
+ the skeleton at once, over the layout's whole area, and the layout appears
360
+ once what its own `loading()` covers has arrived (the layout, its own
361
+ loaders, and the components below it with no `loading()` of their own), at
362
+ least React's 300 ms after the skeleton. A click with no prefetch, in a
363
+ production build, shows the layout with its first chunk and the fallback
364
+ inside it. The covered content is no later with the prefetch. A loader
365
+ below the layout streams as with no prefetch, and what is still pending
366
+ shows its fallback inside the layout: a route's own `loading()`, a
367
+ `<Suspense>` in its content, or, for a read with no boundary of its own,
368
+ the layout's own fallback a second time. React keeps that fallback up at
369
+ least 300 ms and holds the page's other reveals as long, so content that
370
+ would have shown sooner shows up to 300 ms later than with no prefetch.
371
+ - An orphan layout's flagged `loading()` defers its loaders but not its
372
+ handler: the route's handler has already run by then.
373
+ - `clientUrls()` routes and `intercept()` ignore the flag.
374
+
375
+ To defer a single loader and keep the handler in the prefetch, use
376
+ `loader(Def, { prefetch: false })` instead (see `/loader`).
377
+
378
+ A `loading()` skeleton that has appeared stays at least about 300 ms before React reveals what replaces it (React's fallback throttle, [#1078](https://github.com/rangojs/rango/issues/1078)); see "Streaming with Suspense" in [loader](../loader/SKILL.md).
379
+
241
380
  ## Handler Data Ownership
242
381
 
243
382
  When a route has children (orphan layouts, parallels), the handler executes
@@ -97,7 +97,7 @@ Each primitive links to its sub-file (API + recipe + caveats).
97
97
  | one middleware's ordering / short-circuit / cookie+header merge | unit (node) | [`runMiddleware`](./middleware.md) | `@rangojs/router/testing` |
98
98
  | a `"use server"` action's cookie / header / flash output (even on `throw redirect()`) | unit (node) | [`runInRequestContext`](./server-actions.md) | `@rangojs/router/testing` |
99
99
  | a `transition({ when })` browser predicate against `{ kind, from, to, action }` | unit (node) | [`runTransitionWhen`](./predicates.md) (`{ applied, gatedOff, context }`) | `@rangojs/router/testing` |
100
- | a `clientUrls()` `revalidate()` / `isAction(ref)` predicate | unit (node) | [`runClientRevalidate`](./predicates.md) (production chain evaluator + locked defaults) | `@rangojs/router/testing` |
100
+ | a `revalidate()` / `isAction(ref)` predicate, `urls()` or `clientUrls()` | unit (node) | [`runRevalidate`](./predicates.md) (production `evaluateRevalidation`, async) | `@rangojs/router/testing` |
101
101
  | a handle's `collect`/accumulator, a seeded handle read, or a loader handle write | unit | [`collectHandle` / seeded `handles` / `handlePushes`](./handles.md) | `@rangojs/router/testing` |
102
102
  | a CLIENT component reading router context (`useParams`/`useReverse`/`Outlet`/`useNavigation`/`useLoader`) | unit (DOM) | [`renderRoute`](./client-components.md) | `@rangojs/router/testing/dom` |
103
103
  | a stale indicator from `useLoader().isLoading` while a `transition()` navigation re-runs the loader | unit (DOM) | [`renderRoute` + `navigate(url, { loaders })`](./client-components.md#held-navigation) | `@rangojs/router/testing/dom` |
@@ -116,6 +116,7 @@ Each primitive links to its sub-file (API + recipe + caveats).
116
116
  | cache hit/miss/stale, prerender (= a cache hit by design) | e2e + signal | [`assertCacheStatus` (header) / `assertCacheDecision` (telemetry sink)](./cache-prerender.md) | `@rangojs/router/testing[/e2e]` |
117
117
  | a `ppr` route's shell: MISS -> capture -> HIT, what the shell froze vs what a HIT renders live, tag eviction, navigation replay | RSC unit | [`serveShellRequest`](./serve-shell-request.md) | `@rangojs/router/testing/flight` |
118
118
  | a form submitted before hydration: a `useActionState` result the no-JS POST renders, the form state its payload carries | RSC unit | [`createActionForm` + `serveShellRequest({ form })`](./serve-shell-request.md) | `@rangojs/router/testing/flight` |
119
+ | a JS server action call: the segments it re-renders, what `revalidate()` sees on it | RSC unit | [`serveShellRequest({ action })`](./serve-shell-request.md#a-server-action-called-from-the-page) | `@rangojs/router/testing/flight` |
119
120
  | PPR shell HIT/MISS in real HTML (`ppr` path option), shell store key / getShell after flush | e2e + signal | [`assertShellStatus` / `shellCacheKey` + store](./cache-prerender.md#ppr-shell-and-navigation-replay) | `@rangojs/router/testing[/e2e]` |
120
121
  | generated route map drift vs runtime | unit (node) | [`assertGeneratedRoutesMatch`](./reverse-and-types.md) | `@rangojs/router/testing` |
121
122
  | a platform binding (`env.DB` / Durable Object / `env.R2`) | unit/integr. | [your own double via `env`](./bindings.md) | (any primitive's `env` option) |
@@ -147,7 +148,7 @@ beside their function):
147
148
  | `@rangojs/router/testing` | `runLoader`, `runLoaderResult` | [`loader.md`](./loader.md) |
148
149
  | | `runMiddleware` | [`middleware.md`](./middleware.md) |
149
150
  | | `runInRequestContext`, `createTestRequestContext`, `runWithRequestContext`, `toRequest`, `seedVariables` | [`server-actions.md`](./server-actions.md) |
150
- | | `runTransitionWhen`, `runClientRevalidate` | [`predicates.md`](./predicates.md) |
151
+ | | `runTransitionWhen`, `runRevalidate` | [`predicates.md`](./predicates.md) |
151
152
  | | `collectHandle` | [`handles.md`](./handles.md) |
152
153
  | | `withLocationStateKey` | [`client-components.md`](./client-components.md) |
153
154
  | | `dispatch` | [`response-routes.md`](./response-routes.md) |
@@ -299,7 +299,7 @@ it("hydrates as the server rendered it, then shows the stored count", async () =
299
299
  | ------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
300
300
  | `typeof window` / `typeof document` in render | `"undefined"` in the server pass: both globals are removed for it and restored before hydration. A branch renders its server side; if the client side differs, React reports the mismatch. |
301
301
  | An unguarded `window.x` / `document.x` in render | Throws `ReferenceError` in the server pass, as in SSR. Inside a `<Suspense>` the boundary is client-rendered and reported; outside one `renderRoute` rejects. |
302
- | `useSyncExternalStore` | The server pass and the hydration render read `getServerSnapshot`; the render after it reads `getSnapshot`. |
302
+ | Hydration adopts the server HTML | `hydrateRoot` adopts the server-rendered DOM nodes instead of replacing them, and `recoverableErrors` stays empty; a client render of the same tree would replace the nodes. |
303
303
  | A text or element mismatch | React re-renders that tree on the client and reports it: `recoverableErrors` gets `"Hydration failed because ..."` with the diff. |
304
304
  | An attribute-only mismatch (`className`, `href`) | NOT in `recoverableErrors`. React keeps the server attribute and logs `console.error` in development; assert it with `vi.spyOn(console, "error")`. |
305
305
  | `<Suspense>` whose content renders synchronously | In `serverHtml`. It hydrates in a later pass than the tree above it, after that tree's effects, before `renderRoute` resolves. |
@@ -1,8 +1,8 @@
1
- # Testing navigation predicates — runTransitionWhen, runClientRevalidate
1
+ # Testing navigation predicates — runTransitionWhen, runRevalidate
2
2
 
3
- **Layer:** unit (node) · **Import:** `@rangojs/router/testing` · **DSL it tests:** `transition({ when })` (see `/view-transitions`) and `clientUrls()` `revalidate()` (see `/client-urls`)
3
+ **Layer:** unit (node) · **Import:** `@rangojs/router/testing` · **DSL it tests:** `transition({ when })` (see `/view-transitions`) and `revalidate()` in `urls()` and in `clientUrls()` (see `/client-urls`)
4
4
 
5
- Both primitives are synchronous and run the router's own evaluation code on arguments you seed, so a predicate sees the same fields it sees at runtime. Neither renders anything: they answer "would this navigation hold?" and "would this `clientUrls()` loader re-run?". Both predicates run in the browser at runtime.
5
+ Both primitives run the router's own evaluation code on arguments you seed, so a predicate sees the same fields it sees at runtime. Neither renders anything: they answer "would this navigation hold?" and "would this loader re-run?". `runTransitionWhen` is synchronous; `runRevalidate` returns a Promise. A `transition({ when })` predicate runs in the browser at runtime; a `revalidate()` predicate runs on the server, a `clientUrls()` loader's included (the build moves it there).
6
6
 
7
7
  ## runTransitionWhen(when | config, opts?)
8
8
 
@@ -12,12 +12,12 @@ Builds the `TransitionWhenContext` the browser builds and evaluates the predicat
12
12
 
13
13
  Every field is optional. A location (`from` / `to`) is a URL string, a `URL`, or a partial `RouteLocation`: `{ url?, params?, routeName?, state? }`. A `state` given as location-state entries (`[Def(value)]`, what `Link` and `router.push` take) is stored the way a push stores it, so `Def.read(ctx.to)` reads it back; any other value is used as the raw `history.state`.
14
14
 
15
- | Field | Type | Meaning |
16
- | -------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
17
- | `kind` | `"push" \| "replace" \| "pop" \| "action" \| "revalidate"` | Defaults to `"action"` when `action` is given, `"push"` otherwise. |
18
- | `from` | location | The committed location being left. Defaults to `http://localhost/`. |
19
- | `to` | location | The destination. Defaults to `from`; always `from` for `"action"` and `"revalidate"`. |
20
- | `action` | imported action \| `string` \| `{ ref, formData, result, error }` | The triggering action (`isAction()` matches it; `action.id` is its id). Like `runClientRevalidate`, an imported action outside a built app needs its id passed as the string. |
15
+ | Field | Type | Meaning |
16
+ | -------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
17
+ | `kind` | `"push" \| "replace" \| "pop" \| "action" \| "revalidate"` | Defaults to `"action"` when `action` is given, `"push"` otherwise. |
18
+ | `from` | location | The committed location being left. Defaults to `http://localhost/`. |
19
+ | `to` | location | The destination. Defaults to `from`; always `from` for `"action"` and `"revalidate"`. |
20
+ | `action` | imported action \| `string` \| `{ ref, formData, result, error }` | The triggering action (`isAction()` matches it; `action.id` is its id). Like `runRevalidate`, an imported action outside a built app needs its id passed as the string. |
21
21
 
22
22
  ### Returns — `RunTransitionWhenResult`
23
23
 
@@ -62,57 +62,92 @@ it("animates unless the Link pushed { animate: false }", () => {
62
62
  - A Vitest project runs neither the Vite plugin's hoist nor its validation. An inline `when` in a `urls()` file stays a plain function there, and a `"use client"` import is the plain function outside the RSC graph, so a server function is not told apart and is accepted. The loud error for an invalid `when` belongs to dev startup, the build and HMR.
63
63
  - `renderRoute` decides each commit with production's decision code: `router.navigate()` (and `useRouter().push/replace`, `<Link>`) as `kind: "push"` / `"replace"`, `router.refresh()` as `kind: "revalidate"`, and `navigate(url, { transition: false })` without calling the predicate. Pop, action and `clientUrls()` optimistic-swap decisions are e2e territory.
64
64
 
65
- ## runClientRevalidate(fn | fn[], opts?)
65
+ ## runRevalidate(fn | fn[], opts?)
66
66
 
67
- Evaluates one `clientUrls()` loader `revalidate()` predicate, or a chain in declaration order, through the production chain evaluator: the locked default, boolean short-circuit, soft verdicts (`{ defaultShouldRevalidate }`), and a throwing predicate deferring to the default all behave as in the browser. Returns the final `boolean` (the locked default when every predicate defers or throws).
67
+ Evaluates one `revalidate()` predicate, or a chain in declaration order, through the router's production `evaluateRevalidation` inside a seeded request context. It serves `urls()` and `clientUrls()` predicates alike: a `clientUrls()` loader's predicate runs on the server with the `urls()` arguments, so the argument object (`currentParams`, `currentUrl`, `nextParams`, `nextUrl`, `defaultShouldRevalidate`, `context`, `segmentType`, `actionId`, `isAction`, `actionUrl`, `actionResult`, `formData`, `method`, `routeName`/`fromRouteName`/`toRouteName`, `stale`) is the production one. Returns `Promise<boolean>`: the final decision (true re-runs the segment). The locked default, boolean short-circuit and soft verdicts are the production code paths. A throwing predicate fails open to the default (logged with `console.error`); a thrown `Response` is rethrown.
68
68
 
69
- ### Options — `RunClientRevalidateOptions`
69
+ ### Options — `RunRevalidateOptions`
70
70
 
71
- Defaults model a same-URL navigation with no action.
71
+ Defaults model a same-URL navigation with no action, for a route-owned loader: a predicate that defers resolves `false`, and with an `action` it resolves `true`.
72
72
 
73
- | Field | Type | Meaning |
74
- | --------------- | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
75
- | `currentUrl` | `string \| URL` | Source URL. Defaults to `http://localhost/`. |
76
- | `nextUrl` | `string \| URL` | Target URL. Defaults to `currentUrl`. |
77
- | `currentParams` | `Record<string, string>` | Source params. Defaults to `{}`. |
78
- | `nextParams` | `Record<string, string>` | Target params. Defaults to `currentParams`. |
79
- | `stale` | `boolean` | The `stale` arg. Defaults to `false`. |
80
- | `action` | `(...args) => unknown \| string` | The triggering action: one imported server action (its id is read from the build-injected `$id`/`$$id`) or a raw action id string. A namespace object is rejected. Omit for a plain navigation. |
81
- | `actionRequest` | `boolean` | Model the action-triggered refetch GET: `isAction()` stays true but the locked default is the navigation default. Defaults to treating `action` as the action POST itself. |
73
+ | Field | Type | Meaning |
74
+ | ---------------- | --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
75
+ | `currentUrl` | `string \| URL` | The page being left. Defaults to `http://localhost/`. |
76
+ | `nextUrl` | `string \| URL` | The destination. Defaults to `currentUrl`. |
77
+ | `currentParams` | `Record<string, string>` | Source params. Defaults to `{}`. |
78
+ | `nextParams` | `Record<string, string>` | Target params. Defaults to `currentParams`. |
79
+ | `action` | imported action \| `string` \| `{ ref, result, formData, url }` | The triggering action. `ref` is an imported server action (its id is read from `$id ?? $$id`) or an action id string; `result` is `actionResult`, `formData` is `formData`, `url` is `actionUrl`. Makes the request a POST. |
80
+ | `method` | `string` | Overrides the request method (`POST` with an `action`, otherwise `GET`). |
81
+ | `segmentType` | `"layout" \| "route" \| "parallel" \| "loader"` | The segment type the predicate is attached to. Defaults to `"loader"`. |
82
+ | `belongsToRoute` | `boolean` | Defaults to `true`; `false` models a layout's loader, which a navigation keeps by default. |
83
+ | `stale` | `boolean` | The `stale` arg (a history-entry background revalidation). Defaults to `false`. |
84
+ | `routeName` | `string` | `toRouteName` (and `routeName`). |
85
+ | `fromRouteName` | `string` | `fromRouteName`. |
86
+ | `env` | `TEnv` | Environment bindings of the seeded context. |
87
+ | `vars` | `VarsInit` | Variables a middleware would have set, readable with `context.get()`. |
88
+ | `context` | the predicate's `context` | Replaces the seeded context. |
82
89
 
83
- Outside a built app, an imported action carries no id, so pass the id string your predicate matches (for example `"src/actions/cart.ts#addToCart"`); `runClientRevalidate` throws a clear error otherwise.
90
+ Outside a built app, an imported action carries no `$id`: pass the id string your predicate matches (for example `"src/actions/cart.ts#addToCart"`), or stamp `$id` on the imported function in the test (`Object.assign(addToCart, { $id: "src/actions/cart.ts#addToCart" })`); `runRevalidate` throws a clear error otherwise.
84
91
 
85
92
  ### Recipe
86
93
 
94
+ The build moves a `clientUrls()` predicate to the server and accepts only imports and globals in it, so keep a predicate you want to test in its own module without `"use client"` and import it both in the group and here. Vitest does not run the plugin, so there the predicate is a plain function. To run it through the real handler instead, mount the group in the Flight project with `rangoUseClientTransform()` wired, and send a `serveShellRequest` navigation or `action` request ([`./serve-shell-request.md`](./serve-shell-request.md)).
95
+
87
96
  ```ts
88
- import { it, expect } from "vitest";
89
- import { runClientRevalidate } from "@rangojs/router/testing";
90
- import type { ClientRevalidateFn } from "@rangojs/router/client";
97
+ // src/predicates.ts — no "use client"
98
+ import type { ShouldRevalidateFn } from "@rangojs/router";
99
+ import { checkout } from "./actions/cart.js";
91
100
 
92
- const onlyOnTabChange: ClientRevalidateFn = ({ currentUrl, nextUrl }) =>
101
+ export const onTabChange: ShouldRevalidateFn = ({ currentUrl, nextUrl }) =>
93
102
  currentUrl.searchParams.get("tab") !== nextUrl.searchParams.get("tab");
94
103
 
95
- it("revalidates only when the tab changes", () => {
104
+ export const onCheckoutOk: ShouldRevalidateFn = (ctx) =>
105
+ ctx.isAction(checkout) ? ctx.actionResult?.ok === true : undefined;
106
+ ```
107
+
108
+ ```ts
109
+ import { it, expect } from "vitest";
110
+ import { runRevalidate } from "@rangojs/router/testing";
111
+ import { checkout } from "../src/actions/cart.js";
112
+ import { onTabChange, onCheckoutOk } from "../src/predicates.js";
113
+
114
+ // The build stamps a "use server" export with its id; Vitest does not.
115
+ Object.assign(checkout, { $id: "src/actions/cart.ts#checkout" });
116
+
117
+ it("revalidates only when the tab changes", async () => {
96
118
  expect(
97
- runClientRevalidate(onlyOnTabChange, {
119
+ await runRevalidate(onTabChange, {
98
120
  currentUrl: "/settings?tab=profile",
99
121
  nextUrl: "/settings?tab=billing",
100
122
  }),
101
123
  ).toBe(true);
102
124
  expect(
103
- runClientRevalidate(onlyOnTabChange, {
125
+ await runRevalidate(onTabChange, {
104
126
  currentUrl: "/settings?tab=profile",
105
127
  nextUrl: "/settings?tab=profile&sort=asc",
106
128
  }),
107
129
  ).toBe(false);
108
130
  });
131
+
132
+ it("re-runs after checkout only when it succeeded", async () => {
133
+ expect(
134
+ await runRevalidate(onCheckoutOk, {
135
+ action: { ref: checkout, result: { ok: true } },
136
+ }),
137
+ ).toBe(true);
138
+ expect(
139
+ await runRevalidate(onCheckoutOk, {
140
+ action: { ref: checkout, result: { ok: false } },
141
+ }),
142
+ ).toBe(false);
143
+ });
109
144
  ```
110
145
 
111
146
  ## Caveats
112
147
 
113
- - Server-tree `revalidate()` predicates have no dedicated primitive: they are plain functions, so call them with a hand-built args object, or assert the post-action result at e2e.
148
+ - `runRevalidate` proves the predicate's decision, not the build: the hoist's rules (imports and globals only, no browser globals) are checked by the Vite plugin at dev startup and build, not in Vitest. Assert the post-action result of a real group at e2e.
114
149
  - `runTransitionWhen` proves the decision only. Whether the browser actually runs a view transition is e2e territory (see `./e2e-parity.md`).
115
- - Both are synchronous; a predicate returning a Promise is a bug in the predicate, not something to await here.
150
+ - `runTransitionWhen` is synchronous. `runRevalidate` returns a Promise because it awaits the router's `evaluateRevalidation`, but a predicate itself must be synchronous: one returning a Promise is a bug in the predicate (the router keeps the default and warns in development), and the `clientUrls()` build rejects an `async` one.
116
151
 
117
152
  ## See also
118
153