@rangojs/router 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/dist/bin/rango.js +8 -3
  2. package/dist/testing/vitest.js +31 -1
  3. package/dist/types/browser/defer.d.ts +48 -0
  4. package/dist/types/browser/document-revealed.d.ts +17 -0
  5. package/dist/types/browser/event-controller.d.ts +7 -1
  6. package/dist/types/browser/navigation-transaction.d.ts +21 -7
  7. package/dist/types/browser/partial-update.d.ts +34 -5
  8. package/dist/types/browser/react/NavigationProvider.d.ts +13 -3
  9. package/dist/types/browser/react/filter-segment-order.d.ts +7 -0
  10. package/dist/types/browser/response-adapter.d.ts +7 -0
  11. package/dist/types/browser/types.d.ts +19 -9
  12. package/dist/types/browser/validate-redirect-origin.d.ts +11 -0
  13. package/dist/types/build/generate-route-types.d.ts +1 -1
  14. package/dist/types/build/route-types/router-processing.d.ts +1 -1
  15. package/dist/types/build/route-types/scan-filter.d.ts +16 -2
  16. package/dist/types/cache/cf/cf-cache-store.d.ts +16 -1
  17. package/dist/types/cache/cf/cf-cache-types.d.ts +23 -2
  18. package/dist/types/client-urls/client-urls.d.ts +1 -1
  19. package/dist/types/client-urls/hoisted-revalidate.d.ts +13 -0
  20. package/dist/types/client-urls/navigation.d.ts +0 -37
  21. package/dist/types/client-urls/revalidate-registry.d.ts +4 -0
  22. package/dist/types/client-urls/server-projection.d.ts +29 -1
  23. package/dist/types/client-urls/types.d.ts +34 -69
  24. package/dist/types/client.d.ts +0 -1
  25. package/dist/types/errors.d.ts +5 -0
  26. package/dist/types/handles/deferred-resolution.d.ts +2 -1
  27. package/dist/types/handles/pushed-by.d.ts +23 -0
  28. package/dist/types/loader-store.d.ts +8 -0
  29. package/dist/types/prefetch-markers.d.ts +11 -0
  30. package/dist/types/route-definition/helpers-types.d.ts +27 -0
  31. package/dist/types/router/is-action.d.ts +2 -1
  32. package/dist/types/router/segment-resolution/loader-mask.d.ts +3 -2
  33. package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +241 -0
  34. package/dist/types/server/context.d.ts +21 -1
  35. package/dist/types/server/handle-store.d.ts +9 -1
  36. package/dist/types/server/request-context.d.ts +14 -1
  37. package/dist/types/server.d.ts +1 -0
  38. package/dist/types/testing/flight.entry.d.ts +1 -1
  39. package/dist/types/testing/index.d.ts +3 -3
  40. package/dist/types/testing/internal/action-id.d.ts +10 -1
  41. package/dist/types/testing/internal/client-urls-projection.d.ts +16 -0
  42. package/dist/types/testing/internal/test-action.d.ts +8 -0
  43. package/dist/types/testing/run-loader.d.ts +2 -1
  44. package/dist/types/testing/run-revalidate.d.ts +74 -0
  45. package/dist/types/testing/run-transition-when.d.ts +2 -3
  46. package/dist/types/testing/serve-shell-request.d.ts +38 -0
  47. package/dist/types/testing/vitest.d.ts +11 -0
  48. package/dist/types/types/boundaries.d.ts +9 -0
  49. package/dist/types/types/handler-context.d.ts +14 -0
  50. package/dist/types/types/loader-types.d.ts +30 -0
  51. package/dist/types/types/segments.d.ts +21 -4
  52. package/dist/types/urls/path-helper-types.d.ts +25 -4
  53. package/dist/types/urls/type-extraction.d.ts +6 -2
  54. package/dist/types/vite/discovery/discover-routers.d.ts +9 -0
  55. package/dist/types/vite/discovery/state.d.ts +11 -4
  56. package/dist/types/vite/plugins/client-urls-revalidate-hoist.d.ts +54 -0
  57. package/dist/types/vite/plugins/hoist/literal.d.ts +83 -0
  58. package/dist/types/vite/plugins/hoist/scope.d.ts +40 -0
  59. package/dist/types/vite/plugins/hoist/source.d.ts +41 -0
  60. package/dist/types/vite/plugins/transition-when-hoist.d.ts +8 -11
  61. package/dist/types/vite/router-discovery.d.ts +10 -0
  62. package/dist/vite/index.js +843 -309
  63. package/package.json +2 -2
  64. package/skills/caching/SKILL.md +42 -5
  65. package/skills/catalog.json +2 -2
  66. package/skills/client-urls/SKILL.md +113 -24
  67. package/skills/cloudflare/SKILL.md +14 -9
  68. package/skills/hooks/SKILL.md +11 -3
  69. package/skills/links/SKILL.md +24 -0
  70. package/skills/loader/SKILL.md +192 -1
  71. package/skills/migrate-nextjs/SKILL.md +2 -2
  72. package/skills/migrate-react-router/SKILL.md +10 -7
  73. package/skills/migrate-react-router/data-and-actions.md +1 -1
  74. package/skills/ppr/SKILL.md +13 -0
  75. package/skills/prerender/SKILL.md +22 -8
  76. package/skills/rango/SKILL.md +12 -6
  77. package/skills/route/SKILL.md +139 -0
  78. package/skills/testing/SKILL.md +3 -2
  79. package/skills/testing/client-components.md +1 -1
  80. package/skills/testing/predicates.md +67 -32
  81. package/skills/testing/serve-shell-request.md +88 -18
  82. package/skills/testing/setup.md +2 -2
  83. package/skills/typesafety/route-types.md +2 -2
  84. package/skills/view-transitions/SKILL.md +26 -0
  85. package/src/browser/defer.ts +82 -0
  86. package/src/browser/document-revealed.ts +52 -0
  87. package/src/browser/event-controller.ts +43 -32
  88. package/src/browser/navigation-bridge.ts +64 -14
  89. package/src/browser/navigation-client.ts +48 -36
  90. package/src/browser/navigation-store.ts +13 -0
  91. package/src/browser/navigation-transaction.ts +25 -8
  92. package/src/browser/partial-update.ts +528 -135
  93. package/src/browser/prefetch/fetch.ts +2 -1
  94. package/src/browser/react/NavigationProvider.tsx +47 -28
  95. package/src/browser/react/filter-segment-order.ts +13 -2
  96. package/src/browser/response-adapter.ts +20 -0
  97. package/src/browser/rsc-router.tsx +17 -45
  98. package/src/browser/server-action-bridge.ts +19 -63
  99. package/src/browser/types.ts +19 -9
  100. package/src/browser/validate-redirect-origin.ts +16 -0
  101. package/src/build/generate-route-types.ts +1 -0
  102. package/src/build/route-types/router-processing.ts +3 -6
  103. package/src/build/route-types/scan-filter.ts +37 -10
  104. package/src/cache/cf/cf-cache-store.ts +59 -21
  105. package/src/cache/cf/cf-cache-types.ts +24 -2
  106. package/src/cache/document-cache.ts +22 -1
  107. package/src/client-urls/client-urls.ts +22 -6
  108. package/src/client-urls/hoisted-revalidate.ts +25 -0
  109. package/src/client-urls/navigation.ts +0 -96
  110. package/src/client-urls/revalidate-registry.ts +30 -0
  111. package/src/client-urls/server-projection.ts +151 -65
  112. package/src/client-urls/types.ts +39 -71
  113. package/src/client.tsx +9 -5
  114. package/src/errors.ts +15 -0
  115. package/src/handles/deferred-resolution.ts +28 -5
  116. package/src/handles/pushed-by.ts +122 -0
  117. package/src/loader-store.ts +18 -7
  118. package/src/prefetch-markers.ts +13 -0
  119. package/src/route-content-wrapper.tsx +18 -4
  120. package/src/route-definition/dsl-helpers.ts +27 -4
  121. package/src/route-definition/helpers-types.ts +27 -1
  122. package/src/router/intercept-resolution.ts +6 -4
  123. package/src/router/is-action.ts +2 -1
  124. package/src/router/loader-resolution.ts +83 -4
  125. package/src/router/match-api.ts +36 -1
  126. package/src/router/match-middleware/background-revalidation.ts +11 -1
  127. package/src/router/match-middleware/cache-lookup.ts +29 -6
  128. package/src/router/match-middleware/cache-store.ts +17 -1
  129. package/src/router/match-result.ts +14 -2
  130. package/src/router/navigation-snapshot.ts +2 -1
  131. package/src/router/segment-resolution/fresh.ts +22 -18
  132. package/src/router/segment-resolution/loader-cache.ts +2 -1
  133. package/src/router/segment-resolution/loader-mask.ts +5 -3
  134. package/src/router/segment-resolution/prefetch-deferral.ts +543 -0
  135. package/src/router/segment-resolution/revalidation.ts +169 -60
  136. package/src/rsc/rsc-rendering.ts +50 -7
  137. package/src/rsc/shell-capture.ts +2 -1
  138. package/src/segment-loader-promise.ts +23 -20
  139. package/src/segment-system.tsx +60 -26
  140. package/src/server/context.ts +39 -5
  141. package/src/server/handle-store.ts +104 -8
  142. package/src/server/loader-registry.ts +6 -1
  143. package/src/server/request-context.ts +21 -1
  144. package/src/server.ts +4 -0
  145. package/src/testing/action-form.ts +2 -11
  146. package/src/testing/e2e/page-helpers.ts +3 -1
  147. package/src/testing/flight.entry.ts +1 -0
  148. package/src/testing/index.ts +6 -3
  149. package/src/testing/internal/action-id.ts +16 -1
  150. package/src/testing/internal/client-urls-projection.ts +70 -0
  151. package/src/testing/internal/test-action.ts +25 -0
  152. package/src/testing/run-loader.ts +2 -1
  153. package/src/testing/run-revalidate.ts +142 -0
  154. package/src/testing/run-transition-when.ts +7 -8
  155. package/src/testing/serve-shell-request.ts +116 -6
  156. package/src/testing/vitest.ts +67 -1
  157. package/src/types/boundaries.ts +12 -0
  158. package/src/types/handler-context.ts +14 -0
  159. package/src/types/loader-types.ts +30 -0
  160. package/src/types/segments.ts +26 -4
  161. package/src/urls/path-helper-types.ts +27 -5
  162. package/src/urls/type-extraction.ts +30 -10
  163. package/src/use-loader.tsx +18 -13
  164. package/src/vite/discovery/client-urls-projection.ts +20 -21
  165. package/src/vite/discovery/discover-routers.ts +94 -26
  166. package/src/vite/discovery/state.ts +20 -4
  167. package/src/vite/discovery/virtual-module-codegen.ts +22 -17
  168. package/src/vite/plugins/client-urls-revalidate-hoist.ts +564 -0
  169. package/src/vite/plugins/hoist/literal.ts +417 -0
  170. package/src/vite/plugins/hoist/scope.ts +370 -0
  171. package/src/vite/plugins/hoist/source.ts +84 -0
  172. package/src/vite/plugins/transition-when-hoist.ts +113 -779
  173. package/src/vite/rango.ts +2 -0
  174. package/src/vite/router-discovery.ts +93 -32
  175. package/dist/types/client-urls/revalidate-chain.d.ts +0 -33
  176. package/dist/types/client-urls/revalidation-protocol.d.ts +0 -25
  177. package/dist/types/testing/run-client-revalidate.d.ts +0 -43
  178. package/src/client-urls/revalidate-chain.ts +0 -83
  179. package/src/client-urls/revalidation-protocol.ts +0 -56
  180. package/src/testing/run-client-revalidate.ts +0 -92
