@rangojs/router 0.32.0 → 0.33.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/README.md +3 -2
  2. package/dist/bin/browser-7KIJ5HSV.js +15 -0
  3. package/dist/bin/chunk-3B7CKBRO.js +463 -0
  4. package/dist/bin/chunk-524VACCG.js +71 -0
  5. package/dist/bin/chunk-LD7UBHT4.js +196 -0
  6. package/dist/bin/chunk-TO6CRWLN.js +1429 -0
  7. package/dist/bin/chunk-TTXC2U3G.js +2477 -0
  8. package/dist/bin/client-urls-projection-DQLWPDAT.js +28 -0
  9. package/dist/bin/forward-user-plugins-URBZKJP4.js +62 -0
  10. package/dist/bin/rango.js +42 -10173
  11. package/dist/bin/runtime-EPUDEHO4.js +596 -0
  12. package/dist/bin/runtime-discovery-CZ2QHT4Q.js +352 -0
  13. package/dist/bin/state-ACD2MGKJ.js +16 -0
  14. package/dist/bin/temp-rsc-server-QRR6HD6T.js +3190 -0
  15. package/dist/types/bin/rango.d.ts +1 -0
  16. package/dist/types/browser/action-fence.d.ts +20 -0
  17. package/dist/types/browser/cookie-name.d.ts +51 -10
  18. package/dist/types/browser/invalidate-client-cache.d.ts +8 -6
  19. package/dist/types/browser/navigation-bridge.d.ts +36 -1
  20. package/dist/types/browser/navigation-client.d.ts +19 -0
  21. package/dist/types/browser/navigation-store-handle.d.ts +37 -4
  22. package/dist/types/browser/navigation-store.d.ts +2 -2
  23. package/dist/types/browser/navigation-transaction.d.ts +0 -1
  24. package/dist/types/browser/partial-update.d.ts +13 -0
  25. package/dist/types/browser/prefetch/cache.d.ts +96 -8
  26. package/dist/types/browser/prefetch/fetch.d.ts +26 -2
  27. package/dist/types/browser/prefetch/queue.d.ts +3 -2
  28. package/dist/types/browser/rango-state.d.ts +34 -5
  29. package/dist/types/browser/react/Link.d.ts +15 -6
  30. package/dist/types/browser/types.d.ts +25 -0
  31. package/dist/types/build/generate-route-types.d.ts +1 -1
  32. package/dist/types/build/route-types/include-resolution.d.ts +9 -1
  33. package/dist/types/build/route-types/router-processing.d.ts +3 -1
  34. package/dist/types/cache/cache-key-utils.d.ts +12 -8
  35. package/dist/types/cache/cache-scope.d.ts +4 -2
  36. package/dist/types/cache/cf/cf-cache-types.d.ts +9 -5
  37. package/dist/types/cache/document-cache-policy.d.ts +9 -1
  38. package/dist/types/cache/memory-segment-store.d.ts +3 -2
  39. package/dist/types/cache/segment-codec.d.ts +2 -2
  40. package/dist/types/cache/shell-snapshot.d.ts +1 -1
  41. package/dist/types/cache/types.d.ts +12 -7
  42. package/dist/types/cache/vercel/vercel-cache-store.d.ts +2 -1
  43. package/dist/types/client-urls/types.d.ts +7 -5
  44. package/dist/types/prefetch-markers.d.ts +9 -0
  45. package/dist/types/prefetch-source-scope.d.ts +66 -0
  46. package/dist/types/route-definition/helpers-types.d.ts +8 -5
  47. package/dist/types/router/intercept-resolution.d.ts +25 -11
  48. package/dist/types/router/match-context.d.ts +1 -0
  49. package/dist/types/router/match-handlers.d.ts +1 -1
  50. package/dist/types/router/prefetch-cache-ttl.d.ts +11 -3
  51. package/dist/types/router/router-context.d.ts +1 -1
  52. package/dist/types/router/router-options.d.ts +6 -3
  53. package/dist/types/router/segment-resolution/loader-cache.d.ts +8 -0
  54. package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +23 -7
  55. package/dist/types/router/segment-resolution/revalidation.d.ts +19 -0
  56. package/dist/types/router/source-reads.d.ts +53 -0
  57. package/dist/types/router/types.d.ts +1 -1
  58. package/dist/types/rsc/helpers.d.ts +5 -2
  59. package/dist/types/rsc/prefetch-render-error.d.ts +7 -2
  60. package/dist/types/rsc/request-stages.d.ts +2 -0
  61. package/dist/types/rsc/shell-serve.d.ts +6 -1
  62. package/dist/types/server/context.d.ts +23 -6
  63. package/dist/types/server/cookie-store.d.ts +15 -1
  64. package/dist/types/server/document-location-state.d.ts +5 -2
  65. package/dist/types/server/handle-store.d.ts +2 -0
  66. package/dist/types/server/request-context.d.ts +43 -7
  67. package/dist/types/testing/e2e/server.d.ts +16 -1
  68. package/dist/types/testing/render-route.d.ts +66 -2
  69. package/dist/types/testing/serve-shell-request.d.ts +6 -3
  70. package/dist/types/types/handler-context.d.ts +7 -5
  71. package/dist/types/types/loader-types.d.ts +7 -4
  72. package/dist/types/types/segments.d.ts +15 -4
  73. package/dist/types/urls/path-helper-types.d.ts +7 -5
  74. package/dist/types/vite/temp-rsc-server.d.ts +2 -2
  75. package/dist/vite/index.js +449 -189
  76. package/package.json +2 -2
  77. package/skills/app-structure/SKILL.md +74 -0
  78. package/skills/app-structure/e-commerce.md +10 -9
  79. package/skills/app-structure/large-app.md +15 -2
  80. package/skills/caching/SKILL.md +10 -5
  81. package/skills/client-urls/SKILL.md +18 -11
  82. package/skills/comparison/references/framework-comparison.md +13 -8
  83. package/skills/document-cache/SKILL.md +8 -0
  84. package/skills/hooks/navigation.md +8 -8
  85. package/skills/hooks/state.md +24 -8
  86. package/skills/intercept/SKILL.md +33 -12
  87. package/skills/layout/SKILL.md +11 -0
  88. package/skills/links/SKILL.md +101 -4
  89. package/skills/loader/SKILL.md +49 -22
  90. package/skills/migrate-nextjs/SKILL.md +8 -2
  91. package/skills/migrate-react-router/data-and-actions.md +5 -0
  92. package/skills/ppr/SKILL.md +26 -5
  93. package/skills/prerender/SKILL.md +4 -1
  94. package/skills/route/SKILL.md +6 -6
  95. package/skills/router-setup/SKILL.md +2 -1
  96. package/skills/server-actions/SKILL.md +41 -8
  97. package/skills/testing/client-components.md +29 -13
  98. package/skills/testing/serve-shell-request.md +7 -7
  99. package/skills/testing/server-actions.md +8 -0
  100. package/skills/ux-architecture/SKILL.md +67 -18
  101. package/src/bin/rango.ts +39 -13
  102. package/src/browser/action-fence.ts +72 -0
  103. package/src/browser/cookie-name.ts +82 -14
  104. package/src/browser/invalidate-client-cache.ts +9 -7
  105. package/src/browser/navigation-bridge.ts +164 -32
  106. package/src/browser/navigation-client.ts +95 -20
  107. package/src/browser/navigation-store-handle.ts +67 -5
  108. package/src/browser/navigation-store.ts +86 -17
  109. package/src/browser/navigation-transaction.ts +0 -6
  110. package/src/browser/partial-update.ts +31 -12
  111. package/src/browser/prefetch/cache.ts +188 -11
  112. package/src/browser/prefetch/fetch.ts +211 -51
  113. package/src/browser/prefetch/queue.ts +3 -2
  114. package/src/browser/rango-state.ts +50 -8
  115. package/src/browser/react/Link.tsx +20 -9
  116. package/src/browser/react/use-navigation.ts +1 -1
  117. package/src/browser/rsc-router.tsx +8 -1
  118. package/src/browser/server-action-bridge.ts +3 -3
  119. package/src/browser/types.ts +25 -0
  120. package/src/build/generate-route-types.ts +1 -0
  121. package/src/build/route-types/include-resolution.ts +40 -3
  122. package/src/build/route-types/router-processing.ts +170 -25
  123. package/src/build/route-types/scan-filter.ts +1 -0
  124. package/src/cache/cache-key-utils.ts +12 -8
  125. package/src/cache/cache-runtime.ts +5 -1
  126. package/src/cache/cache-scope.ts +28 -7
  127. package/src/cache/cf/cf-cache-types.ts +9 -5
  128. package/src/cache/document-cache-policy.ts +27 -0
  129. package/src/cache/document-cache.ts +5 -0
  130. package/src/cache/memory-segment-store.ts +3 -2
  131. package/src/cache/segment-codec.ts +2 -2
  132. package/src/cache/shell-snapshot.ts +1 -1
  133. package/src/cache/types.ts +12 -7
  134. package/src/cache/vercel/vercel-cache-store.ts +2 -1
  135. package/src/client-urls/types.ts +7 -5
  136. package/src/prefetch-markers.ts +10 -0
  137. package/src/prefetch-source-scope.ts +137 -0
  138. package/src/route-definition/helpers-types.ts +8 -5
  139. package/src/router/intercept-resolution.ts +94 -50
  140. package/src/router/loader-resolution.ts +8 -6
  141. package/src/router/match-api.ts +33 -29
  142. package/src/router/match-context.ts +5 -0
  143. package/src/router/match-handlers.ts +2 -1
  144. package/src/router/match-middleware/cache-lookup.ts +21 -13
  145. package/src/router/match-result.ts +39 -87
  146. package/src/router/navigation-snapshot.ts +18 -3
  147. package/src/router/prefetch-cache-ttl.ts +15 -3
  148. package/src/router/revalidation.ts +53 -23
  149. package/src/router/router-context.ts +2 -1
  150. package/src/router/router-options.ts +6 -3
  151. package/src/router/segment-resolution/loader-cache.ts +21 -3
  152. package/src/router/segment-resolution/prefetch-deferral.ts +38 -31
  153. package/src/router/segment-resolution/revalidation.ts +40 -12
  154. package/src/router/source-reads.ts +122 -0
  155. package/src/router/types.ts +2 -1
  156. package/src/router.ts +11 -12
  157. package/src/rsc/helpers.ts +8 -3
  158. package/src/rsc/prefetch-render-error.ts +7 -2
  159. package/src/rsc/progressive-enhancement.ts +3 -2
  160. package/src/rsc/request-stages.ts +59 -0
  161. package/src/rsc/rsc-rendering.ts +49 -33
  162. package/src/rsc/server-action.ts +2 -1
  163. package/src/rsc/shell-capture.ts +4 -5
  164. package/src/rsc/shell-serve.ts +12 -1
  165. package/src/segment-system.tsx +1 -1
  166. package/src/server/context.ts +37 -6
  167. package/src/server/cookie-store.ts +22 -1
  168. package/src/server/document-location-state.ts +23 -2
  169. package/src/server/handle-store.ts +2 -0
  170. package/src/server/request-context.ts +113 -28
  171. package/src/testing/dispatch.ts +3 -0
  172. package/src/testing/e2e/fixture.ts +4 -12
  173. package/src/testing/e2e/server.ts +91 -4
  174. package/src/testing/render-route.tsx +269 -4
  175. package/src/testing/serve-shell-request.ts +6 -3
  176. package/src/types/handler-context.ts +7 -5
  177. package/src/types/loader-types.ts +7 -4
  178. package/src/types/segments.ts +15 -4
  179. package/src/urls/path-helper-types.ts +7 -5
  180. package/src/vite/temp-rsc-server.ts +2 -2
