@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
@@ -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
@@ -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,
@@ -616,7 +616,10 @@ export function createPartialUpdater(
616
616
 
617
617
  // The page the client is on, which the server diffs the target against:
618
618
  // the committed entry (the store's URL until tx.commit()), whose segment
619
- // ids are sent above, or the intercept's source. Never window.location:
619
+ // ids are sent above. A navigation names an intercept's source in its own
620
+ // header, so the client path stays the committed URL (the slot's params
621
+ // compare against it); an action's or a stale revalidation's refetch
622
+ // still names the source as the client path. Never window.location:
620
623
  // a popstate has already moved it to the popped entry before that
621
624
  // entry's fetch commits. Sent from there, a back/forward was told the
622
625
  // target is also the source and kept the page being left (#1030), and a
@@ -624,7 +627,8 @@ export function createPartialUpdater(
624
627
  // against the popped entry while holding the page on screen's copies, so
625
628
  // a layout kept for one page showed the other's.
626
629
  const previousUrl =
627
- (mode.type !== "leave-intercept" && interceptSourceUrl) ||
630
+ ((mode.type === "action" || mode.type === "stale-revalidation") &&
631
+ interceptSourceUrl) ||
628
632
  segmentState.currentUrl;
629
633
 
630
634
  if (IS_BROWSER_DEBUG) {
@@ -711,11 +715,15 @@ export function createPartialUpdater(
711
715
  targetUrl: url,
712
716
  segmentIds: segments,
713
717
  previousUrl,
714
- // The entry being left (a popstate has not moved it yet), unless
715
- // previousUrl names an intercept's source.
718
+ // The entry being left (a popstate has not moved it yet). Dropped
719
+ // whenever an intercept source is present: the server diffs from the
720
+ // source, which `_rsc_rt` does not name, and ignores it beside the
721
+ // source header (router/navigation-snapshot.ts resolveNavigation).
716
722
  rewriteTarget: interceptSourceUrl
717
723
  ? undefined
718
724
  : store.getHistoryEntryMemory()?.rewriteTarget,
725
+ interceptSourceUrl:
726
+ mode.type === "navigate" ? interceptSourceUrl : undefined,
719
727
  staleRevalidation:
720
728
  mode.type === "stale-revalidation" || segments.length === 0,
721
729
  boundary,
@@ -279,9 +279,9 @@ function executePrefetchFetch(
279
279
  rangoState: string,
280
280
  /**
281
281
  * An intent prefetch (prefetchDirect: hover, `prefetch()`): sent as
282
- * PREFETCH_INTENT so the server defers nothing, as in the navigation it
283
- * starts early. A speculative one (prefetchQueued) sends `1` and lets the
284
- * server defer `prefetch: false` work (docs/design/prefetch-false.md, R16).
282
+ * PREFETCH_INTENT so the server runs `prefetch: "intent"` work and defers
283
+ * only `prefetch: false` work. A speculative one (prefetchQueued) sends `1`
284
+ * and lets the server defer both (docs/design/prefetch-false.md, R16).
285
285
  */
286
286
  intent: boolean,
287
287
  expectedRouterId?: string,
@@ -5,6 +5,7 @@ import { joinMount } from "../router/substitute-pattern-params.js";
5
5
  import { tryTrieMatch } from "../router/trie-matching.js";
6
6
  import type { LoaderDefinition } from "../types.js";
7
7
  import { isHoistedRevalidateRef } from "./hoisted-revalidate.js";
8
+ import { isPrefetchFlag, type PrefetchFlag } from "../prefetch-markers.js";
8
9
  import type {
9
10
  ClientPathOptions,
10
11
  ClientTransitionConfig,
@@ -141,7 +142,7 @@ interface LoaderItem extends ItemBase {
141
142
  readonly definition: LoaderDefinition<any, any>;
142
143
  readonly revalidate: ClientUrlLoaderRecord["revalidate"];
143
144
  readonly ssr?: false;
144
- readonly prefetch?: false;
145
+ readonly prefetch?: PrefetchFlag;
145
146
  }
146
147
 
147
148
  interface RevalidateItem extends ItemBase {
@@ -360,10 +361,11 @@ export const loader: ClientUrlHelpers["loader"] = (
360
361
  if (
361
362
  process.env.NODE_ENV !== "production" &&
362
363
  options?.prefetch !== undefined &&
363
- typeof options.prefetch !== "boolean"
364
+ typeof options.prefetch !== "boolean" &&
365
+ options.prefetch !== "intent"
364
366
  ) {
365
367
  throw new Error(
366
- `clientUrls() loader() prefetch must be a boolean (got ${JSON.stringify(options.prefetch)}). Omit it (or pass true) to run the loader in prefetches.`,
368
+ `clientUrls() loader() prefetch must be a boolean or "intent" (got ${JSON.stringify(options.prefetch)}). Omit it (or pass true) to run the loader in prefetches.`,
367
369
  );
368
370
  }
369
371
  const items = use ? runUse(use, "loader use") : [];
@@ -379,7 +381,9 @@ export const loader: ClientUrlHelpers["loader"] = (
379
381
  )
380
382
  .map((item) => item.fn),
381
383
  ...(options?.ssr === false ? { ssr: false as const } : {}),
382
- ...(options?.prefetch === false ? { prefetch: false as const } : {}),
384
+ ...(isPrefetchFlag(options?.prefetch)
385
+ ? { prefetch: options.prefetch }
386
+ : {}),
383
387
  });
384
388
  };
385
389
 
@@ -630,7 +634,7 @@ function applyConfig(
630
634
  loader: item.definition,
631
635
  revalidate: Object.freeze([...item.revalidate]),
632
636
  ...(item.ssr === false ? { ssr: false as const } : {}),
633
- ...(item.prefetch === false ? { prefetch: false as const } : {}),
637
+ ...(item.prefetch !== undefined ? { prefetch: item.prefetch } : {}),
634
638
  }),
635
639
  );
636
640
  } else if (item.type === "loading") {
@@ -754,8 +758,8 @@ function collectIntercepts(
754
758
  Object.freeze({
755
759
  loader: useItem.definition,
756
760
  revalidate: Object.freeze([...useItem.revalidate]),
757
- ...(useItem.prefetch === false
758
- ? { prefetch: false as const }
761
+ ...(useItem.prefetch !== undefined
762
+ ? { prefetch: useItem.prefetch }
759
763
  : {}),
760
764
  }),
761
765
  );
@@ -113,6 +113,11 @@ export interface ClientUrlProjectionRoute {
113
113
  * loader(), so a prefetch of the route defers them. Absent (= none) in
114
114
  * projections serialized before the option existed. */
115
115
  readonly deferrableLoaderIndices?: readonly number[];
116
+ /** Indices into loaderIds of loaders declared loader(Def, { prefetch:
117
+ * "intent" }); materialization passes the option through, so a
118
+ * speculative prefetch defers them and an intent prefetch runs them.
119
+ * Absent (= none) in projections serialized before the option existed. */
120
+ readonly intentLoaderIndices?: readonly number[];
116
121
  /** Per loader (aligned with loaderIds), its revalidate() arguments in
117
122
  * declaration order: a hoisted predicate's key, whose registration
118
123
  * materialization attaches (revalidate-registry.ts), or a boolean
@@ -140,6 +145,8 @@ export interface ClientUrlProjectionIntercept {
140
145
  readonly hasLoading: boolean;
141
146
  /** As on a route (ClientUrlProjectionRoute.deferrableLoaderIndices). */
142
147
  readonly deferrableLoaderIndices?: readonly number[];
148
+ /** As on a route (ClientUrlProjectionRoute.intentLoaderIndices). */
149
+ readonly intentLoaderIndices?: readonly number[];
143
150
  /** As on a route (ClientUrlProjectionRoute.loaderRevalidate). */
144
151
  readonly loaderRevalidate?: readonly (readonly (string | boolean)[])[];
145
152
  }
@@ -381,7 +388,7 @@ function serializeLoaderOptions(
381
388
  options: SerializeClientUrlPatternsOptions,
382
389
  ): Pick<
383
390
  ClientUrlProjectionRoute,
384
- "deferrableLoaderIndices" | "loaderRevalidate"
391
+ "deferrableLoaderIndices" | "intentLoaderIndices" | "loaderRevalidate"
385
392
  > {
386
393
  const loaderRevalidate = loaders.map(({ loader, revalidate }) =>
387
394
  revalidate.flatMap((entry): (string | boolean)[] => {
@@ -403,10 +410,17 @@ function serializeLoaderOptions(
403
410
  loaders,
404
411
  ({ prefetch }) => prefetch === false,
405
412
  );
413
+ const intentLoaderIndices = indicesWhere(
414
+ loaders,
415
+ ({ prefetch }) => prefetch === "intent",
416
+ );
406
417
  return {
407
418
  ...(deferrableLoaderIndices.length > 0
408
419
  ? { deferrableLoaderIndices: Object.freeze(deferrableLoaderIndices) }
409
420
  : {}),
421
+ ...(intentLoaderIndices.length > 0
422
+ ? { intentLoaderIndices: Object.freeze(intentLoaderIndices) }
423
+ : {}),
410
424
  ...(loaderRevalidate.some((keys) => keys.length > 0)
411
425
  ? {
412
426
  loaderRevalidate: Object.freeze(
@@ -582,6 +596,7 @@ interface ProjectedLoaders {
582
596
  | "loaderRevalidate"
583
597
  | "awaitedLoaderIndices"
584
598
  | "deferrableLoaderIndices"
599
+ | "intentLoaderIndices"
585
600
  >;
586
601
  /** Names the owner in an error: `route "/a"`, `intercept ".b"`. */
587
602
  readonly label: string;
@@ -817,8 +832,8 @@ function materializeRouteItems(
817
832
  };
818
833
 
819
834
  // The loader() items of a route's or an intercept's projected loaders: each
820
- // stub with its `ssr: false` and `prefetch: false` flags and its
821
- // revalidate() predicates.
835
+ // stub with its `ssr: false` and `prefetch` flags and its revalidate()
836
+ // predicates.
822
837
  const materializeLoaders = (owner: ProjectedLoaders) =>
823
838
  owner.projected.loaderIds.map((id, loaderIndex) => {
824
839
  const revalidators = loaderRevalidators(reference, owner, loaderIndex);
@@ -831,6 +846,9 @@ function materializeRouteItems(
831
846
  ...(owner.projected.deferrableLoaderIndices?.includes(
832
847
  loaderIndex,
833
848
  ) && { prefetch: false }),
849
+ ...(owner.projected.intentLoaderIndices?.includes(loaderIndex) && {
850
+ prefetch: "intent" as const,
851
+ }),
834
852
  },
835
853
  () => revalidators.map((fn) => revalidate(fn)),
836
854
  );
@@ -7,6 +7,7 @@ import type {
7
7
  } from "../types.js";
8
8
  import type { DefaultEnv } from "../types/global-namespace.js";
9
9
  import type { HoistedRevalidateRef } from "./hoisted-revalidate.js";
10
+ import type { PrefetchFlag } from "../prefetch-markers.js";
10
11
  import type { TrieMatchResult } from "../router/trie-matching.js";
11
12
  import type { PathOptions } from "../urls/pattern-types.js";
12
13
  import type { SearchSchema } from "../search-params.js";
@@ -85,13 +86,12 @@ export interface ClientUrlLoaderRecord {
85
86
  */
86
87
  readonly ssr?: false;
87
88
  /**
88
- * loader(Def, { prefetch: false }): a viewport or render prefetch of the
89
- * route, or of an intercept's target from its source, defers this loader
90
- * (a hover prefetch runs it) and the click
91
- * that adopts it runs it in its fill request (see {@link LoaderOptions}).
92
- * Projected onto the materialized stub.
89
+ * loader(Def, { prefetch: false | "intent" }): a prefetch of the route, or
90
+ * of an intercept's target from its source, defers this loader and the
91
+ * click that adopts it runs it in its fill request (see
92
+ * {@link LoaderOptions}). Projected onto the materialized stub.
93
93
  */
94
- readonly prefetch?: false;
94
+ readonly prefetch?: PrefetchFlag;
95
95
  }
96
96
 
97
97
  /**
@@ -169,14 +169,13 @@ export interface ClientUrlHelpers {
169
169
  * settled return freezes into the shell (nested promises stay live
170
170
  * holes).
171
171
  *
172
- * Pass `{ prefetch: false }` to keep this loader out of the route's
173
- * viewport and render prefetches; a hover prefetch runs it (see
174
- * {@link LoaderOptions}). An in-group click still shows
175
- * the destination at once, commits the adopted prefetch with the other
176
- * loaders' data, and runs only the flagged loaders in its one fill
177
- * request. A `layout()` loader with the flag is flagged on every route
178
- * below it. Inside intercept() it defers the loader in a viewport or
179
- * render prefetch of the intercept from its source, as in urls().
172
+ * Pass `{ prefetch: false | "intent" }` to keep this loader out of the
173
+ * route's prefetches (see {@link LoaderOptions}). An in-group click still
174
+ * shows the destination at once, commits the adopted prefetch with the
175
+ * other loaders' data, and runs only the flagged loaders in its one fill
176
+ * request. A `layout()` loader with the
177
+ * flag is flagged on every route below it. Inside intercept() it defers
178
+ * the loader in a prefetch of the intercept from its source, as in urls().
180
179
  */
181
180
  readonly loader: <TData>(
182
181
  definition: LoaderDefinition<TData>,
package/src/errors.ts CHANGED
@@ -6,6 +6,8 @@
6
6
  * - Cause property for structured debugging data
7
7
  */
8
8
 
9
+ import { isPrefetchFlag } from "./prefetch-markers.js";
10
+
9
11
  /**
10
12
  * Options for error construction
11
13
  */
@@ -418,6 +420,21 @@ export function assertBooleanOption(
418
420
  );
419
421
  }
420
422
 
423
+ /**
424
+ * Rejects a `prefetch` DSL option that is neither a boolean nor `"intent"`
425
+ * (undefined passes), as an invariant.
426
+ */
427
+ export function assertPrefetchOption(
428
+ value: unknown,
429
+ option: string,
430
+ effect: string,
431
+ ): void {
432
+ invariant(
433
+ value === undefined || typeof value === "boolean" || isPrefetchFlag(value),
434
+ `${option} must be a boolean or "intent" (got ${JSON.stringify(value)}). Omit it (or pass true) to ${effect}.`,
435
+ );
436
+ }
437
+
421
438
  /**
422
439
  * Sanitize errors for production - prevents leaking sensitive information
423
440
  *
@@ -12,13 +12,27 @@ export const PREFETCH_HEADER = "X-Rango-Prefetch";
12
12
  /**
13
13
  * PREFETCH_HEADER's value on an intent prefetch: a hover or an explicit
14
14
  * `prefetch()` call (browser/prefetch/fetch.ts prefetchDirect). The server
15
- * defers nothing in it: `prefetch: false` loaders and the work behind a
16
- * flagged `loading()` run, as in the navigation it starts early. A
17
- * speculative prefetch (viewport, render) sends `1`, and any value other than
18
- * this one reads as speculative.
15
+ * runs the work flagged `prefetch: "intent"` in it and defers the work
16
+ * flagged `prefetch: false`. A speculative prefetch (viewport, render) sends
17
+ * `1` and defers both; any value other than this one reads as speculative.
19
18
  */
20
19
  export const PREFETCH_INTENT = "intent";
21
20
 
21
+ /**
22
+ * A recorded `prefetch` flag of loader() or loading(): `false` defers the
23
+ * work in every prefetch, `"intent"` in a speculative one only
24
+ * (router/segment-resolution/prefetch-deferral.ts).
25
+ */
26
+ export type PrefetchFlag = false | typeof PREFETCH_INTENT;
27
+
28
+ /** A `prefetch` DSL option as written: a boolean, or `"intent"`. */
29
+ export type PrefetchOption = boolean | PrefetchFlag;
30
+
31
+ /** The option asks for deferral: `prefetch: false` or `prefetch: "intent"`. */
32
+ export function isPrefetchFlag(value: unknown): value is PrefetchFlag {
33
+ return value === false || value === PREFETCH_INTENT;
34
+ }
35
+
22
36
  /**
23
37
  * The last row of a prefetch whose render reported an error
24
38
  * (rsc/prefetch-render-error.ts): a Flight hint with a code React does not
@@ -25,7 +25,11 @@ import {
25
25
  type InterceptConfig,
26
26
  type LoaderEntry,
27
27
  } from "../server/context";
28
- import { assertBooleanOption, invariant } from "../errors";
28
+ import {
29
+ assertBooleanOption,
30
+ assertPrefetchOption,
31
+ invariant,
32
+ } from "../errors";
29
33
  import { validateUserRouteName } from "../route-name.js";
30
34
  import { isCachedFunction } from "../cache/taint.js";
31
35
  import { RangoContext } from "../server/context";
@@ -48,6 +52,7 @@ import type {
48
52
  UseItems,
49
53
  } from "../route-types.js";
50
54
  import { isRenderableLoading } from "../types/boundaries.js";
55
+ import { isPrefetchFlag } from "../prefetch-markers.js";
51
56
  import type { RouteHelpers } from "./helpers-types.js";
52
57
  import {
53
58
  resolveHandlerUse,
@@ -922,7 +927,7 @@ const loader: RouteHelpers<any, any>["loader"] = (
922
927
  "loader() ssr",
923
928
  "stream on every render",
924
929
  );
925
- assertBooleanOption(
930
+ assertPrefetchOption(
926
931
  optionsGiven?.prefetch,
927
932
  "loader() prefetch",
928
933
  "run the loader in prefetches",
@@ -938,7 +943,9 @@ const loader: RouteHelpers<any, any>["loader"] = (
938
943
  loader: loaderDef,
939
944
  revalidate: [] as ShouldRevalidateFn<any, any>[],
940
945
  ...(optionsGiven?.ssr === false && { bake: true as const }),
941
- ...(optionsGiven?.prefetch === false && { prefetch: false as const }),
946
+ ...(isPrefetchFlag(optionsGiven?.prefetch) && {
947
+ prefetch: optionsGiven.prefetch,
948
+ }),
942
949
  // The declaring layout's / parallel's / cache()'s scope. A route's (and
943
950
  // an intercept's) loaders have none: they resolve from the route's name,
944
951
  // like the handler.
@@ -997,7 +1004,7 @@ const loading: RouteHelpers<any, any>["loading"] = (component, options) => {
997
1004
  invariant(false, "No parent entry available for loading()");
998
1005
  }
999
1006
 
1000
- assertBooleanOption(
1007
+ assertPrefetchOption(
1001
1008
  options?.prefetch,
1002
1009
  "loading() prefetch",
1003
1010
  "render what the fallback covers in prefetches",
@@ -1018,8 +1025,8 @@ const loading: RouteHelpers<any, any>["loading"] = (component, options) => {
1018
1025
  // `parent.loading`, so the document evaluation (ssr: false) agrees with
1019
1026
  // the navigation one. Last loading() wins, like `loading` itself.
1020
1027
  parent.loadingPrefetch =
1021
- options?.prefetch === false && isRenderableLoading(resolved)
1022
- ? false
1028
+ isPrefetchFlag(options?.prefetch) && isRenderableLoading(resolved)
1029
+ ? options.prefetch
1023
1030
  : undefined;
1024
1031
 
1025
1032
  const name = `$${store.getNextIndex("loading")}`;
@@ -35,6 +35,7 @@ import type {
35
35
  UseItems,
36
36
  } from "../route-types.js";
37
37
  import type { StaticHandlerRef } from "../static-handler.js";
38
+ import type { PrefetchOption } from "../prefetch-markers.js";
38
39
 
39
40
  // Re-export route item types for backward compatibility
40
41
  export type {
@@ -333,15 +334,16 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
333
334
  * loader(RecommendationsLoader), // still streams behind loading()
334
335
  * ```
335
336
  *
336
- * Pass `{ prefetch: false }` to keep the loader out of speculative
337
- * prefetches (viewport, render): such a prefetch skips it and the click
338
- * that adopts it fetches it in one follow-up request; a hover prefetch runs
339
- * it (see {@link LoaderOptions}). Only on a segment the
337
+ * Pass `{ prefetch: false }` to keep the loader out of prefetches: a
338
+ * prefetch skips it and the click that adopts it fetches it in one
339
+ * follow-up request. `{ prefetch: "intent" }` defers it in viewport and
340
+ * render prefetches only (see {@link LoaderOptions}). Only on a segment the
340
341
  * client does not have yet: a loader the client already holds runs in the
341
342
  * prefetch when it revalidates.
342
343
  *
343
344
  * ```typescript
344
345
  * loader(ReviewsLoader, { prefetch: false }),
346
+ * loader(RelatedLoader, { prefetch: "intent" }),
345
347
  * ```
346
348
  *
347
349
  * @param loaderDef - Loader created with createLoader()
@@ -362,16 +364,18 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
362
364
  * // Skip loading on SSR, only show on client navigation
363
365
  * loading(<Skeleton />, { ssr: false })
364
366
  *
365
- * // Keep everything behind this fallback out of viewport and render
366
- * // prefetches (a hover prefetch runs it)
367
+ * // Keep everything behind this fallback out of prefetches
367
368
  * loading(<Skeleton />, { prefetch: false })
369
+ *
370
+ * // Deferred in viewport and render prefetches only
371
+ * loading(<Skeleton />, { prefetch: "intent" })
368
372
  * ```
369
373
  * @param component - The loading UI to show during navigation
370
374
  * @param options - Configuration options
371
375
  * @param options.ssr - If false, skip showing loading on document requests (SSR)
372
- * @param options.prefetch - If false, the flag applies when this entry's
373
- * segment is new to the client. A viewport or render prefetch then skips
374
- * the whole unit (a hover prefetch runs it): the
376
+ * @param options.prefetch - `false` or `"intent"` (see
377
+ * {@link LoaderOptions}); the flag applies when this entry's segment is
378
+ * new to the client. A prefetch then skips the whole unit: the
375
379
  * handler, its loaders, its orphan layouts and parallel slots and, for a
376
380
  * layout, every deeper entry; the click that adopts the prefetch shows
377
381
  * this fallback and fetches them in one follow-up request, which reads
@@ -386,7 +390,7 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
386
390
  */
387
391
  loading: (
388
392
  component: ReactNode | (() => ReactNode),
389
- options?: { ssr?: boolean; prefetch?: boolean },
393
+ options?: { ssr?: boolean; prefetch?: PrefetchOption },
390
394
  ) => LoadingItem;
391
395
  /**
392
396
  * Attach an error boundary to catch errors in this segment and children
@@ -731,17 +731,17 @@ function createLoaderExecutor<TEnv>(
731
731
  );
732
732
  }
733
733
 
734
- // prefetch: false (segment-resolution/prefetch-deferral.ts). A
735
- // deferrable loader runs in a fill request, where its entry is held
734
+ // prefetch: false or "intent" (segment-resolution/prefetch-deferral.ts).
735
+ // A deferrable loader runs in a fill request, where its entry is held
736
736
  // and its handler does not run: there is no render to wait for.
737
737
  // Refused on every request kind, so it is seen on a document load.
738
738
  const deferral = internal._prefetchDeferral;
739
739
  if (deferral?.scope.loaderIds.has(currentLoaderId)) {
740
740
  throw new Error(
741
741
  `ctx.rendered() is not available to loader "${currentLoaderId}": it is deferred in prefetches ` +
742
- `(registered with loader(Def, { prefetch: false }), or behind a loading(fallback, { prefetch: false })), ` +
742
+ `(registered with loader(Def, { prefetch: false }) or { prefetch: "intent" }, or behind a loading(fallback, { prefetch: false }) or { prefetch: "intent" }), ` +
743
743
  `and the fill request that runs it afterwards does not re-run its entry's handler, so there is no render to wait for and no handle data to read. ` +
744
- `Remove { prefetch: false } from the loader (or from the loading() above it), or stop calling ctx.rendered() in this loader.`,
744
+ `Remove { prefetch: false } or { prefetch: "intent" } from the loader (or from the loading() above it), or stop calling ctx.rendered() in this loader.`,
745
745
  );
746
746
  }
747
747
  if (deferral?.mode === "fill") {
@@ -491,9 +491,7 @@ export async function createMatchContextForPartial<TEnv>(
491
491
  const deferral = planPrefetchDeferral(
492
492
  snapshot.entries,
493
493
  partialDeferralMode({
494
- fill: kind === "fill",
495
- // Speculative only: an intent prefetch defers nothing.
496
- prefetch: kind === "prefetch",
494
+ kind,
497
495
  method: request.method,
498
496
  isAction,
499
497
  isShellCapture: reqCtx?._shellCaptureState?.run === true,