@@ -0,0 +1,122 @@
1
+ import { LOADER_SEGMENT_ID } from "../browser/react/filter-segment-order.js";
2
+ import type { HandleData } from "../server/handle-store.js";
3
+
4
+ /**
5
+ * Which run pushed each handle value, so a partial response replaces only the
6
+ * pushes of what it re-ran (#1103).
7
+ *
8
+ * A segment's handle bucket holds its handler's pushes and those of the
9
+ * loaders registered on it: a loader pushes under its owning segment's id
10
+ * (fresh.ts, revalidation.ts pin `_currentSegmentId`). A navigation or an
11
+ * action response decides each of them on its own: the handler re-runs and a
12
+ * loader whose revalidate() answers false keeps its data on the client. The
13
+ * response carries only what re-ran, so replacing the whole bucket with it
14
+ * dropped the pushes of the loader whose data the page still shows (the
15
+ * document title, a breadcrumb), and a loader re-run under a handler that did
16
+ * not re-run took the handler's pushes with it.
17
+ *
18
+ * The record rides in the snapshot itself, beside each handle, under a name
19
+ * no handle has: `data[pushedByKey(h)][segmentId][i]` is the $$id of the
20
+ * loader whose run pushed `data[h][segmentId][i]`, or null for the segment's
21
+ * handler. A bucket without a loader push has no entry. Inside the snapshot it
22
+ * crosses Flight and every clone and history-cache copy with the values;
23
+ * collectHandleData reads a handle by its $$id, so no collect sees it.
24
+ * resolveDeferredHandleValues keeps it aligned when it drops a value.
25
+ */
26
+ const PUSHED_BY_SUFFIX = "\u0000pushedBy";
27
+
28
+ /** The snapshot key holding `handleName`'s pushedBy record. */
29
+ export function pushedByKey(handleName: string): string {
30
+ return handleName + PUSHED_BY_SUFFIX;
31
+ }
32
+
33
+ /** True for a pushedBy record's key, false for a handle's. */
34
+ export function isPushedByKey(name: string): boolean {
35
+ return name.endsWith(PUSHED_BY_SUFFIX);
36
+ }
37
+
38
+ /** `data` without its pushedBy records, as a reader outside the router sees it. */
39
+ export function withoutPushedBy(data: HandleData): HandleData {
40
+ const out: HandleData = {};
41
+ for (const name in data) {
42
+ if (!isPushedByKey(name)) out[name] = data[name];
43
+ }
44
+ return out;
45
+ }
46
+
47
+ type PushedBy = readonly (string | null)[] | undefined;
48
+
49
+ const keysOf = (a: object | undefined, b: object | undefined): Set<string> =>
50
+ new Set([...Object.keys(a ?? {}), ...Object.keys(b ?? {})]);
51
+
52
+ /**
53
+ * The handle data after a partial response's snapshot `incoming` (each stream
54
+ * yield holds everything the response pushed so far). A segment outside
55
+ * `segmentOrder` loses its buckets. In the others a value stands for the run
56
+ * that pushed it: the segment's handler, or a loader `matched` registers on
57
+ * the segment (`${shortCode}D${index}.${$$id}`, a loader's pushes being filed
58
+ * under its shortCode; an id it does not register, a dependency or a loader a
59
+ * handler read, counts as the handler's). A run that has values in the
60
+ * response, or ran for it (`resolvedIds`: the handlers and loaders that ran),
61
+ * replaces its values with the ones it delivered, nothing when it delivered
62
+ * none. The other runs keep theirs in place, and the response's values take
63
+ * the position of the first value they replace (the end when they replace
64
+ * none). New containers throughout: an object a reader was handed (a history
65
+ * entry's data) is never written to.
66
+ */
67
+ export function mergePartialHandleData(
68
+ current: HandleData,
69
+ incoming: HandleData,
70
+ segmentOrder: readonly string[],
71
+ matched: readonly string[],
72
+ resolvedIds: readonly string[] | undefined,
73
+ ): HandleData {
74
+ // A run's key: a segment's id for its handler, `${segmentId}\0${$$id}` for
75
+ // a loader (its segment id with the `D${index}.` marker replaced).
76
+ const runKey = (id: string): string =>
77
+ id.replace(LOADER_SEGMENT_ID, "\u0000");
78
+ const inOrder = new Set(segmentOrder);
79
+ const registered = new Set(matched.map(runKey));
80
+ const ran = new Set(resolvedIds?.map(runKey));
81
+ const out: HandleData = {};
82
+ for (const name of keysOf(current, incoming)) {
83
+ if (isPushedByKey(name)) continue;
84
+ const byName = pushedByKey(name);
85
+ const buckets: Record<string, unknown[]> = (out[name] = {});
86
+ for (const segmentId of keysOf(current[name], incoming[name])) {
87
+ if (!inOrder.has(segmentId)) continue;
88
+ const run = (by: PushedBy, i: number): string => {
89
+ const key = `${segmentId}\u0000${by?.[i]}`;
90
+ return registered.has(key) ? key : segmentId;
91
+ };
92
+ const currentBy = current[byName]?.[segmentId] as PushedBy;
93
+ const incomingValues = incoming[name]?.[segmentId] ?? [];
94
+ const incomingBy = incoming[byName]?.[segmentId] as PushedBy;
95
+ const delivered = new Set(
96
+ incomingValues.map((_, i) => run(incomingBy, i)),
97
+ );
98
+ const values: unknown[] = [];
99
+ const by: (string | null)[] = [];
100
+ let at = -1;
101
+ (current[name]?.[segmentId] ?? []).forEach((value, i) => {
102
+ const key = run(currentBy, i);
103
+ if (delivered.has(key) || ran.has(key)) {
104
+ if (at < 0) at = values.length;
105
+ } else {
106
+ values.push(value);
107
+ by.push(currentBy?.[i] ?? null);
108
+ }
109
+ });
110
+ if (at < 0) at = values.length;
111
+ values.splice(at, 0, ...incomingValues);
112
+ by.splice(
113
+ at,
114
+ 0,
115
+ ...incomingValues.map((_, i) => incomingBy?.[i] ?? null),
116
+ );
117
+ if (values.length > 0) buckets[segmentId] = values;
118
+ if (by.some((run) => run !== null)) (out[byName] ??= {})[segmentId] = by;
119
+ }
120
+ }
121
+ return out;
122
+ }
@@ -36,6 +36,7 @@
36
36
  */
