@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
@@ -239,6 +239,11 @@ export interface MatchPipelineState {
239
239
  // Whether cache should be revalidated (SWR)
240
240
  shouldRevalidate?: boolean;
241
241
 
242
+ // Whether the prerender entry that answered is past its soft staleAt: it
243
+ // serves while a refresh is due, so it is not what the page will hold once
244
+ // that lands (MatchResult.fromStorage counts it live)
245
+ storedStale?: boolean;
246
+
242
247
  // Source of cache hit ("runtime" or "prerender")
243
248
  cacheSource?: "runtime" | "prerender";
244
249
 
@@ -155,10 +155,11 @@ export interface MatchHandlerDeps<TEnv = any> {
155
155
  defaultErrorBoundary: ReactNode | ErrorBoundaryHandler | undefined;
156
156
  findMatch: (pathname: string, ms?: any) => any;
157
157
  findInterceptForRoute: (
158
- routeKey: string,
158
+ routeKeys: string | readonly string[],
159
159
  parentEntry: EntryData | null,
160
160
  selectorContext: InterceptSelectorContext | null,
161
161
  isAction: boolean,
162
+ held?: ReadonlySet<string>,
162
163
  ) => { intercept: InterceptEntry; entry: EntryData } | null;
163
164
  telemetry?: TelemetrySink;
164
165
  /**
@@ -83,6 +83,7 @@ import {
83
83
  import { ShellRecordUnavailableError } from "../../cache/shell-snapshot.js";
84
84
  import { prerenderStoreShortCircuits } from "../navigation-snapshot.js";
85
85
  import { paramsEqual } from "../params-util.js";
86
+ import { markSourceHeld, readsOfAllParams } from "../source-reads.js";
86
87
  import { requestHeaders } from "../../server/request-headers.js";
87
88
  import { isInterceptSegment } from "../../browser/intercept-utils.js";
88
89
  import {
@@ -144,7 +145,8 @@ async function ensurePrerenderDeps(): Promise<void> {
144
145
  * - `prerender-build`: the bundled build manifest (immutable).
145
146
  * - `prerender-overlay`: the writable on-demand overlay. Its entry may be
146
147
  * newer than the client's copy: one that a refresh (possibly inside this
147
- * very action) replaced.
148
+ * very action) replaced. `stale`: it is past its soft `staleAt` (serves
149
+ * while a refresh is due).
148
150
  */
149
151
  type StoredSegmentSource =
150
152
  | {
@@ -154,7 +156,8 @@ type StoredSegmentSource =
154
156
  scope: CacheScope;
155
157
  restoreHandles?: (sentIds: ReadonlySet<string>) => Promise<void>;
156
158
  }
157
- | { kind: "prerender-build" | "prerender-overlay"; entry: PrerenderEntry };
159
+ | { kind: "prerender-build"; entry: PrerenderEntry }
160
+ | { kind: "prerender-overlay"; entry: PrerenderEntry; stale: boolean };
158
161
 
159
162
  /**
160
163
  * Decode a prerender entry's segments and replay its handle data into the
@@ -339,12 +342,15 @@ function storedEntrySends<TEnv>(
339
342
  ctx.clientSegmentSet.size > 0
340
343
  ? router.buildEntryRevalidateMap?.(ctx.entries)
341
344
  : undefined;
342
- // A keep that hangs on the source's params is right for this source only:
343
- // scope a prefetch of the answer to it, as a predicate that read the
344
- // source would (#1331).
345
+ // A keep that hangs on the source's params is right for the pages with
346
+ // the same params only: scope a prefetch of the answer to them, as a
347
+ // predicate that compared every param would (#1331).
345
348
  const heldForSource = (): false => {
346
- (ctx.handlerContext as InternalHandlerContext<any, TEnv>)._sourceHeld =
347
- true;
349
+ markSourceHeld(
350
+ ctx.handlerContext as InternalHandlerContext<any, TEnv>,
351
+ readsOfAllParams(ctx.prevParams),
352
+ { params: ctx.prevParams, routeName: undefined },
353
+ );
348
354
  return false;
349
355
  };
350
356
  const decide = (segment: ResolvedSegment): boolean | Promise<boolean> => {
@@ -563,6 +569,11 @@ async function* serveStoredSegments<TEnv>(
563
569
  state.cacheHit = true;
564
570
  state.cacheSource = fromCache ? "runtime" : "prerender";
565
571
  if (fromCache) state.shouldRevalidate = source.shouldRevalidate;
572
+ // Not shouldRevalidate: that one starts the cache() scope's background
573
+ // revalidation.
574
+ if (source.kind === "prerender-overlay" && source.stale) {
575
+ state.storedStale = true;
576
+ }
566
577
  state.cachedSegments = segments;
567
578
  const cachedMatchedIds = [...liveMatchedIds, ...segments.map((s) => s.id)];
568
579
  state.cachedMatchedIds = cachedMatchedIds;
@@ -807,11 +818,8 @@ async function readPrerenderSource<TEnv>(
807
818
  // A stale "removed" marker is rechecked like a stale page: only a
808
819
  // refresh that hit notFound() (or declined) stamps one with a ttl or
809
820
  // tags. A prerender.remove() marker has neither and never gets here.
810
- if (
811
- overlay.config.onRevalidate &&
812
- reqCtx &&
813
- isStoredEntryStale(stored, Date.now())
814
- ) {
821
+ const stale = isStoredEntryStale(stored, Date.now());
822
+ if (overlay.config.onRevalidate && reqCtx && stale) {
815
823
  scheduleOverlayRevalidation(
816
824
  serializePrerenderKey(key),
817
825
  overlay.config.onRevalidate,
@@ -833,7 +841,7 @@ async function readPrerenderSource<TEnv>(
833
841
  )._prerenderRemoved = true;
834
842
  return null;
835
843
  }
836
- return { kind: "prerender-overlay", entry: stored.entry };
844
+ return { kind: "prerender-overlay", entry: stored.entry, stale };
837
845
  }
838
846
  }
839
847
 
@@ -115,7 +115,6 @@ import type {
115
115
  import { isPlaceholder } from "../types/segments.js";
116
116
  import { interceptMatched } from "../browser/intercept-utils.js";
117
117
  import type { MatchContext, MatchPipelineState } from "./match-context.js";
118
- import { debugLog } from "./logging.js";
119
118
  import { appendMetric } from "./metrics.js";
120
119
  import {
121
120
  collectInterceptTargetNames,
@@ -132,83 +131,11 @@ export async function collectSegments(
132
131
  return segments;
133
132
  }
134
133
 
135
- /**
136
- * Deduplicate inherited loader segments by loaderId.
137
- *
138
- * When a route has loaders and a child layout has parallel slots, the same
139
- * loader is resolved twice: once for the route and once inherited into the
140
- * layout (tagged with `_inherited`). The inherited copy is only needed when
141
- * the route uses `loading()` — in that case, the loader data is inside a
142
- * LoaderBoundary/Suspense that parallel slots can't reach through. Without
143
- * loading(), useLoader() traverses parent contexts and finds the data.
144
- */
145
- function deduplicateLoaderSegments(
146
- segments: ResolvedSegment[],
147
- logPrefix: string,
148
- ): { segments: ResolvedSegment[]; removedIds: Set<string> } {
149
- // Single pass: original (non-inherited) loaderIds, all loaderIds grouped by
150
- // namespace, and namespaces of segments that declare loading().
151
- const originalLoaders = new Set<string>();
152
- const loaderIdsByNamespace = new Map<string, string[]>();
153
- const namespacesWithLoading = new Set<string>();
154
- for (const s of segments) {
155
- if (s.type === "loader" && s.loaderId) {
156
- if (!s._inherited) originalLoaders.add(s.loaderId);
157
- const ids = loaderIdsByNamespace.get(s.namespace);
158
- if (ids) ids.push(s.loaderId);
159
- else loaderIdsByNamespace.set(s.namespace, [s.loaderId]);
160
- } else if (
161
- s.type !== "loader" &&
162
- s.loading !== undefined &&
163
- s.loading !== false
164
- ) {
165
- namespacesWithLoading.add(s.namespace);
166
- }
167
- }
168
-
169
- const loadersWithLoading = new Set<string>();
170
- for (const ns of namespacesWithLoading) {
171
- for (const id of loaderIdsByNamespace.get(ns) ?? []) {
172
- loadersWithLoading.add(id);
173
- }
174
- }
175
-
176
- const result: ResolvedSegment[] = [];
177
- const removedIds = new Set<string>();
178
-
179
- for (const s of segments) {
180
- if (
181
- s.type === "loader" &&
182
- s.loaderId &&
183
- s._inherited &&
184
- originalLoaders.has(s.loaderId) &&
185
- !loadersWithLoading.has(s.loaderId)
186
- ) {
187
- removedIds.add(s.id);
188
- continue;
189
- }
190
- result.push(s);
191
- }
192
-
193
- if (removedIds.size > 0) {
194
- debugLog(
195
- logPrefix,
196
- `deduped ${removedIds.size} inherited loader segment(s)`,
197
- );
198
- }
199
-
200
- return { segments: result, removedIds };
201
- }
202
-
203
134
  export function buildMatchResult<TEnv>(
204
135
  allSegments: ResolvedSegment[],
205
136
  ctx: MatchContext<TEnv>,
206
137
  state: MatchPipelineState,
207
138
  ): MatchResult {
208
- const logPrefix = ctx.isFullMatch
209
- ? "[Router.match]"
210
- : "[Router.matchPartial]";
211
-
212
139
  let allIds: string[];
213
140
  let segmentsToRender: ResolvedSegment[];
214
141
  let resolvedIds: string[];
@@ -271,19 +198,11 @@ export function buildMatchResult<TEnv>(
271
198
  }
272
199
  }
273
200
 
274
- const { segments: dedupedSegments, removedIds } = deduplicateLoaderSegments(
275
- segmentsToRender,
276
- logPrefix,
277
- );
278
-
279
- const matchedIds =
280
- removedIds.size > 0 ? allIds.filter((id) => !removedIds.has(id)) : allIds;
281
-
282
- // One pass over dedupedSegments: strip the internal _handlerRan marker and
201
+ // One pass over segmentsToRender: strip the internal _handlerRan marker and
283
202
  // collect the diff ids (id is unchanged by the strip) together.
284
203
  const cleanedSegments: ResolvedSegment[] = [];
285
204
  const diff: string[] = [];
286
- for (const s of dedupedSegments) {
205
+ for (const s of segmentsToRender) {
287
206
  if (s._handlerRan === undefined) {
288
207
  cleanedSegments.push(s);
289
208
  } else {
@@ -297,10 +216,13 @@ export function buildMatchResult<TEnv>(
297
216
  // origin — the browser-local clientUrls matcher declines its optimistic
298
217
  // presentation for these (see collectInterceptTargetNames).
299
218
  const interceptTargets = collectInterceptTargetNames(ctx.manifestEntry);
219
+ const sourceHeld = (
220
+ ctx.handlerContext as InternalHandlerContext<any, TEnv> | undefined
221
+ )?._sourceHeld;
300
222
 
301
223
  return {
302
224
  segments: cleanedSegments,
303
- matched: matchedIds,
225
+ matched: allIds,
304
226
  diff,
305
227
  resolvedIds,
306
228
  params: ctx.matched.params,
@@ -308,15 +230,45 @@ export function buildMatchResult<TEnv>(
308
230
  slots: Object.keys(state.slots).length > 0 ? state.slots : undefined,
309
231
  ...(interceptTargets.length > 0 ? { interceptTargets } : {}),
310
232
  ...(ctx.interceptTargeted ? { interceptTargeted: true } : {}),
311
- ...((ctx.handlerContext as InternalHandlerContext<any, TEnv> | undefined)
312
- ?._sourceHeld
313
- ? { sourceHeld: true }
233
+ ...(sourceHeld ? { sourceHeld } : {}),
234
+ ...(!ctx.isFullMatch && sendsOnlyStored(segmentsToRender, state)
235
+ ? { fromStorage: true as const }
314
236
  : {}),
315
237
  routeMiddleware:
316
238
  ctx.routeMiddleware.length > 0 ? ctx.routeMiddleware : undefined,
317
239
  };
318
240
  }
319
241
 
242
+ /**
243
+ * Whether a partial answer sends only what the cache lookup decoded from
244
+ * storage (MatchResult.fromStorage). By object, not by id: a
245
+ * segment the live walk resolved under a stored one's id (a live layout above
246
+ * a cache() boundary, an intercept resolved live) is not the stored copy.
247
+ * Any loader segment ran in this request, so it is live. Defaults to live:
248
+ * counted stored by mistake, a segment brings back the cached failures #1170
249
+ * removed. A stale cache() record (state.shouldRevalidate, refreshed in the
250
+ * background), a stale on-demand Prerender overlay entry (state.storedStale,
251
+ * which only the overlay can be: the build manifest never goes stale) and an
252
+ * answer that sends nothing count as live too: the browser would keep the
253
+ * first two past their refresh, and the third is a source page's answer.
254
+ */
255
+ function sendsOnlyStored(
256
+ sent: readonly ResolvedSegment[],
257
+ state: MatchPipelineState,
258
+ ): boolean {
259
+ if (
260
+ !state.cacheHit ||
261
+ !state.cachedSegments ||
262
+ state.shouldRevalidate ||
263
+ state.storedStale ||
264
+ sent.length === 0
265
+ ) {
266
+ return false;
267
+ }
268
+ const stored = new Set(state.cachedSegments);
269
+ return sent.every((s) => s.type !== "loader" && stored.has(s));
270
+ }
271
+
320
272
  export async function collectMatchResult<TEnv>(
321
273
  pipeline: AsyncGenerator<ResolvedSegment>,
322
274
  ctx: MatchContext<TEnv>,
@@ -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
+ }
@@ -21,6 +21,8 @@ import { _getRequestContext } from "../server/request-context.js";
21
21
  import { isAutoGeneratedRouteName } from "../route-name.js";
22
22
  import { paramsEqual } from "./params-util.js";
23
23
  import { makeIsAction } from "./is-action.js";
24
+ import { createSourceReads, hasSourceReads } from "../prefetch-source-scope.js";
25
+ import { markSourceHeld, recordingParams } from "./source-reads.js";
24
26
 
25
27
  const REVALIDATE_ALWAYS: ShouldRevalidateFn<any, any> = () => true;
26
28
  const REVALIDATE_NEVER: ShouldRevalidateFn<any, any> = () => false;
@@ -155,18 +157,45 @@ export async function evaluateRevalidation<TEnv>(
155
157
  // second, contradicting entry would let flushRevalidationTrace count the
156
158
  // segment as both revalidated and skipped.
157
159
  const floorTrue = defaultOverride?.floor === true && defaultOverride.value;
158
- // A predicate read the page navigated away from. A segment it keeps then
159
- // stays held for this source only: a prefetch of the answer, adopted on
160
- // another page, would keep that page's copy (#1331). A re-render is
161
- // source-safe, it sends the segment.
162
- let readSource = false;
163
- 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 {
164
182
  if (floorTrue && !finalVal) {
165
183
  pushTrace(defaultShouldRevalidate, true, `${reason}:floored`);
166
184
  return true;
167
185
  }
168
- if (!finalVal && readSource) {
169
- (context as InternalHandlerContext<any, TEnv>)._sourceHeld = true;
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
+ });
170
199
  }
171
200
  pushTrace(defaultShouldRevalidate, finalVal, reason);
172
201
  return finalVal;
@@ -174,6 +203,8 @@ export async function evaluateRevalidation<TEnv>(
174
203
 
175
204
  let defaultShouldRevalidate: boolean;
176
205
  let defaultReason: string;
206
+ // The default hangs on paramsChanged / searchChanged (the nav lane below).
207
+ let defaultComparesSource = false;
177
208
 
178
209
  if (defaultOverride) {
179
210
  defaultShouldRevalidate = defaultOverride.value;
@@ -197,6 +228,8 @@ export async function evaluateRevalidation<TEnv>(
197
228
  defaultReason = reasons.parentChain;
198
229
  }
199
230
  } else {
231
+ defaultComparesSource =
232
+ segment.type === "route" || segment.belongsToRoute === true;
200
233
  if (segment.type === "route") {
201
234
  const routeChanged = paramsChanged || searchChanged;
202
235
  defaultShouldRevalidate = routeChanged;
@@ -245,40 +278,36 @@ export async function evaluateRevalidation<TEnv>(
245
278
  segmentId: segment.id,
246
279
  });
247
280
  }
248
- return decide(defaultShouldRevalidate, defaultReason);
281
+ return decide(defaultShouldRevalidate, defaultReason, true);
249
282
  }
250
283
 
251
284
  const prevSegment = getPrevSegment ? await getPrevSegment() : null;
285
+ currentParams = prevSegment?.params || prevParams;
252
286
 
253
287
  let currentSuggestion = defaultShouldRevalidate;
254
288
 
255
289
  const toRouteName =
256
290
  routeKey && !isAutoGeneratedRouteName(routeKey) ? routeKey : undefined;
257
- const prevRouteKey = reqCtx?._prevRouteKey;
258
- const fromRouteName =
259
- prevRouteKey && !isAutoGeneratedRouteName(prevRouteKey)
260
- ? prevRouteKey
261
- : undefined;
262
291
 
263
292
  const isActionFn = makeIsAction(actionContext?.actionId);
264
293
 
265
- const currentParams = prevSegment?.params || prevParams;
266
-
267
294
  for (const { name, fn } of revalidations) {
268
295
  let result: any;
269
296
  try {
270
297
  result = fn({
271
298
  get currentParams() {
272
- readSource = true;
273
- return currentParams;
299
+ return (recordedParams ??= recordingParams(currentParams, reads));
274
300
  },
275
301
  get currentUrl() {
276
- readSource = true;
302
+ reads.url = true;
277
303
  return prevUrl;
278
304
  },
279
305
  nextParams,
280
306
  nextUrl,
281
- defaultShouldRevalidate: currentSuggestion,
307
+ get defaultShouldRevalidate() {
308
+ readDefault = true;
309
+ return currentSuggestion;
310
+ },
282
311
  context,
283
312
  // Segment metadata (which segment is being evaluated)
284
313
  segmentType: segment.type,
@@ -293,7 +322,7 @@ export async function evaluateRevalidation<TEnv>(
293
322
  method: request.method,
294
323
  routeName: toRouteName,
295
324
  get fromRouteName() {
296
- readSource = true;
325
+ reads.route = true;
297
326
  return fromRouteName;
298
327
  },
299
328
  toRouteName,
@@ -346,7 +375,7 @@ export async function evaluateRevalidation<TEnv>(
346
375
  revalidator: name,
347
376
  revalidate: result,
348
377
  });
349
- return decide(result, `hard:${name}`);
378
+ return decide(result, `hard:${name}`, readDefault);
350
379
  } else if (
351
380
  result &&
352
381
  typeof result === "object" &&
@@ -372,5 +401,6 @@ export async function evaluateRevalidation<TEnv>(
372
401
  revalidate: currentSuggestion,
373
402
  });
374
403
  const softNames = revalidations.map((r) => r.name).join(",");
375
- 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);
376
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