@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
@@ -1,6 +1,9 @@
1
1
  /**
2
- * `prefetch: false`: what a prefetch request defers and what its fill request
3
- * runs (docs/design/prefetch-false.md).
2
+ * `prefetch: false` and `prefetch: "intent"`: what a prefetch request defers
3
+ * and what its fill request runs (docs/design/prefetch-false.md). A
4
+ * speculative prefetch (viewport, render) defers both flags; an intent
5
+ * prefetch (a hover, `prefetch()`) defers `false` only and runs `"intent"`
6
+ * work as if it were unflagged.
4
7
  *
5
8
  * Two layers. The SCOPE is static per matched chain: which loader
6
9
  * registrations are deferrable and which entries can be a handler unit. The
@@ -37,19 +40,19 @@ import {
37
40
  import { requestHeaders } from "../../server/request-headers.js";
38
41
  import type { HandlerContext, InternalHandlerContext } from "../../types.js";
39
42
  import type { CacheScope } from "../../cache/cache-scope.js";
40
- import { PREFETCH_HEADER, PREFETCH_INTENT } from "../../prefetch-markers.js";
43
+ import {
44
+ PREFETCH_HEADER,
45
+ PREFETCH_INTENT,
46
+ isPrefetchFlag,
47
+ type PrefetchFlag,
48
+ } from "../../prefetch-markers.js";
41
49
  import { WIRE } from "../../wire-params.js";
42
50
  import { isInterceptSegment } from "../../browser/intercept-utils.js";
43
51
 
44
52
  /**
45
53
  * What a request says it is, read once when its context is created
46
54
  * (RequestContext._requestKind). A fill is marked on the raw URL and wins
47
- * over the prefetch header. `"prefetch"` is a speculative prefetch (viewport,
48
- * render) and `"intent"` a hover or an explicit `prefetch()` call
49
- * (PREFETCH_INTENT): both are prefetches, and only the first defers. Whether
50
- * a prefetch defers anything is decided per match, by partialDeferralMode: a
51
- * shell capture inherits the context of the request that scheduled it and is
52
- * never a prefetch.
55
+ * over the prefetch header; an intent prefetch carries PREFETCH_INTENT.
53
56
  */