37
37
 
38
38
  import { unwrapsSynchronously } from "./thenable-status.js";
39
+ import { asStream } from "./browser/defer.js";
39
40
 
40
41
  /** The loader-segment shape announcePendingStreams reads (ResolvedSegment). */
41
42
  export interface PendingStreamSource {
@@ -538,14 +539,23 @@ export class LoaderStore {
538
539
  this.pendingStreams.set(loaderId, set);
539
540
  }
540
541
  set.add(stream);
541
- const release = () => {
542
- const current = this.pendingStreams.get(loaderId);
543
- if (!current || !current.delete(stream)) return;
544
- if (current.size === 0) this.pendingStreams.delete(loaderId);
545
- };
542
+ const release = () => this.releasePendingStream(loaderId, stream);
546
543
  stream.then(release, release);
547
544
  }
548
545
 
546
+ /**
547
+ * Stop tracking a stream. trackPendingStream does it as the stream settles;
548
+ * an abandoned fill's hole (browser/partial-update.ts releaseHoles) never
549
+ * settles, and
550
+ * left tracked it would pin the family's held readers on every later
551
+ * transition.
552
+ */
553
+ releasePendingStream(loaderId: string, stream: Promise<unknown>): void {
554
+ const current = this.pendingStreams.get(loaderId);
555
+ if (!current || !current.delete(stream)) return;
556
+ if (current.size === 0) this.pendingStreams.delete(loaderId);
557
+ }
558
+
549
559
  /** True while any navigation stream for `loaderId` is still pending. */
