@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
@@ -26,11 +26,12 @@ import {
26
26
  } from "./segment-resolution/revalidation.js";
27
27
  import { fillSends } from "./segment-resolution/prefetch-deferral.js";
28
28
  import type { ResolutionPass } from "./segment-resolution/resolution-pass.js";
29
- import { getRouterContext } from "./router-context.js";
29
+ import { getRouterContext, type InterceptResult } from "./router-context.js";
30
30
  import { runInSegmentTagScope } from "../cache/cache-tag.js";
31
31
  import {
32
32
  RangoContext,
33
33
  latchPprHeaderScopeForEntries,
34
+ runAsSegment,
34
35
  } from "../server/context.js";
35
36
  import { _getRequestContext } from "../server/request-context.js";
36
37
 
@@ -60,18 +61,11 @@ function latchPprHeaderScopeForInterceptTarget(
60
61
  * If no when() conditions are defined, the intercept always activates.
61
62
  * A selector that throws yields the conservative default, no intercept (the
62
63
  * full page renders), and is logged; it never fails the request.
63
- *
64
- * During action revalidation, when() is NOT evaluated.
65
64
  */
66
65
  export function evaluateInterceptWhen(
67
66
  intercept: InterceptEntry,
68
67
  selectorContext: InterceptSelectorContext | null,
69
- isAction: boolean,
70
68
  ): boolean {
71
- if (isAction) {
72
- return true;
73
- }
74
-
75
69
  if (!intercept.when || intercept.when.length === 0) {
76
70
  return true;
77
71
  }
@@ -92,9 +86,34 @@ export function evaluateInterceptWhen(
92
86
  }
93
87
 
94
88
  /**
95
- * Find an intercept for the target route by walking up the entry chain.
96
- * Returns the first (innermost) matching intercept along with the entry that defines it.
89
+ * Walk the intercepts declared along `fromEntry`'s chain in selection order,
90
+ * an entry's own and then its orphans' (nested ones included) in render
91
+ * order, from `fromEntry` up, until `pick` returns a value. Tolerates partial
92
+ * entries (unit-test mocks omit the arrays); real registration always
93
+ * populates layout/intercept.
97
94
  */
95
+ function findOnChain<T>(
96
+ fromEntry: EntryData | null,
97
+ pick: (intercept: InterceptEntry, entry: EntryData) => T | undefined,
98
+ ): T | undefined {
99
+ const visit = (entry: EntryData): T | undefined => {
100
+ for (const intercept of entry.intercept ?? []) {
101
+ const found = pick(intercept, entry);
102
+ if (found !== undefined) return found;
103
+ }
104
+ return undefined;
105
+ };
106
+ let current: EntryData | null = fromEntry;
107
+ let prev: EntryData | null = null;
108
+ while (current) {
109
+ const found = visit(current) ?? findInOrphans(current, visit, prev);
110
+ if (found !== undefined) return found;
111
+ prev = current;
112
+ current = current.parent;
113
+ }
114
+ return undefined;
115
+ }
116
+
98
117
  /**
99
118
  * Collect every intercept TARGET route name reachable from an origin entry —
100
119
  * the same chain walk findInterceptForRoute() performs when this location is
@@ -110,55 +129,78 @@ export function collectInterceptTargetNames(
110
129
  fromEntry: EntryData | null,
111
130
  ): string[] {
112
131
  const names = new Set<string>();
113
- // Tolerate partial entries (unit-test mocks omit the arrays); real
114
- // registration always populates layout/intercept.
115
- const add = (source: EntryData): undefined => {
116
- for (const intercept of source.intercept ?? []) {
117
- names.add(intercept.routeName);
118
- }
132
+ findOnChain(fromEntry, (intercept) => {
133
+ names.add(intercept.routeName);
119
134
  return undefined;
120
- };
121
- let current: EntryData | null = fromEntry;
122
- let prev: EntryData | null = null;
123
- while (current) {
124
- add(current);
125
- findInOrphans(current, add, prev);
126
- prev = current;
127
- current = current.parent;
128
- }
135
+ });
129
136
  return [...names];
130
137
  }
131
138
 
139
+ /**
140
+ * The intercept for the target route, with the entry that declares it.
141
+ * `targetRouteKeys` are the names it may be declared for, in precedence
142
+ * order: the route's own name, then its local name inside include()
143
+ * (match-api.ts). A navigation, or an action with no `held`, takes the first
144
+ * along the chain from `fromEntry` up under the first name that has one: on a
145
+ * navigation, the first whose when() passes; on an action, the first, when()
146
+ * not evaluated.
147
+ *
148
+ * An action given `held` (the request's segment ids) re-renders the intercept
149
+ * on screen: one whose slot id the client holds, null when it holds none. Its
150
+ * candidates are collected under every name in one walk, the route's own
151
+ * name's first: a held id under the own name (a parallel() default of the
152
+ * slot's name) must not win before the local name is looked at. One held:
153
+ * that one, when() not evaluated, so an open intercept stays open when its
154
+ * when() would now reject. Several held (intercepts on one entry that fill one
155
+ * slot share its id, as does a parallel() slot of that name): the ones whose
156
+ * slot has no parallel() default on their entry, if any (a default holds its
157
+ * slot id whether or not an intercept is open there). One left: that one,
158
+ * when() not evaluated. Else the first whose when() passes against the
159
+ * action's intercept source, else the first.
160
+ */
132
161
  export function findInterceptForRoute(
133
- targetRouteKey: string,
162
+ targetRouteKeys: string | readonly string[],
134
163
  fromEntry: EntryData | null,
135
164
  selectorContext: InterceptSelectorContext | null = null,
136
165
  isAction: boolean = false,
137
- ): { intercept: InterceptEntry; entry: EntryData } | null {
138
- const match = (source: EntryData) => {
139
- for (const intercept of source.intercept) {
140
- if (
141
- intercept.routeName === targetRouteKey &&
142
- evaluateInterceptWhen(intercept, selectorContext, isAction)
143
- ) {
144
- return { intercept, entry: source };
145
- }
166
+ held?: ReadonlySet<string>,
167
+ ): InterceptResult | null {
168
+ const names =
169
+ typeof targetRouteKeys === "string" ? [targetRouteKeys] : targetRouteKeys;
170
+ if (!isAction || !held) {
171
+ for (const name of names) {
172
+ const found = findOnChain(fromEntry, (intercept, entry) =>
173
+ intercept.routeName === name &&
174
+ (isAction || evaluateInterceptWhen(intercept, selectorContext))
175
+ ? { intercept, entry }
176
+ : undefined,
177
+ );
178
+ if (found) return found;
146
179
  }
147
- return undefined;
148
- };
149
- let current: EntryData | null = fromEntry;
150
- let prev: EntryData | null = null;
151
-
152
- while (current) {
153
- // current first, then its orphans (nested ones included) in render order.
154
- const found = match(current) ?? findInOrphans(current, match, prev);
155
- if (found) return found;
156
-
157
- prev = current;
158
- current = current.parent;
180
+ return null;
159
181
  }
160
-
161
- return null;
182
+ const byName: InterceptResult[][] = names.map(() => []);
183
+ findOnChain(fromEntry, (intercept, entry) => {
184
+ const rank = names.indexOf(intercept.routeName);
185
+ if (rank !== -1 && held.has(interceptSlotId(entry, intercept))) {
186
+ byName[rank]!.push({ intercept, entry });
187
+ }
188
+ return undefined;
189
+ });
190
+ const open = byName.flat();
191
+ if (open.length < 2) return open[0] ?? null;
192
+ // A parallel() default of the slot's name holds its slot id whichever
193
+ // intercept is open, so a candidate that has one proves nothing about what
194
+ // is on screen: among several, the ones without a default are the open ones.
195
+ const unshadowed = open.filter(
196
+ (c) => !c.entry.parallel?.[c.intercept.slotName],
197
+ );
198
+ const pool = unshadowed.length > 0 ? unshadowed : open;
199
+ if (pool.length === 1) return pool[0]!;
200
+ return (
201
+ pool.find((c) => evaluateInterceptWhen(c.intercept, selectorContext)) ??
202
+ pool[0]!
203
+ );
162
204
  }
163
205
 
164
206
  /**
@@ -357,7 +399,9 @@ export async function resolveInterceptEntry<TEnv>(
357
399
  handlerResult =
358
400
  typeof handler === "function"
359
401
  ? handleHandlerResult(
360
- runInSegmentTagScope(slotSegmentId, handler, context),
402
+ runAsSegment(slotSegmentId, () =>
403
+ runInSegmentTagScope(slotSegmentId, handler, context),
404
+ ),
361
405
  )
362
406
  : handler;
363
407
  } catch (error) {
@@ -70,6 +70,8 @@ import {
70
70
  scopeReverse,
71
71
  runInsideLoaderBodyScope,
72
72
  runInsideLoaderRun,
73
+ runAsSegment,
74
+ segmentPinOf,
73
75
  isInsidePushCallbackScope,
74
76
  runInsidePushCallbackScope,
75
77
  type LoaderRunIdentity,
@@ -871,8 +873,10 @@ function createLoaderExecutor<TEnv>(
871
873
  Promise.resolve(
872
874
  runInsideLoaderBodyScope(
873
875
  () =>
874
- runInsideLoaderRun(identity, () =>
875
- loaderFn(loaderCtx as LoaderContext<any, TEnv>),
876
+ runAsSegment(owningSegmentId, () =>
877
+ runInsideLoaderRun(identity, () =>
878
+ loaderFn(loaderCtx as LoaderContext<any, TEnv>),
879
+ ),
876
880
  ),
877
881
  loader.$$id,
878
882
  recordedTags,
@@ -982,8 +986,7 @@ export function setupLoaderAccess<TEnv>(
982
986
  if (isHandle(item)) {
983
987
  const handle = item;
984
988
  const store = handleStoreRef;
985
- const segmentId = (ctx as InternalHandlerContext<any, TEnv>)
986
- ._currentSegmentId;
989
+ const segmentId = segmentPinOf(ctx as InternalHandlerContext<any, TEnv>);
987
990
 
988
991
  if (!segmentId) {
989
992
  throw new Error(
@@ -1057,8 +1060,7 @@ export function setupBuildUse<TEnv>(ctx: HandlerContext<any, TEnv>): void {
1057
1060
  if (isHandle(item)) {
1058
1061
  const handle = item;
1059
1062
  const store = handleStoreRef;
1060
- const segmentId = (ctx as InternalHandlerContext<any, TEnv>)
1061
- ._currentSegmentId;
1063
+ const segmentId = segmentPinOf(ctx as InternalHandlerContext<any, TEnv>);
1062
1064
 
1063
1065
  if (!segmentId) {
1064
1066
  throw new Error(
@@ -410,23 +410,29 @@ export async function createMatchContextForPartial<TEnv>(
410
410
  id.includes(".@"),
411
411
  );
412
412
  const skipInterceptForAction = isAction && !clientHasInterceptSegments;
413
+ // The names an intercept may target this route by, in precedence order:
414
+ // its own name, then its local name inside include().
415
+ const interceptNames =
416
+ snapshot.localRouteName !== matched.routeKey
417
+ ? [matched.routeKey, snapshot.localRouteName]
418
+ : [matched.routeKey];
419
+ const findIntercept = (held?: ReadonlySet<string>) =>
420
+ findInterceptForRoute(
421
+ interceptNames,
422
+ snapshot.manifestEntry.parent,
423
+ interceptSelectorContext,
424
+ isAction,
425
+ held,
426
+ );
427
+ // An action re-renders the intercept the client holds, under either name
428
+ // (one walk over both, findInterceptForRoute); holding none, the first
429
+ // declared.
413
430
  const interceptResult =
414
431
  nav.isSameRouteNavigation || skipInterceptForAction
415
432
  ? null
416
- : findInterceptForRoute(
417
- matched.routeKey,
418
- snapshot.manifestEntry.parent,
419
- interceptSelectorContext,
420
- isAction,
421
- ) ||
422
- (snapshot.localRouteName !== matched.routeKey
423
- ? findInterceptForRoute(
424
- snapshot.localRouteName,
425
- snapshot.manifestEntry.parent,
426
- interceptSelectorContext,
427
- isAction,
428
- )
429
- : null);
433
+ : isAction
434
+ ? findIntercept(nav.clientSegmentSet) || findIntercept()
435
+ : findIntercept();
430
436
 
431
437
  // The page's copy of a clientUrls() layout() loader under the route it
432
438
  // leaves holds the destination route's (client-urls/layout-loader-copies.ts).
@@ -451,24 +457,21 @@ export async function createMatchContextForPartial<TEnv>(
451
457
  }
452
458
 
453
459
  // Whether an intercept targets this route at all: the same walk as above
454
- // with isAction=true, which skips when(). If one does, the response depends
455
- // on the source even when none applied (a when() that rejected it, a
456
- // same-route navigation), so it must not share the source-agnostic prefetch
457
- // slot (#1007). The walk is over the TARGET's chain, so a route no
458
- // intercept targets never depends on the source this way.
459
- const targetsIntercept = (routeKey: string): boolean =>
460
- findInterceptForRoute(
461
- routeKey,
462
- snapshot.manifestEntry.parent,
463
- null,
464
- true,
465
- ) !== null;
460
+ // with isAction=true and nothing held, which skips when(). If one does, the
461
+ // response depends on the source even when none applied (a when() that
462
+ // rejected it, a same-route navigation), so it must not share the
463
+ // source-agnostic prefetch slot (#1007). The walk is over the TARGET's
464
+ // chain, so a route no intercept targets never depends on the source this
465
+ // way.
466
466
  const interceptTargeted =
467
467
  !!interceptResult ||
468
468
  (!skipInterceptForAction &&
469
- (targetsIntercept(matched.routeKey) ||
470
- (snapshot.localRouteName !== matched.routeKey &&
471
- targetsIntercept(snapshot.localRouteName))));
469
+ findInterceptForRoute(
470
+ interceptNames,
471
+ snapshot.manifestEntry.parent,
472
+ null,
473
+ true,
474
+ ) !== null);
472
475
 
473
476
  const isIntercept = !!interceptResult;
474
477
  // A normal-route navigation may replay a PPR shell's canonical segment
@@ -489,6 +492,7 @@ export async function createMatchContextForPartial<TEnv>(
489
492
  snapshot.entries,
490
493
  partialDeferralMode({
491
494
  fill: kind === "fill",
495
+ // Speculative only: an intent prefetch defers nothing.
492
496
  prefetch: kind === "prefetch",
493
497
  method: request.method,
494
498
  isAction,
@@ -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
@@ -313,12 +316,15 @@ function cachedSegmentSends<TEnv>(
313
316
  * Whether a prerender entry's segment is sent. On a partial navigation the
314
317
  * client keeps the segments it holds. The route's own segments (an
315
318
  * intercept's included) are sent again when the params changed (e.g., a
316
- * different guide slug). An overlay entry may be newer than the client's
317
- * copy: on an action or a stale request they are re-sent too, as a live
319
+ * different guide slug), and kept on equal params whatever the search: a
320
+ * prerender does not vary by search. An overlay entry may be newer than the
321
+ * client's copy: on an action or a stale request they are re-sent too, as a live
318
322
  * action re-render re-sends them (revalidation.ts "action:route-segment");
319
323
  * the immutable bundled manifest keeps the client's copy. A held segment
320
324
  * above the route stays held, as a live walk keeps it ("nav:non-route-skip"),
321
- * unless its revalidate() rules ask for it when the params changed.
325
+ * unless its revalidate() rules ask for it when the params changed. A route
326
+ * segment, or one with rules, kept on equal params marks the match
327
+ * source-held: the decision hangs on the source's params.
322
328
  *
323
329
  * Decided once per decoded segment, by object: an intercept variant can hold
324
330
  * a layout's parallel and the intercept's slot under one id. decodeStoredEntry
@@ -333,9 +339,20 @@ function storedEntrySends<TEnv>(
333
339
  const paramsChanged = !paramsEqual(ctx.matched.params, ctx.prevParams);
334
340
  const resendRouteSegments = replaceable && (ctx.isAction || ctx.stale);
335
341
  const entryRevalidateMap =
336
- paramsChanged && router.buildEntryRevalidateMap
337
- ? router.buildEntryRevalidateMap(ctx.entries)
342
+ ctx.clientSegmentSet.size > 0
343
+ ? router.buildEntryRevalidateMap?.(ctx.entries)
338
344
  : undefined;
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).
348
+ const heldForSource = (): false => {
349
+ markSourceHeld(
350
+ ctx.handlerContext as InternalHandlerContext<any, TEnv>,
351
+ readsOfAllParams(ctx.prevParams),
352
+ { params: ctx.prevParams, routeName: undefined },
353
+ );
354
+ return false;
355
+ };
339
356
  const decide = (segment: ResolvedSegment): boolean | Promise<boolean> => {
340
357
  if (!ctx.clientSegmentSet.has(segment.id)) return true;
341
358
  // A fill keeps it (fillSends).
@@ -346,10 +363,16 @@ function storedEntrySends<TEnv>(
346
363
  );
347
364
  if (fill !== undefined) return fill;
348
365
  if (segment.type === "route" || segment.belongsToRoute === true) {
349
- return paramsChanged || resendRouteSegments;
366
+ if (paramsChanged || resendRouteSegments) return true;
367
+ // Kept on equal params, whatever the search: a source with other
368
+ // params is sent it.
369
+ return heldForSource();
350
370
  }
351
371
  const revalidate = entryRevalidateMap?.get(segment.id)?.revalidate;
352
372
  if (!revalidate?.length) return false;
373
+ // Kept on equal params: from another source the rules run and may send
374
+ // it.
375
+ if (!paramsChanged) return heldForSource();
353
376
  return router.evaluateRevalidation({
354
377
  segment,
355
378
  prevParams: ctx.prevParams,
@@ -546,6 +569,11 @@ async function* serveStoredSegments<TEnv>(
546
569
  state.cacheHit = true;
547
570
  state.cacheSource = fromCache ? "runtime" : "prerender";
548
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
+ }
549
577
  state.cachedSegments = segments;
550
578
  const cachedMatchedIds = [...liveMatchedIds, ...segments.map((s) => s.id)];
551
579
  state.cachedMatchedIds = cachedMatchedIds;
@@ -790,11 +818,8 @@ async function readPrerenderSource<TEnv>(
790
818
  // A stale "removed" marker is rechecked like a stale page: only a
791
819
  // refresh that hit notFound() (or declined) stamps one with a ttl or
792
820
  // tags. A prerender.remove() marker has neither and never gets here.
793
- if (
794
- overlay.config.onRevalidate &&
795
- reqCtx &&
796
- isStoredEntryStale(stored, Date.now())
797
- ) {
821
+ const stale = isStoredEntryStale(stored, Date.now());
822
+ if (overlay.config.onRevalidate && reqCtx && stale) {
798
823
  scheduleOverlayRevalidation(
799
824
  serializePrerenderKey(key),
800
825
  overlay.config.onRevalidate,
@@ -816,7 +841,7 @@ async function readPrerenderSource<TEnv>(
816
841
  )._prerenderRemoved = true;
817
842
  return null;
818
843
  }
819
- return { kind: "prerender-overlay", entry: stored.entry };
844
+ return { kind: "prerender-overlay", entry: stored.entry, stale };
820
845
  }
821
846
  }
822
847
 
@@ -107,11 +107,14 @@
107
107
  * 3. Diff against previous state with diff[]
108
108
  * 4. Render slot content if slots present
109
109
  */
110
- import type { MatchResult, ResolvedSegment } from "../types.js";
110
+ import type {
111
+ InternalHandlerContext,
112
+ MatchResult,
113
+ ResolvedSegment,
114
+ } from "../types.js";
111
115
  import { isPlaceholder } from "../types/segments.js";
112
116
  import { interceptMatched } from "../browser/intercept-utils.js";
113
117
  import type { MatchContext, MatchPipelineState } from "./match-context.js";
114
- import { debugLog } from "./logging.js";
115
118
  import { appendMetric } from "./metrics.js";
116
119
  import {
117
120
  collectInterceptTargetNames,
@@ -128,83 +131,11 @@ export async function collectSegments(
128
131
  return segments;
129
132
  }
130
133
 
131
- /**
132
- * Deduplicate inherited loader segments by loaderId.
133
- *
134
- * When a route has loaders and a child layout has parallel slots, the same
135
- * loader is resolved twice: once for the route and once inherited into the
136
- * layout (tagged with `_inherited`). The inherited copy is only needed when
137
- * the route uses `loading()` — in that case, the loader data is inside a
138
- * LoaderBoundary/Suspense that parallel slots can't reach through. Without
139
- * loading(), useLoader() traverses parent contexts and finds the data.
140
- */
141
- function deduplicateLoaderSegments(
142
- segments: ResolvedSegment[],
143
- logPrefix: string,
144
- ): { segments: ResolvedSegment[]; removedIds: Set<string> } {
145
- // Single pass: original (non-inherited) loaderIds, all loaderIds grouped by
146
- // namespace, and namespaces of segments that declare loading().
147
- const originalLoaders = new Set<string>();
148
- const loaderIdsByNamespace = new Map<string, string[]>();
149
- const namespacesWithLoading = new Set<string>();
150
- for (const s of segments) {
151
- if (s.type === "loader" && s.loaderId) {
152
- if (!s._inherited) originalLoaders.add(s.loaderId);
153
- const ids = loaderIdsByNamespace.get(s.namespace);
154
- if (ids) ids.push(s.loaderId);
155
- else loaderIdsByNamespace.set(s.namespace, [s.loaderId]);
156
- } else if (
157
- s.type !== "loader" &&
158
- s.loading !== undefined &&
159
- s.loading !== false
160
- ) {
161
- namespacesWithLoading.add(s.namespace);
162
- }
163
- }
164
-
165
- const loadersWithLoading = new Set<string>();
166
- for (const ns of namespacesWithLoading) {
167
- for (const id of loaderIdsByNamespace.get(ns) ?? []) {
168
- loadersWithLoading.add(id);
169
- }
170
- }
171
-
172
- const result: ResolvedSegment[] = [];
173
- const removedIds = new Set<string>();
174
-
175
- for (const s of segments) {
176
- if (
177
- s.type === "loader" &&
178
- s.loaderId &&
179
- s._inherited &&
180
- originalLoaders.has(s.loaderId) &&
181
- !loadersWithLoading.has(s.loaderId)
182
- ) {
183
- removedIds.add(s.id);
184
- continue;
185
- }
186
- result.push(s);
187
- }
188
-
189
- if (removedIds.size > 0) {
190
- debugLog(
191
- logPrefix,
192
- `deduped ${removedIds.size} inherited loader segment(s)`,
193
- );
194
- }
195
-
196
- return { segments: result, removedIds };
197
- }
198
-
199
134
  export function buildMatchResult<TEnv>(
200
135
  allSegments: ResolvedSegment[],
201
136
  ctx: MatchContext<TEnv>,
202
137
  state: MatchPipelineState,
203
138
  ): MatchResult {
204
- const logPrefix = ctx.isFullMatch
205
- ? "[Router.match]"
206
- : "[Router.matchPartial]";
207
-
208
139
  let allIds: string[];
209
140
  let segmentsToRender: ResolvedSegment[];
210
141
  let resolvedIds: string[];
@@ -267,19 +198,11 @@ export function buildMatchResult<TEnv>(
267
198
  }
268
199
  }
269
200
 
270
- const { segments: dedupedSegments, removedIds } = deduplicateLoaderSegments(
271
- segmentsToRender,
272
- logPrefix,
273
- );
274
-
275
- const matchedIds =
276
- removedIds.size > 0 ? allIds.filter((id) => !removedIds.has(id)) : allIds;
277
-
278
- // One pass over dedupedSegments: strip the internal _handlerRan marker and
201
+ // One pass over segmentsToRender: strip the internal _handlerRan marker and
279
202
  // collect the diff ids (id is unchanged by the strip) together.
280
203
  const cleanedSegments: ResolvedSegment[] = [];
281
204
  const diff: string[] = [];
282
- for (const s of dedupedSegments) {
205
+ for (const s of segmentsToRender) {
283
206
  if (s._handlerRan === undefined) {
284
207
  cleanedSegments.push(s);
285
208
  } else {
@@ -293,10 +216,13 @@ export function buildMatchResult<TEnv>(
293
216
  // origin — the browser-local clientUrls matcher declines its optimistic
294
217
  // presentation for these (see collectInterceptTargetNames).
295
218
  const interceptTargets = collectInterceptTargetNames(ctx.manifestEntry);
219
+ const sourceHeld = (
220
+ ctx.handlerContext as InternalHandlerContext<any, TEnv> | undefined
221
+ )?._sourceHeld;
296
222
 
297
223
  return {
298
224
  segments: cleanedSegments,
299
- matched: matchedIds,
225
+ matched: allIds,
300
226
  diff,
301
227
  resolvedIds,
302
228
  params: ctx.matched.params,
@@ -304,11 +230,45 @@ export function buildMatchResult<TEnv>(
304
230
  slots: Object.keys(state.slots).length > 0 ? state.slots : undefined,
305
231
  ...(interceptTargets.length > 0 ? { interceptTargets } : {}),
306
232
  ...(ctx.interceptTargeted ? { interceptTargeted: true } : {}),
233
+ ...(sourceHeld ? { sourceHeld } : {}),
234
+ ...(!ctx.isFullMatch && sendsOnlyStored(segmentsToRender, state)
235
+ ? { fromStorage: true as const }
236
+ : {}),
307
237
  routeMiddleware:
308
238
  ctx.routeMiddleware.length > 0 ? ctx.routeMiddleware : undefined,
309
239
  };
310
240
  }
311
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
+
312
272
  export async function collectMatchResult<TEnv>(
313
273
  pipeline: AsyncGenerator<ResolvedSegment>,
314
274
  ctx: MatchContext<TEnv>,
@@ -78,6 +78,12 @@ export interface MiddlewareContext<
78
78
 
79
79
  setTheme?: (theme: Theme) => void;
80
80
 
81
+ /**
82
+ * Attach location state entries to this response (see the handler
83
+ * context's `setLocationState`). Entries accumulate across definitions;
84
+ * for one definition the last write wins. Set it here rather than in a
85
+ * handler on a `ppr` route: a shell HIT runs no handler.
86
+ */
81
87
  setLocationState(entries: LocationStateEntry | LocationStateEntry[]): void;
82
88
 
83
89
  /** Global route names only; see {@link GlobalReverseFunction}. */