@rangojs/router 0.22.1 → 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 (179) 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 +842 -308
  63. package/package.json +1 -1
  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/request-context.ts +21 -1
  143. package/src/server.ts +4 -0
  144. package/src/testing/action-form.ts +2 -11
  145. package/src/testing/e2e/page-helpers.ts +3 -1
  146. package/src/testing/flight.entry.ts +1 -0
  147. package/src/testing/index.ts +6 -3
  148. package/src/testing/internal/action-id.ts +16 -1
  149. package/src/testing/internal/client-urls-projection.ts +70 -0
  150. package/src/testing/internal/test-action.ts +25 -0
  151. package/src/testing/run-loader.ts +2 -1
  152. package/src/testing/run-revalidate.ts +142 -0
  153. package/src/testing/run-transition-when.ts +7 -8
  154. package/src/testing/serve-shell-request.ts +116 -6
  155. package/src/testing/vitest.ts +67 -1
  156. package/src/types/boundaries.ts +12 -0
  157. package/src/types/handler-context.ts +14 -0
  158. package/src/types/loader-types.ts +30 -0
  159. package/src/types/segments.ts +26 -4
  160. package/src/urls/path-helper-types.ts +27 -5
  161. package/src/urls/type-extraction.ts +30 -10
  162. package/src/use-loader.tsx +18 -13
  163. package/src/vite/discovery/client-urls-projection.ts +20 -21
  164. package/src/vite/discovery/discover-routers.ts +94 -26
  165. package/src/vite/discovery/state.ts +20 -4
  166. package/src/vite/discovery/virtual-module-codegen.ts +22 -17
  167. package/src/vite/plugins/client-urls-revalidate-hoist.ts +564 -0
  168. package/src/vite/plugins/hoist/literal.ts +417 -0
  169. package/src/vite/plugins/hoist/scope.ts +370 -0
  170. package/src/vite/plugins/hoist/source.ts +84 -0
  171. package/src/vite/plugins/transition-when-hoist.ts +113 -779
  172. package/src/vite/rango.ts +2 -0
  173. package/src/vite/router-discovery.ts +93 -32
  174. package/dist/types/client-urls/revalidate-chain.d.ts +0 -33
  175. package/dist/types/client-urls/revalidation-protocol.d.ts +0 -25
  176. package/dist/types/testing/run-client-revalidate.d.ts +0 -43
  177. package/src/client-urls/revalidate-chain.ts +0 -83
  178. package/src/client-urls/revalidation-protocol.ts +0 -56
  179. package/src/testing/run-client-revalidate.ts +0 -92
@@ -14,6 +14,7 @@ import {
14
14
  getParallelEntries,
15
15
  getParallelSlotEntries,
16
16
  type EntryData,
17
+ type LoaderEntry,
17
18
  type ParallelEntryData,
18
19
  } from "../../server/context";
19
20
  import type {
@@ -34,6 +35,13 @@ import {
34
35
  } from "../logging.js";
35
36
  import { resolveLoaderData } from "./loader-cache.js";
36
37
  import { entryLoadingMasksLoaders } from "./loader-mask.js";
38
+ import {
39
+ defersLoader,
40
+ deferUnit,
41
+ getPrefetchDeferral,
42
+ isFillRequest,
43
+ registerDeferredLoader,
44
+ } from "./prefetch-deferral.js";
37
45
  import {
38
46
  handleHandlerResult,
39
47
  warnOnStreamedResponse,
@@ -102,6 +110,45 @@ function emitRevalidationDecision(
102
110
  });
103
111
  }
104
112
 
