@rangojs/router 0.33.1 → 0.34.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 (63) hide show
  1. package/dist/bin/{chunk-TTXC2U3G.js → chunk-4HHFQSDC.js} +10 -2
  2. package/dist/bin/{chunk-3B7CKBRO.js → chunk-MWOHBDPM.js} +9 -9
  3. package/dist/bin/{client-urls-projection-DQLWPDAT.js → client-urls-projection-4Q5EQDAL.js} +2 -2
  4. package/dist/bin/rango.js +1 -1
  5. package/dist/bin/{runtime-EPUDEHO4.js → runtime-6WLCJCN7.js} +1 -1
  6. package/dist/bin/{runtime-discovery-3HH6BP3K.js → runtime-discovery-2ZTYRZ3V.js} +2 -2
  7. package/dist/bin/{temp-rsc-server-JALTIWLT.js → temp-rsc-server-EUNKRFJD.js} +3 -3
  8. package/dist/testing/vitest.js +5 -0
  9. package/dist/types/browser/history-state.d.ts +21 -0
  10. package/dist/types/browser/navigation-transaction.d.ts +2 -0
  11. package/dist/types/client-urls/server-projection.d.ts +7 -0
  12. package/dist/types/client-urls/types.d.ts +13 -14
  13. package/dist/types/errors.d.ts +5 -0
  14. package/dist/types/loader-store.d.ts +11 -1
  15. package/dist/types/prefetch-markers.d.ts +13 -4
  16. package/dist/types/route-definition/helpers-types.d.ts +14 -10
  17. package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +39 -21
  18. package/dist/types/server/context.d.ts +6 -5
  19. package/dist/types/testing/render-route.d.ts +21 -1
  20. package/dist/types/testing/serve-shell-request.d.ts +7 -7
  21. package/dist/types/testing/vitest-stubs/plugin-rsc-browser.d.ts +33 -0
  22. package/dist/types/types/loader-types.d.ts +10 -8
  23. package/dist/types/urls/path-helper-types.d.ts +9 -8
  24. package/dist/vite/index.js +30 -13
  25. package/package.json +1 -1
  26. package/skills/app-structure/large-app.md +3 -0
  27. package/skills/client-urls/SKILL.md +13 -10
  28. package/skills/hooks/data.md +5 -0
  29. package/skills/hooks/state.md +13 -11
  30. package/skills/intercept/SKILL.md +8 -4
  31. package/skills/links/SKILL.md +15 -12
  32. package/skills/loader/SKILL.md +52 -39
  33. package/skills/ppr/SKILL.md +1 -1
  34. package/skills/route/SKILL.md +15 -9
  35. package/skills/testing/SKILL.md +1 -0
  36. package/skills/testing/client-components.md +67 -19
  37. package/skills/testing/serve-shell-request.md +19 -7
  38. package/skills/ux-architecture/SKILL.md +16 -11
  39. package/src/browser/history-state.ts +48 -0
  40. package/src/browser/navigation-bridge.ts +12 -1
  41. package/src/browser/navigation-transaction.ts +14 -6
  42. package/src/browser/partial-update.ts +12 -4
  43. package/src/browser/prefetch/fetch.ts +3 -3
  44. package/src/client-urls/client-urls.ts +11 -7
  45. package/src/client-urls/server-projection.ts +21 -3
  46. package/src/client-urls/types.ts +13 -14
  47. package/src/errors.ts +17 -0
  48. package/src/loader-store.ts +41 -4
  49. package/src/prefetch-markers.ts +18 -4
  50. package/src/route-definition/dsl-helpers.ts +13 -6
  51. package/src/route-definition/helpers-types.ts +14 -10
  52. package/src/router/loader-resolution.ts +4 -4
  53. package/src/router/match-api.ts +1 -3
  54. package/src/router/segment-resolution/prefetch-deferral.ts +108 -50
  55. package/src/rsc/rsc-rendering.ts +1 -1
  56. package/src/server/context.ts +6 -5
  57. package/src/testing/render-route.tsx +122 -7
  58. package/src/testing/serve-shell-request.ts +7 -7
  59. package/src/testing/vitest-stubs/plugin-rsc-browser.ts +61 -0
  60. package/src/testing/vitest.ts +5 -0
  61. package/src/types/loader-types.ts +10 -8
  62. package/src/urls/path-helper-types.ts +9 -8
  63. package/src/use-loader.tsx +29 -7
@@ -1191,20 +1191,22 @@ The costs and constraints:
1191
1191
  Also available in `clientUrls()` route groups (`/client-urls`), where the
1192
1192
  loader-heavy shape makes it most useful.
1193
1193
 
1194
- ## `prefetch: false` — Keep a Loader Out of Speculative Prefetches
1194
+ ## `prefetch: false` and `prefetch: "intent"` — Keep a Loader Out of Prefetches
1195
1195
 
1196
1196
  A `<Link>` prefetch is a navigation sent early: the server renders every
1197
1197
  handler and every loader for it. With the default `"viewport"` strategy that
1198
1198
  means a grid of forty product links runs forty copies of your slowest loader
1199
1199
  for pages most visitors never open. Pass `prefetch: false` to keep one loader
1200
- out of those speculative (viewport and render) prefetches:
1200
+ out of every prefetch, or `prefetch: "intent"` to keep it out of the
1201
+ speculative ones only:
1201
1202
 