@@ -32,27 +32,41 @@ import {
32
32
  type EntryData,
33
33
  type InterceptEntry,
34
34
  type LoaderEntry,
35
+ segmentPinOf,
35
36
  } from "../../server/context.js";
36
37
  import { requestHeaders } from "../../server/request-headers.js";
37
38
  import type { HandlerContext, InternalHandlerContext } from "../../types.js";
38
39
  import type { CacheScope } from "../../cache/cache-scope.js";
39
- import { PREFETCH_HEADER } from "../../prefetch-markers.js";
40
+ import { PREFETCH_HEADER, PREFETCH_INTENT } from "../../prefetch-markers.js";
40
41
  import { WIRE } from "../../wire-params.js";
41
42
  import { isInterceptSegment } from "../../browser/intercept-utils.js";
42
43
 
43
44
  /**
44
45
  * What a request says it is, read once when its context is created
45
46
  * (RequestContext._requestKind). A fill is marked on the raw URL and wins
46
- * over the prefetch header. Whether a prefetch defers anything is decided per
47
- * match, by partialDeferralMode: a shell capture inherits the context of the
48
- * request that scheduled it and is never a prefetch.
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.
49
53
  */
50
54
  export function requestKind(
51
55
  request: Request,
52
56
  rawUrl: URL,
53
- ): "prefetch" | "fill" | undefined {
57
+ ): RequestKind | undefined {
54
58
  if (rawUrl.searchParams.has(WIRE.fill)) return "fill";
55
- return requestHeaders(request).has(PREFETCH_HEADER) ? "prefetch" : undefined;
59
+ const prefetch = requestHeaders(request).get(PREFETCH_HEADER);
60
+ if (prefetch === null) return undefined;
61
+ return prefetch === PREFETCH_INTENT ? "intent" : "prefetch";
62
+ }
63
+
64
+ /** requestKind's answer for a marked request. */
65
+ export type RequestKind = "prefetch" | "intent" | "fill";
66
+
67
+ /** A prefetch of either kind: speculative or intent. */
68
+ export function isPrefetchKind(kind: RequestKind | undefined): boolean {
69
+ return kind === "prefetch" || kind === "intent";
56
70
  }