113
+ // ---------------------------------------------------------------------------
114
+ // prefetch: false (prefetch-deferral.ts)
115
+ // ---------------------------------------------------------------------------
116
+
117
+ /**
118
+ * How to deliver a loader a prefetch deferred, kept so deliverStartedLoaders
119
+ * can deliver it after all. Keyed by the emitted segment object.
120
+ */
121
+ const deferredLoaderRuns = new WeakMap<ResolvedSegment, () => unknown>();
122
+
123
+ /**
124
+ * A deferred loader something started in this request (a handler that awaits
125
+ * it with ctx.use(), or another loader) ran in the prefetch whatever its flag
126
+ * says: deliver that run instead of having the fill run it a second time. A
127
+ * reader starts it through its registration (prefetch-deferral.ts
128
+ * startDeferredLoader), so the delivery's resolveLoaderData finds the same
129
+ * run or cache() binding. Called once resolution has awaited every handler it
130
+ * awaits; a streamed handler that reads the loader later is not seen, and
131
+ * that loader runs again in the fill.
132
+ */
133
+ function deliverStartedLoaders<TEnv>(
134
+ ctx: HandlerContext<any, TEnv>,
135
+ segments: ResolvedSegment[],
136
+ ): void {
137
+ const plan = getPrefetchDeferral(ctx);
138
+ if (plan?.mode !== "prefetch") return;
139
+ const internal = ctx as InternalHandlerContext<any, TEnv>;
140
+ const started = (loaderId: string): boolean =>
141
+ plan.deferredLoaders?.get(loaderId)?.started === true ||
142
+ internal._loaderStarted?.(loaderId) === true;
143
+ for (const segment of segments) {
144
+ if (!segment.deferred || segment.type !== "loader") continue;
145
+ const run = deferredLoaderRuns.get(segment);
146
+ if (!run || !started(segment.loaderId!)) continue;
147
+ delete segment.deferred;
148
+ segment.loaderData = run();
149
+ }
150
+ }
151
+
105
152
  // ---------------------------------------------------------------------------
106
153
  // Revalidation path (partial match)
107
154
  // ---------------------------------------------------------------------------
@@ -147,6 +194,7 @@ export async function resolveLoadersWithRevalidation<TEnv>(
147
194
  }));
148
195
 
149
196
  const matchedIds = loaderMeta.map((m) => m.segmentId);
197
+ const fill = isFillRequest(ctx);
150
198
 