550
560
  isStreamPending(loaderId: string): boolean {
551
561
  return this.pendingStreams.has(loaderId);
@@ -566,12 +576,13 @@ export class LoaderStore {
566
576
  */
567
577
  announcePendingStreams(segments: readonly PendingStreamSource[]): void {
568
578
  for (const segment of segments) {
579
+ const data = asStream(segment.loaderData);
569
580
  if (
570
581
  segment.type === "loader" &&
571
582
  segment.loaderId &&
572
- segment.loaderData instanceof Promise
583
+ data instanceof Promise
573
584
  ) {
574
- this.trackPendingStream(segment.loaderId, segment.loaderData);
585
+ this.trackPendingStream(segment.loaderId, data);
575
586
  }
576
587
  }
577
588
  for (const loaderId of this.pendingStreams.keys()) {
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The wire markers of `prefetch: false` (docs/design/prefetch-false.md):
3
+ * shared by the browser that sends them and the server that reads them
4
+ * (router/segment-resolution/prefetch-deferral.ts requestKind). Dependency
5
+ * free, like segment-fragments.ts: the browser must not import the server
6
+ * module that reads them.
7
+ */
8
+
9
+ /** Request header of a `<Link>` prefetch (browser/prefetch/fetch.ts). */
10
+ export const PREFETCH_HEADER = "X-Rango-Prefetch";
11
+
12
+ /** Query param of a fill request (browser/navigation-client.ts). */
13
+ export const FILL_PARAM = "_rsc_fill";
@@ -10,12 +10,14 @@ import {
10
10
  LOADER_REDIRECT,
11
11
  } from "./decode-loader-results.js";
12
12
  import { LoaderRedirect } from "./loader-redirect.js";
13
+ import { Defer, readDefer } from "./browser/defer.js";
13
14
  import type { BoundaryAudit } from "./suspense-audit.js";
14
15
  import {
15
16
  AuditedFallback,
16
17
  useBoundaryAudit,
17
18
  useRevealAudit,
18
19
  } from "./suspense-audit-react.js";
20
+ import { SETTLED_THENABLE } from "./thenable-status.js";
19
21
 
20
22
  /**
21
23
  * Router-owned error boundary for read-site loader errors. segment-system
@@ -141,7 +143,7 @@ export function AuditedRouteContent({
141
143
  const audit = useBoundaryAudit(
142
144
  `content:${segmentId}`,
143
145
  segmentId ?? "",
144
- content,
146
+ content instanceof Defer ? content.promise : content,
145
147
  );
146
148
  return (
147
149
  <Suspense
@@ -174,8 +176,16 @@ const Suspender = ({
174
176
  // A pending promise suspends here and the wrapping Suspense shows the
175
177
  // loading() fallback; a node renders without suspending. The wrapper tree
176
178
  // (RouteContentWrapper > Suspense > Suspender) is identical either way (see
177
- // docs/tree-structure.md), like LoaderResolver's resolved-data branch.
178
- return content instanceof Promise ? use(content) : content;
179
+ // docs/tree-structure.md), like LoaderResolver's resolved-data branch. A
180
+ // `prefetch: false` hole is read here, by the read rule, so the filled
181
+ // page's node later takes the same place. The content can be a pending
182
+ // promise in one tree and its node in the next: use() at this place on
183
+ // every render, as LoaderResolver does.
184
+ return content instanceof Defer
185
+ ? (readDefer(content) as ReactNode)
186
+ : content instanceof Promise
187
+ ? use(content)
188
+ : (use(SETTLED_THENABLE), content);
179
189
  };
180
190
 
181
191
  /**
@@ -329,10 +339,14 @@ function LoaderResolver({
329
339
  );
330
340
  }
331
341
 
342
+ // A slot's aggregate can be a pending promise on one render and its arrived
343
+ // array on the next (segment-loader-promise.ts): use() at this place on
344
+ // every render, or React reports a component that suspended in use() and
345
+ // then finished without it (the useLoader pattern, use-loader.tsx).
332
346
  const resolvedData =
333
347
  loaderDataPromise instanceof Promise
334
348
  ? use(loaderDataPromise)
335
- : loaderDataPromise;
349
+ : (use(SETTLED_THENABLE), loaderDataPromise);
336
350
 
337
351
  const { loaderData, errorFallback } = decodeLoaderResults(
338
352
  resolvedData,
@@ -22,7 +22,7 @@ import {
22
22
  type InterceptConfig,
23
23
  type LoaderEntry,
24
24
  } from "../server/context";
25
- import { invariant } from "../errors";
25
+ import { assertBooleanOption, invariant } from "../errors";
26
26
  import { validateUserRouteName } from "../route-name.js";
27
27
  import { isCachedFunction } from "../cache/taint.js";
28
28
  import { RangoContext } from "../server/context";
@@ -44,6 +44,7 @@ import type {
44
44
  TransitionItem,
45
45
  UseItems,
46
46
  } from "../route-types.js";
47
+ import { isRenderableLoading } from "../types/boundaries.js";
47
48
  import type { RouteHelpers } from "./helpers-types.js";
48
49
  import {
49
50
  resolveHandlerUse,
@@ -899,9 +900,15 @@ const loader: RouteHelpers<any, any>["loader"] = (
899
900
  (optionsGiven as { stream?: unknown } | undefined)?.stream === undefined,
900
901
  "loader() stream was replaced: use loader(Def, { ssr: false }) — the same knob as loading(fallback, { ssr: false }) — to await the loader before first flush on document requests.",
901
902
  );
902
- invariant(
903
- optionsGiven?.ssr === undefined || typeof optionsGiven.ssr === "boolean",
904
- `loader() ssr must be a boolean (got ${JSON.stringify(optionsGiven?.ssr)}). Omit it (or pass true) to stream on every render.`,
903
+ assertBooleanOption(
904
+ optionsGiven?.ssr,
905
+ "loader() ssr",
906
+ "stream on every render",
907
+ );
908
+ assertBooleanOption(
909
+ optionsGiven?.prefetch,
910
+ "loader() prefetch",
911
+ "run the loader in prefetches",
905
912
  );
906
913
 
907
914
  const name = `${ctx.namespace}.$${store.getNextIndex("loader")}`;
@@ -917,6 +924,7 @@ const loader: RouteHelpers<any, any>["loader"] = (
917
924
  ...(optionsGiven?.ssr === false && { bake: true as const }),
918
925
  ...(optionsGiven?.ssr === false &&
919
926
  ctx.isSSR && { awaitBeforeFlush: true as const }),
927
+ ...(optionsGiven?.prefetch === false && { prefetch: false as const }),
920
928
  };
921
929
 
922
930
  // Merge handler.use defaults (attached to the loader definition) with explicit use
@@ -969,6 +977,12 @@ const loading: RouteHelpers<any, any>["loading"] = (component, options) => {
969
977
  invariant(false, "No parent entry available for loading()");
970
978
  }
971
979
 
980
+ assertBooleanOption(
981
+ options?.prefetch,
982
+ "loading() prefetch",
983
+ "render what the fallback covers in prefetches",
984
+ );
985
+
972
986
  // Unwrap function form: loading(() => <Skeleton />) → loading(<Skeleton />)
973
987
  const resolved =
974
988
  typeof component === "function" ? (component as () => any)() : component;
@@ -980,6 +994,15 @@ const loading: RouteHelpers<any, any>["loading"] = (component, options) => {
980
994
  parent.loading = resolved;
981
995
  }
982
996
 
997
+ // The flag means "what this fallback covers": without a fallback to show
998
+ // there is nothing to defer behind. Recorded from `resolved`, not
999
+ // `parent.loading`, so the document evaluation (ssr: false) agrees with
1000
+ // the navigation one. Last loading() wins, like `loading` itself.
1001
+ parent.loadingPrefetch =
1002
+ options?.prefetch === false && isRenderableLoading(resolved)
1003
+ ? false
1004
+ : undefined;
1005
+
983
1006
  const name = `$${store.getNextIndex("loading")}`;
984
1007
  return { name, type: "loading" } as LoadingItem;
985
1008
  };
@@ -328,6 +328,16 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
328
328
  * loader(RecommendationsLoader), // still streams behind loading()
329
329
  * ```
330
330
  *
331
+ * Pass `{ prefetch: false }` to keep the loader out of prefetches: a
332
+ * prefetch skips it and the click that adopts the prefetch fetches it in
333
+ * one follow-up request (see {@link LoaderOptions}). Only on a segment the
334
+ * client does not have yet: a loader the client already holds runs in the
335
+ * prefetch when it revalidates.
336
+ *
337
+ * ```typescript
338
+ * loader(ReviewsLoader, { prefetch: false }),
339
+ * ```
340
+ *
331
341
  * @param loaderDef - Loader created with createLoader()
332
342
  * @param optionsOrUse - Delivery options, or the use() callback when passing none
333
343
  * @param use - Optional callback for loader-specific revalidation rules
@@ -345,14 +355,30 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
345
355
  *
346
356
  * // Skip loading on SSR, only show on client navigation
347
357
  * loading(<Skeleton />, { ssr: false })
358
+ *
359
+ * // Keep everything behind this fallback out of prefetches
360
+ * loading(<Skeleton />, { prefetch: false })
348
361
  * ```
349
362
  * @param component - The loading UI to show during navigation
350
363
  * @param options - Configuration options
351
364
  * @param options.ssr - If false, skip showing loading on document requests (SSR)
365
+ * @param options.prefetch - If false, the flag applies when this entry's
366
+ * segment is new to the client. A prefetch then skips the whole unit: the
367
+ * handler, its loaders, its orphan layouts and parallel slots and, for a
368
+ * layout, every deeper entry; the click that adopts the prefetch shows
369
+ * this fallback and fetches them in one follow-up request, which reads
370
+ * cache() and "use cache" as any request does (a prefetch reads neither
371
+ * for skipped work). A segment the client already holds is never
372
+ * deferred: a prefetch renders it as usual when it revalidates, and what
373
+ * is below it follows its own flags. To keep a child out of prefetches
374
+ * while moving inside a section, flag the child's own loading(). An entry
375
+ * whose handler output is stored (cache(), ppr, Prerender/Static) is
376
+ * served as usual and only its loaders are skipped. Loaders behind the
377
+ * fallback cannot call ctx.rendered().
352
378
  */
353
379
  loading: (
354
380
  component: ReactNode | (() => ReactNode),
355
- options?: { ssr?: boolean },
381
+ options?: { ssr?: boolean; prefetch?: boolean },
356
382
  ) => LoadingItem;
357
383
  /**
358
384
  * Attach an error boundary to catch errors in this segment and children
@@ -331,8 +331,9 @@ export async function resolveInterceptEntry<TEnv>(
331
331
  deps.wrapLoaderPromise(
332
332
  // The DSL binding's funnel, as for route loaders: a loader's own
333
333
  // cache() applies here too.
334
- runInsideLoaderScope(() =>
335
- resolveLoaderData(loaderEntry, context, context.pathname),
334
+ runInsideLoaderScope(
335
+ () => resolveLoaderData(loaderEntry, context, context.pathname),
336
+ loader.$$id,
336
337
  ),
337
338
  parentEntry,
338
339
  segmentId,
@@ -564,8 +565,9 @@ export async function resolveInterceptLoadersOnly<TEnv>(
564
565
  deps.wrapLoaderPromise(
565
566
  // The DSL binding's funnel, as for route loaders: a loader's own
566
567
  // cache() applies here too.
567
- runInsideLoaderScope(() =>
568
- resolveLoaderData(loaderEntry, context, context.pathname),
568
+ runInsideLoaderScope(
569
+ () => resolveLoaderData(loaderEntry, context, context.pathname),
570
+ loader.$$id,
569
571
  ),
570
572
  parentEntry,
571
573
  segmentId,
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Shared `isAction()` matcher for revalidate predicates (server + clientUrls).
2
+ * Shared `isAction()` matcher for revalidate() predicates (server) and
3
+ * transition({ when }) (browser).
3
4
  *
4
5
  * Action identity is `actionId`. The helper resolves an imported reference the
5
6
  * same way the action boundary derives that id (`$id ?? $$id`), so a
@@ -55,6 +55,12 @@ import {
55
55
  } from "../server/handle-store.js";
56
56
  import { getFetchableLoader } from "../server/fetchable-loader-store.js";
57
57
  import { _getRequestContext } from "../server/request-context.js";
58
+ import {
59
+ firstUnitCandidate,
60
+ loaderOwner,
61
+ startDeferredLoader,
62
+ type PrefetchDeferral,
63
+ } from "./segment-resolution/prefetch-deferral.js";
58
64
  import {
59
65
  assertNonCacheableReadAllowed,
60
66
  isInsideLoaderScope,
@@ -372,6 +378,29 @@ function detectLoaderCycle(
372
378
  return dfs(to);
373
379
  }
374
380
 
381
+ const warnedRenderedBesideUnit = new Set<string>();
382
+
383
+ /**
384
+ * Dev only, once per loader: a loader that is not deferrable awaits
385
+ * ctx.rendered() in a tree where a prefetch can skip a handler. The prefetch
386
+ * rejects that call (see rendered() below); saying so on a document or a
387
+ * navigation shows it before production, where prefetch is automatic.
388
+ */
389
+ function warnRenderedBesideUnit(
390
+ deferral: PrefetchDeferral,
391
+ loaderId: string,
392
+ ): void {
393
+ if (warnedRenderedBesideUnit.has(loaderId)) return;
394
+ const unit = firstUnitCandidate(deferral);
395
+ if (!unit) return;
396
+ warnedRenderedBesideUnit.add(loaderId);
397
+ console.warn(
398
+ `[rango] loader "${loaderId}" awaits ctx.rendered() on a route where "${unit.shortCode}" (${unit.id}) has loading(fallback, { prefetch: false }). ` +
399
+ `A prefetch of this route skips that handler, so ctx.rendered() throws in the prefetch and this loader is an error on the click that adopts it. ` +
400
+ `Remove { prefetch: false } from that loading(), or stop calling ctx.rendered() in this loader.`,
401
+ );
402
+ }
403
+
375
404
  /**
376
405
  * Creates a memoizing loader executor with cycle detection.
377
406
  * Shared by setupLoaderAccess and setupLoaderAccessSilent; only the handle
@@ -397,7 +426,9 @@ function createLoaderExecutor<TEnv>(
397
426
  /**
398
427
  * A stale refresh's executor (setupLoaderAccess _runLoaderIsolated): its
399
428
  * runs' pushes are diverted from the page's store, so a run's end says
400
- * nothing about the page's replayed values (HandleStore.settleLoaderRun).
429
+ * nothing about the page's replayed values (HandleStore.settleLoaderRun),
430
+ * and it never starts a loader the page's prefetch deferred
431
+ * (startDeferredLoader): a refresh reads each loader fresh.
401
432
  */
402
433
  detached?: boolean;
403
434
  /**
@@ -472,7 +503,14 @@ function createLoaderExecutor<TEnv>(
472
503
  const bound =
473
504
  callerLoaderId !== null &&
474
505
  internal._loaderCacheOverrides?.get(loader.$$id);
475
- const memo = bound || loaderPromises.get(loader.$$id);
506
+ // A loader the prefetch deferred starts through its registration, cache()
507
+ // binding included, as a navigation's run of it does.
508
+ const memo =
509
+ bound ||
510
+ loaderPromises.get(loader.$$id) ||
511
+ (executorOptions?.detached
512
+ ? undefined
513
+ : startDeferredLoader(ctx, loader.$$id));
476
514
  if (memo) {
477
515
  if (recordTags) readValueTags(memo);
478
516
  // A "use cache" body reading it refuses an identity read the run made
@@ -511,8 +549,9 @@ function createLoaderExecutor<TEnv>(
511
549
  // resolution continues — by the time the async body pushes, it would be
512
550
  // stale. Attributing to the owning route/layout segment gives handle
513
551
  // pushes the same granularity handler pushes have (segmentOrder-correct
514
- // for breadcrumb accumulation).
515
- const owningSegmentId = internal._currentSegmentId;
552
+ // for breadcrumb accumulation). A loader a prefetch deferred and a reader
553
+ // starts anyway keeps the segment that registers it (loaderOwner).
554
+ const owningSegmentId = loaderOwner(ctx, loader.$$id);
516
555
 
517
556
  let renderedResolved = false;
518
557
  let renderedPromise: Promise<void> | null = null;
@@ -633,6 +672,32 @@ function createLoaderExecutor<TEnv>(
633
672
  );
634
673
  }
635
674
 
675
+ // prefetch: false (segment-resolution/prefetch-deferral.ts). A
676
+ // deferrable loader runs in a fill request, where its entry is held
677
+ // and its handler does not run: there is no render to wait for.
678
+ // Refused on every request kind, so it is seen on a document load.
679
+ const deferral = internal._prefetchDeferral;
680
+ if (deferral?.scope.loaderIds.has(currentLoaderId)) {
681
+ throw new Error(
682
+ `ctx.rendered() is not available to loader "${currentLoaderId}": it is deferred in prefetches ` +
683
+ `(registered with loader(Def, { prefetch: false }), or behind a loading(fallback, { prefetch: false })), ` +
684
+ `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. ` +
685
+ `Remove { prefetch: false } from the loader (or from the loading() above it), or stop calling ctx.rendered() in this loader.`,
686
+ );
687
+ }
688
+ if (deferral?.mode === "fill") {
689
+ throw new Error(
690
+ `ctx.rendered() is not available to loader "${currentLoaderId}" in a fill request: the handlers the client already holds do not run, so their handle data is missing.`,
691
+ );
692
+ }
693
+ if (
694
+ process.env.NODE_ENV !== "production" &&
695
+ deferral &&
696
+ !deferral.mode
697
+ ) {
698
+ warnRenderedBesideUnit(deferral, currentLoaderId);
699
+ }
700
+
636
701
  // awaitBeforeFlush cycle: segment resolution awaits this loader
637
702
  // (loader(Def, { ssr: false })), the barrier awaits segment
638
703
  // resolution, and rendered() awaits the barrier — waiting here can
@@ -684,6 +749,17 @@ function createLoaderExecutor<TEnv>(
684
749
  // keeps a handler from depending on this loader.
685
750
  const streaming = reqCtx._treeHasStreaming === true;
686
751
  renderedPromise = reqCtx._renderBarrier.then(async () => {
752
+ // A prefetch that skipped a handler unit has none of that handler's
753
+ // pushes, and the fill never re-runs this loader: an error, never
754
+ // a handle list with a hole in it. Known only once resolution has
755
+ // decided the unit, which is what the barrier waits for.
756
+ if (deferral?.mode === "prefetch" && deferral.deferredUnit) {
757
+ throw new Error(
758
+ `ctx.rendered() in loader "${currentLoaderId}" cannot settle in a prefetch of this route: segment "${deferral.deferredUnit}" has loading(fallback, { prefetch: false }), ` +
759
+ `so the prefetch skipped its handler and its handle data does not exist. ` +
760
+ `Remove { prefetch: false } from that loading(), or stop calling ctx.rendered() in this loader.`,
761
+ );
762
+ }
687
763
  if (streaming) {
688
764
  reqCtx._handleStore.seal();
689
765
  await reqCtx._handleStore.settled;
@@ -807,6 +883,9 @@ export function setupLoaderAccess<TEnv>(
807
883
  // pushes, and a later run here replaces them (HandleStore.pushReplayed).
808
884
  const claimed = new Set<string>();
809
885
  const internal = ctx as InternalHandlerContext<any, TEnv>;
886
+ internal._loaderStarted = (loaderId) =>
887
+ loaderPromises.has(loaderId) ||
888
+ internal._loaderCacheOverrides?.has(loaderId) === true;
810
889
  internal._claimLoaderPushes = (loaderId) => {
811
890
  if (loaderPromises.has(loaderId) || claimed.has(loaderId)) return false;
812
891
  claimed.add(loaderId);
@@ -32,7 +32,12 @@ import {
32
32
  getContext,
33
33
  type InterceptSelectorContext,
34
34
  } from "../server/context";
35
- import type { ErrorBoundaryHandler, ErrorInfo, MatchResult } from "../types";
35
+ import type {
36
+ ErrorBoundaryHandler,
37
+ ErrorInfo,
38
+ InternalHandlerContext,
39
+ MatchResult,
40
+ } from "../types";
36
41
  import type { ReactNode } from "react";
37
42
  import type { MatchContext } from "./match-context.js";
38
43
  import type { MatchApiDeps, ActionContext } from "./types.js";
@@ -49,6 +54,11 @@ import {
49
54
  type RouteSnapshot,
50
55
  } from "./route-snapshot.js";
51
56
  import { resolveNavigation } from "./navigation-snapshot.js";
57
+ import {
58
+ partialDeferralMode,
59
+ planPrefetchDeferral,
60
+ requestKind,
61
+ } from "./segment-resolution/prefetch-deferral.js";
52
62
 
53
63
  /**
54
64
  * Create match context for full requests (document/SSR).
@@ -174,6 +184,9 @@ export async function createMatchContextForFull<TEnv>(
174
184
  if (reqCtx?._shellCaptureRun) {
175
185
  reqCtx._shellCaptureLoaderLanes = routeLoaderLanes(snapshot.entries);
176
186
  }
187
+ // A document never defers; the plan feeds the ctx.rendered() guards.
188
+ (handlerContext as InternalHandlerContext<any, TEnv>)._prefetchDeferral =
189
+ planPrefetchDeferral(snapshot.entries, undefined, matched, cacheScope);
177
190
 
178
191
  return {
179
192
  request,
@@ -453,6 +466,28 @@ export async function createMatchContextForPartial<TEnv>(
453
466
  if (cacheScope) armRecordTagOwners();
454
467
  if (bindsLoaderCache(snapshot.entries)) armLoaderTagSets();
455
468
 
469
+ // `prefetch: false` (segment-resolution/prefetch-deferral.ts). With no
470
+ // request context (a match run on its own) the request is asked directly.
471
+ const kind = reqCtx ? reqCtx._requestKind : requestKind(request, rawUrl);
472
+ const deferral = planPrefetchDeferral(
473
+ snapshot.entries,
474
+ partialDeferralMode({
475
+ fill: kind === "fill",
476
+ prefetch: kind === "prefetch",
477
+ method: request.method,
478
+ isAction,
479
+ isIntercept,
480
+ isShellCapture: reqCtx?._shellCaptureRun === true,
481
+ }),
482
+ matched,
483
+ cacheScope,
484
+ // As the request listed them, not the copy forced above: see
485
+ // prefetch-deferral.ts.
486
+ nav.clientSegmentSet,
487
+ );
488
+ (handlerContext as InternalHandlerContext<any, TEnv>)._prefetchDeferral =
489
+ deferral;
490
+
456
491
  return {
457
492
  request,
458
493
  url: cleanUrl,
@@ -113,6 +113,7 @@ import {
113
113
  runWithRequestContext,
114
114
  type RequestContext,
115
115
  } from "../../server/request-context.js";
116
+ import { isFillRequest } from "../segment-resolution/prefetch-deferral.js";
116
117
 
117
118
  /**
118
119
  * Re-render a cached route in the background and write it with
@@ -281,7 +282,16 @@ export function withBackgroundRevalidation<TEnv>(
281
282
  // Only trigger background revalidation if:
282
283
  // 1. Cache was hit and stale
283
284
  // 2. Cache scope exists
284
- if (!state.shouldRevalidate || !state.cacheHit || !ctx.cacheScope) {
285
+ // 3. The request is not a fill (prefetch: false). A fill runs no handler
286
+ // of a segment the client holds, and the re-render runs every handler
287
+ // of the route. The record stays stale until a request that reads it
288
+ // arrives: the prefetch before the fill already was one.
289
+ if (
290
+ !state.shouldRevalidate ||
291
+ !state.cacheHit ||
292
+ !ctx.cacheScope ||
293
+ isFillRequest(ctx.handlerContext)
294
+ ) {
285
295
  return;
286
296
  }
287
297