57
71
 
58
72
  /** Static, per matched chain. */
@@ -173,7 +187,6 @@ const EMPTY_SCOPE: DeferralScope = {
173
187
  };
174
188
 
175
189
  const scopes = new WeakMap<EntryData, DeferralScope>();
176
- const warnedBakeLoaders = new Set<string>();
177
190
 
178
191
  /**
179
192
  * The deferral scope of a matched chain, memoized on its leaf entry (entries
@@ -192,9 +205,6 @@ export function resolveDeferralScope(
192
205
  const loaderIds = new Set<string>();
193
206
  const units = new Map<EntryData, DeferralUnit>();
194
207
  const chain = new Set(entries);
195
- // ssr: false on a ppr route is the bake lane (loader-cache.ts
196
- // resolveLoaderData): shell material, served from the record.
197
- const pprRoute = isPprEntry(leaf);
198
208
  // A clientUrls() group route renders loading() itself and its handler is
199
209
  // ClientUrlsRoot (segment-system.tsx keys it by group): a unit would hand
200
210
  // React a promise in place of the optimistic destination on screen. So
@@ -214,20 +224,8 @@ export function resolveDeferralScope(
214
224
  for (const loaderEntry of entry.loader ?? []) {
215
225
  const own = loaderEntry.prefetch === false;
216
226
  if (!own && behind.length === 0) continue;
217
- if (pprRoute && loaderEntry.bake) {
218
- if (
219
- process.env.NODE_ENV !== "production" &&
220
- loaderEntry.prefetch === false &&
221
- !warnedBakeLoaders.has(loaderEntry.loader.$$id)
222
- ) {
223
- warnedBakeLoaders.add(loaderEntry.loader.$$id);
224
- console.warn(
225
- `[rango] loader "${loaderEntry.loader.$$id}" is registered with ssr: false and prefetch: false on a ppr route ("${leaf.id}"). ` +
226
- `Under ppr an ssr: false loader is baked into the shell and served from it, so prefetch: false is ignored there. Drop one of the two options.`,
227
- );
228
- }
229
- continue;
230
- }
227
+ // An ssr: false loader on a ppr route is in scope too: whether this
228
+ // request serves it from a shell pin is per request (defersLoader).
231
229
  loaders.set(loaderEntry, own ? true : behind);
232
230
  loaderIds.add(loaderEntry.loader.$$id);
233
231
  }
@@ -303,7 +301,9 @@ export function resolveInterceptDeferralScope(
303
301
 
304
302
  /**
305
303
  * Which half of the feature a partial request is, if either (`fill` and
306
- * `prefetch` are requestKind's answer). Both halves are GETs: a request of
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
307
307
  * another method that carries either marker is a normal request. A prefetch
308
308
  * defers only on a plain GET navigation: an action re-renders what it
309
309
  * changed, and a shell capture is never a prefetch whatever the request that
@@ -496,7 +496,7 @@ export function startDeferredLoader(
496
496
  /**
497
497
  * The segment a run of `loaderId` files its handle pushes under: the one
498
498
  * that registers it when it is registered (registerDeferredLoader), else the
499
- * segment being resolved.
499
+ * segment whose code starts it (segmentPinOf).
500
500
  */