1202
1203
  ```tsx
1203
1204
  import { Suspense } from "react";
1204
1205
 
1205
1206
  path("/product/:slug", ProductPage, { name: "product" }, () => [
1206
- loader(PriceLoader), // cheap: still runs in the prefetch
1207
- loader(ReviewsLoader, { prefetch: false }), // expensive: skipped in a viewport prefetch
1207
+ loader(PriceLoader), // cheap: runs in every prefetch
1208
+ loader(RelatedLoader, { prefetch: "intent" }), // skipped in a viewport prefetch, runs on hover
1209
+ loader(ReviewsLoader, { prefetch: false }), // expensive: skipped in every prefetch
1208
1210
  loading(<ProductSkeleton />),
1209
1211
  ]),
1210
1212
  ```
@@ -1212,22 +1214,30 @@ path("/product/:slug", ProductPage, { name: "product" }, () => [
1212
1214
  (`prefetch` sits beside `ssr` in `LoaderOptions`; combine them as
1213
1215
  `{ ssr: false, prefetch: false }` if you need both.)
1214
1216
 
1215
- A hover is different: the visitor is about to click. A hover prefetch (on a
1217
+ | Request | unflagged | `prefetch: false` | `prefetch: "intent"` |
1218
+ | ------------------------------------------------------------ | ---------- | ------------------------ | ------------------------ |
1219
+ | Document, navigation | runs | runs | runs |
1220
+ | Viewport or render prefetch (`X-Rango-Prefetch: 1`) | runs | skipped, filled at click | skipped, filled at click |
1221
+ | Hover, `useRouter().prefetch()` (`X-Rango-Prefetch: intent`) | runs | skipped, filled at click | runs, no fill |
1222
+ | A prefetch already cached or in flight when the hover lands | used as is | used as is | used as is |
1223
+
1224
+ A hover means the visitor is about to click. A hover prefetch (on a
1216
1225
  `"hover"`, `"viewport"` or `"adaptive"` link, or a delegated plain anchor) and
1217
- `useRouter().prefetch()` send `X-Rango-Prefetch: intent` and run the loader
1218
- with the rest of the page, so the click commits complete and sends no second
1219
- request. A hover sends its prefetch only when the link has none cached or in
1220
- flight: on a link a viewport prefetch already answered, the hover sends
1221
- nothing and the click fills the deferred loader as below. The cost is plain:
1222
- each hover that misses the prefetch cache runs the loader, so a pointer
1223
- passing over a list of `"hover"` links runs it once per link. Touch devices
1224
- have no hover (`"adaptive"` resolves to viewport there) and keep the fill.
1225
-
1226
- What happens in a speculative prefetch:
1227
-
1228
- - A **viewport or render prefetch** of the route does not run the loader and
1229
- does not read its `cache()`; the payload marks it deferred. The rest of the
1230
- page prefetches as usual.
1226
+ `useRouter().prefetch()` run a loader marked `"intent"` with the rest of the
1227
+ page; when every flagged loader and `loading()` on the route is marked
1228
+ `"intent"`, the click commits complete and sends no second request. A hover
1229
+ sends its prefetch only when the link has none cached or in flight: on a link
1230
+ a viewport prefetch already answered, the hover sends nothing and the click
1231
+ fills the deferred loader as below. The cost of `"intent"` is plain: each
1232
+ hover that misses the prefetch cache runs the loader, so a pointer passing
1233
+ over a list of `"hover"` links runs it once per link. Keep a loader that is
1234
+ too expensive for that on `false`. Touch devices have no hover (`"adaptive"`
1235
+ resolves to viewport there) and keep the fill.
1236
+
1237
+ What happens in a prefetch that skips the loader:
1238
+
1239
+ - The prefetch does not run the loader and does not read its `cache()`; the
1240
+ payload marks it deferred. The rest of the page prefetches as usual.
1231
1241
  - Only on a loader new to the page. A loader already on screen is never
1232
1242
  deferred: if its revalidation (its `revalidate()` or the default) re-runs
1233
1243
  it, it runs in the prefetch with the rest of the destination, exactly as
@@ -1277,18 +1287,18 @@ function Reviews() {
1277
1287
 
1278
1288
  The limits:
1279
1289
 
1280
- - **A click before its viewport prefetch has answered waits for it, then
1281
- fetches the deferred part.** The flag is there to keep expensive or slow
1282
- work out of every speculative prefetch, so that an API that is costly to
1283
- call is not called for every link a visitor might click. A click that comes
1284
- while the link's viewport or render prefetch is still in flight reuses that
1285
- prefetch, as it does without the
1286
- flag, and sends the fill when the prefetch answers. The page shows no later
1290
+ - **A click before its prefetch has answered waits for it, then fetches the
1291
+ deferred part.** The flag is there to keep expensive or slow work out of
1292
+ prefetches, so that an API that is costly to call is not called for every
1293
+ link a visitor might click. A click that comes while the link's prefetch is
1294
+ still in flight reuses that prefetch, as it does without the flag, and
1295
+ sends the fill when the prefetch answers. The page shows no later
1287
1296
  than with no prefetch; the deferred part shows its fallback until the fill
1288
1297
  lands, and can complete later than a click with no prefetch, by up to what
1289
1298
  was left of the prefetch. That is the price of keeping the work out of the
1290
- prefetch. A click while a hover's prefetch is in flight adopts it with no
1291
- fill: the loader is already running in it.
1299
+ prefetch. A click while a hover's prefetch is in flight, on a route whose
1300
+ flags are all `"intent"`, adopts it with no fill: the loader is already
1301
+ running in it.
1292
1302
  - **A navigation started before the fill answers cancels it.** It is
1293
1303
  cancelled when the newer navigation starts, not when that one commits. If
1294
1304
  the newer navigation then ends without replacing the page (a redirect the
@@ -1313,8 +1323,9 @@ The limits:
1313
1323
  every request kind (a document load included, so you see it the first time
1314
1324
  you open the page in development) if it calls `await ctx.rendered()`. The
1315
1325
  fill does not re-run the entry's handler, so there is no render to wait for
1316
- and no handle data to read. Remove `{ prefetch: false }` from the loader, or
1317
- stop calling `ctx.rendered()`. Pushing a handle from the loader is fine.
1326
+ and no handle data to read. That holds for `"intent"` too, on every request.
1327
+ Remove the `prefetch` flag from the loader, or stop calling
1328
+ `ctx.rendered()`. Pushing a handle from the loader is fine.
1318
1329
  - **`ctx.set()` values from held handlers are not visible.** The fill runs the
1319
1330
  deferred work and middleware only. A handler or layout the client already
1320
1331
  holds does not run again, so a variable it set with `ctx.set()` is missing
@@ -1376,16 +1387,17 @@ The limits:
1376
1387
  need it), or drop the flag from a loader the page cannot render without.
1377
1388
 
1378
1389
  - **`clientUrls()` routes take it on `loader()`.** In a `clientUrls()`
1379
- definition `loader(Def, { prefetch: false })` works as here: a click
1390
+ definition `loader(Def, { prefetch: false })` and `{ prefetch: "intent" }`
1391
+ work as here: a click
1380
1392
  inside the group shows the destination at once, commits the prefetched
1381
1393
  data with the URL, and one fill runs the flagged loaders (see
1382
1394
  `/client-urls` → "`prefetch: false`"). A flagged `loading()` defers nothing
1383
1395
  on a group route, from the group or from a server layout above it.
1384
- - **Inside `intercept()`.** In `urls()` and in `clientUrls()`, a viewport or
1385
- render prefetch of the intercept's target from its source page defers the
1386
- flagged loader (a hover's runs it, and its click sends no fill): the
1387
- click opens the modal at once, with its `loading()`, and one fill, sent
1388
- from the source, runs the loader. The page under the modal stays. With no
1396
+ - **Inside `intercept()`.** In `urls()` and in `clientUrls()`, a prefetch of
1397
+ the intercept's target from its source page defers the flagged loader (a
1398
+ hover's runs a loader marked `"intent"`, and its click sends no fill for
1399
+ it): the click opens the modal at once, with its `loading()`, and one fill,
1400
+ sent from the source, runs the loader. The page under the modal stays. With no
1389
1401
  `loading()` the slot is empty until the fill answers, as on a plain click
1390
1402
  until its response. The modal's loaders resolve together: it shows its
1391
1403
  `loading()` until every one has arrived. A flagged `loading()` inside
@@ -1401,13 +1413,14 @@ The limits:
1401
1413
  `/caching`, `/prerender`, `/ppr` → "`prefetch: false` on a `ppr` route").
1402
1414
 
1403
1415
  To skip a handler along with its loaders, flag the `loading()` instead (see
1404
- `/route` → "`loading()` options"). `loader(Def, { prefetch: false })` accepts
1405
- only a boolean; anything else throws `loader() prefetch must be a boolean`.
1416
+ `/route` → "`loading()` options"). `loader(Def, { prefetch })` accepts a
1417
+ boolean or `"intent"`; anything else throws
1418
+ `loader() prefetch must be a boolean or "intent"`.
1406
1419
 
1407
1420
  Test it with `serveShellRequest(router, url, { partial: { prefetch: true } })`
1408
1421
  and `result.readDeferred()` (see `/testing`). That is a speculative prefetch;
1409
1422
  add `headers: { "X-Rango-Prefetch": "intent" }` to serve a hover's, which
1410
- defers nothing.
1423
+ runs the `"intent"` loaders and defers the `false` ones.
1411
1424
 
1412
1425
  ## Fetchable Loaders
1413
1426
 
@@ -1176,7 +1176,7 @@ document request captured:
1176
1176
  | Replays a shell a document request captured (`HIT`) | Served from its pin, not deferred, never run; the click sends nothing |
1177
1177
  | Replays a navigation-only shell, or misses (cold page) | Deferred: the click sends one fill that runs it |
1178
1178
  | Serves an explicit `cache()` hit | Deferred: the click sends one fill that runs it |
1179
- | Hover prefetch (`X-Rango-Prefetch: intent`) | Never deferred |
1179
+ | Hover prefetch (`X-Rango-Prefetch: intent`) | As the rows above; a loader marked `prefetch: "intent"` runs instead |
1180
1180
 
1181
1181
  A cold prefetch still schedules the background capture that stores the
1182
1182
  page's navigation shell, and that capture runs the loader once. Once a
@@ -242,10 +242,11 @@ path("/product/:slug", ProductPage, { name: "product" }, () => [
242
242
 
243
243
  `loading(fallback, options?)` takes two options:
244
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 viewport and render prefetches (a hover prefetch runs it); the click shows the fallback and fetches that part itself. |
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 every prefetch, hovers included; the click shows the fallback and fetches that part itself. |
249
+ | `prefetch: "intent"` | The same for viewport and render prefetches; a hover prefetch or `useRouter().prefetch()` runs it, and its click sends no second request. |
249
250
 
250
251
  ```tsx
251
252
  path("/orders", OrdersPage, { name: "orders" }, () => [
@@ -255,13 +256,17 @@ path("/orders", OrdersPage, { name: "orders" }, () => [
255
256
  ```
256
257
 
257
258
  **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 viewport
259
- or render prefetch then skips the entry's handler, its loaders, its orphan layouts and its
259
+ the browser: the page the prefetch is sent from does not show it. A prefetch
260
+ then skips the entry's handler, its loaders, its orphan layouts and its
260
261
  parallel slots. On a `layout()` the fallback covers the whole outlet, so every
261
262
  deeper entry is skipped with it. A parallel slot with its own flagged
262
263
  `loading()` is a separate unit: the route beside it still renders in the
263
264
  prefetch. The prefetch payload carries a placeholder for each skipped piece,
264
- marked deferred, with its fallback: the page's skeleton.
265
+ marked deferred, with its fallback: the page's skeleton. `prefetch: "intent"`
266
+ defers the same pieces in a viewport or render prefetch; a hover's prefetch
267
+ renders them, so a route whose flags are all `"intent"` is prefetched whole
268
+ on hover. Everything below about `false` holds for `"intent"` in the
269
+ prefetches that defer it.
265
270
 
266
271
  **An entry the browser already holds is never deferred.** Inside a section
267
272
  whose `layout()` has a flagged `loading()`, the layout is on screen, so its
@@ -347,7 +352,7 @@ the block's work, its loaders included, out of prefetches. See `/caching`,
347
352
  flag does nothing there. The last `loading()` on an entry wins, flag
348
353
  included.
349
354
  - 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
355
+ request kind, for either flag. See `/loader` → "`prefetch: false`" for this and the other
351
356
  loader limits (`ctx.set()` values, late `redirect()`/`notFound()`, a loader a
352
357
  handler awaits).
353
358
  - A layout the client already holds is not deferred (its fallback would not
@@ -373,7 +378,8 @@ the block's work, its loaders included, out of prefetches. See `/caching`,
373
378
  - `clientUrls()` routes and `intercept()` ignore the flag.
374
379
 
375
380
  To defer a single loader and keep the handler in the prefetch, use
376
- `loader(Def, { prefetch: false })` instead (see `/loader`).
381
+ `loader(Def, { prefetch: false })` or `{ prefetch: "intent" }` instead (see
382
+ `/loader`).
377
383
 
378
384
  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
385
 
@@ -101,6 +101,7 @@ Each primitive links to its sub-file (API + recipe + caveats).
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` |
104
+ | a component that refreshes with `useLoader().load()` or `useRefreshLoaders()`, and the readers that show the answer | unit (DOM) | [`renderRoute` + `router.respondToLoads`](./client-components.md#load-and-refresh-groups) | `@rangojs/router/testing/dom` |
104
105
  | a component reading `useLocationState(Def)` after `router.push(url, { state: [Def(value)] })` | unit (DOM) | [`renderRoute` + `withLocationStateKey`](./client-components.md#location-state) | `@rangojs/router/testing[/dom]` |
105
106
  | location state while a navigation is pending (a loader the test holds), or after a back/forward | unit (DOM) | [`navigate(url, { state, loaders })`, a `popstate` event](./client-components.md#state-while-a-navigation-is-pending) | `@rangojs/router/testing[/dom]` |
106
107
  | a CLIENT component's hydration: its first client render against its own server HTML (`typeof window`, a late `<Suspense>`) | unit (DOM) | [`renderRoute` + `hydrate: true`](./client-components.md#hydration) | `@rangojs/router/testing/dom` |
@@ -33,23 +33,23 @@
33
33
 
34
34
  ### Context — client hooks it makes resolve (what your code receives)
35
35
 
36
- | Hook | Meaning |
37
- | ------------------------------ | --------------------------------------------------------------------------------------------- |
38
- | `useParams` | Params from the matched leaf pattern, with `options.params` merged over. |
39
- | `useReverse` | Reverse a name->pattern map to a URL; merges `useParams()` and the `mount`/`basename` prefix. |
40
- | `useHref` | Resolve an href against the mount/basename. |
41
- | `useMount` | The `include()` mount prefix (`options.mount`), else `"/"`. |
42
- | `useNavigation` | Navigation controller state: `loading` from a `navigate()` call to its commit, else `idle`. |
43
- | `useRouter` | The router handle, including `.basename`. |
44
- | `usePathname` | Current committed pathname. |
45
- | `useSearchParams` | Search params from the `request` URL. |
46
- | `useNonce` | SEEDED CSP nonce (`options.nonce`), else `undefined` (the browser default). |
47
- | `useLoader` / `useFetchLoader` | SEEDED loader data (read path, not run path). Held-navigation `isLoading` is modeled (below). |
48
- | `useLocationState` | SEEDED `history.state` value, or the `state` a navigation or a `popstate` brought (below). |
49
- | `useHandle` | SEEDED handle output (globally accumulated). |
50
- | `Outlet` | Renders the next segment in the chain (layout nesting). |
51
- | `useOutlet` | Next-segment `content` plus SEEDED `options.outletPending`. |
52
- | `useTheme` | Theme; throws without `options.theme` (see caveat). |
36
+ | Hook | Meaning |
37
+ | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
38
+ | `useParams` | Params from the matched leaf pattern, with `options.params` merged over. |
39
+ | `useReverse` | Reverse a name->pattern map to a URL; merges `useParams()` and the `mount`/`basename` prefix. |
40
+ | `useHref` | Resolve an href against the mount/basename. |
41
+ | `useMount` | The `include()` mount prefix (`options.mount`), else `"/"`. |
42
+ | `useNavigation` | Navigation controller state: `loading` from a `navigate()` call to its commit, else `idle`. |
43
+ | `useRouter` | The router handle, including `.basename`. |
44
+ | `usePathname` | Current committed pathname. |
45
+ | `useSearchParams` | Search params from the `request` URL. |
46
+ | `useNonce` | SEEDED CSP nonce (`options.nonce`), else `undefined` (the browser default). |
47
+ | `useLoader` / `useFetchLoader` | SEEDED loader data (read path, not run path). Held-navigation `isLoading` is modeled (below), and `load()` is answered from the seeds ([`load()`](#load-and-refresh-groups)). |
48
+ | `useLocationState` | SEEDED `history.state` value, or the `state` a navigation or a `popstate` brought (below). |
49
+ | `useHandle` | SEEDED handle output (globally accumulated). |
50
+ | `Outlet` | Renders the next segment in the chain (layout nesting). |
51
+ | `useOutlet` | Next-segment `content` plus SEEDED `options.outletPending`. |
52
+ | `useTheme` | Theme; throws without `options.theme` (see caveat). |
53
53
 
54
54
  ### Returns — `RenderRouteResult`
55
55
 
@@ -77,8 +77,11 @@ type RenderRouteResult = RenderResult & {
77
77
  },
78
78
  ): Promise<void>;
79
79
  // re-render the current location, as router.refresh() does:
80
- // a spec's when gets kind "revalidate" (`to` is `from`)
81
- refresh(): Promise<void>;
80
+ // a spec's when gets kind "revalidate" (`to` is `from`); `loaders` is the
81
+ // data the response carries
82
+ refresh(options?: {
83
+ loaders?: ReadonlyArray<readonly [LoaderDefinition<any>, unknown]>;
84
+ }): Promise<void>;
82
85
  // keep `url` as a prefetch made now carries it (the seeds then, `loaders`
83
86
  // over them); a later navigate(url) without `loaders` commits that data
84
87
  prefetch(
@@ -94,6 +97,11 @@ type RenderRouteResult = RenderResult & {
94
97
  loaders?: ReadonlyArray<readonly [LoaderDefinition<any>, unknown]>;
95
98
  keepClientCache?: boolean;
96
99
  }): Promise<void>;
100
+ // what a later useLoader().load() (or refreshGroup refresh) of each
101
+ // loader resolves to; renders nothing
102
+ respondToLoads(options: {
103
+ loaders?: ReadonlyArray<readonly [LoaderDefinition<any>, unknown]>;
104
+ }): void;
97
105
  pathname(): string;
98
106
  params(): Record<string, string>;
99
107
  store: NavigationStore; // advanced
@@ -454,6 +462,46 @@ it("dims the held price while the next product streams", async () => {
454
462
  - Pass settled data as a plain value, not `Promise.resolve(value)`: React has not observed a plain promise yet, so it counts as pending and renders one `stale` frame. (A Flight chunk that has already settled carries its status and is skipped, which a plain value mirrors.)
455
463
  - `loading()` is not a spec field: a `<Suspense>` in your layout component stands in for it.
456
464
 
465
+ ## `load()` and refresh groups
466
+
467
+ `useLoader().load()`, `useFetchLoader().load()` and a `useRefreshLoaders()` refresh reach renderRoute's loader endpoint, so a refresh button, a polling component, or a value one reader refreshes and another shows is unit-testable without a server or a `fetch` stub. The endpoint answers a GET for a loader with the last value the test gave it: the render-time `loaders`/`loaderData`, or `router.respondToLoads({ loaders })`, which only sets the answer (a `navigate()`, `prefetch()`, `action()` or `refresh()` seeds the page it commits, not the answer). A `load({ params })` is answered with a `loaderError`. The value is read when `load()` sends its request. A pending Promise holds the answer until it settles (`isLoading` stays `true`), and a rejected one arrives as the loader's error. The value reaches the component as given, never serialized.
468
+
469
+ The answer decodes through `@vitejs/plugin-rsc/browser`, which `rangoTestAliases()` (and `rangoTestConfig()`) alias to the testing kit's stub; remove any `vi.mock("@vitejs/plugin-rsc/browser")` you wrote for it.
470
+
471
+ ```tsx
472
+ import { fireEvent, waitFor } from "@testing-library/react";
473
+ import { renderRoute } from "@rangojs/router/testing/dom";
474
+ import { useLoader } from "@rangojs/router/client";
475
+ import { CartLoader } from "../src/loaders"; // createLoader(fn, true)
476
+
477
+ function CartBadge() {
478
+ const { data, load, isLoading } = useLoader(CartLoader);
479
+ return (
480
+ <button data-testid="cart" onClick={() => load()}>
481
+ {isLoading ? "..." : data.count}
482
+ </button>
483
+ );
484
+ }
485
+
486
+ it("refreshes the count from the server", async () => {
487
+ const { getByTestId, router } = await renderRoute(
488
+ [{ path: "/cart", Component: CartBadge }],
489
+ { request: "/cart", loaders: [[CartLoader, { count: 1 }]] },
490
+ );
491
+ router.respondToLoads({ loaders: [[CartLoader, { count: 3 }]] });
492
+ fireEvent.click(getByTestId("cart"));
493
+ // load() loads its decoder lazily: wait for the answer, not one act()
494
+ await waitFor(() => expect(getByTestId("cart").textContent).toBe("3"));
495
+ });
496
+ ```
497
+
498
+ | `load()` call | Under `renderRoute` |
499
+ | ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
500
+ | `load()` with no options, a `key`, or a `refreshGroup` | Answered. Readers of a registered loader converge on the value; a reader that mounts later, or that a navigation brings new route data, shows that route data instead, as in production. |
501
+ | `load({ params })`, `load({ method: "POST", body })` | NOT answered: the request goes to the `fetch` your test environment has. Test the loader body with `runLoader`, and the round trip at e2e. |
502
+ | A loader the test never gave a value | Answered with an error naming the loader (`error` set; `throwOnError` throws it). |
503
+ | Any other request (a navigation's or action's Flight payload) | Passed through to the `fetch` installed before the mount. renderRoute never decodes one: a `navigate()` or `action()` seeds its data directly. A `fetch` stub the test installs after the mount replaces the endpoint. |
504
+
457
505
  ## Caveats
458
506
 
459
507
  - Client tree ONLY. Does NOT catch server/client boundary reference-identity remount bugs, real Flight serialization errors, loader execution, middleware, or handler ordering — those are `renderServerTree` / `renderHandler` / e2e territory. Loader data is SEEDED, never run. `hydrate: true` adds an HTML pass of this same client tree ([Hydration](#hydration)), not a render of your routes on a server.
@@ -20,13 +20,13 @@ A `Prerender` route is served as in production: from the artifact `router.matchF
20
20
 
21
21
  ### Options — `ServeShellRequestOptions`
22
22
 
23
- | Field | Type | Meaning |
24
- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
25
- | `cacheStore` | `SegmentCacheStore` | The store for this request, in place of the store your `createRouter({ cache })` config returns. The rest of that config (`enabled`, `searchParams`) still applies. Omit it to use the router's own store. |
26
- | `env` | `TEnv` | Bindings, as `router.fetch(request, { env })` passes them. A function `cache` config receives them. |
27
- | `headers` | `HeadersInit` | Request headers (e.g. `cookie`). A document request defaults `accept` to `text/html`. |
28
- | `partial` | `true \| { from?: string; rewriteTarget?: string; segments?: string[]; prefetch?: boolean; fill?: boolean; interceptSource?: string; stale?: boolean; boundary?: string }` | Serve the navigation request the browser sends instead of a document GET: from `from` (default the site root) with `segments` mounted (default none). `prefetch: true` sends the speculative kind a viewport or render `<Link>` prefetch sends (`X-Rango-Prefetch: 1`), so `prefetch: false` work is deferred; add `headers: { "X-Rango-Prefetch": "intent" }` to send a hover's or `prefetch()`'s intent prefetch instead, which defers nothing. `fill: true` sends the follow-up request (`_rsc_fill=1`) the browser makes after adopting a prefetch; pass the prefetch's delivered ids, without the deferred ones, as `segments`, and the same URL as `from`. `interceptSource` sends the intercept source (`X-RSC-Router-Intercept-Source`) the browser sends with the fill of an intercept, whose `from` is that source too. `rewriteTarget` sends what the page at `from` carried as its payload's `metadata.rewriteTarget` when middleware rewrote it (`_rsc_rt`), as the browser does, so the server diffs against the route that rendered that page. `stale: true` sends a stale request (`_rsc_stale`), as the browser sends it: every `revalidate()` sees `stale: true` (see `revalidate()`'s `stale`), segments take an action's revalidation defaults, and it renders live, as an action does: neither the route cache (`cache()`) nor the document cache serves or stores it, and a `ppr` route's `replayStatus` is `{ outcome: "BYPASS", reason: "stale" }`. `boundary` names the error segment of a thrown action's boundary the page at `from` shows (`_rsc_boundary`), as the background revalidation an earlier action owes that page sends it, with `stale: true` and no `segments`: the response renders every segment above the boundary and nothing at or below it, claims the boundary in `matched` and sends no segment for it. The server reads it only beside `stale`. |
29
- | `action` | `{ ref: Function; id: string; args?: unknown[]; segments?: string[]; rewriteTarget?: string }` | Send the request a JS server action call makes from the page at `url`: a POST of `args` (encoded as the browser encodes them) with `?_rsc_action`, the `rsc-action` header, `X-RSC-Router-Client-Path` and `_rsc_segments` (the page's mounted segment ids, default none), plus the page's `rewriteTarget` as for `partial`. Registers `ref` under `id` (`<module id>#<export name>`). Not with `partial` or `form`. |
23
+ | Field | Type | Meaning |
24
+ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
25
+ | `cacheStore` | `SegmentCacheStore` | The store for this request, in place of the store your `createRouter({ cache })` config returns. The rest of that config (`enabled`, `searchParams`) still applies. Omit it to use the router's own store. |
26
+ | `env` | `TEnv` | Bindings, as `router.fetch(request, { env })` passes them. A function `cache` config receives them. |
27
+ | `headers` | `HeadersInit` | Request headers (e.g. `cookie`). A document request defaults `accept` to `text/html`. |
28
+ | `partial` | `true \| { from?: string; rewriteTarget?: string; segments?: string[]; prefetch?: boolean; fill?: boolean; interceptSource?: string; stale?: boolean; boundary?: string }` | Serve the navigation request the browser sends instead of a document GET: from `from` (default the site root) with `segments` mounted (default none). `prefetch: true` sends the speculative kind a viewport or render `<Link>` prefetch sends (`X-Rango-Prefetch: 1`), so `prefetch: false` and `prefetch: "intent"` work is deferred; add `headers: { "X-Rango-Prefetch": "intent" }` to send a hover's or `prefetch()`'s intent prefetch instead, which runs the `"intent"` work and defers the `false` work. `fill: true` sends the follow-up request (`_rsc_fill=1`) the browser makes after adopting a prefetch; pass the prefetch's delivered ids, without the deferred ones, as `segments`, and the same URL as `from`. `interceptSource` sends the intercept source (`X-RSC-Router-Intercept-Source`) the browser sends with the fill of an intercept, whose `from` is that source too. `rewriteTarget` sends what the page at `from` carried as its payload's `metadata.rewriteTarget` when middleware rewrote it (`_rsc_rt`), as the browser does, so the server diffs against the route that rendered that page. `stale: true` sends a stale request (`_rsc_stale`), as the browser sends it: every `revalidate()` sees `stale: true` (see `revalidate()`'s `stale`), segments take an action's revalidation defaults, and it renders live, as an action does: neither the route cache (`cache()`) nor the document cache serves or stores it, and a `ppr` route's `replayStatus` is `{ outcome: "BYPASS", reason: "stale" }`. `boundary` names the error segment of a thrown action's boundary the page at `from` shows (`_rsc_boundary`), as the background revalidation an earlier action owes that page sends it, with `stale: true` and no `segments`: the response renders every segment above the boundary and nothing at or below it, claims the boundary in `matched` and sends no segment for it. The server reads it only beside `stale`. |
29
+ | `action` | `{ ref: Function; id: string; args?: unknown[]; segments?: string[]; rewriteTarget?: string }` | Send the request a JS server action call makes from the page at `url`: a POST of `args` (encoded as the browser encodes them) with `?_rsc_action`, the `rsc-action` header, `X-RSC-Router-Client-Path` and `_rsc_segments` (the page's mounted segment ids, default none), plus the page's `rewriteTarget` as for `partial`. Registers `ref` under `id` (`<module id>#<export name>`). Not with `partial` or `form`. |
30
30
 
31
31
  ### Returns — `ServeShellRequestResult`
32
32
 
@@ -294,6 +294,18 @@ expect(await fill.readDeferred()).toEqual([]); // a fill defers nothing
294
294
 
295
295
  `deliveredIds` is the payload's `metadata.matched`, read from `prefetch.flight`. Count the loader's runs in your own fixture: the prefetch ran it zero times, the fill once.
296
296
 
297
+ For a loader marked `prefetch: "intent"`, send the hover's prefetch too: it runs that loader and still defers a `prefetch: false` one beside it.
298
+
299
+ ```ts
300
+ const hover = await serveShellRequest(router, "/product/1", {
301
+ cacheStore,
302
+ headers: { "X-Rango-Prefetch": "intent" },
303
+ partial: { from: "/", prefetch: true },
304
+ });
305
+ // loader(RelatedLoader, { prefetch: "intent" }) ran; ReviewsLoader is deferred.
306
+ expect(await hover.readDeferred()).toHaveLength(1);
307
+ ```
308
+
297
309
  ### Intercepts
298
310
 
299
311
  A prefetch of an `intercept()`'s target from its source page defers the intercept's flagged loaders, loader segments of its slot (`${parent}.@modalD${i}.${$$id}`). The browser sends the fill of an intercept from the source, with the intercept source header: pass both.
@@ -314,16 +314,20 @@ The rule: **prefetch broadly, and flag what is expensive.**
314
314
  already on screen whose `revalidate()` re-runs it for the destination is
315
315
  prefetched with it, so the click does not wait on a fill for data the page
316
316
  is already showing.
317
- - `prefetch: false` applies to speculative prefetches: viewport and render.
318
- A hover, or `useRouter().prefetch()`, is intent to click and runs the
319
- loader, so its click needs no fill. A hover over a link a viewport prefetch
320
- already answered sends nothing, and that click still fills.
317
+ - `prefetch: false` applies to every prefetch, hovers included.
318
+ `prefetch: "intent"` applies to speculative prefetches only (viewport and
319
+ render): a hover, or `useRouter().prefetch()`, is intent to click and runs
320
+ the loader, so on a route whose flags are all `"intent"` its click needs no
321
+ fill. Pick `"intent"` for a loader worth starting on hover, `false` for one
322
+ too expensive to run on every cursor pass. A hover over a link a viewport
323
+ prefetch already answered sends nothing, and that click still fills.
321
324
  - The trade-off of a deferring prefetch, measured on a `clientUrls()` tab in
322
- a consumer app (dev, a loaded machine), before a hover's prefetch ran
323
- flagged loaders: with the tab's loader flagged, a hover then a click 150 ms
324
- later showed the content in 843 to 918 ms; with the tab prefetched whole, in
325
- 77 to 157 ms. The flagged click pays the fill request and then React's
326
- 300 ms on the tab's fallback. A viewport-prefetched link still pays it.
325
+ a consumer app (dev, a loaded machine): with the tab's loader skipped by
326
+ the hover's prefetch, a hover then a click 150 ms later showed the content
327
+ in 843 to 918 ms; with the tab prefetched whole, in 77 to 157 ms. The
328
+ deferred click pays the fill request and then React's 300 ms on the tab's
329
+ fallback. `prefetch: "intent"` buys the second number on hover; a
330
+ viewport-prefetched link still pays the first.
327
331
  - A click that adopts a prefetch with a flagged loader shows the page at once
328
332
  and sends one fill request for the flagged loaders. Read a flagged loader
329
333
  under its own `<Suspense>`: with no boundary, React keeps the page being
@@ -335,7 +339,7 @@ The rule: **prefetch broadly, and flag what is expensive.**
335
339
  or a grid does not prefetch each link again after every navigation. The server
336
340
  makes an answer page-only (`x-rsc-prefetch-scope: source`) when sharing it
337
341
  would be wrong: an intercept targets the route or the page sending it, the
338
- answer defers a `prefetch: false` unit the page does not show yet, a
342
+ answer defers a flagged unit the page does not show yet, a
339
343
  `revalidate()` on the target read `currentUrl` and kept its segment, a rule
340
344
  on the route (or inside its `path()`) read the page being left and kept the
341
345
  segment through the route's default, or a kept answer sends no segment at
@@ -388,7 +392,8 @@ The rule: **prefetch broadly, and flag what is expensive.**
388
392
  and the list stays mounted under it; a document load of the URL renders the
389
393
  full page; Back closes the modal. Recipe 3.5.
390
394
  - An intercept's loaders each take their own `revalidate()`, and a flagged
391
- `prefetch: false` loader opens the modal at once with its `loading()`. Its
395
+ (`prefetch: false` or `"intent"`) loader opens the modal at once with its
396
+ `loading()`. Its
392
397
  errors render the declaring layout's `errorBoundary()`.
393
398
  - Inside a group, `intercept()` takes a dot-local named target in the same
394
399
  definition and `loader()`/`loading()` only, and only navigations from inside
@@ -8,6 +8,7 @@ import {
8
8
  replaceCurrentHistoryState,
9
9
  resolveLocationStateEntries,
10
10
  stampLocationState,
11
+ type LocationStateSnapshot,
11
12
  } from "./react/location-state-shared.js";
12
13
  import { WIRE } from "../wire-params.js";
13
14
 
@@ -110,6 +111,53 @@ export function buildHistoryState(
110
111
  return Object.keys(result).length > 0 ? result : null;
111
112
  }
112
113
 
114
+ /**
115
+ * The entry a commit onto the entry on screen writes (router.refresh(), the
116
+ * cross-tab refresh): `entryState`, read when the commit lands, keeps its
117
+ * location state. Its scroll key and idx go (pushHistoryWithIdx and
118
+ * ensureHistoryKey write them again), and its intercept marks are this
119
+ * commit's: a refresh renders the URL's full page. Server-set state merges
120
+ * over the kept state. A flash a reader already cleared from the entry stays
121
+ * cleared: the entry is copied, never rebuilt from what readers show.
122
+ */
123
+ export function keptEntryState(
124
+ entryState: unknown,
125
+ routerState?: { intercept?: boolean; sourceUrl?: string },
126
+ serverState?: Record<string, unknown>,
127
+ ): Record<string, unknown> | null {
128
+ let result: Record<string, unknown> =
129
+ entryState !== null && typeof entryState === "object"
130
+ ? { ...entryState }
131
+ : {};
132
+ delete result.key;
133
+ delete result.idx;
134
+ delete result.intercept;
135
+ delete result.sourceUrl;
136
+ if (routerState?.intercept) {
137
+ result.intercept = true;
138
+ if (routerState.sourceUrl) result.sourceUrl = routerState.sourceUrl;
139
+ }
140
+ if (serverState && Object.keys(serverState).length > 0) {
141
+ result = addLocationState(result, serverState);
142
+ }
143
+ return Object.keys(result).length > 0 ? result : null;
144
+ }
145
+
146
+ /**
147
+ * What readers get from a commit onto the entry on screen: the state they
148
+ * show, plus the server's, recorded under the client's version. Not re-read
149
+ * from the entry, so a flash value on screen stays until the next navigation
150
+ * or back/forward.
151
+ */
152
+ export function shownLocationState(
153
+ shown: LocationStateSnapshot,
154
+ serverState?: Record<string, unknown>,
155
+ ): Record<string, unknown> {
156
+ const result: Record<string, unknown> = { ...shown, ...serverState };
157
+ stampLocationState(result);
158
+ return result;
159
+ }
160
+
113
161
  /** Check if a history state object contains location state keys. */
114
162
  export function hasLocationState(state: unknown): boolean {
115
163
  if (!state || typeof state !== "object") return false;
@@ -489,6 +489,9 @@ export function createNavigationBridge(
489
489
  // Clear intercept source URL to ensure server doesn't treat this as intercept
490
490
  store.setInterceptSourceUrl(null);
491
491
  }
492
+ // The page under an open intercept; null once leaving. Navigations
493
+ // send it so the server resolves the target from there.
494
+ const interceptSourceUrl = store.getInterceptSourceUrl() ?? undefined;
492
495
 
493
496
  // Before navigating away, update the source page's cache with the latest
494
497
  // handleData. This ensures the cache has correct handleData even if handles
@@ -560,6 +563,8 @@ export function createNavigationBridge(
560
563
  !cachedSegments.some((s) => s.type === "error") &&
561
564
  !isInterceptOnlyCache(cachedSegments) &&
562
565
  !isLeavingIntercept &&
566
+ // The history entry's segments are not what the client holds here.
567
+ !interceptSourceUrl &&
563
568
  !cached?.stale &&
564
569
  !options?._skipCache &&
565
570
  // A cached intercept entry for this URL (from any source page) means
@@ -637,6 +642,7 @@ export function createNavigationBridge(
637
642
  ? { type: "leave-intercept" as const }
638
643
  : {
639
644
  type: "navigate" as const,
645
+ ...(interceptSourceUrl && { interceptSourceUrl }),
640
646
  ...(hasUsableCache
641
647
  ? {
642
648
  targetCacheSegments: cachedSegments,
@@ -769,7 +775,12 @@ export function createNavigationBridge(
769
775
  [],
770
776
  false,
771
777
  tx.handle.signal,
772
- tx.with({ url: window.location.href, replace: true, scroll: false }),
778
+ tx.with({
779
+ url: window.location.href,
780
+ replace: true,
781
+ scroll: false,
782
+ sameEntry: true,
783
+ }),
773
784
  { type: "navigate", refresh: true, boundary: shown[at]?.id },
774
785
  );
775
786
  } catch (error) {