54
57
  export function requestKind(
55
58
  request: Request,
@@ -69,8 +72,8 @@ export function isPrefetchKind(kind: RequestKind | undefined): boolean {
69
72
  return kind === "prefetch" || kind === "intent";
70
73
  }
71
74
 
72
- /** Static, per matched chain. */
73
- interface DeferralScope {
75
+ /** What one kind of prefetch can defer in a matched chain. */
76
+ interface DeferralSet {
74
77
  /**
75
78
  * Loader registrations a prefetch can defer, each with what makes it so:
76
79
  * `true` for its own flag, else the segment ids of the flagged entries whose
@@ -78,8 +81,6 @@ interface DeferralScope {
78
81
  * segments is new to the client.
79
82
  */
80
83
  readonly loaders: ReadonlyMap<LoaderEntry, true | readonly string[]>;
81
- /** `$$id`s of `loaders`: the loaders that cannot call ctx.rendered(). */
82
- readonly loaderIds: ReadonlySet<string>;
83
84
  /**
84
85
  * Entries whose handler a prefetch can skip (a flagged loading() with a
85
86
  * fallback to show). `index` is the chain index that owns the entry: its
@@ -90,6 +91,24 @@ interface DeferralScope {
90
91
  readonly units: ReadonlyMap<EntryData, DeferralUnit>;
91
92
  }
92
93
 
94
+ /**
95
+ * Static, per matched chain: what a speculative prefetch defers (every flag,
96
+ * `false` or `"intent"`), and in `intent` what an intent prefetch defers.
97
+ */
98
+ interface DeferralScope extends DeferralSet {
99
+ /**
100
+ * `$$id`s of `loaders`: the loaders that cannot call ctx.rendered(). The
101
+ * speculative set on every request: a loader an intent prefetch runs is
102
+ * still run by a speculative prefetch's fill, where its handler does not.
103
+ */
104
+ readonly loaderIds: ReadonlySet<string>;
105
+ /**
106
+ * What an intent prefetch defers: the scope with every `"intent"` flag
107
+ * read as unflagged. Absent when no flag is `"intent"`: the scope itself.
108
+ */
109
+ readonly intent?: DeferralSet;
110
+ }
111
+
93
112
  interface DeferralUnit {
94
113
  readonly index: number;
95
114
  readonly ids: readonly string[];
@@ -98,6 +117,11 @@ interface DeferralUnit {
98
117
  /** Per match: InternalHandlerContext._prefetchDeferral. */
99
118
  export interface PrefetchDeferral {
100
119
  readonly scope: DeferralScope;
120
+ /**
121
+ * What this request defers: `scope`, or `scope.intent` for an intent
122
+ * prefetch. The ctx.rendered() guards read `scope`.
123
+ */
124
+ readonly defers: DeferralSet;
101
125
  /**
102
126
  * "prefetch": deferral replaces execution. "fill": the request that follows
103
127
  * an adopted prefetch; a held segment is skipped outright. Unset on every
@@ -188,10 +212,36 @@ const EMPTY_SCOPE: DeferralScope = {
188
212
 
189
213
  const scopes = new WeakMap<EntryData, DeferralScope>();
190
214
 
215
+ /** A flag an intent prefetch defers: `false` only. */
216
+ const offFlag = (flag: PrefetchFlag | undefined): boolean => flag === false;
217
+ /** A flag only an intent prefetch runs. */
218
+ const intentFlag = (flag: PrefetchFlag | undefined): boolean =>
219
+ flag === PREFETCH_INTENT;
220
+
221
+ const isEmptySet = (set: DeferralSet): boolean =>
222
+ set.loaders.size === 0 && set.units.size === 0;
223
+
224
+ /**
225
+ * The scope of a collector, which returns what a prefetch whose flags
226
+ * `counts` accepts defers. `intent` is set only when an `"intent"` flag
227
+ * changes what is deferred: the collector under `intentFlag` finds something.
228
+ */
229
+ function scopeOf(
230
+ collect: (counts: (flag: PrefetchFlag | undefined) => boolean) => DeferralSet,
231
+ ): DeferralScope {
232
+ const all = collect(isPrefetchFlag);
233
+ if (isEmptySet(all)) return EMPTY_SCOPE;
234
+ return {
235
+ ...all,
236
+ loaderIds: new Set([...all.loaders.keys()].map((l) => l.loader.$$id)),
237
+ ...(!isEmptySet(collect(intentFlag)) && { intent: collect(offFlag) }),
238
+ };
239
+ }
240
+
191
241
  /**
192
242
  * The deferral scope of a matched chain, memoized on its leaf entry (entries
193
243
  * are per-isSSR manifest objects, replaced on HMR). EMPTY_SCOPE when nothing
194
- * in the tree declares `prefetch: false`.
244
+ * in the tree declares `prefetch: false` or `prefetch: "intent"`.
195
245
  */
196
246
  export function resolveDeferralScope(
197
247
  entries: readonly EntryData[],
@@ -200,9 +250,18 @@ export function resolveDeferralScope(
200
250
  if (!leaf) return EMPTY_SCOPE;
201
251
  const cached = scopes.get(leaf);
202
252
  if (cached) return cached;
253
+ const scope = scopeOf((counts) => collectDeferrable(entries, counts));
254
+ scopes.set(leaf, scope);
255
+ return scope;
256
+ }
203
257
 
258
+ /** What a prefetch whose flags are those `counts` accepts defers in a chain. */
259
+ function collectDeferrable(
260
+ entries: readonly EntryData[],
261
+ counts: (flag: PrefetchFlag | undefined) => boolean,
262
+ ): DeferralSet {
263
+ const leaf = entries[entries.length - 1]!;
204
264
  const loaders = new Map<LoaderEntry, true | readonly string[]>();
205
- const loaderIds = new Set<string>();
206
265
  const units = new Map<EntryData, DeferralUnit>();
207
266
  const chain = new Set(entries);
208
267
  // A clientUrls() group route renders loading() itself and its handler is
@@ -212,7 +271,7 @@ export function resolveDeferralScope(
212
271
  // layout's above it; a flagged loading() defers nothing.
213
272
  const clientGroupChain = leaf.clientGroup !== undefined;
214
273
  const flagsLoading = (entry: EntryData): boolean =>
215
- !clientGroupChain && entry.loadingPrefetch === false;
274
+ !clientGroupChain && counts(entry.loadingPrefetch);
216
275
 
217
276
  // `behind`: the segment ids of the flagged entries whose fallback covers
218
277
  // `entry`'s loaders.
@@ -222,12 +281,11 @@ export function resolveDeferralScope(
222
281
  index: number,
223
282
  ): void => {
224
283
  for (const loaderEntry of entry.loader ?? []) {
225
- const own = loaderEntry.prefetch === false;
284
+ const own = counts(loaderEntry.prefetch);
226
285
  if (!own && behind.length === 0) continue;
227
286
  // An ssr: false loader on a ppr route is in scope too: whether this
228
287
  // request serves it from a shell pin is per request (defersLoader).
229
288
  loaders.set(loaderEntry, own ? true : behind);
230
- loaderIds.add(loaderEntry.loader.$$id);
231
289
  }
232
290
  for (const parallelEntry of getParallelEntries(entry.parallel)) {
233
291
  // A slot's segment id, as resolveParallelSegments
@@ -262,13 +320,7 @@ export function resolveDeferralScope(
262
320
  // A layout's fallback covers its outlet: every deeper chain entry.
263
321
  if (flagged && entry.type !== "route") behind = own;
264
322
  });
265
-
266
- const scope: DeferralScope =
267
- loaders.size === 0 && units.size === 0
268
- ? EMPTY_SCOPE
269
- : { loaders, loaderIds, units };
270
- scopes.set(leaf, scope);
271
- return scope;
323
+ return { loaders, units };
272
324
  }
273
325
 
274
326
  const interceptScopes = new WeakMap<InterceptEntry, DeferralScope>();
@@ -286,24 +338,21 @@ export function resolveInterceptDeferralScope(
286
338
  ): DeferralScope {
287
339
  const cached = interceptScopes.get(intercept);
288
340
  if (cached) return cached;
289
- const flagged = intercept.loader.filter((l) => l.prefetch === false);
290
- const scope: DeferralScope =
291
- flagged.length === 0
292
- ? EMPTY_SCOPE
293
- : {
294
- loaders: new Map(flagged.map((l) => [l, true as const])),
295
- loaderIds: new Set(flagged.map((l) => l.loader.$$id)),
296
- units: new Map(),
297
- };
341
+ const scope = scopeOf((counts) => ({
342
+ loaders: new Map(
343
+ intercept.loader
344
+ .filter((l) => counts(l.prefetch))
345
+ .map((l) => [l, true as const]),
346
+ ),
347
+ units: new Map(),
348
+ }));
298
349
  interceptScopes.set(intercept, scope);
299
350
  return scope;
300
351
  }
301
352
 
302
353
  /**
303
- * Which half of the feature a partial request is, if either (`fill` and
304
- * `prefetch` are requestKind's answer). `prefetch` is a speculative prefetch
305
- * only: an intent prefetch defers nothing, like the navigation it starts
306
- * early (R16). Both halves are GETs: a request of
354
+ * The kind a partial request plans its deferral as, if any (requestKind's
355
+ * answer, filtered). Both halves of the feature are GETs: a request of
307
356
  * another method that carries either marker is a normal request. A prefetch
308
357
  * defers only on a plain GET navigation: an action re-renders what it
309
358
  * changed, and a shell capture is never a prefetch whatever the request that
@@ -311,17 +360,20 @@ export function resolveInterceptDeferralScope(
311
360
  * capture runs the full match): each one states its own reason.
312
361
  */
313
362
  export function partialDeferralMode(request: {
314
- fill: boolean;
315
- prefetch: boolean;
363
+ kind: RequestKind | undefined;
316
364
  method: string;
317
365
  isAction: boolean;
318
366
  isShellCapture: boolean;
319
- }): PrefetchDeferral["mode"] {
367
+ }): RequestKind | undefined {
320
368
  if (request.method !== "GET") return undefined;
321
- if (request.fill) return "fill";
322
- return request.prefetch && !request.isAction && !request.isShellCapture
323
- ? "prefetch"
324
- : undefined;
369
+ if (request.kind === "fill") return "fill";
370
+ if (
371
+ isPrefetchKind(request.kind) &&
372
+ (request.isAction || request.isShellCapture)
373
+ ) {
374
+ return undefined;
375
+ }
376
+ return request.kind;
325
377
  }
326
378
 
327
379
  export const NOTHING_HELD: ReadonlySet<string> = new Set();
@@ -331,16 +383,22 @@ export const NOTHING_HELD: ReadonlySet<string> = new Set();
331
383
  * request is not a fill (the common case: nothing to carry). `held` is the
332
384
  * client's segment ids as the request listed them. `scope` is the chain's
333
385
  * unless the request resolves an intercept (resolveInterceptDeferralScope).
386
+ * An intent prefetch plans as a prefetch over `scope.intent`: a tree whose
387
+ * flags are all `"intent"` defers nothing in it, and its response is the
388
+ * navigation's (no `_payloadDeferred`, so no source scope).
334
389
  */
335
390
  export function planPrefetchDeferral(
336
391
  entries: readonly EntryData[],
337
- mode: PrefetchDeferral["mode"],
392
+ requested: RequestKind | undefined,
338
393
  matched: { pr?: boolean; od?: boolean },
339
394
  cacheScope: CacheScope | null | undefined,
340
395
  held: ReadonlySet<string> = NOTHING_HELD,
341
396
  scope: DeferralScope = resolveDeferralScope(entries),
342
397
  ): PrefetchDeferral | undefined {
343
- if (scope === EMPTY_SCOPE && mode !== "fill") return undefined;
398
+ if (scope === EMPTY_SCOPE && requested !== "fill") return undefined;
399
+ const mode = requested === "intent" ? "prefetch" : requested;
400
+ const defers: DeferralSet =
401
+ requested === "intent" ? (scope.intent ?? scope) : scope;
344
402
 
345
403
  const leaf = entries[entries.length - 1];
346
404
  let storedFrom = Infinity;
@@ -368,7 +426,7 @@ export function planPrefetchDeferral(
368
426
  const skipped = new Set<string>();
369
427
  let skipsChain = false;
370
428
  if (mode === "prefetch") {
371
- for (const [entry, unit] of scope.units) {
429
+ for (const [entry, unit] of defers.units) {
372
430
  if (!unitEligible(storedFrom, entry, unit)) continue;
373
431
  for (const id of unit.ids) {
374
432
  if (held.has(id)) continue;
@@ -382,7 +440,7 @@ export function planPrefetchDeferral(
382
440
  write: !skipsChain,
383
441
  refresh: mode !== "fill",
384
442
  };
385
- return { scope, mode, storedFrom, held, skipped, record };
443
+ return { scope, defers, mode, storedFrom, held, skipped, record };
386
444
  }
387
445
 
388
446
  export function getPrefetchDeferral(
@@ -529,7 +587,7 @@ export function defersLoader(
529
587
  ): boolean {
530
588
  const plan = getPrefetchDeferral(ctx);
531
589
  if (plan?.mode !== "prefetch" || plan.held.has(segmentId)) return false;
532
- const by = plan.scope.loaders.get(loaderEntry);
590
+ const by = plan.defers.loaders.get(loaderEntry);
533
591
  const deferrable =
534
592
  by === true || (by !== undefined && by.some((id) => !plan.held.has(id)));
535
593
  return deferrable && !servesPin(loaderEntry);
@@ -1120,7 +1120,7 @@ function renderPreparedRscResponse<TEnv>(
1120
1120
 
1121
1121
  const rscHeaders: Record<string, string> = {
1122
1122
  "content-type": "text/x-component;charset=utf-8",
1123
- vary: `accept, X-Rango-State, X-RSC-Router-Client-Path, ${SEGMENT_FRAGMENT_CAPABILITY_HEADER}, ${SEGMENT_FRAGMENT_RECOVERY_HEADER}`,
1123
+ vary: `accept, X-Rango-State, X-RSC-Router-Client-Path, X-RSC-Router-Intercept-Source, ${SEGMENT_FRAGMENT_CAPABILITY_HEADER}, ${SEGMENT_FRAGMENT_RECOVERY_HEADER}`,
1124
1124
  // Router identity, so the client can verify pre-decode (before importing
1125
1125
  // chunks) that this content payload belongs to its app and refuse a
1126
1126
  // foreign one (cache/proxy/bug). Control-only reload/redirect responses
@@ -14,6 +14,7 @@ import type {
14
14
  import { invariant, DslContextError } from "../errors";
15
15
  import type { DefaultRouteName } from "../types/global-namespace.js";
16
16
  import type { ContextVar } from "../context-var.js";
17
+ import type { PrefetchFlag } from "../prefetch-markers.js";
17
18
  import { PPR_LANE_HINT } from "../rsc/shell-capture-constants.js";
18
19
  import type { ShellCaptureState } from "../rsc/shell-capture.js";
19
20
  import {
@@ -125,13 +126,13 @@ export type EntryPropDatas = {
125
126
  export type EntryPropRender = {
126
127
  loading?: ReactNode | false;
127
128
  /**
128
- * loading(fallback, { prefetch: false }) with a renderable fallback: a
129
- * speculative prefetch defers what the fallback covers while the entry's
129
+ * loading(fallback, { prefetch: false | "intent" }) with a renderable
130
+ * fallback: a prefetch defers what the fallback covers while the entry's
130
131
  * segment is new to the client
131
132
  * (router/segment-resolution/prefetch-deferral.ts). Set on both DSL
132
133
  * evaluations, whatever `ssr` did to `loading`.
133
134
  */
134
- loadingPrefetch?: false;
135
+ loadingPrefetch?: PrefetchFlag;
135
136
  /**
136
137
  * loading(fallback, { ssr: false }), on both DSL evaluations; a document
137
138
  * then sets `loading` to false (applyDocumentModeLoading).
@@ -172,11 +173,11 @@ export type LoaderEntry = {
172
173
  */
173
174
  bake?: true;
174
175
  /**
175
- * loader(Def, { prefetch: false }): a speculative prefetch defers this
176
+ * loader(Def, { prefetch: false | "intent" }): a prefetch defers this
176
177
  * registration while its segment is new to the client
177
178
  * (router/segment-resolution/prefetch-deferral.ts).
178
179
  */
179
- prefetch?: false;
180
+ prefetch?: PrefetchFlag;
180
181
  /**
181
182
  * The declaring layout's / parallel's / cache()'s include scope, for the
182
183
  * loader body's `ctx.reverse(".name")` (its DSL kickoff carries it,
@@ -100,13 +100,13 @@ export interface ServeShellRequestOptions<TEnv = any> {
100
100
  * `result.replayStatus`.
101
101
  *
102
102
  * `prefetch: true` sends the speculative kind a viewport or render
103
- * `<Link>` prefetch sends (`X-Rango-Prefetch: 1`), so `prefetch: false`
104
- * loaders and `loading()` entries are deferred: `result.readDeferred()`
105
- * lists them. A hover's or `prefetch()`'s intent prefetch defers nothing;
106
- * serve it with `prefetch: true` and `headers: { "X-Rango-Prefetch":
107
- * "intent" }`, which `prefetch: true` leaves as given. `fill: true`
108
- * sends the request the browser follows an adopted prefetch with
109
- * (`_rsc_fill`): pass the ids the prefetch delivered, without the deferred
103
+ * `<Link>` prefetch sends (`X-Rango-Prefetch: 1`), so loaders and
104
+ * `loading()` entries flagged `prefetch: false` or `"intent"` are deferred:
105
+ * `result.readDeferred()` lists them. A hover's intent prefetch defers
106
+ * `false` only; serve it with `prefetch: true` and `headers: {
107
+ * "X-Rango-Prefetch": "intent" }`, which `prefetch: true` leaves as given.
108
+ * `fill: true` sends the request the browser follows an adopted prefetch
109
+ * with (`_rsc_fill`): pass the ids the prefetch delivered, without the deferred
110
110
  * ones, as `segments`, and the same URL as `from`. `interceptSource` sends
111
111
  * the intercept source (`X-RSC-Router-Intercept-Source`) the browser sends
112
112
  * from an open modal and with the fill of an intercept, whose `from` is
@@ -11,6 +11,7 @@ import type {
11
11
  DefaultVars,
12
12
  } from "./global-namespace.js";
13
13
  import type { RequestScope } from "./request-scope.js";
14
+ import type { PrefetchOption } from "../prefetch-markers.js";
14
15
 
15
16
  /**
16
17
  * Context passed to loader functions during execution
@@ -207,13 +208,14 @@ export type LoaderFn<
207
208
  export type LoaderOptions = {
208
209
  ssr?: boolean;
209
210
  /**
210
- * `prefetch: false` keeps this loader out of speculative prefetches. A
211
- * viewport or render `<Link>` prefetch (a partial request with
212
- * `X-Rango-Prefetch: 1`) does not execute it; the payload marks it
213
- * deferred. A hover prefetch and `useRouter().prefetch()`
214
- * (`X-Rango-Prefetch: intent`) count as intent to click and run it, so a
215
- * click after a hover needs no follow-up request. A document request and a
216
- * navigation with no prefetch to adopt run it as usual.
211
+ * `prefetch: false` keeps this loader out of every `<Link>` prefetch: a
212
+ * prefetch (a partial request with `X-Rango-Prefetch`) does not execute
213
+ * it; the payload marks it deferred. `prefetch: "intent"` keeps it out of
214
+ * speculative prefetches only: a viewport or render prefetch
215
+ * (`X-Rango-Prefetch: 1`) defers it, and a hover prefetch or
216
+ * `useRouter().prefetch()` (`X-Rango-Prefetch: intent`) runs it, so a click
217
+ * after a hover needs no follow-up request for it. A document request and
218
+ * a navigation with no prefetch to adopt run it as usual.
217
219
  *
218
220
  * A deferred loader never blocks the navigation that adopts the prefetch:
219
221
  * it behaves like a loader that is still streaming. Its own `<Suspense>`
@@ -238,7 +240,7 @@ export type LoaderOptions = {
238
240
  * request only deferred work and middleware run, so values a held handler
239
241
  * would have set with `ctx.set()` are not visible.
240
242
  */
241
- prefetch?: boolean;
243
+ prefetch?: PrefetchOption;
242
244
  };
243
245
 
244
246
  /**
@@ -58,6 +58,7 @@ import type {
58
58
  } from "./pattern-types.js";
59
59
  import type { ExtractRoutes, ExtractResponses } from "./type-extraction.js";
60
60
  import type { ClientUrlPatterns } from "../client-urls/types.js";
61
+ import type { PrefetchOption } from "../prefetch-markers.js";
61
62
 
62
63
  /**
63
64
  * Base path function signature for defining routes with URL patterns.
@@ -364,10 +365,10 @@ export type PathHelpers<TEnv> = {
364
365
  * freezes into the shell (nested promises stay live holes) — the pre-flush
365
366
  * promise applied to the prelude.
366
367
  *
367
- * Pass `{ prefetch: false }` to keep the loader out of speculative
368
- * prefetches (viewport, render): such a prefetch skips it and the click
369
- * that adopts it fetches it in one follow-up request; a hover prefetch runs
370
- * it (see {@link LoaderOptions}). Only on a segment the
368
+ * Pass `{ prefetch: false }` to keep the loader out of prefetches: a
369
+ * prefetch skips it and the click that adopts it fetches it in one
370
+ * follow-up request. `{ prefetch: "intent" }` defers it in viewport and
371
+ * render prefetches only (see {@link LoaderOptions}). Only on a segment the
371
372
  * client does not have yet: a loader the client already holds runs in the
372
373
  * prefetch when it revalidates.
373
374
  */
@@ -381,9 +382,9 @@ export type PathHelpers<TEnv> = {
381
382
  * Attach a loading component to the current route/layout.
382
383
  *
383
384
  * `{ ssr: false }` skips the fallback on document requests. `{ prefetch:
384
- * false }` keeps what the fallback covers out of viewport and render
385
- * prefetches (a hover prefetch runs it), when this entry's segment is new
386
- * to the client: the entry's handler, its loaders,
385
+ * false }` keeps what the fallback covers out of prefetches (`"intent"`:
386
+ * viewport and render prefetches only, see {@link LoaderOptions}), when this
387
+ * entry's segment is new to the client: the entry's handler, its loaders,
387
388
  * its orphan layouts and parallel slots and, for a layout, every deeper
388
389
  * entry. The click that adopts the prefetch shows the fallback and fetches
389
390
  * them in one follow-up request, which reads cache() and "use cache" as
@@ -397,7 +398,7 @@ export type PathHelpers<TEnv> = {
397
398
  */
398
399
  loading: (
399
400
  component: ReactNode | (() => ReactNode),
400
- options?: { ssr?: boolean; prefetch?: boolean },
401
+ options?: { ssr?: boolean; prefetch?: PrefetchOption },
401
402
  ) => LoadingItem;
402
403
 
403
404
  /**