501
501
  export function loaderOwner(
502
502
  ctx: HandlerContext<any, any>,
@@ -504,8 +504,7 @@ export function loaderOwner(
504
504
  ): string | undefined {
505
505
  const internal = ctx as InternalHandlerContext<any, any>;
506
506
  return (
507
- internal._deferredLoaders?.get(loaderId)?.owner ??
508
- internal._currentSegmentId
507
+ internal._deferredLoaders?.get(loaderId)?.owner ?? segmentPinOf(internal)
509
508
  );
510
509
  }
511
510
 
@@ -514,18 +513,26 @@ export function loaderOwner(
514
513
  * does not hold that segment, and the loader is flagged itself or sits behind
515
514
  * the fallback of a flagged entry the client does not hold either. Under a
516
515
  * flagged layout the client holds, a new route's loaders run.
516
+ *
517
+ * `servesPin` (loader-cache.ts servesLoaderPin) answers whether this request
518
+ * serves the loader from a pin of a document-captured shell. Such a loader
519
+ * is not deferred: its body does not decide the value, and the pin is what
520
+ * the document shows. On every other request it would run, so it is
521
+ * deferred as on a route without ppr. Asked when loaders resolve, after a
522
+ * replay HIT has armed the pins (rsc-rendering.ts matchPartialWithPprReplay).
517
523
  */
518
524
  export function defersLoader(
519
525
  ctx: HandlerContext<any, any>,
520
526
  loaderEntry: LoaderEntry,
521
527
  segmentId: string,
528
+ servesPin: (loaderEntry: LoaderEntry) => boolean,
522
529
  ): boolean {
523
530
  const plan = getPrefetchDeferral(ctx);
524
531
  if (plan?.mode !== "prefetch" || plan.held.has(segmentId)) return false;
525
532
  const by = plan.scope.loaders.get(loaderEntry);
526
- return (
527
- by === true || (by !== undefined && by.some((id) => !plan.held.has(id)))
528
- );
533
+ const deferrable =
534
+ by === true || (by !== undefined && by.some((id) => !plan.held.has(id)));
535
+ return deferrable && !servesPin(loaderEntry);
529
536
  }
530
537
 
531
538
  /** A unit whose handler output is not stored: a prefetch can skip it. */
@@ -40,7 +40,7 @@ import {
40
40
  pushRevalidationTraceEntry,
41
41
  isTraceActive,
42
42
  } from "../logging.js";
43
- import { resolveLoaderData } from "./loader-cache.js";
43
+ import { resolveLoaderData, servesLoaderPin } from "./loader-cache.js";
44
44
  import {
45
45
  entryLoadingMasksLoaders,
46
46
  isShellCaptureActive,
@@ -65,6 +65,7 @@ import {
65
65
  noteThrower,
66
66
  } from "./helpers.js";
67
67
  import { applyViewTransitionDefault } from "./view-transition-default.js";
68
+ import { isRenderableLoading } from "../../types/boundaries.js";
68
69
  import { isLayoutLoaderCopy } from "../../client-urls/layout-loader-copies.js";
69
70
  import { getRouterContext } from "../router-context.js";
70
71
  import { observeEvent, observeSegmentHandler } from "../instrument.js";
@@ -74,6 +75,7 @@ import {
74
75
  track,
75
76
  RangoContext,
76
77
  runInsideLoaderScope,
78
+ runAsSegment,
77
79
  latchCachedHeaderScope,
78
80
  latchPprHeaderScopeForEntries,
79
81
  } from "../../server/context.js";
@@ -267,6 +269,28 @@ export function ownerOf(
267
269
  };
268
270
  }
269
271
 
272
+ /**
273
+ * A route's loader copies inherited into a child layout with parallel slots
274
+ * are redundant beside the route's own loader segments: true when the route
275
+ * renders no loading() fallback on any request, so nothing hides the route's
276
+ * copy from useLoader() in the slots. A redundant copy is never resolved, on
277
+ * any request: the walkers check this before they inherit.
278
+ *
279
+ * Decided from the entry, never from the route segment's `loading`, which
280
+ * differs between requests: `loading(fallback, { ssr: false })` is `null` on
281
+ * a document (applyDocumentModeLoading) and the fallback on partials, and the
282
+ * walkers emit `loading(false)` as `null` too. A copy sent on one request and
283
+ * not on the next gives the layout a loader entry it did not have, so
284
+ * renderSegments wraps it in StreamedLoaderErrorBoundary and React remounts
285
+ * it with its Outlet (docs/tree-structure.md). Resolving a redundant copy and
286
+ * dropping it from the payload later is not enough: when the route's own
287
+ * loader is held (its revalidate() declines), the copy is new to the client,
288
+ * so it re-runs and is sent without the route's copy beside it.
289
+ */
290
+ export function inheritedCopyRedundant(route: EntryData): boolean {
291
+ return !isRenderableLoading(route.loading) && route.loadingSsr !== false;
292
+ }
293
+
270
294
  /**
271
295
  * resolveOwnerLoaders has something to resolve for `entry`'s loaders. The
272
296
  * walker checks before the call: most entries own none, and the async call
@@ -526,10 +550,14 @@ export async function resolveOwnerLoaders<TEnv>(
526
550
  // Only a loader on a segment the client lacks (defersLoader). It
527
551
  // would have run. Registered, not started: it runs, and reads its
528
552
  // cache(), only if a reader starts it (startDeferredLoader).
529
- if (defersLoader(ctx, loaderEntry, segmentId)) {
553
+ if (defersLoader(ctx, loaderEntry, segmentId, servesLoaderPin)) {
530
554
  registerDeferredLoader(ctx, loader.$$id, shortCode, () =>
531
555
  kickoff(loaderEntry, segmentId),
532
556
  );
557
+ // A ppr shell record this request restored keeps an ssr: false
558
+ // loader's pushes as copies that wait for its run (loaderPins "hole").
559
+ // No run is made here: drop them, the fill sends the run's pushes.
560
+ _getRequestContext()?._handleStore?.settleLoaderRun(loader.$$id);
533
561
  const deferred: ResolvedSegment = {
534
562
  id: segmentId,
535
563
  namespace: owner.namespace,
@@ -656,7 +684,8 @@ export async function resolveLoadersOnly<TEnv>(
656
684
  entry.type === "route" &&
657
685
  entry.loader &&
658
686
  entry.loader.length > 0 &&
659
- Object.keys(layoutEntry.parallel).length > 0
687
+ Object.keys(layoutEntry.parallel).length > 0 &&
688
+ !inheritedCopyRedundant(entry)
660
689
  ) {
661
690
  const inherited = await resolveOwnerLoaders(
662
691
  pass,
@@ -666,7 +695,6 @@ export async function resolveLoadersOnly<TEnv>(
666
695
  for (const seg of inherited.segments) {
667
696
  if (!seenIds.has(seg.id)) {
668
697
  seenIds.add(seg.id);
669
- seg._inherited = true;
670
698
  allLoaderSegments.push(seg);
671
699
  }
672
700
  }
@@ -805,7 +833,7 @@ async function resolveParallelSlotComponent<TEnv>(
805
833
  // A throw before the slot streams reaches the owning entry's boundary.
806
834
  try {
807
835
  if (hasLoadingFallback) {
808
- const result = run();
836
+ const result = runAsSegment(parallelId, run);
809
837
  if (result instanceof Promise) {
810
838
  warnOnStreamedResponse(result, parallelId);
811
839
  result.finally(doneHandler).catch(() => {});
@@ -1174,7 +1202,9 @@ async function resolveRouteComponent<TEnv>(
1174
1202
  }
1175
1203
  if (!actionContext) {
1176
1204
  const result = handleHandlerResult(
1177
- observeSegmentHandler(entry.shortCode, entry.id, handler, context),
1205
+ runAsSegment(entry.shortCode, () =>
1206
+ observeSegmentHandler(entry.shortCode, entry.id, handler, context),
1207
+ ),
1178
1208
  );
1179
1209
  if (result instanceof Promise) {
1180
1210
  warnOnStreamedResponse(result, entry.id);
@@ -1475,17 +1505,14 @@ export async function resolveOrphanLayout<TEnv>(
1475
1505
  parentRouteEntry &&
1476
1506
  parentRouteEntry.loader &&
1477
1507
  parentRouteEntry.loader.length > 0 &&
1478
- Object.keys(orphan.parallel).length > 0
1508
+ Object.keys(orphan.parallel).length > 0 &&
1509
+ !inheritedCopyRedundant(parentRouteEntry)
1479
1510
  ) {
1480
1511
  const inheritedResult = await resolveOwnerLoaders(
1481
1512
  pass,
1482
1513
  ownerOf(parentRouteEntry, orphan.shortCode),
1483
1514
  belongsToRoute,
1484
1515
  );
1485
- // Tag as inherited so buildMatchResult can deduplicate when safe
1486
- for (const s of inheritedResult.segments) {
1487
- s._inherited = true;
1488
- }
1489
1516
  segments.push(...inheritedResult.segments);
1490
1517
  matchedIds.push(...inheritedResult.matchedIds);
1491
1518
  }
@@ -1505,7 +1532,8 @@ export async function resolveOrphanLayout<TEnv>(
1505
1532
  handlerRan = true;
1506
1533
  // Its handler's pushes are its own. Pinned only while it runs: a route
1507
1534
  // under loading() streams its handler, whose pushes after an await
1508
- // read the pin after this returns.
1535
+ // read the pin after this returns where a runtime lost its
1536
+ // runAsSegment scope (segmentPinOf).
1509
1537
  const internal = context as InternalHandlerContext<any, TEnv>;
1510
1538
  const pinned = internal._currentSegmentId;
1511
1539
  internal._currentSegmentId = orphan.shortCode;
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Recording what revalidate() rules read of the source page, so a prefetch
3
+ * answer a rule kept is shared between the pages with equal values of what
4
+ * was read (prefetch-source-scope.ts).
5
+ */
6
+
7
+ import {
8
+ createSourceReads,
9
+ formatSourceLabel,
10
+ mergeSourceReads,
11
+ PARAMS_SCOPE_PREFIX,
12
+ shapeOfReads,
13
+ type SourcePage,
14
+ type SourceReads,
15
+ } from "../prefetch-source-scope.js";
16
+
17
+ /**
18
+ * The source group of a match whose diff a source read decided: the reads
19
+ * every kept decision made, unioned, and the source page they read.
20
+ */
21
+ export interface SourceHeld extends SourcePage {
22
+ reads: SourceReads;
23
+ }
24
+
25
+ /**
26
+ * A copy of `params` whose reads land in `reads`: an enumerable getter per
27
+ * key, so a spread, Object.entries, JSON.stringify or structuredClone reads
28
+ * every key. Not a Proxy: structuredClone refuses one. A read of an absent
29
+ * key is not recorded and needs no record: the key set is part of the group
30
+ * (`reads.keys`), so a page in the group lacks that key too.
31
+ */
32
+ export function recordingParams(
33
+ params: Readonly<Record<string, string>>,
34
+ reads: SourceReads,
35
+ ): Record<string, string> {
36
+ reads.keys = true;
37
+ const view: Record<string, string> = {};
38
+ for (const name of Object.keys(params)) {
39
+ Object.defineProperty(view, name, {
40
+ enumerable: true,
41
+ configurable: true,
42
+ get() {
43
+ reads.params.add(name);
44
+ return params[name];
45
+ },
46
+ // A rule may write to the object, as to the plain one it replaces:
47
+ // what it wrote is its own value, not the source's.
48
+ set(value: string) {
49
+ Object.defineProperty(view, name, {
50
+ value,
51
+ writable: true,
52
+ enumerable: true,
53
+ configurable: true,
54
+ });
55
+ },
56
+ });
57
+ }
58
+ return view;
59
+ }
60
+
61
+ /**
62
+ * Add `reads` to the source group of the match `context` decides for. All
63
+ * of one match's decisions read the same source page; a caller that reads
64
+ * no route name passes none.
65
+ */
66
+ export function markSourceHeld(
67
+ context: { _sourceHeld?: SourceHeld },
68
+ reads: SourceReads,
69
+ page: SourcePage,
70
+ ): void {
71
+ const held = (context._sourceHeld ??= {
72
+ reads: createSourceReads(),
73
+ params: page.params,
74
+ routeName: undefined,
75
+ });
76
+ if (page.routeName !== undefined) held.routeName = page.routeName;
77
+ mergeSourceReads(held.reads, reads);
78
+ }
79
+
80
+ /**
81
+ * The `x-rsc-prefetch-scope` of a partial response, or undefined when every
82
+ * source page may share it.
83
+ *
84
+ * - `source`: the exact source page's. A route an intercept targets renders
85
+ * the modal or the full page depending on the source, so a full page
86
+ * prefetched where the intercept does not apply would otherwise serve a
87
+ * later click where it does (#1007); a response with deferred units
88
+ * answers one page's prefetch (`sourceOnly`); a kept rule that read
89
+ * `currentUrl` can depend on anything in it; and a source-held answer that
90
+ * sends nothing (`sentNothing`) commits treeless on the page that adopts
91
+ * it, which keeps its whole tree without checking it holds the ids the
92
+ * answer kept (partial-update.ts): only the source is known to hold them.
93
+ * - `params; <label>`: a revalidate() rule read the source's params or route
94
+ * name and kept a segment. A page that adopted the answer would keep its
95
+ * own copy of that segment (#1331), which is right only where the rule
96
+ * decides the same: on pages with equal values of what it read. The label
97
+ * names them (prefetch-source-scope.ts); the browser stores the answer
98
+ * under it. A page that lacks a kept id refetches (the commit's
99
+ * missing-segment check), as for a shared answer.
100
+ */
101
+ export function prefetchScopeOf(
102
+ sourceOnly: boolean,
103
+ held: SourceHeld | undefined,
104
+ sentNothing: boolean,
105
+ ): string | undefined {
106
+ if (sourceOnly) return "source";
107
+ if (!held) return undefined;
108
+ if (held.reads.url || sentNothing) return "source";
109
+ return (
110
+ PARAMS_SCOPE_PREFIX + formatSourceLabel(shapeOfReads(held.reads), held)
111
+ );
112
+ }
113
+
114
+ /** Every param of the source read: its params must equal the page's. */
115
+ export function readsOfAllParams(
116
+ params: Readonly<Record<string, string>>,
117
+ ): SourceReads {
118
+ const reads = createSourceReads();
119
+ reads.keys = true;
120
+ for (const name of Object.keys(params)) reads.params.add(name);
121
+ return reads;
122
+ }
@@ -91,10 +91,11 @@ export interface MatchApiDeps<TEnv = any> {
91
91
  findMatch: (pathname: string, ms?: any) => any;
92
92
  getMetricsStore: () => any;
93
93
  findInterceptForRoute: (
94
- routeKey: string,
94
+ routeKeys: string | readonly string[],
95
95
  parentEntry: EntryData | null,
96
96
  selectorContext: InterceptSelectorContext | null,
97
97
  isAction: boolean,
98
+ held?: ReadonlySet<string>,
98
99
  ) => { intercept: InterceptEntry; entry: EntryData } | null;
99
100
  callOnError: SegmentResolutionDeps<TEnv>["callOnError"];
100
101
  findNearestErrorBoundary: SegmentResolutionDeps<TEnv>["findNearestErrorBoundary"];
package/src/router.ts CHANGED
@@ -654,12 +654,21 @@ export function createRouter<TEnv = any>(
654
654
  viewTransitionDefault: viewTransitionOption,
655
655
  };
656
656
 
657
+ // Create segment resolution wrappers bound to segmentDeps
658
+ const {
659
+ resolveLoadersOnly,
660
+ buildEntryRevalidateMap,
661
+ resolveAllSegments,
662
+ findInterceptForRoute,
663
+ resolveInterceptEntry,
664
+ resolveInterceptLoadersOnly,
665
+ } = createSegmentWrappers<TEnv>(segmentDeps);
666
+
657
667
  // Match API dependencies
658
668
  const matchApiDeps: MatchApiDeps<TEnv> = {
659
669
  findMatch: (pathname: string, ms?: any) => findMatch(pathname, ms),
660
670
  getMetricsStore,
661
- findInterceptForRoute: (routeKey, parentEntry, selectorContext, isAction) =>
662
- findInterceptForRoute(routeKey, parentEntry, selectorContext, isAction),
671
+ findInterceptForRoute,
663
672
  callOnError,
664
673
  findNearestErrorBoundary,
665
674
  // Use per-router manifest when available, otherwise the static named map
@@ -667,16 +676,6 @@ export function createRouter<TEnv = any>(
667
676
  getRouteMap: () => getRouterManifest(routerId) ?? mergedRouteMap,
668
677
  };
669
678
 
670
- // Create segment resolution wrappers bound to segmentDeps
671
- const {
672
- resolveLoadersOnly,
673
- buildEntryRevalidateMap,
674
- resolveAllSegments,
675
- findInterceptForRoute,
676
- resolveInterceptEntry,
677
- resolveInterceptLoadersOnly,
678
- } = createSegmentWrappers<TEnv>(segmentDeps);
679
-
680
679
  // Lazy evaluation deps — captures closure state for extracted evaluateLazyEntry
681
680
  const lazyEvalDeps: LazyEvalDeps<TEnv> = {
682
681
  routesEntries,
@@ -10,7 +10,10 @@ import {
10
10
  setRequestContextParams,
11
11
  } from "../server/request-context.js";
12
12
  import type { RequestContext } from "../server/request-context.js";
13
- import { resolveLocationStateEntries } from "../browser/react/location-state-shared.js";
13
+ import {
14
+ resolveLocationStateEntries,
15
+ type LocationStateEntry,
16
+ } from "../browser/react/location-state-shared.js";
14
17
  import {
15
18
  isRedirectResponse,
16
19
  isWebSocketUpgradeResponse,
@@ -343,13 +346,15 @@ export function interceptRedirectForPartial(
343
346
  * is populated. A `document` payload (a page load's or a no-JS form post's
344
347
  * bootstrap payload) that carries state marks the request
345
348
  * `_documentLocationState`: it is the visitor's, so no shared cache keeps it
346
- * (server/document-location-state.ts).
349
+ * (server/document-location-state.ts). `locationState` defaults to the
350
+ * current request context's; a shell HIT's tail passes the state set before
351
+ * its render (rsc/rsc-rendering.ts serveShellHit).
347
352
  */
348
353
  export function attachLocationStateIfPresent(
349
354
  payload: RscPayload,
350
355
  document = false,
356
+ locationState: LocationStateEntry[] | undefined = getLocationState(),
351
357
  ): void {
352
- const locationState = getLocationState();
353
358
  if (locationState && locationState.length > 0) {
354
359
  payload.metadata!.locationState =
355
360
  resolveLocationStateEntries(locationState);
@@ -11,8 +11,13 @@
11
11
  * So the body ends cleanly with one more row, PREFETCH_FAILED_ROW, which the
12
12
  * Flight decoder skips and teeWithCompletion (browser/response-adapter.ts)
13
13
  * reads as a stream that did not end cleanly: the client evicts the prefetch
14
- * and never respawns it. Prefetch responses carry no Cache-Control, so the
15
- * browser HTTP cache keeps none of it either.
14
+ * and never respawns it. The router puts no Cache-Control of its own on a
15
+ * prefetch with a live part, the only kind whose render can report an error
16
+ * here, so the browser HTTP cache keeps none of it unless the route sets one;
17
+ * a prefetch with nothing live in it carries `private, max-age`
18
+ * (request-stages.ts applyStoredPrefetchCacheControl); a prefetch
19
+ * scoped to its source page or carrying deferred units is sent
20
+ * `private, no-cache` (rsc-rendering.ts NOT_REUSED).
16
21
  *
17
22
  * The errors are the render's `_renderErrors`: a Flight error row, a streamed
18
23
  * handler recovered into its boundary, a loader that threw (redirect() and
@@ -13,6 +13,7 @@ import {
13
13
  setRequestContextParams,
14
14
  } from "../server/request-context.js";
15
15
  import { printServerError } from "../server/print-server-error.js";
16
+ import { runActionBody } from "../server/cookie-store.js";
16
17
  import { createSsrHtmlStage } from "./ssr-setup.js";
17
18
  import type { MiddlewareFn } from "../router/middleware.js";
18
19
  import { executeMiddleware } from "../router/middleware.js";
@@ -187,7 +188,7 @@ async function runFormAction<TEnv>(
187
188
  // Meter the no-JS form action as the action phase, same as the JS path.
188
189
  actionResult = await observePhase(
189
190
  PHASES.action(useActionStateId ?? "useActionState"),
190
- () => boundAction(),
191
+ () => runActionBody(() => boundAction()),
191
192
  );
192
193
  } catch (error) {
193
194
  // Handle thrown redirect (e.g., throw redirect('/path'))
@@ -216,7 +217,7 @@ async function runFormAction<TEnv>(
216
217
  try {
217
218
  const loadedAction = await ctx.loadServerAction(directActionId);
218
219
  actionResult = await observePhase(PHASES.action(directActionId), () =>
219
- loadedAction.apply(null, args),
220
+ runActionBody(() => loadedAction.apply(null, args)),
220
221
  );
221
222
  } catch (error) {
222
223
  // Handle thrown redirect (e.g., throw redirect('/path'))
@@ -84,6 +84,9 @@ import {
84
84
  import { INTERNAL_RANGO_DEBUG } from "../internal-debug.js";
85
85
  import type { CacheErrorCategory } from "../cache/cache-error.js";
86
86
  import { WIRE } from "../wire-params.js";
87
+ import { isPrefetchKind } from "../router/segment-resolution/prefetch-deferral.js";
88
+ import { storedPrefetchCacheControl } from "../router/prefetch-cache-ttl.js";
89
+ import { carriesClientState } from "../cache/document-cache-policy.js";
87
90
  import {
88
91
  carryLocationStateAcrossRedirect,
89
92
  consumeDocumentLocationState,
@@ -103,6 +106,8 @@ export interface StageRouter<TEnv = unknown> {
103
106
  readonly onTimeout?: OnTimeoutCallback<TEnv>;
104
107
  /** Names the cookie location state crosses a document redirect in. */
105
108
  readonly resolvedStateCookieName: string;
109
+ /** The in-memory prefetch TTL in ms; also a stored prefetch's max-age. */
110
+ readonly prefetchCacheTTL: number;
106
111
  }
107
112
 
108
113
  /** Report an error through the router's onError. */
@@ -682,6 +687,57 @@ export interface FinalizeRequestOptions<TEnv> {
682
687
  beforeGuard?: (response: Response) => void;
683
688
  }
684
689
 
690
+ /**
691
+ * A prefetch with nothing live in it (`_servedFromStorage`: a document cache
692
+ * HIT, or a partial whose every sent segment came from storage and ran no
693
+ * loader) leaves with the router's prefetch Cache-Control, so the browser
694
+ * HTTP cache can answer a later prefetch or the click with it.
695
+ * Nothing in such an answer can fail after its 200 left; any other prefetch
696
+ * carries none (#1170). Set here, after every middleware: the document cache
697
+ * reads the route's own header when it stores the response (s-maxage), the
698
+ * browser gets this one, in place of any other the route set.
699
+ *
700
+ * Left as it is: a partial sent NOT_REUSED (scoped to its source page, or
701
+ * carrying deferred units), a header that forbids storing it (`no-store`,
702
+ * `no-cache`, a zero `max-age`), a redirect a middleware returned after the render (`redirect`,
703
+ * converted to a Flight redirect above), and a response carrying one
704
+ * client's state (a per-client signal header, location state), which a
705
+ * stored copy would replay to later requests.
706
+ */
707
+ function applyStoredPrefetchCacheControl(
708
+ response: Response,
709
+ reqCtx: RequestContext<any>,
710
+ url: URL,
711
+ router: StageRouter<any>,
712
+ redirect: boolean,
713
+ ): void {
714
+ if (
715
+ redirect ||
716
+ !reqCtx._servedFromStorage ||
717
+ !isPrefetchKind(reqCtx._requestKind) ||
718
+ !url.searchParams.has(WIRE.partial) ||
719
+ reqCtx._payloadSourceScoped ||
720
+ reqCtx._payloadDeferred ||
721
+ response.status !== 200 ||
722
+ !response.headers.get("content-type")?.startsWith("text/x-component") ||
723
+ FORBIDS_STORE.test(response.headers.get("cache-control") ?? "") ||
724
+ carriesClientState(response, reqCtx)
725
+ ) {
726
+ return;
727
+ }
728
+ const cacheControl = storedPrefetchCacheControl(router.prefetchCacheTTL);
729
+ if (cacheControl) trySetHeader(response, "cache-control", cacheControl);
730
+ }
731
+
732
+ /**
733
+ * A Cache-Control directive that forbids reusing the response unvalidated:
734
+ * `no-store`, `no-cache`, or a zero `max-age` (`s-maxage` is the shared
735
+ * caches' and does not match). The route asked for a fresh answer each time;
736
+ * the router's `max-age` would override it.
737
+ */
738
+ const FORBIDS_STORE =
739
+ /(^|[\s,;])(no-store|no-cache|max-age=["']?0+["']?)(?=$|[\s,;=])/i;
740
+
685
741
  /**
686
742
  * The response the global chain returned, made ready to leave: a partial or
687
743
  * action request's redirect converted, onResponse callbacks drained, the
@@ -694,6 +750,7 @@ export function finalizeRequestResponse<TEnv>(
694
750
  const { ctx, reqCtx, request, url, env } = opts;
695
751
  const router = ctx.router;
696
752
  let response: Response;
753
+ let redirect = false;
697
754
  if (opts.hasMiddleware) {
698
755
  if (
699
756
  url.searchParams.has(WIRE.partial) ||
@@ -704,6 +761,7 @@ export function finalizeRequestResponse<TEnv>(
704
761
  ctx.createRedirectFlightResponse,
705
762
  { requestOrigin: url.origin, basename: router.basename },
706
763
  );
764
+ redirect = intercepted !== null;
707
765
  response = intercepted ?? finalizeResponse(downstream);
708
766
  } else {
709
767
  response = finalizeResponse(downstream);
@@ -715,6 +773,7 @@ export function finalizeRequestResponse<TEnv>(
715
773
  if (opts.rewrittenTo !== undefined) {
716
774
  trySetHeader(response, REWRITE_HEADER, opts.rewrittenTo);
717
775
  }
776
+ applyStoredPrefetchCacheControl(response, reqCtx, url, router, redirect);
718
777
  opts.beforeGuard?.(response);
719
778
 
720
779
  // Single open-redirect chokepoint: every response (PE, full-page,