@rangojs/router 0.33.2 → 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 (46) hide show
  1. package/dist/bin/{chunk-YP4HENIU.js → chunk-4HHFQSDC.js} +6 -1
  2. package/dist/bin/{chunk-3B7CKBRO.js → chunk-MWOHBDPM.js} +9 -9
  3. package/dist/bin/{client-urls-projection-J2BGDKQ4.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-O6KQ5KO2.js → runtime-discovery-2ZTYRZ3V.js} +2 -2
  7. package/dist/bin/{temp-rsc-server-BOHXBOR7.js → temp-rsc-server-EUNKRFJD.js} +3 -3
  8. package/dist/types/client-urls/server-projection.d.ts +7 -0
  9. package/dist/types/client-urls/types.d.ts +13 -14
  10. package/dist/types/errors.d.ts +5 -0
  11. package/dist/types/prefetch-markers.d.ts +13 -4
  12. package/dist/types/route-definition/helpers-types.d.ts +14 -10
  13. package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +39 -21
  14. package/dist/types/server/context.d.ts +6 -5
  15. package/dist/types/testing/serve-shell-request.d.ts +7 -7
  16. package/dist/types/types/loader-types.d.ts +10 -8
  17. package/dist/types/urls/path-helper-types.d.ts +9 -8
  18. package/dist/vite/index.js +26 -12
  19. package/package.json +1 -1
  20. package/skills/app-structure/large-app.md +3 -0
  21. package/skills/client-urls/SKILL.md +13 -10
  22. package/skills/intercept/SKILL.md +8 -4
  23. package/skills/links/SKILL.md +15 -12
  24. package/skills/loader/SKILL.md +52 -39
  25. package/skills/ppr/SKILL.md +1 -1
  26. package/skills/route/SKILL.md +15 -9
  27. package/skills/testing/serve-shell-request.md +19 -7
  28. package/skills/ux-architecture/SKILL.md +16 -11
  29. package/src/browser/navigation-bridge.ts +6 -0
  30. package/src/browser/partial-update.ts +12 -4
  31. package/src/browser/prefetch/fetch.ts +3 -3
  32. package/src/client-urls/client-urls.ts +11 -7
  33. package/src/client-urls/server-projection.ts +21 -3
  34. package/src/client-urls/types.ts +13 -14
  35. package/src/errors.ts +17 -0
  36. package/src/prefetch-markers.ts +18 -4
  37. package/src/route-definition/dsl-helpers.ts +13 -6
  38. package/src/route-definition/helpers-types.ts +14 -10
  39. package/src/router/loader-resolution.ts +4 -4
  40. package/src/router/match-api.ts +1 -3
  41. package/src/router/segment-resolution/prefetch-deferral.ts +108 -50
  42. package/src/rsc/rsc-rendering.ts +1 -1
  43. package/src/server/context.ts +6 -5
  44. package/src/testing/serve-shell-request.ts +7 -7
  45. package/src/types/loader-types.ts +10 -8
  46. package/src/urls/path-helper-types.ts +9 -8
@@ -111,6 +111,17 @@ var init_debug = __esm({
111
111
  }
112
112
  });
113
113
 
114
+ // src/prefetch-markers.ts
115
+ var PREFETCH_HEADER, PREFETCH_INTENT, PREFETCH_FAILED_ROW;
116
+ var init_prefetch_markers = __esm({
117
+ "src/prefetch-markers.ts"() {
118
+ "use strict";
119
+ PREFETCH_HEADER = "X-Rango-Prefetch";
120
+ PREFETCH_INTENT = "intent";
121
+ PREFETCH_FAILED_ROW = ":H_0\n";
122
+ }
123
+ });
124
+
114
125
  // src/internal-debug.ts
115
126
  var INTERNAL_RANGO_DEBUG;
