@rangojs/router 0.31.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 (192) 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 -10143
  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/history-state.d.ts +13 -2
  19. package/dist/types/browser/invalidate-client-cache.d.ts +8 -6
  20. package/dist/types/browser/navigation-bridge.d.ts +36 -1
  21. package/dist/types/browser/navigation-client.d.ts +19 -0
  22. package/dist/types/browser/navigation-store-handle.d.ts +37 -4
  23. package/dist/types/browser/navigation-store.d.ts +2 -2
  24. package/dist/types/browser/navigation-transaction.d.ts +0 -1
  25. package/dist/types/browser/partial-update.d.ts +13 -0
  26. package/dist/types/browser/prefetch/cache.d.ts +96 -8
  27. package/dist/types/browser/prefetch/fetch.d.ts +26 -2
  28. package/dist/types/browser/prefetch/queue.d.ts +3 -2
  29. package/dist/types/browser/rango-state.d.ts +34 -5
  30. package/dist/types/browser/react/Link.d.ts +19 -13
  31. package/dist/types/browser/react/location-state-shared.d.ts +2 -1
  32. package/dist/types/browser/types.d.ts +25 -0
  33. package/dist/types/build/generate-route-types.d.ts +1 -1
  34. package/dist/types/build/route-types/include-resolution.d.ts +9 -1
  35. package/dist/types/build/route-types/router-processing.d.ts +3 -1
  36. package/dist/types/cache/cache-key-utils.d.ts +12 -8
  37. package/dist/types/cache/cache-scope.d.ts +4 -2
  38. package/dist/types/cache/cf/cf-cache-types.d.ts +9 -5
  39. package/dist/types/cache/document-cache-policy.d.ts +9 -1
  40. package/dist/types/cache/memory-segment-store.d.ts +3 -2
  41. package/dist/types/cache/segment-codec.d.ts +2 -2
  42. package/dist/types/cache/shell-snapshot.d.ts +1 -1
  43. package/dist/types/cache/types.d.ts +12 -7
  44. package/dist/types/cache/vercel/vercel-cache-store.d.ts +2 -1
  45. package/dist/types/client-urls/types.d.ts +7 -5
  46. package/dist/types/prefetch-markers.d.ts +9 -0
  47. package/dist/types/prefetch-source-scope.d.ts +66 -0
  48. package/dist/types/route-definition/helpers-types.d.ts +8 -5
  49. package/dist/types/route-definition/redirect.d.ts +5 -0
  50. package/dist/types/router/intercept-resolution.d.ts +25 -11
  51. package/dist/types/router/match-context.d.ts +1 -0
  52. package/dist/types/router/match-handlers.d.ts +1 -1
  53. package/dist/types/router/middleware-types.d.ts +6 -0
  54. package/dist/types/router/prefetch-cache-ttl.d.ts +11 -3
  55. package/dist/types/router/router-context.d.ts +1 -1
  56. package/dist/types/router/router-options.d.ts +6 -3
  57. package/dist/types/router/segment-resolution/loader-cache.d.ts +8 -0
  58. package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +23 -7
  59. package/dist/types/router/segment-resolution/revalidation.d.ts +19 -0
  60. package/dist/types/router/source-reads.d.ts +53 -0
  61. package/dist/types/router/types.d.ts +1 -1
  62. package/dist/types/rsc/helpers.d.ts +8 -3
  63. package/dist/types/rsc/prefetch-render-error.d.ts +7 -2
  64. package/dist/types/rsc/request-stages.d.ts +4 -0
  65. package/dist/types/rsc/shell-serve.d.ts +6 -1
  66. package/dist/types/server/context.d.ts +23 -6
  67. package/dist/types/server/cookie-store.d.ts +15 -1
  68. package/dist/types/server/document-location-state.d.ts +59 -0
  69. package/dist/types/server/handle-store.d.ts +2 -0
  70. package/dist/types/server/request-context.d.ts +65 -12
  71. package/dist/types/testing/e2e/server.d.ts +16 -1
  72. package/dist/types/testing/render-route.d.ts +83 -2
  73. package/dist/types/testing/serve-shell-request.d.ts +15 -3
  74. package/dist/types/types/handler-context.d.ts +21 -4
  75. package/dist/types/types/loader-types.d.ts +7 -4
  76. package/dist/types/types/segments.d.ts +18 -1
  77. package/dist/types/urls/path-helper-types.d.ts +7 -5
  78. package/dist/types/vite/temp-rsc-server.d.ts +2 -2
  79. package/dist/types/vite/utils/forward-user-plugins.d.ts +32 -2
  80. package/dist/vite/index.js +451 -191
  81. package/package.json +2 -2
  82. package/skills/app-structure/SKILL.md +74 -0
  83. package/skills/app-structure/e-commerce.md +10 -9
  84. package/skills/app-structure/large-app.md +15 -2
  85. package/skills/caching/SKILL.md +10 -5
  86. package/skills/client-urls/SKILL.md +18 -11
  87. package/skills/comparison/references/framework-comparison.md +16 -10
  88. package/skills/document-cache/SKILL.md +13 -0
  89. package/skills/hooks/navigation.md +8 -8
  90. package/skills/hooks/state.md +132 -12
  91. package/skills/intercept/SKILL.md +33 -11
  92. package/skills/layout/SKILL.md +11 -0
  93. package/skills/links/SKILL.md +101 -4
  94. package/skills/loader/SKILL.md +49 -22
  95. package/skills/migrate-nextjs/SKILL.md +8 -2
  96. package/skills/migrate-react-router/data-and-actions.md +5 -0
  97. package/skills/ppr/SKILL.md +38 -5
  98. package/skills/prerender/SKILL.md +4 -1
  99. package/skills/route/SKILL.md +6 -6
  100. package/skills/router-setup/SKILL.md +2 -1
  101. package/skills/server-actions/SKILL.md +49 -8
  102. package/skills/testing/client-components.md +73 -32
  103. package/skills/testing/serve-shell-request.md +45 -20
  104. package/skills/testing/server-actions.md +8 -0
  105. package/skills/ux-architecture/SKILL.md +67 -15
  106. package/src/bin/rango.ts +39 -13
  107. package/src/browser/action-fence.ts +72 -0
  108. package/src/browser/cookie-name.ts +82 -14
  109. package/src/browser/history-state.ts +24 -2
  110. package/src/browser/invalidate-client-cache.ts +9 -7
  111. package/src/browser/navigation-bridge.ts +164 -32
  112. package/src/browser/navigation-client.ts +95 -20
  113. package/src/browser/navigation-store-handle.ts +67 -5
  114. package/src/browser/navigation-store.ts +86 -17
  115. package/src/browser/navigation-transaction.ts +0 -6
  116. package/src/browser/partial-update.ts +31 -12
  117. package/src/browser/prefetch/cache.ts +188 -11
  118. package/src/browser/prefetch/fetch.ts +211 -51
  119. package/src/browser/prefetch/queue.ts +3 -2
  120. package/src/browser/rango-state.ts +50 -8
  121. package/src/browser/react/Link.tsx +24 -16
  122. package/src/browser/react/location-state-shared.ts +8 -1
  123. package/src/browser/react/use-navigation.ts +1 -1
  124. package/src/browser/rsc-router.tsx +13 -3
  125. package/src/browser/server-action-bridge.ts +3 -3
  126. package/src/browser/types.ts +25 -0
  127. package/src/build/generate-route-types.ts +1 -0
  128. package/src/build/route-types/include-resolution.ts +40 -3
  129. package/src/build/route-types/router-processing.ts +170 -25
  130. package/src/build/route-types/scan-filter.ts +1 -0
  131. package/src/build/runtime-discovery.ts +46 -8
  132. package/src/cache/cache-key-utils.ts +12 -8
  133. package/src/cache/cache-runtime.ts +5 -1
  134. package/src/cache/cache-scope.ts +28 -7
  135. package/src/cache/cf/cf-cache-types.ts +9 -5
  136. package/src/cache/document-cache-policy.ts +37 -1
  137. package/src/cache/document-cache.ts +12 -0
  138. package/src/cache/memory-segment-store.ts +3 -2
  139. package/src/cache/segment-codec.ts +2 -2
  140. package/src/cache/shell-snapshot.ts +1 -1
  141. package/src/cache/types.ts +12 -7
  142. package/src/cache/vercel/vercel-cache-store.ts +2 -1
  143. package/src/client-urls/types.ts +7 -5
  144. package/src/prefetch-markers.ts +10 -0
  145. package/src/prefetch-source-scope.ts +137 -0
  146. package/src/route-definition/helpers-types.ts +8 -5
  147. package/src/route-definition/redirect.ts +5 -0
  148. package/src/router/intercept-resolution.ts +94 -50
  149. package/src/router/loader-resolution.ts +8 -6
  150. package/src/router/match-api.ts +33 -29
  151. package/src/router/match-context.ts +5 -0
  152. package/src/router/match-handlers.ts +2 -1
  153. package/src/router/match-middleware/cache-lookup.ts +39 -14
  154. package/src/router/match-result.ts +45 -85
  155. package/src/router/middleware-types.ts +6 -0
  156. package/src/router/navigation-snapshot.ts +18 -3
  157. package/src/router/prefetch-cache-ttl.ts +15 -3
  158. package/src/router/revalidation.ts +63 -13
  159. package/src/router/router-context.ts +2 -1
  160. package/src/router/router-options.ts +6 -3
  161. package/src/router/segment-resolution/loader-cache.ts +21 -3
  162. package/src/router/segment-resolution/prefetch-deferral.ts +38 -31
  163. package/src/router/segment-resolution/revalidation.ts +40 -12
  164. package/src/router/source-reads.ts +122 -0
  165. package/src/router/types.ts +2 -1
  166. package/src/router.ts +11 -12
  167. package/src/rsc/helpers.ts +20 -6
  168. package/src/rsc/prefetch-render-error.ts +7 -2
  169. package/src/rsc/progressive-enhancement.ts +8 -2
  170. package/src/rsc/request-stages.ts +87 -1
  171. package/src/rsc/rsc-rendering.ts +78 -32
  172. package/src/rsc/server-action.ts +2 -1
  173. package/src/rsc/shell-capture.ts +4 -5
  174. package/src/rsc/shell-serve.ts +12 -1
  175. package/src/segment-system.tsx +1 -1
  176. package/src/server/context.ts +37 -6
  177. package/src/server/cookie-store.ts +22 -1
  178. package/src/server/document-location-state.ts +334 -0
  179. package/src/server/handle-store.ts +2 -0
  180. package/src/server/request-context.ts +137 -33
  181. package/src/testing/dispatch.ts +6 -0
  182. package/src/testing/e2e/fixture.ts +4 -12
  183. package/src/testing/e2e/server.ts +91 -4
  184. package/src/testing/render-route.tsx +309 -10
  185. package/src/testing/run-revalidate.ts +11 -1
  186. package/src/testing/serve-shell-request.ts +22 -3
  187. package/src/types/handler-context.ts +21 -4
  188. package/src/types/loader-types.ts +7 -4
  189. package/src/types/segments.ts +18 -1
  190. package/src/urls/path-helper-types.ts +7 -5
  191. package/src/vite/temp-rsc-server.ts +2 -2
  192. package/src/vite/utils/forward-user-plugins.ts +13 -5