151
199
  const revalidationChecks = await Promise.all(
152
200
  loaderMeta.map(
@@ -173,6 +221,8 @@ export async function resolveLoadersWithRevalidation<TEnv>(
173
221
  }
174
222
  return true;
175
223
  }
224
+ // Fill: a held segment is skipped outright, no predicate runs.
225
+ if (fill) return false;
176
226
 
177
227
  const dummySegment: ResolvedSegment = {
178
228
  id: segmentId,
@@ -226,35 +276,72 @@ export async function resolveLoadersWithRevalidation<TEnv>(
226
276
  // (matchPartialWithPprReplay) seeds once its doc record hits.
227
277
  const bakeLane = !entryLoadingMasksLoaders(entry.loading);
228
278
 
279
+ const kickoff = (
280
+ loaderEntry: LoaderEntry,
281
+ segmentId: string,
282
+ loaderId: string,
283
+ ): Promise<unknown> =>
284
+ runInsideLoaderScope(
285
+ () =>
286
+ resolveLoaderData(
287
+ loaderEntry,
288
+ ctx,
289
+ ctx.pathname,
290
+ // `bake`, not awaitBeforeFlush: a navigation evaluation never
291
+ // carries awaitBeforeFlush, and an ssr: false loader must pin
292
+ // here exactly as on the document HIT.
293
+ bakeLane || loaderEntry.bake === true ? segmentId : null,
294
+ ),
295
+ loaderId,
296
+ );
297
+ const start = (
298
+ loaderEntry: LoaderEntry,
299
+ segmentId: string,
300
+ loaderId: string,
301
+ ) =>
302
+ deps.wrapLoaderPromise(
303
+ kickoff(loaderEntry, segmentId, loaderId),
304
+ entry,
305
+ segmentId,
306
+ ctx.pathname,
307
+ errorContext,
308
+ );
309
+
229
310
  const loadersToRun = revalidationChecks.filter((c) => c.shouldRun);
230
311
  const segments: ResolvedSegment[] = loadersToRun.map(
231
- ({ loaderEntry, loader, segmentId, index }) => ({
232
- id: segmentId,
233
- namespace: entry.id,
234
- type: "loader" as const,
235
- index,
236
- component: null,
237
- params: ctx.params,
238
- loaderId: loader.$$id,
239
- loaderData: deps.wrapLoaderPromise(
240
- runInsideLoaderScope(() =>
241
- resolveLoaderData(
242
- loaderEntry,
243
- ctx,
244
- ctx.pathname,
245
- // `bake`, not awaitBeforeFlush: a navigation evaluation never
246
- // carries awaitBeforeFlush, and an ssr: false loader must pin
247
- // here exactly as on the document HIT.
248
- bakeLane || loaderEntry.bake === true ? segmentId : null,
249
- ),
250
- ),
251
- entry,
252
- segmentId,
253
- ctx.pathname,
254
- errorContext,
255
- ),
256
- belongsToRoute,
257
- }),
312
+ ({ loaderEntry, loader, segmentId, index }) => {
313
+ const base = {
314
+ id: segmentId,
315
+ namespace: entry.id,
316
+ type: "loader" as const,
317
+ index,
318
+ component: null,
319
+ params: ctx.params,
320
+ loaderId: loader.$$id,
321
+ };
322
+ // Only a loader on a segment the client lacks (defersLoader). It
323
+ // would have run. Registered, not started: it runs, and reads its
324
+ // cache(), only if a reader starts it (startDeferredLoader).
325
+ if (defersLoader(ctx, loaderEntry, segmentId)) {
326
+ registerDeferredLoader(ctx, loader.$$id, shortCode, () =>
327
+ kickoff(loaderEntry, segmentId, loader.$$id),
328
+ );
329
+ const deferred: ResolvedSegment = {
330
+ ...base,
331
+ belongsToRoute,
332
+ deferred: true,
333
+ };
334
+ deferredLoaderRuns.set(deferred, () =>
335
+ start(loaderEntry, segmentId, loader.$$id),
336
+ );
337
+ return deferred;
338
+ }
339
+ return {
340
+ ...base,
341
+ loaderData: start(loaderEntry, segmentId, loader.$$id),
342
+ belongsToRoute,
343
+ };
344
+ },
258
345
  );
259
346
 
260
347
  return { segments, matchedIds };
@@ -372,6 +459,7 @@ export async function resolveLoadersOnlyWithRevalidation<TEnv>(
372
459
  await collectEntryLoaders(entry, entry.type === "route");
373
460
  }
374
461
 
462
+ deliverStartedLoaders(context, allLoaderSegments);
375
463
  return { segments: allLoaderSegments, matchedIds: allMatchedIds };
376
464
  }
377
465
 
@@ -612,8 +700,17 @@ export async function resolveParallelSegmentsWithRevalidation<TEnv>(
612
700
  // on the next request, perpetuating the staleness).
613
701
  matchedIds.push(parallelId);
614
702
 
703
+ // A slot below a unit a prefetch skipped goes with it, and no predicate
704
+ // runs: a prefetch sends what the slot declares instead of running its
705
+ // handler.
706
+ let deferred = deferUnit(context, parallelId, "below");
615
707
  let shouldResolve: boolean;
616
- if (isFullRefetch) {
708
+ if (isFillRequest(context)) {
709
+ // Fill: only what the client does not hold renders, no predicate runs.
710
+ shouldResolve = !clientSegmentIds.has(parallelId);
711
+ } else if (deferred) {
712
+ shouldResolve = true;
713
+ } else if (isFullRefetch) {
617
714
  // Client has nothing cached — slot MUST render. User revalidate fns are
618
715
  // bypassed here because returning false would leave the segment blank
619
716
  // with no client-side fallback.
@@ -687,18 +784,22 @@ export async function resolveParallelSegmentsWithRevalidation<TEnv>(
687
784
  shouldResolve,
688
785
  );
689
786
 
690
- const { component, handlerRan } = await resolveParallelSlotComponent({
691
- shouldResolve,
692
- parallelEntry,
693
- slot,
694
- parallelId,
695
- handler,
696
- context,
697
- deps,
698
- routeKey,
699
- params,
700
- boundaryEntry: options?.boundaryEntry ?? entry,
701
- });
787
+ // A slot with its own flagged loading() is its own unit.
788
+ if (shouldResolve) deferred ??= deferUnit(context, parallelId, "slot");
789
+ const { component, handlerRan } = deferred
790
+ ? { component: null, handlerRan: false }
791
+ : await resolveParallelSlotComponent({
792
+ shouldResolve,
793
+ parallelEntry,
794
+ slot,
795
+ parallelId,
796
+ handler,
797
+ context,
798
+ deps,
799
+ routeKey,
800
+ params,
801
+ boundaryEntry: options?.boundaryEntry ?? entry,
802
+ });
702
803
 
703
804
  segments.push({
704
805
  id: parallelId,
@@ -719,6 +820,7 @@ export async function resolveParallelSegmentsWithRevalidation<TEnv>(
719
820
  ...(parallelEntry.mountPath
720
821
  ? { mountPath: parallelEntry.mountPath }
721
822
  : {}),
823
+ ...deferred,
722
824
  });
723
825
 
724
826
  if (loaderOrder === "after") {
@@ -750,6 +852,7 @@ export async function resolveEntryHandlerWithRevalidation<TEnv>(
750
852
  const matchedId = entry.shortCode;
751
853
 
752
854
  let handlerRan = false;
855
+ let deferred: { readonly deferred: true } | undefined;
753
856
  const component = await revalidate(
754
857
  async () => {
755
858
  const hasSegment = clientSegmentIds.has(entry.shortCode);
@@ -777,6 +880,8 @@ export async function resolveEntryHandlerWithRevalidation<TEnv>(
777
880
  }
778
881
  return true;
779
882
  }
883
+ // Fill: a held segment is skipped outright, no predicate runs.
884
+ if (isFillRequest(context)) return false;
780
885
 
781
886
  const dummySegment: ResolvedSegment = {
782
887
  id: entry.shortCode,
@@ -826,6 +931,10 @@ export async function resolveEntryHandlerWithRevalidation<TEnv>(
826
931
  return shouldRevalidate;
827
932
  },
828
933
  async () => {
934
+ // Only an entry the client lacks (defersUnit). Its handler would
935
+ // have run, and a prefetch sends the entry's fallback in its place.
936
+ deferred = deferUnit(context, entry.shortCode, "chain");
937
+ if (deferred) return null;
829
938
  handlerRan = true;
830
939
  const doneHandler = track(`handler:${entry.id}`, 2);
831
940
  (context as InternalHandlerContext<any, TEnv>)._currentSegmentId =
@@ -928,6 +1037,7 @@ export async function resolveEntryHandlerWithRevalidation<TEnv>(
928
1037
  : {}),
929
1038
  ...(entry.mountPath ? { mountPath: entry.mountPath } : {}),
930
1039
  _handlerRan: handlerRan,
1040
+ ...deferred,
931
1041
  };
932
1042
 
933
1043
  return { segment, matchedId };
@@ -1020,10 +1130,8 @@ export async function resolveSegmentWithRevalidation<TEnv>(
1020
1130
  }
1021
1131
  }
1022
1132
 
1023
- if (routeHandlerResult) {
1024
- // Route entry: handler already executed above; resolve parallels
1025
- // (handler data visible) then push handler segment last for tree order.
1026
- const parallelResult = await resolveParallelSegmentsWithRevalidation(
1133
+ const resolveSlots = () =>
1134
+ resolveParallelSegmentsWithRevalidation(
1027
1135
  entry,
1028
1136
  params,
1029
1137
  context,
@@ -1038,6 +1146,11 @@ export async function resolveSegmentWithRevalidation<TEnv>(
1038
1146
  actionContext,
1039
1147
  stale,
1040
1148
  );
1149
+
1150
+ if (routeHandlerResult) {
1151
+ // Route entry: handler already executed above; resolve parallels
1152
+ // (handler data visible) then push handler segment last for tree order.
1153
+ const parallelResult = await resolveSlots();
1041
1154
  segments.push(...parallelResult.segments);
1042
1155
  matchedIds.push(...parallelResult.matchedIds);
1043
1156
 
@@ -1064,21 +1177,7 @@ export async function resolveSegmentWithRevalidation<TEnv>(
1064
1177
  segments.push(handlerResult.segment);
1065
1178
  matchedIds.push(handlerResult.matchedId);
1066
1179
 
1067
- const parallelResult = await resolveParallelSegmentsWithRevalidation(
1068
- entry,
1069
- params,
1070
- context,
1071
- belongsToRoute,
1072
- clientSegmentIds,
1073
- prevParams,
1074
- request,
1075
- prevUrl,
1076
- nextUrl,
1077
- routeKey,
1078
- deps,
1079
- actionContext,
1080
- stale,
1081
- );
1180
+ const parallelResult = await resolveSlots();
1082
1181
  segments.push(...parallelResult.segments);
1083
1182
  matchedIds.push(...parallelResult.matchedIds);
1084
1183
 
@@ -1192,6 +1291,7 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
1192
1291
  // so ctx.set() values are visible to parallel children.
1193
1292
  matchedIds.push(orphan.shortCode);
1194
1293
 
1294
+ let deferred: { readonly deferred: true } | undefined;
1195
1295
  const component = await revalidate(
1196
1296
  async () => {
1197
1297
  if (!clientSegmentIds.has(orphan.shortCode)) {
@@ -1208,6 +1308,8 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
1208
1308
  }
1209
1309
  return true;
1210
1310
  }
1311
+ // Fill: a held segment is skipped outright, no predicate runs.
1312
+ if (isFillRequest(context)) return false;
1211
1313
 
1212
1314
  const dummySegment: ResolvedSegment = {
1213
1315
  id: orphan.shortCode,
@@ -1246,7 +1348,12 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
1246
1348
  );
1247
1349
  return shouldRevalidate;
1248
1350
  },
1249
- async () => resolveLayoutComponent(orphan, context),
1351
+ async () => {
1352
+ // Below a unit a prefetch skipped: the orphan goes with it.
1353
+ deferred = deferUnit(context, orphan.shortCode, "below");
1354
+ if (deferred) return null;
1355
+ return resolveLayoutComponent(orphan, context);
1356
+ },
1250
1357
  () => null,
1251
1358
  );
1252
1359
 
@@ -1265,6 +1372,7 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
1265
1372
  deps.viewTransitionDefault,
1266
1373
  ),
1267
1374
  ...(orphan.mountPath ? { mountPath: orphan.mountPath } : {}),
1375
+ ...deferred,
1268
1376
  });
1269
1377
 
1270
1378
  // Resolve the orphan layout's parallel slots through the shared main-path
@@ -1425,5 +1533,6 @@ export async function resolveAllSegmentsWithRevalidation<TEnv>(
1425
1533
  }
1426
1534
  }
1427
1535
 
1536
+ deliverStartedLoaders(context, allSegments);
1428
1537
  return { segments: allSegments, matchedIds };
1429
1538
  }
@@ -94,6 +94,7 @@ import {
94
94
  type DevShellLookup,
95
95
  } from "./shell-build-manifest.js";
96
96
  import { contextGet } from "../context-var.js";
97
+ import { PREFETCH_HEADER } from "../prefetch-markers.js";
97
98
  import {
98
99
  isWarmReplace,
99
100
  noteWarmShellEvent,
@@ -302,7 +303,10 @@ type PreparedRender =
302
303
  | {
303
304
  kind: "payload";
304
305
  payload: RscPayload;
305
- /** The response depends on the source page: an intercept targets the route. */
306
+ /**
307
+ * The response depends on the source page: an intercept targets the
308
+ * route, or it carries deferred units (`prefetch: false`).
309
+ */
306
310
  sourceScoped: boolean;
307
311
  pprReplayStatus?: PprReplayStatus;
308
312
  /** The key a navigation-only heal capture stores under, when one is needed. */
@@ -831,6 +835,12 @@ function* preparePayloadPlan<TEnv>(
831
835
  };
832
836
  }
833
837
 
838
+ // What a prefetch defers depends on what the source page holds: a page
839
+ // that holds the segment gets it rendered. Adopted from another page, a
840
+ // deferring payload held the click on a fill for content a plain click
841
+ // showed at once, and ran held work again.
842
+ const deferring = result.segments.some((s) => s.deferred);
843
+ if (deferring) reqCtx._payloadDeferred = true;
834
844
  return {
835
845
  kind: "payload",
836
846
  payload: {
@@ -860,7 +870,7 @@ function* preparePayloadPlan<TEnv>(
860
870
  stateCookieName: ctx.router.resolvedStateCookieName,
861
871
  },
862
872
  },
863
- sourceScoped: result.interceptTargeted === true,
873
+ sourceScoped: result.interceptTargeted === true || deferring,
864
874
  pprReplayStatus,
865
875
  healKey,
866
876
  };
@@ -1200,16 +1210,34 @@ function renderPreparedRscResponse<TEnv>(
1200
1210
  rscHeaders["x-rsc-prefetch-scope"] = "source";
1201
1211
  }
1202
1212
  // Enable browser HTTP caching for prefetch responses only.
1203
- // Requires X-Rango-Prefetch header (sent by Link prefetch fetch),
1204
- // a source-agnostic response (see sourceScoped above), and a configured
1205
- // cache-control value (false disables caching).
1206
- const isPrefetch = requestHeaders(request).has("X-Rango-Prefetch");
1207
- if (isPrefetch && isPartial && !sourceScoped) {
1213
+ // Requires a prefetch request (X-Rango-Prefetch, sent by Link prefetch
1214
+ // fetch), a source-agnostic response (see sourceScoped above), and a
1215
+ // configured cache-control value (false disables caching).
1216
+ if (reqCtx._requestKind === "prefetch" && isPartial && !sourceScoped) {
1208
1217
  const cc = ctx.router.prefetchCacheControl;
1209
1218
  if (cc) {
1210
1219
  rscHeaders["cache-control"] = cc;
1211
1220
  }
1212
1221
  }
1222
+ // prefetch: false (docs/design/prefetch-false.md, R7). A response that
1223
+ // carries deferred units answers one page's prefetch, and a fill is the
1224
+ // second half of one adoption: neither is reused by a shared cache or the
1225
+ // document cache (which also refuses a payload marked `_payloadDeferred`),
1226
+ // whatever Cache-Control the route set. A route with no flag keeps its
1227
+ // headers.
1228
+ if (
1229
+ reqCtx._payloadDeferred ||
1230
+ (isPartial && reqCtx._requestKind === "fill")
1231
+ ) {
1232
+ rscHeaders["cache-control"] = NOT_REUSED;
1233
+ }
1234
+ // The browser may still keep the body, and answers from it without asking
1235
+ // while it reloads a document for back/forward. A navigation is the same
1236
+ // URL as its prefetch: the header that tells them apart keeps the deferring
1237
+ // body from answering one.
1238
+ if (reqCtx._payloadDeferred) {
1239
+ rscHeaders.vary += `, ${PREFETCH_HEADER}`;
1240
+ }
1213
1241
 
1214
1242
  const isFlightResponse = isRscRequest(request, url, isPartial);
1215
1243
  const stageTracking = {
@@ -1694,6 +1722,21 @@ function publishTailTiming(
1694
1722
 
1695
1723
  const PENDING = Symbol("pending");
1696
1724
 
1725
+ /**
1726
+ * Cache-Control of a response that is not to be reused: one that carries
1727
+ * deferred units (`prefetch: false`), and a fill. `private` keeps it out of
1728
+ * shared caches; `no-cache` makes the browser ask again, and with no
1729
+ * validator on the response that is a full request. The browser may keep the
1730
+ * body all the same (Chromium does) and answers from it, without asking,
1731
+ * while it reloads a document for back/forward. That is harmless: the client
1732
+ * completes any payload that carries deferred work, wherever it came from.
1733
+ * Not `no-store`:
1734
+ * Chromium reports a `no-store` fetch whose body is read through a stream
1735
+ * as canceled (net::ERR_ABORTED in DevTools, and Playwright's
1736
+ * response.finished() never settles), though every byte arrived.
1737
+ */
1738
+ const NOT_REUSED = "private, no-cache";
1739
+
1697
1740
  /**
1698
1741
  * Whether `value` is not a promise, or a promise that has already settled:
1699
1742
  * racing it against an already-resolved promise, a settled one's reaction is
@@ -73,6 +73,7 @@ import {
73
73
  } from "../server/context.js";
74
74
  import { isThenable } from "../handles/is-thenable.js";
75
75
  import { requestHeaders } from "../server/request-headers.js";
76
+ import { PREFETCH_HEADER } from "../prefetch-markers.js";
76
77
  import type {
77
78
  CachedEntryData,
78
79
  ShellCacheEntry,
@@ -1448,7 +1449,7 @@ function createNavigationCaptureRequest(request: Request, url: URL): Request {
1448
1449
  headers.set("accept", "text/html");
1449
1450
  for (const name of [
1450
1451
  "rsc-action",
1451
- "x-rango-prefetch",
1452
+ PREFETCH_HEADER,
1452
1453
  "x-rango-state",
1453
1454
  "x-rsc-hmr",
1454
1455
  "x-rsc-router-client-path",
@@ -1,5 +1,6 @@
1
1
  import type { ResolvedSegment } from "./types.js";
2
2
  import { INTERNAL_RANGO_DEBUG } from "./internal-debug.js";
3
+ import { asStream } from "./browser/defer.js";
3
4
 
4
5
  /**
5
6
  * Browser: a boundary is handed the settled value. React treats a promise as
@@ -7,7 +8,13 @@ import { INTERNAL_RANGO_DEBUG } from "./internal-debug.js";
7
8
  * promise it has not read, in a render that cannot wait, shows its fallback.
8
9
  * So a cache entry records its array once the aggregate fulfils and later
9
10
  * calls return that same array; until then (and when it rejects) they return
10
- * the promise. A boundary with no loaders gets one shared empty array.
11
+ * the promise. A boundary with no loaders gets one shared empty array. An
12
+ * aggregate whose sources are all streams that have fulfilled (React's
13
+ * `status` on the thenable, which Flight sets itself; a plain value is not a
14
+ * stream, whatever fields it carries) is known on its first
15
+ * call and is the array from the start: the sources of a page whose entry was
16
+ * rewritten without a render (browser/partial-update.ts fillHoles) are
17
+ * first aggregated by the next navigation that keeps them.
11
18
  *
12
19
  * Server: a fresh promise per call, so Suspense emits the fallback in the
13
20
  * streamed HTML. A shared resolved promise would carry React's `.status`
@@ -22,10 +29,10 @@ const IS_BROWSER = typeof window !== "undefined";
22
29
 
23
30
  interface LoaderCacheEntry {
24
31
  sources: any[];
25
- // Resolves to the same array it records in `value`; a rejection passes
26
- // through untouched.
27
- promise: Promise<any[]>;
28
- value?: any[];
32
+ // The aggregate while it is pending (a rejection passes through untouched),
33
+ // then the array it fulfilled with. The array from the start for sources
34
+ // that have all fulfilled.
35
+ result: Promise<any[]> | any[];
29
36
  }
30
37
 
31
38
  // Cap the per-key entries array. A stable first-ref (e.g. a layout loader whose
@@ -91,13 +98,7 @@ export function buildLoaderPromise(loaders: ResolvedSegment[]): Promise<any[]> {
91
98
  }
92
99
  }
93
100
  }
94
- return Promise.all(
95
- loaders.map((loader) =>
96
- loader.loaderData instanceof Promise
97
- ? loader.loaderData
98
- : Promise.resolve(loader.loaderData),
99
- ),
100
- );
101
+ return Promise.all(loaders.map((loader) => asStream(loader.loaderData)));
101
102
  }
102
103
 
103
104
  function isObjectLike(value: unknown): value is object {
@@ -131,17 +132,19 @@ export function getMemoizedLoaderPromise(
131
132
  if (entries) {
132
133
  for (const entry of entries) {
133
134
  if (hasSameReferences(entry.sources, sources)) {
134
- return entry.value ?? entry.promise;
135
+ return entry.result;
135
136
  }
136
137
  }
137
138
  }
138
139
 
139
- const newEntry = { sources } as LoaderCacheEntry;
140
- const promise = buildLoaderPromise(loaders).then((values) => {
141
- newEntry.value = values;
142
- return values;
143
- });
144
- newEntry.promise = promise;
140
+ const newEntry: LoaderCacheEntry = { sources, result: [] };
141
+ newEntry.result = sources.every(
142
+ (source) =>
143
+ source instanceof Promise &&
144
+ (source as { status?: string }).status === "fulfilled",
145
+ )
146
+ ? sources.map((source) => source.value)
147
+ : buildLoaderPromise(loaders).then((values) => (newEntry.result = values));
145
148
  if (entries) {
146
149
  // Bound the array: drop the oldest entry before appending when at the cap.
147
150
  if (entries.length >= MAX_ENTRIES_PER_KEY) {
@@ -153,5 +156,5 @@ export function getMemoizedLoaderPromise(
153
156
  } else {
154
157
  primitiveLoaderCache.set(first, [newEntry]);
155
158
  }
156
- return promise;
159
+ return newEntry.result;
157
160
  }