116
127
  var init_internal_debug = __esm({
@@ -725,17 +736,6 @@ var init_validate_redirect_origin = __esm({
725
736
  }
726
737
  });
727
738
 
728
- // src/prefetch-markers.ts
729
- var PREFETCH_HEADER, PREFETCH_INTENT, PREFETCH_FAILED_ROW;
730
- var init_prefetch_markers = __esm({
731
- "src/prefetch-markers.ts"() {
732
- "use strict";
733
- PREFETCH_HEADER = "X-Rango-Prefetch";
734
- PREFETCH_INTENT = "intent";
735
- PREFETCH_FAILED_ROW = ":H_0\n";
736
- }
737
- });
738
-
739
739
  // src/browser/response-adapter.ts
740
740
  function isForeignRouterId(response, expectedId) {
741
741
  const got = response.headers.get("X-RSC-Router-Id");
@@ -4594,6 +4594,9 @@ import * as React4 from "react";
4594
4594
  // src/server/context.ts
4595
4595
  import { AsyncLocalStorage as AsyncLocalStorage2 } from "node:async_hooks";
4596
4596
 
4597
+ // src/errors.ts
4598
+ init_prefetch_markers();
4599
+
4597
4600
  // src/cache/cache-key-utils.ts
4598
4601
  function routerKeyPrefix(routerId2) {
4599
4602
  return `${encodeURIComponent(routerId2)}@`;
@@ -5363,6 +5366,9 @@ function insertSegments(node, segments, index, leafBase, paramNames) {
5363
5366
  }
5364
5367
  }
5365
5368
 
5369
+ // src/client-urls/client-urls.ts
5370
+ init_prefetch_markers();
5371
+
5366
5372
  // src/browser/react/use-navigation.ts
5367
5373
  import { useContext as useContext9, useEffect as useEffect7, useRef as useRef5 } from "react";
5368
5374
 
@@ -5924,6 +5930,9 @@ import { jsx as jsx9 } from "react/jsx-runtime";
5924
5930
  // src/server/root-layout.tsx
5925
5931
  import { Fragment as Fragment3, jsx as jsx10 } from "react/jsx-runtime";
5926
5932
 
5933
+ // src/route-definition/dsl-helpers.ts
5934
+ init_prefetch_markers();
5935
+
5927
5936
  // src/prerender.ts
5928
5937
  var PRERENDER_PASSTHROUGH = Object.freeze({
5929
5938
  __brand: "prerenderPassthrough"
@@ -6318,8 +6327,13 @@ function serializeLoaderOptions(loaders, owner, options) {
6318
6327
  loaders,
6319
6328
  ({ prefetch }) => prefetch === false
6320
6329
  );
6330
+ const intentLoaderIndices = indicesWhere(
6331
+ loaders,
6332
+ ({ prefetch }) => prefetch === "intent"
6333
+ );
6321
6334
  return {
6322
6335
  ...deferrableLoaderIndices.length > 0 ? { deferrableLoaderIndices: Object.freeze(deferrableLoaderIndices) } : {},
6336
+ ...intentLoaderIndices.length > 0 ? { intentLoaderIndices: Object.freeze(intentLoaderIndices) } : {},
6323
6337
  ...loaderRevalidate.some((keys) => keys.length > 0) ? {
6324
6338
  loaderRevalidate: Object.freeze(
6325
6339
  loaderRevalidate.map((keys) => Object.freeze(keys))
@@ -7330,7 +7344,7 @@ function resolveVersionsFrom(table, routerId2) {
7330
7344
  // package.json
7331
7345
  var package_default = {
7332
7346
  name: "@rangojs/router",
7333
- version: "0.33.2",
7347
+ version: "0.34.0",
7334
7348
  description: "Django-inspired RSC router with composable URL patterns",
7335
7349
  keywords: [
7336
7350
  "react",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.33.2",
3
+ "version": "0.34.0",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -706,6 +706,9 @@ Rules of thumb:
706
706
  never become clicks: activity feeds, analytics panels, reviews below the
707
707
  fold, a list behind a tab.
708
708
  - Do not flag what the likely click needs first: the page's main content.
709
+ - Flag a loader worth starting on hover `prefetch: "intent"` instead: viewport
710
+ and render prefetches skip it, and a hover or `useRouter().prefetch()` runs
711
+ it, so that click needs no fill for it.
709
712
  - Read a flagged loader inside its own `<Suspense>`; with no boundary, React
710
713
  keeps the page being left on screen until the fill answers.
711
714
  - A same-route click (`/app/projects/1` to `/app/projects/2`) prefetches a
@@ -132,7 +132,7 @@ flow into the generated route map, so `href()`, `ctx.reverse()` and
132
132
  | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
133
133
  | `path()` | Options are `name`, `search`, `trailingSlash`, `ppr` (shell caching — see `/ppr` → The loader lane rule); no response variants. Use callback may contain `loader()`, `loading()`, `transition()` |
134
134
  | `layout()` | `layout(Component, () => [...])` — children are required and must contain at least one `path()`; may also hold `loader()`, `loading()`, `intercept()`, nested `layout()` |
135
- | `loader()` | `loader(Def, use?)` or `loader(Def, { ssr: false, prefetch: false }, use?)` — see below |
135
+ | `loader()` | `loader(Def, use?)` or `loader(Def, { ssr: false, prefetch: false \| "intent" }, use?)` — see below |
136
136
  | `loading()` | Route-level boundary around the optimistic render; inline `<Suspense>` at read sites keeps the destination's chrome visible while only the reads wait |
137
137
  | `revalidate()` | Valid **inside a loader() use callback only**; runs on the server (the build hoists it out of the `"use client"` module) |
138
138
  | `transition()` | Inside a `path()` use callback only (at most one). ViewTransition config plus a browser-run `when` predicate (see `/view-transitions`); same-route navs in a group already hold without it |
@@ -623,25 +623,27 @@ shell, such as a `useActionState` form (React suspends it once to encode its
623
623
  action), is in the first flush too: the router gives React up to three more
624
624
  turns before it, and none when the document is already complete.
625
625
 
626
- ## `prefetch: false` — keep a loader out of speculative prefetches
626
+ ## `prefetch: false` — keep a loader out of prefetches
627
627
 
628
628
  A `<Link>` prefetch of a group route runs its loaders. Flag the expensive one
629
- and a viewport or render prefetch skips it. A hover prefetch, and
630
- `useRouter().prefetch()`, is intent to click: it runs the loader, and the
631
- click sends no fill (`/loader` → "`prefetch: false`").
629
+ `prefetch: false` and every prefetch skips it. Flag it `prefetch: "intent"`
630
+ and only a viewport or render prefetch skips it: a hover prefetch, and
631
+ `useRouter().prefetch()`, is intent to click, runs it, and the click sends
632
+ no fill for it (`/loader` → "`prefetch: false`").
632
633
 
633
634
  ```tsx
634
635
  path("/product/:slug", ProductPage, { name: "product" }, () => [
635
636
  loader(PriceLoader),
636
- loader(ReviewsLoader, { prefetch: false }), // kept out of viewport prefetches
637
+ loader(RelatedLoader, { prefetch: "intent" }), // kept out of viewport prefetches
638
+ loader(ReviewsLoader, { prefetch: false }), // kept out of every prefetch
637
639
  loading(<ProductSkeleton />),
638
640
  ]),
639
641
  ```
640
642
 
641
643
  A click inside the group still shows the destination at once. With a
642
- viewport or render prefetch received, the URL and the other loaders' data move with the click
644
+ prefetch received, the URL and the other loaders' data move with the click
643
645
  too, `useOutlet().pending` ends with that commit, and one follow-up request
644
- (the fill) runs only the flagged loaders. Read the flagged loader inside its
646
+ (the fill) runs only the loaders it skipped. Read the flagged loader inside its
645
647
  own `<Suspense>` to show the page and that fallback at once; with no boundary
646
648
  React holds the page being left until the fill answers, as it would with no
647
649
  prefetch. Same rules as on a server route (`/loader` → "`prefetch: false`"):
@@ -660,8 +662,9 @@ rest of the destination; otherwise it is held. A same-route click
660
662
  - `loading()` in `clientUrls()` takes no options, and a flagged `loading()`
661
663
  on a server layout above the group defers nothing for the group's routes.
662
664
  - On an `intercept()` loader the flag works as in `urls()`: an in-group
663
- viewport or render prefetch of the intercept's target defers it, and the
664
- click opens the modal at once with one fill for it; a hover's runs it.
665
+ prefetch of the intercept's target defers it, and the click opens the
666
+ modal at once with one fill for it; a hover's runs a loader marked
667
+ `"intent"`.
665
668
  - With `ssr: false` on a `ppr` group route the flag works as on a server
666
669
  route: deferred unless the prefetch serves the loader from a pin of a
667
670
  document-captured shell (`/ppr` → "`prefetch: false` on a `ppr` route").
@@ -69,6 +69,7 @@ export const urlpatterns = urls(({ path, layout, intercept, loader, loading }) =
69
69
  | Click link `/shop/product/abc` | `<ProductModal />` in `@modal`, background preserved |
70
70
  | Direct URL `/shop/product/abc`, or a reload | Full `<ProductPage />` page |
71
71
  | `router.refresh()`, or another tab's action | Full `<ProductPage />` page, as a reload |
72
+ | Link inside the intercept to another intercepted URL | That intercept, over the same source page |
72
73
  | Back from the open intercept | The intercept closes; the source page stays |
73
74
  | Back/Forward onto the intercept's entry | The intercept over its source page |
74
75
  | The same, uncached, from a page other than the source | Full `<ProductPage />` page, as a document load |
@@ -311,13 +312,16 @@ intercepted: it gets the full page.
311
312
  ### `prefetch: false` on an intercept's loaders
312
313
 
313
314
  `loader(Def, { prefetch: false })` inside `intercept()` keeps that loader out
314
- of a viewport or render prefetch of the target from the source page (a hover
315
- prefetch runs it, and its click opens the modal complete with no fill):
315
+ of every prefetch of the target from the source page. `{ prefetch: "intent" }`
316
+ keeps it out of viewport and render prefetches only: a hover prefetch runs
317
+ it, and when every flagged loader of the modal is `"intent"` its click opens
318
+ the modal complete with no fill:
316
319
 
317
320
  ```typescript
318
321
  intercept("@modal", "product", <ProductModal />, () => [
319
322
  loader(ProductLoader),
320
- loader(ReviewsLoader, { prefetch: false }), // skipped in a viewport prefetch
323
+ loader(PhotosLoader, { prefetch: "intent" }), // skipped in a viewport prefetch, runs on hover
324
+ loader(ReviewsLoader, { prefetch: false }), // skipped in every prefetch
321
325
  loading(<ModalSkeleton />),
322
326
  ]);
323
327
  ```
@@ -330,7 +334,7 @@ click shows nothing until its response. The modal's loaders resolve together,
330
334
  so its `loading()` shows until the last one arrives. A handler that reads the
331
335
  flagged loader with `ctx.use()` before its first `await` (or one with no
332
336
  `loading()`) gets it run in the prefetch instead: one run, no fill for it.
333
- `loading(…, { prefetch: false })` inside `intercept()` defers nothing.
337
+ A `prefetch` option on `loading()` inside `intercept()` defers nothing.
334
338
 
335
339
  A prefetch of a route an intercept targets, or one sent from such a route, is
336
340
  stored per source page, whether or not the intercept applies from that page
@@ -619,14 +619,15 @@ A prefetch is a navigation sent early (a partial request with an
619
619
  `X-Rango-Prefetch` header), and by default the server renders everything for
620
620
  it: every handler and every loader. That is the point, a click then commits
621
621
  without a request, but it is also forty renders for a grid of forty links.
622
- Keep the expensive part out of speculative (viewport, render) prefetches on
623
- the route, not on the link:
622
+ Keep the expensive part out of prefetches on the route, not on the link:
624
623
 
625
624
  ```tsx
626
- // skip one loader
625
+ // skip one loader in every prefetch
627
626
  loader(ReviewsLoader, { prefetch: false }),
628
627
  // skip what the fallback covers
629
628
  loading(<OrdersSkeleton />, { prefetch: false }),
629
+ // skip it in viewport and render prefetches only: a hover runs it
630
+ loader(RelatedLoader, { prefetch: "intent" }),
630
631
  ```
631
632
 
632
633
  A click that adopts such a prefetch commits what was prefetched, shows the
@@ -635,13 +636,15 @@ is new to the page is deferred: a segment already on screen (the layout of the
635
636
  section you are in, the route itself on `/product/a` to `/product/b`) is
636
637
  prefetched as usual.
637
638
 
638
- A hover prefetch is treated as intent and runs everything, flagged work
639
- included (`X-Rango-Prefetch: intent`, where a viewport or render prefetch
640
- sends `1`); so does `useRouter().prefetch()`. Its click commits complete with
641
- no extra request. A hover sends that request only when the link has no
642
- prefetch cached or in flight: on a viewport link whose prefetch has already
643
- answered, it sends nothing, and the click fetches the deferred part as above.
644
- See
639
+ A hover prefetch is treated as intent (`X-Rango-Prefetch: intent`, where a
640
+ viewport or render prefetch sends `1`); so is `useRouter().prefetch()`. It
641
+ runs the work marked `prefetch: "intent"` and still skips the work marked
642
+ `prefetch: false`, so on a route whose flags are all `"intent"` its click
643
+ commits complete with no extra request. A hover sends that request only when
644
+ the link has no prefetch cached or in flight: on a viewport link whose
645
+ prefetch has already answered, it sends nothing, and the click fetches the
646
+ deferred part as above. Middleware that skips side effects in prefetches
647
+ should test for the header's presence, not for `=== "1"`. See
645
648
  `/loader` → "`prefetch: false`" and `/route` → "`loading()` options". To stop
646
649
  a link or a whole section from prefetching at all, use the `prefetch` prop or
647
650
  `data-prefetch-scope` above.
@@ -668,8 +671,8 @@ The answer is page-only (`x-rsc-prefetch-scope: source`) when:
668
671
 
669
672
  - An intercept targets the route, or the page that sent the prefetch,
670
673
  whether or not it applies here.
671
- - The answer defers a `prefetch: false` loader or `loading()` that the page
672
- does not show yet (a viewport or render prefetch; a hover's defers nothing).
674
+ - The answer defers a `prefetch: false` or `prefetch: "intent"` loader or
675
+ `loading()` that the page does not show yet.
673
676
  - A `revalidate()` on the target read `currentUrl` and kept its segment.
674
677
  - A `revalidate()` on the route, or on a segment inside its `path()`, read
675
678
  the page being left and kept the segment through the route's default
@@ -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
 
@@ -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.