@@ -117,7 +117,9 @@ function getShownRouteUrl(url: URL): URL | null {
117
117
  if (!shown) return null;
118
118
  try {
119
119
  const parsed = new URL(shown, url.origin);
120
- return parsed.origin === url.origin ? formSearch(parsed) : null;
120
+ return parsed.origin === url.origin
121
+ ? withoutHash(formSearch(parsed))
122
+ : null;
121
123
  } catch {
122
124
  return null;
123
125
  }
@@ -134,6 +136,19 @@ function formSearch(url: URL): URL {
134
136
  return url;
135
137
  }
136
138
 
139
+ /**
140
+ * `url` without its `#fragment`. The browser sends its whole address as the
141
+ * client path, anchor included, but never a fragment in a request URL, so the
142
+ * server's `nextUrl` has none. Left on the page-left URLs, `currentUrl` and
143
+ * `nextUrl` of one address differ (`/t/42#questions` vs `/t/42`) and a
144
+ * `revalidate()` rule comparing them reads two addresses. A fragment is never
145
+ * server state: it names no route, param or query.
146
+ */
147
+ function withoutHash(url: URL): URL {
148
+ url.hash = "";
149
+ return url;
150
+ }
151
+
137
152
  export async function resolveNavigation(
138
153
  request: Request,
139
154
  url: URL,
@@ -155,7 +170,7 @@ export async function resolveNavigation(
155
170
 
156
171
  let visitor: URL;
157
172
  try {
158
- visitor = formSearch(new URL(previousUrl, url.origin));
173
+ visitor = withoutHash(formSearch(new URL(previousUrl, url.origin)));
159
174
  } catch {
160
175
  return null;
161
176
  }
@@ -166,7 +181,7 @@ export async function resolveNavigation(
166
181
  let interceptContextUrl: URL;
167
182
  try {
168
183
  interceptContextUrl = interceptSourceUrl
169
- ? new URL(interceptSourceUrl, url.origin)
184
+ ? withoutHash(new URL(interceptSourceUrl, url.origin))
170
185
  : prevUrl;
171
186
  } catch {
172
187
  interceptContextUrl = prevUrl;
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Resolve the prefetch cache TTL once, at router init, into the values the
3
3
  * rest of the router consumes: seconds, and milliseconds for the client-side
4
- * in-memory prefetch cache (shipped in payload metadata). Prefetch responses
5
- * carry no Cache-Control: the browser HTTP cache would keep a prefetch whose
6
- * render failed after its 200 went out (#1170).
4
+ * in-memory prefetch cache (shipped in payload metadata). A prefetch with a
5
+ * live part carries no Cache-Control: the browser HTTP cache would keep a
6
+ * prefetch whose render failed after its 200 went out (#1170). One answered
7
+ * from storage carries storedPrefetchCacheControl.
7
8
  *
8
9
  * `false` disables prefetch caching (seconds 0). A non-finite input
9
10
  * (NaN/Infinity, e.g. from an env-derived number that failed to parse) is
@@ -41,3 +42,14 @@ export function resolvePrefetchCacheTTL(
41
42
 
42
43
  return { seconds, ms: seconds * 1000 };
43
44
  }
45
+
46
+ /**
47
+ * The Cache-Control of a prefetch answer with nothing live in it
48
+ * (`_servedFromStorage`): the browser HTTP cache keeps it as long
49
+ * as the in-memory prefetch cache would. Undefined when prefetch caching is
50
+ * off.
51
+ */
52
+ export function storedPrefetchCacheControl(ttlMs: number): string | undefined {
53
+ const seconds = Math.floor(ttlMs / 1000);
54
+ return seconds > 0 ? `private, max-age=${seconds}` : undefined;
55
+ }
@@ -7,6 +7,7 @@
7
7
  import type {
8
8
  ResolvedSegment,
9
9
  HandlerContext,
10
+ InternalHandlerContext,
10
11
  ShouldRevalidateFn,
11
12
  } from "../types";
12
13
  import type { ActionContext } from "./types";
@@ -20,6 +21,8 @@ import { _getRequestContext } from "../server/request-context.js";
20
21
  import { isAutoGeneratedRouteName } from "../route-name.js";
21
22
  import { paramsEqual } from "./params-util.js";
22
23
  import { makeIsAction } from "./is-action.js";
24
+ import { createSourceReads, hasSourceReads } from "../prefetch-source-scope.js";
25
+ import { markSourceHeld, recordingParams } from "./source-reads.js";
23
26
 
24
27
  const REVALIDATE_ALWAYS: ShouldRevalidateFn<any, any> = () => true;
25
28
  const REVALIDATE_NEVER: ShouldRevalidateFn<any, any> = () => false;
@@ -154,17 +157,54 @@ export async function evaluateRevalidation<TEnv>(
154
157
  // second, contradicting entry would let flushRevalidationTrace count the
155
158
  // segment as both revalidated and skipped.
156
159
  const floorTrue = defaultOverride?.floor === true && defaultOverride.value;
157
- function decide(finalVal: boolean, reason: string): boolean {
160
+ // The source page the predicates see.
161
+ let currentParams: Record<string, string> = prevParams;
162
+ const prevRouteKey = reqCtx?._prevRouteKey;
163
+ const fromRouteName =
164
+ prevRouteKey && !isAutoGeneratedRouteName(prevRouteKey)
165
+ ? prevRouteKey
166
+ : undefined;
167
+ // What the predicates read of the page navigated away from. A segment they
168
+ // keep then stays held for the pages with equal values of those reads only:
169
+ // a prefetch of the answer, adopted on a page with other values, would keep
170
+ // that page's copy (#1331). A re-render is source-safe, it sends the
171
+ // segment. Reads before a throw count: the fallback is decided after them.
172
+ const reads = createSourceReads();
173
+ let recordedParams: Record<string, string> | undefined;
174
+ // A rule read `defaultShouldRevalidate`.
175
+ let readDefault = false;
176
+ /** `usedDefault`: the decision is the default's, or a rule read it. */
177
+ function decide(
178
+ finalVal: boolean,
179
+ reason: string,
180
+ usedDefault: boolean,
181
+ ): boolean {
158
182
  if (floorTrue && !finalVal) {
159
183
  pushTrace(defaultShouldRevalidate, true, `${reason}:floored`);
160
184
  return true;
161
185
  }
186
+ if (!finalVal && hasSourceReads(reads)) {
187
+ // The navigation default of a route segment, and of a segment the route
188
+ // owns, compares the target's params and search with the source's, and
189
+ // no read records that: a page grouped by the rules' reads but with
190
+ // other params would adopt the keep. Seen from a page whose params
191
+ // equal a rewritten target's (/en/p/1 prefetching /deals, which renders
192
+ // /en/p/1): grouped by locale, /en/p/2 adopted the answer and kept
193
+ // product 2 at /deals.
194
+ if (usedDefault && defaultComparesSource) reads.url = true;
195
+ markSourceHeld(context as InternalHandlerContext<any, TEnv>, reads, {
196
+ params: currentParams,
197
+ routeName: fromRouteName,
198
+ });
199
+ }
162
200
  pushTrace(defaultShouldRevalidate, finalVal, reason);
163
201
  return finalVal;
164
202
  }
165
203
 
166
204
  let defaultShouldRevalidate: boolean;
167
205
  let defaultReason: string;
206
+ // The default hangs on paramsChanged / searchChanged (the nav lane below).
207
+ let defaultComparesSource = false;
168
208
 
169
209
  if (defaultOverride) {
170
210
  defaultShouldRevalidate = defaultOverride.value;
@@ -188,6 +228,8 @@ export async function evaluateRevalidation<TEnv>(
188
228
  defaultReason = reasons.parentChain;
189
229
  }
190
230
  } else {
231
+ defaultComparesSource =
232
+ segment.type === "route" || segment.belongsToRoute === true;
191
233
  if (segment.type === "route") {
192
234
  const routeChanged = paramsChanged || searchChanged;
193
235
  defaultShouldRevalidate = routeChanged;
@@ -236,20 +278,16 @@ export async function evaluateRevalidation<TEnv>(
236
278
  segmentId: segment.id,
237
279
  });
238
280
  }
239
- return decide(defaultShouldRevalidate, defaultReason);
281
+ return decide(defaultShouldRevalidate, defaultReason, true);
240
282
  }
241
283
 
242
284
  const prevSegment = getPrevSegment ? await getPrevSegment() : null;
285
+ currentParams = prevSegment?.params || prevParams;
243
286
 
244
287
  let currentSuggestion = defaultShouldRevalidate;
245
288
 
246
289
  const toRouteName =
247
290
  routeKey && !isAutoGeneratedRouteName(routeKey) ? routeKey : undefined;
248
- const prevRouteKey = reqCtx?._prevRouteKey;
249
- const fromRouteName =
250
- prevRouteKey && !isAutoGeneratedRouteName(prevRouteKey)
251
- ? prevRouteKey
252
- : undefined;
253
291
 
254
292
  const isActionFn = makeIsAction(actionContext?.actionId);
255
293
 
@@ -257,11 +295,19 @@ export async function evaluateRevalidation<TEnv>(
257
295
  let result: any;
258
296
  try {
259
297
  result = fn({
260
- currentParams: prevSegment?.params || prevParams, // Use segment params if available, else route params
261
- currentUrl: prevUrl,
298
+ get currentParams() {
299
+ return (recordedParams ??= recordingParams(currentParams, reads));
300
+ },
301
+ get currentUrl() {
302
+ reads.url = true;
303
+ return prevUrl;
304
+ },
262
305
  nextParams,
263
306
  nextUrl,
264
- defaultShouldRevalidate: currentSuggestion,
307
+ get defaultShouldRevalidate() {
308
+ readDefault = true;
309
+ return currentSuggestion;
310
+ },
265
311
  context,
266
312
  // Segment metadata (which segment is being evaluated)
267
313
  segmentType: segment.type,
@@ -275,7 +321,10 @@ export async function evaluateRevalidation<TEnv>(
275
321
  formData: actionContext?.formData,
276
322
  method: request.method,
277
323
  routeName: toRouteName,
278
- fromRouteName,
324
+ get fromRouteName() {
325
+ reads.route = true;
326
+ return fromRouteName;
327
+ },
279
328
  toRouteName,
280
329
  stale: cacheStale ? true : stale,
281
330
  });
@@ -326,7 +375,7 @@ export async function evaluateRevalidation<TEnv>(
326
375
  revalidator: name,
327
376
  revalidate: result,
328
377
  });
329
- return decide(result, `hard:${name}`);
378
+ return decide(result, `hard:${name}`, readDefault);
330
379
  } else if (
331
380
  result &&
332
381
  typeof result === "object" &&
@@ -352,5 +401,6 @@ export async function evaluateRevalidation<TEnv>(
352
401
  revalidate: currentSuggestion,
353
402
  });
354
403
  const softNames = revalidations.map((r) => r.name).join(",");
355
- return decide(currentSuggestion, `soft-chain:${softNames}`);
404
+ // A soft result may carry the default it read on: counted as the default's.
405
+ return decide(currentSuggestion, `soft-chain:${softNames}`, true);
356
406
  }
@@ -94,10 +94,11 @@ export interface RouterContext<TEnv = any> {
94
94
  ) => CacheScope | null;
95
95
 
96
96
  findInterceptForRoute: (
97
- routeKey: string,
97
+ routeKeys: string | readonly string[],
98
98
  parentEntry: EntryData | null,
99
99
  selectorContext: InterceptSelectorContext,
100
100
  isAction: boolean,
101
+ held?: ReadonlySet<string>,
101
102
  ) => InterceptResult | null;
102
103
 
103
104
  /** The segment walker; a full match passes freshPassInput (FRESH). */
@@ -535,9 +535,12 @@ export interface RangoOptions<TEnv = any> {
535
535
  * TTL (in seconds) for the client-side in-memory prefetch cache.
536
536
  *
537
537
  * Controls how long prefetch responses are kept in the client-side
538
- * in-memory cache. Prefetch responses carry no `Cache-Control`, so the
539
- * browser's HTTP cache does not keep them: a prefetch whose render failed
540
- * after its 200 went out would otherwise answer later requests.
538
+ * in-memory cache. A prefetch with nothing live in it (a document cache
539
+ * HIT, or every sent segment from a `cache()` record or a Prerender entry,
540
+ * with no loader) also carries `Cache-Control: private, max-age=<ttl>`,
541
+ * so the browser's HTTP cache keeps it as long. Any other prefetch carries
542
+ * none: a prefetch whose render failed after its 200 went out would
543
+ * otherwise answer later requests.
541
544
  *
542
545
  * The cache is automatically invalidated on server actions regardless
543
546
  * of TTL, so this is primarily a staleness safety net.
@@ -285,9 +285,10 @@ export function routeLoaderLanes(
285
285
  * records. A capture serves none: its bake-lane loaders execute.
286
286
  *
287
287
  * The one answer to "is this loader served from the shell record on this
288
- * request", for its value (resolveLoaderData) and for the record's copies of
289
- * its handle pushes (loaderPins): both look the loader up here and nowhere
290
- * else. A condition that makes pins, or one loader's pin, not apply belongs
288
+ * request", for its value (resolveLoaderData), for the record's copies of
289
+ * its handle pushes (loaderPins) and for whether a prefetch defers it
290
+ * (servesLoaderPin): all look the loader up here and nowhere else. A
291
+ * condition that makes pins, or one loader's pin, not apply belongs
291
292
  * here or in the seed's decode (shell-snapshot.ts buildShellLoaderSeed),
292
293
  * never after a lookup, or the two disagree: issues #1001 and #1003 were the
293
294
  * pushes decided from the request's type while the value was decided from
@@ -299,6 +300,23 @@ function servedPins(
299
300
  return isShellCaptureActive(reqCtx) ? undefined : reqCtx?._shellLoaderSeed;
300
301
  }
301
302
 
303
+ /**
304
+ * This request serves `loaderEntry` from a pin (servedPins), so its body does
305
+ * not decide the value: resolveLoaderData serves a pin to an `ssr: false`
306
+ * registration only. A prefetch does not defer such a loader
307
+ * (prefetch-deferral.ts defersLoader): nothing would run, and the pin is the
308
+ * value the document shows.
309
+ */
310
+ export function servesLoaderPin(
311
+ loaderEntry: LoaderEntry,
312
+ reqCtx: RequestContext<any> | undefined = _getRequestContext(),
313
+ ): boolean {
314
+ return (
315
+ loaderEntry.bake === true &&
316
+ servedPins(reqCtx)?.has(loaderEntry.loader.$$id) === true
317
+ );
318
+ }
319
+
302
320
  /**
303
321
  * What a record restored on a request over the matched chain `entries` is to
304
322
  * each loader's handle pushes (HandleStore RecordAuthority), from the pins
@@ -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;