@rangojs/router 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/dist/bin/rango.js +8 -3
  2. package/dist/testing/vitest.js +31 -1
  3. package/dist/types/browser/defer.d.ts +48 -0
  4. package/dist/types/browser/document-revealed.d.ts +17 -0
  5. package/dist/types/browser/event-controller.d.ts +7 -1
  6. package/dist/types/browser/navigation-transaction.d.ts +21 -7
  7. package/dist/types/browser/partial-update.d.ts +34 -5
  8. package/dist/types/browser/react/NavigationProvider.d.ts +13 -3
  9. package/dist/types/browser/react/filter-segment-order.d.ts +7 -0
  10. package/dist/types/browser/response-adapter.d.ts +7 -0
  11. package/dist/types/browser/types.d.ts +19 -9
  12. package/dist/types/browser/validate-redirect-origin.d.ts +11 -0
  13. package/dist/types/build/generate-route-types.d.ts +1 -1
  14. package/dist/types/build/route-types/router-processing.d.ts +1 -1
  15. package/dist/types/build/route-types/scan-filter.d.ts +16 -2
  16. package/dist/types/cache/cf/cf-cache-store.d.ts +16 -1
  17. package/dist/types/cache/cf/cf-cache-types.d.ts +23 -2
  18. package/dist/types/client-urls/client-urls.d.ts +1 -1
  19. package/dist/types/client-urls/hoisted-revalidate.d.ts +13 -0
  20. package/dist/types/client-urls/navigation.d.ts +0 -37
  21. package/dist/types/client-urls/revalidate-registry.d.ts +4 -0
  22. package/dist/types/client-urls/server-projection.d.ts +29 -1
  23. package/dist/types/client-urls/types.d.ts +34 -69
  24. package/dist/types/client.d.ts +0 -1
  25. package/dist/types/errors.d.ts +5 -0
  26. package/dist/types/handles/deferred-resolution.d.ts +2 -1
  27. package/dist/types/handles/pushed-by.d.ts +23 -0
  28. package/dist/types/loader-store.d.ts +8 -0
  29. package/dist/types/prefetch-markers.d.ts +11 -0
  30. package/dist/types/route-definition/helpers-types.d.ts +27 -0
  31. package/dist/types/router/is-action.d.ts +2 -1
  32. package/dist/types/router/segment-resolution/loader-mask.d.ts +3 -2
  33. package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +241 -0
  34. package/dist/types/server/context.d.ts +21 -1
  35. package/dist/types/server/handle-store.d.ts +9 -1
  36. package/dist/types/server/request-context.d.ts +14 -1
  37. package/dist/types/server.d.ts +1 -0
  38. package/dist/types/testing/flight.entry.d.ts +1 -1
  39. package/dist/types/testing/index.d.ts +3 -3
  40. package/dist/types/testing/internal/action-id.d.ts +10 -1
  41. package/dist/types/testing/internal/client-urls-projection.d.ts +16 -0
  42. package/dist/types/testing/internal/test-action.d.ts +8 -0
  43. package/dist/types/testing/run-loader.d.ts +2 -1
  44. package/dist/types/testing/run-revalidate.d.ts +74 -0
  45. package/dist/types/testing/run-transition-when.d.ts +2 -3
  46. package/dist/types/testing/serve-shell-request.d.ts +38 -0
  47. package/dist/types/testing/vitest.d.ts +11 -0
  48. package/dist/types/types/boundaries.d.ts +9 -0
  49. package/dist/types/types/handler-context.d.ts +14 -0
  50. package/dist/types/types/loader-types.d.ts +30 -0
  51. package/dist/types/types/segments.d.ts +21 -4
  52. package/dist/types/urls/path-helper-types.d.ts +25 -4
  53. package/dist/types/urls/type-extraction.d.ts +6 -2
  54. package/dist/types/vite/discovery/discover-routers.d.ts +9 -0
  55. package/dist/types/vite/discovery/state.d.ts +11 -4
  56. package/dist/types/vite/plugins/client-urls-revalidate-hoist.d.ts +54 -0
  57. package/dist/types/vite/plugins/hoist/literal.d.ts +83 -0
  58. package/dist/types/vite/plugins/hoist/scope.d.ts +40 -0
  59. package/dist/types/vite/plugins/hoist/source.d.ts +41 -0
  60. package/dist/types/vite/plugins/transition-when-hoist.d.ts +8 -11
  61. package/dist/types/vite/router-discovery.d.ts +10 -0
  62. package/dist/vite/index.js +843 -309
  63. package/package.json +2 -2
  64. package/skills/caching/SKILL.md +42 -5
  65. package/skills/catalog.json +2 -2
  66. package/skills/client-urls/SKILL.md +113 -24
  67. package/skills/cloudflare/SKILL.md +14 -9
  68. package/skills/hooks/SKILL.md +11 -3
  69. package/skills/links/SKILL.md +24 -0
  70. package/skills/loader/SKILL.md +192 -1
  71. package/skills/migrate-nextjs/SKILL.md +2 -2
  72. package/skills/migrate-react-router/SKILL.md +10 -7
  73. package/skills/migrate-react-router/data-and-actions.md +1 -1
  74. package/skills/ppr/SKILL.md +13 -0
  75. package/skills/prerender/SKILL.md +22 -8
  76. package/skills/rango/SKILL.md +12 -6
  77. package/skills/route/SKILL.md +139 -0
  78. package/skills/testing/SKILL.md +3 -2
  79. package/skills/testing/client-components.md +1 -1
  80. package/skills/testing/predicates.md +67 -32
  81. package/skills/testing/serve-shell-request.md +88 -18
  82. package/skills/testing/setup.md +2 -2
  83. package/skills/typesafety/route-types.md +2 -2
  84. package/skills/view-transitions/SKILL.md +26 -0
  85. package/src/browser/defer.ts +82 -0
  86. package/src/browser/document-revealed.ts +52 -0
  87. package/src/browser/event-controller.ts +43 -32
  88. package/src/browser/navigation-bridge.ts +64 -14
  89. package/src/browser/navigation-client.ts +48 -36
  90. package/src/browser/navigation-store.ts +13 -0
  91. package/src/browser/navigation-transaction.ts +25 -8
  92. package/src/browser/partial-update.ts +528 -135
  93. package/src/browser/prefetch/fetch.ts +2 -1
  94. package/src/browser/react/NavigationProvider.tsx +47 -28
  95. package/src/browser/react/filter-segment-order.ts +13 -2
  96. package/src/browser/response-adapter.ts +20 -0
  97. package/src/browser/rsc-router.tsx +17 -45
  98. package/src/browser/server-action-bridge.ts +19 -63
  99. package/src/browser/types.ts +19 -9
  100. package/src/browser/validate-redirect-origin.ts +16 -0
  101. package/src/build/generate-route-types.ts +1 -0
  102. package/src/build/route-types/router-processing.ts +3 -6
  103. package/src/build/route-types/scan-filter.ts +37 -10
  104. package/src/cache/cf/cf-cache-store.ts +59 -21
  105. package/src/cache/cf/cf-cache-types.ts +24 -2
  106. package/src/cache/document-cache.ts +22 -1
  107. package/src/client-urls/client-urls.ts +22 -6
  108. package/src/client-urls/hoisted-revalidate.ts +25 -0
  109. package/src/client-urls/navigation.ts +0 -96
  110. package/src/client-urls/revalidate-registry.ts +30 -0
  111. package/src/client-urls/server-projection.ts +151 -65
  112. package/src/client-urls/types.ts +39 -71
  113. package/src/client.tsx +9 -5
  114. package/src/errors.ts +15 -0
  115. package/src/handles/deferred-resolution.ts +28 -5
  116. package/src/handles/pushed-by.ts +122 -0
  117. package/src/loader-store.ts +18 -7
  118. package/src/prefetch-markers.ts +13 -0
  119. package/src/route-content-wrapper.tsx +18 -4
  120. package/src/route-definition/dsl-helpers.ts +27 -4
  121. package/src/route-definition/helpers-types.ts +27 -1
  122. package/src/router/intercept-resolution.ts +6 -4
  123. package/src/router/is-action.ts +2 -1
  124. package/src/router/loader-resolution.ts +83 -4
  125. package/src/router/match-api.ts +36 -1
  126. package/src/router/match-middleware/background-revalidation.ts +11 -1
  127. package/src/router/match-middleware/cache-lookup.ts +29 -6
  128. package/src/router/match-middleware/cache-store.ts +17 -1
  129. package/src/router/match-result.ts +14 -2
  130. package/src/router/navigation-snapshot.ts +2 -1
  131. package/src/router/segment-resolution/fresh.ts +22 -18
  132. package/src/router/segment-resolution/loader-cache.ts +2 -1
  133. package/src/router/segment-resolution/loader-mask.ts +5 -3
  134. package/src/router/segment-resolution/prefetch-deferral.ts +543 -0
  135. package/src/router/segment-resolution/revalidation.ts +169 -60
  136. package/src/rsc/rsc-rendering.ts +50 -7
  137. package/src/rsc/shell-capture.ts +2 -1
  138. package/src/segment-loader-promise.ts +23 -20
  139. package/src/segment-system.tsx +60 -26
  140. package/src/server/context.ts +39 -5
  141. package/src/server/handle-store.ts +104 -8
  142. package/src/server/loader-registry.ts +6 -1
  143. package/src/server/request-context.ts +21 -1
  144. package/src/server.ts +4 -0
  145. package/src/testing/action-form.ts +2 -11
  146. package/src/testing/e2e/page-helpers.ts +3 -1
  147. package/src/testing/flight.entry.ts +1 -0
  148. package/src/testing/index.ts +6 -3
  149. package/src/testing/internal/action-id.ts +16 -1
  150. package/src/testing/internal/client-urls-projection.ts +70 -0
  151. package/src/testing/internal/test-action.ts +25 -0
  152. package/src/testing/run-loader.ts +2 -1
  153. package/src/testing/run-revalidate.ts +142 -0
  154. package/src/testing/run-transition-when.ts +7 -8
  155. package/src/testing/serve-shell-request.ts +116 -6
  156. package/src/testing/vitest.ts +67 -1
  157. package/src/types/boundaries.ts +12 -0
  158. package/src/types/handler-context.ts +14 -0
  159. package/src/types/loader-types.ts +30 -0
  160. package/src/types/segments.ts +26 -4
  161. package/src/urls/path-helper-types.ts +27 -5
  162. package/src/urls/type-extraction.ts +30 -10
  163. package/src/use-loader.tsx +18 -13
  164. package/src/vite/discovery/client-urls-projection.ts +20 -21
  165. package/src/vite/discovery/discover-routers.ts +94 -26
  166. package/src/vite/discovery/state.ts +20 -4
  167. package/src/vite/discovery/virtual-module-codegen.ts +22 -17
  168. package/src/vite/plugins/client-urls-revalidate-hoist.ts +564 -0
  169. package/src/vite/plugins/hoist/literal.ts +417 -0
  170. package/src/vite/plugins/hoist/scope.ts +370 -0
  171. package/src/vite/plugins/hoist/source.ts +84 -0
  172. package/src/vite/plugins/transition-when-hoist.ts +113 -779
  173. package/src/vite/rango.ts +2 -0
  174. package/src/vite/router-discovery.ts +93 -32
  175. package/dist/types/client-urls/revalidate-chain.d.ts +0 -33
  176. package/dist/types/client-urls/revalidation-protocol.d.ts +0 -25
  177. package/dist/types/testing/run-client-revalidate.d.ts +0 -43
  178. package/src/client-urls/revalidate-chain.ts +0 -83
  179. package/src/client-urls/revalidation-protocol.ts +0 -56
  180. package/src/testing/run-client-revalidate.ts +0 -92
@@ -5,6 +5,7 @@ import type {
5
5
  NavigateOptionsInternal,
6
6
  ResolvedSegment,
7
7
  } from "./types.js";
8
+ import { isPlaceholder } from "../types/segments.js";
8
9
  import { setAppVersion } from "./app-version.js";
9
10
  import { isActionFenceActive } from "./action-fence.js";
10
11
  import { getRangoState } from "./rango-state.js";
@@ -14,7 +15,12 @@ import {
14
15
  createNavigationTransaction,
15
16
  resolveNavigationState,
16
17
  } from "./navigation-transaction.js";
17
- import { buildHistoryState, pushHistoryWithIdx } from "./history-state.js";
18
+ import {
19
+ buildHistoryState,
20
+ mergeLocationState,
21
+ pushHistoryWithIdx,
22
+ } from "./history-state.js";
23
+ import { consumeHandles } from "./react/NavigationProvider.js";
18
24
  import {
19
25
  handleNavigationStart,
20
26
  handleNavigationEnd,
@@ -120,6 +126,21 @@ export function setupNavigationBridgeDelegatedPrefetch(
120
126
  );
121
127
  }
122
128
 
129
+ /**
130
+ * A history entry's segments, when they can be shown. An entry left before
131
+ * its fill landed (`prefetch: false`) holds placeholders, not segments:
132
+ * restoring it would suspend on holes nobody resolves, so it is fetched.
133
+ */
134
+ function isRestorable(
135
+ segments: ResolvedSegment[] | undefined,
136
+ ): segments is ResolvedSegment[] {
137
+ return (
138
+ segments !== undefined &&
139
+ segments.length > 0 &&
140
+ !segments.some(isPlaceholder)
141
+ );
142
+ }
143
+
123
144
  /**
124
145
  * Create a navigation bridge for handling client-side navigation
125
146
  *
@@ -154,6 +175,20 @@ export function createNavigationBridge(
154
175
  onUpdate,
155
176
  renderSegments,
156
177
  getVersion: () => version,
178
+ fill: {
179
+ // A fill follows a redirect as a navigation (`bridge` is below).
180
+ redirect: (url, state) =>
181
+ void bridge.navigate(url, { state, replace: true, _skipCache: true }),
182
+ // No payload follows (`treeless`): the entry's readers are told now.
183
+ locationState: (state) => {
184
+ eventController.commitLocationState(mergeLocationState(state), true);
185
+ eventController.flushRouteState();
186
+ },
187
+ handles: (metadata) => {
188
+ if (metadata.handles) consumeHandles(metadata, eventController, store);
189
+ },
190
+ flush: eventController.flushRouteState,
191
+ },
157
192
  });
158
193
 
159
194
  /**
@@ -195,7 +230,7 @@ export function createNavigationBridge(
195
230
  });
196
231
  }
197
232
 
198
- return {
233
+ const bridge: NavigationBridge = {
199
234
  /**
200
235
  * Navigate to a URL
201
236
  * Uses cached segments for SWR revalidation when available
@@ -247,7 +282,9 @@ export function createNavigationBridge(
247
282
 
248
283
  const historyKey = generateHistoryKey(url, { intercept: isIntercept });
249
284
 
250
- // Copy current segments to the new history key so back/forward restores instantly
285
+ // Copy current segments to the new history key so back/forward restores instantly.
286
+ // The array itself, not a clone: getPageHistoryKeys reads a shared
287
+ // array as the same page (the document's late handle pushes).
251
288
  const currentKey = store.getHistoryKey();
252
289
  const currentCache = store.getCachedSegments(currentKey);
253
290
  if (currentCache?.segments) {
@@ -356,8 +393,10 @@ export function createNavigationBridge(
356
393
  );
357
394
  cachedSegments = cachedSegments.map((targetSeg) => {
358
395
  const sourceSeg = sourceSegmentMap.get(targetSeg.id);
359
- // Use source (current page) version for shared segments - it's fresher
360
- return sourceSeg || targetSeg;
396
+ // Use source (current page) version for shared segments - it's
397
+ // fresher. A placeholder waiting for its fill (prefetch: false) is
398
+ // not a copy of the segment: the target keeps its own.
399
+ return sourceSeg && !isPlaceholder(sourceSeg) ? sourceSeg : targetSeg;
361
400
  });
362
401
  }
363
402
 
@@ -374,8 +413,7 @@ export function createNavigationBridge(
374
413
  // 4. redirect-with-state - force re-render so hooks read fresh state
375
414
  // 5. stale cache - server action invalidated it, need fresh data with loading state
376
415
  const hasUsableCache =
377
- cachedSegments &&
378
- cachedSegments.length > 0 &&
416
+ isRestorable(cachedSegments) &&
379
417
  !isInterceptOnlyCache(cachedSegments) &&
380
418
  !hasInterceptCache &&
381
419
  !isLeavingIntercept &&
@@ -463,7 +501,9 @@ export function createNavigationBridge(
463
501
  targetCacheHandleData: cachedHandleData,
464
502
  }
465
503
  : {}),
466
- optimisticPresented: clientUrlPresentation !== null,
504
+ ...(clientUrlPresentation && {
505
+ presentation: clientUrlPresentation,
506
+ }),
467
507
  },
468
508
  );
469
509
  } catch (error) {
@@ -580,7 +620,9 @@ export function createNavigationBridge(
580
620
  * Uses cached segments when available for instant restoration
581
621
  */
582
622
  async handlePopstate(): Promise<void> {
583
- // Abort any pending navigation to prevent race conditions
623
+ // Abort any pending navigation to prevent race conditions, and the
624
+ // fill of the page being left (prefetch: false), which its committed
625
+ // navigation still owns.
584
626
  eventController.abortNavigation();
585
627
  handleTraversalStart();
586
628
 
@@ -648,7 +690,7 @@ export function createNavigationBridge(
648
690
  // revalidates (SWR) instead of serving it as fresh.
649
691
  const isStale = (cached?.stale ?? false) || isActionFenceActive();
650
692
 
651
- if (cachedSegments && cachedSegments.length > 0) {
693
+ if (isRestorable(cachedSegments)) {
652
694
  // transition({ when }) decides the restore (kind "pop") against the
653
695
  // entry being left, so before the store moves to the restored entry.
654
696
  // The decision the entry was committed with is not reused: it was
@@ -818,8 +860,12 @@ export function createNavigationBridge(
818
860
  ? { type: "leave-intercept" }
819
861
  : undefined,
820
862
  );
821
- // Restore scroll position after fetch completes
822
- handleNavigationEnd({ restore: true, isStreaming });
863
+ // Restore scroll position after fetch completes. A traversal that
864
+ // was left returns here too, its response ignored: it restores
865
+ // nothing.
866
+ if (!tx.handle.signal.aborted) {
867
+ handleNavigationEnd({ restore: true, isStreaming });
868
+ }
823
869
  } catch (error) {
824
870
  // Aborted or superseded by a newer navigation: bail without clobbering
825
871
  // content with a boundary (see navigate()).
@@ -888,13 +934,16 @@ export function createNavigationBridge(
888
934
  // 3. The user presses back and the browser restores from bfcache
889
935
  // At that point, currentNavigation is still set from step 1, so
890
936
  // getState() returns "loading" and the progress bar shows.
891
- // Abort the stale navigation to reset state to idle.
937
+ // Abort the stale navigation to reset state to idle. Only that one: the
938
+ // page restored is not being left, so a committed navigation's
939
+ // prefetch: false fill keeps its signal, and fails as a navigation if
940
+ // the browser dropped its request while the page was frozen.
892
941
  const handlePageShow = (event: PageTransitionEvent) => {
893
942
  if (event.persisted) {
894
943
  debugLog(
895
944
  "[Browser] Page restored from bfcache, resetting navigation state",
896
945
  );
897
- eventController.abortNavigation();
946
+ eventController.abortNavigation(true);
898
947
  }
899
948
  };
900
949
 
@@ -933,6 +982,7 @@ export function createNavigationBridge(
933
982
  store.clearHistoryCache();
934
983
  },
935
984
  };
985
+ return bridge;
936
986
  }
937
987
 
938
988
  export { createNavigationBridge as default };
@@ -14,7 +14,6 @@ import {
14
14
  } from "./logging.js";
15
15
  import { getRangoState } from "./rango-state.js";
16
16
  import { isActionFenceActive } from "./action-fence.js";
17
- import { CLIENT_REVALIDATION_HEADER } from "../client-urls/revalidation-protocol.js";
18
17
  import {
19
18
  expandPayloadFragments,
20
19
  SEGMENT_FRAGMENT_CAPABILITY_HEADER,
@@ -22,6 +21,7 @@ import {
22
21
  SegmentFragmentDecodeError,
23
22
  } from "../segment-fragments.js";
24
23
  import {
24
+ abortUntilResponse,
25
25
  extractRscHeaderUrl,
26
26
  emptyResponse,
27
27
  handleReloadHeader,
@@ -38,6 +38,7 @@ import {
38
38
  type DecodedPrefetch,
39
39
  } from "./prefetch/cache.js";
40
40
  import { cancelAllPrefetches } from "./prefetch/loader.js";
41
+ import { FILL_PARAM } from "../prefetch-markers.js";
41
42
 
42
43
  /**
43
44
  * Create a navigation client for fetching RSC payloads
@@ -80,11 +81,11 @@ export function createNavigationClient(
80
81
  previousUrl,
81
82
  signal,
82
83
  staleRevalidation,
83
- clientRevalidation,
84
84
  interceptSourceUrl,
85
85
  version,
86
86
  routerId,
87
87
  hmr,
88
+ fill,
88
89
  } = options;
89
90
 
90
91
  const debugEnabled = isBrowserDebugEnabled();
@@ -114,11 +115,18 @@ export function createNavigationClient(
114
115
  if (routerId) {
115
116
  fetchUrl.searchParams.set("_rsc_rid", routerId);
116
117
  }
118
+ // A param, not a header: the document cache decides from the raw URL
119
+ // (cache/document-cache.ts), and it keeps the fill out of every
120
+ // prefetch key below.
121
+ if (fill) {
122
+ fetchUrl.searchParams.set(FILL_PARAM, "1");
123
+ }
117
124
 
118
125
  // If the lazy prefetch runtime has not loaded yet, prevent a deferred
119
126
  // speculative request from starting after this navigation misses the
120
- // synchronous cache lookup and begins its own fetch.
121
- cancelAllPrefetches(targetUrl);
127
+ // synchronous cache lookup and begins its own fetch. A fill is not a
128
+ // navigation: the links on the page it completes stay prefetched.
129
+ if (!fill) cancelAllPrefetches(targetUrl);
122
130
 
123
131
  // Check completed in-memory prefetch cache before making a network
124
132
  // request. Try the source-scoped key first (populated when the server
@@ -136,6 +144,7 @@ export function createNavigationClient(
136
144
  const canUsePrefetch =
137
145
  !staleRevalidation &&
138
146
  !hmr &&
147
+ !fill &&
139
148
  !interceptSourceUrl &&
140
149
  !isActionFenceActive();
141
150
  const rangoState = getRangoState();
@@ -242,39 +251,42 @@ export function createNavigationClient(
242
251
  });
243
252
  }
244
253
 
245
- const responsePromise = fetch(fetchUrl, {
246
- // During an action's flight the state is not rotated, so the old
247
- // X-Rango-State still matches the Vary-keyed HTTP-cache entry; bypass
248
- // it so a genuine mid-action navigation fetches fresh instead of being
249
- // served the stale prefetched bytes. Fragment recovery also bypasses
250
- // HTTP caches so it reaches the server's decode-and-evict path.
251
- ...((isActionFenceActive() || fragmentRecovery) && {
252
- cache: "no-store" as RequestCache,
253
- }),
254
- headers: {
255
- "X-RSC-Router-Client-Path": previousUrl,
256
- // Reuse the single per-operation read (see rangoState above): the
257
- // cache-key lookup and this header must agree on one value, and the
258
- // cookie read has side effects (external-rotation notify) we do not
259
- // want to fire twice per navigation.
260
- "X-Rango-State": rangoState,
261
- ...(fragmentPassthrough && {
262
- [SEGMENT_FRAGMENT_CAPABILITY_HEADER]: "1",
263
- }),
264
- ...(fragmentRecovery && {
265
- [SEGMENT_FRAGMENT_RECOVERY_HEADER]: "1",
266
- }),
267
- ...(clientRevalidation && {
268
- [CLIENT_REVALIDATION_HEADER]: clientRevalidation,
254
+ // `signal` cancels the request until its response arrives
255
+ // (abortUntilResponse). A prefetch: false fill passes its
256
+ // navigation's signal, which stays abortable after the commit
257
+ // (event-controller.ts streamingNavigation).
258
+ const responsePromise = abortUntilResponse(signal, (requestSignal) =>
259
+ fetch(fetchUrl, {
260
+ // During an action's flight the state is not rotated, so the old
261
+ // X-Rango-State still matches the Vary-keyed HTTP-cache entry; bypass
262
+ // it so a genuine mid-action navigation fetches fresh instead of being
263
+ // served the stale prefetched bytes. Fragment recovery also bypasses
264
+ // HTTP caches so it reaches the server's decode-and-evict path.
265
+ ...((isActionFenceActive() || fragmentRecovery || fill) && {
266
+ cache: "no-store" as RequestCache,
269
267
  }),
270
- ...(tx && { "X-RSC-Router-Request-Id": tx.requestId }),
271
- ...(interceptSourceUrl && {
272
- "X-RSC-Router-Intercept-Source": interceptSourceUrl,
273
- }),
274
- ...(hmr && { "X-RSC-HMR": "1" }),
275
- },
276
- signal,
277
- }).then((response) => {
268
+ headers: {
269
+ "X-RSC-Router-Client-Path": previousUrl,
270
+ // Reuse the single per-operation read (see rangoState above): the
271
+ // cache-key lookup and this header must agree on one value, and the
272
+ // cookie read has side effects (external-rotation notify) we do not
273
+ // want to fire twice per navigation.
274
+ "X-Rango-State": rangoState,
275
+ ...(fragmentPassthrough && {
276
+ [SEGMENT_FRAGMENT_CAPABILITY_HEADER]: "1",
277
+ }),
278
+ ...(fragmentRecovery && {
279
+ [SEGMENT_FRAGMENT_RECOVERY_HEADER]: "1",
280
+ }),
281
+ ...(tx && { "X-RSC-Router-Request-Id": tx.requestId }),
282
+ ...(interceptSourceUrl && {
283
+ "X-RSC-Router-Intercept-Source": interceptSourceUrl,
284
+ }),
285
+ ...(hmr && { "X-RSC-HMR": "1" }),
286
+ },
287
+ signal: requestSignal,
288
+ }),
289
+ ).then((response) => {
278
290
  const validated = validateRscHeaders(
279
291
  response,
280
292
  "fetch",
@@ -656,6 +656,19 @@ export function createNavigationStore(
656
656
  ];
657
657
  },
658
658
 
659
+ /**
660
+ * Entries that show the same page as `historyKey`'s. A shallow navigation
661
+ * (navigation-bridge.ts) caches the page's segments array under the new
662
+ * key; every other commit caches a new array, so identity is the page.
663
+ */
664
+ getPageHistoryKeys(historyKey: string): string[] {
665
+ const segments = historyCache.find(([key]) => key === historyKey)?.[1];
666
+ if (!segments) return [historyKey];
667
+ return historyCache
668
+ .filter((entry) => entry[1] === segments)
669
+ .map(([key]) => key);
670
+ },
671
+
659
672
  /**
660
673
  * Mark every history entry stale WITHOUT clearing the prefetch caches or
661
674
  * rotating the rango state. The jar-divergence observer calls this after an
@@ -92,6 +92,23 @@ interface BoundCommitOverrides {
92
92
  treeless?: boolean;
93
93
  }
94
94
 
95
+ /**
96
+ * What a commit decided about scroll, in the shape the update that follows
97
+ * carries it (NavigationUpdate.scroll). A navigation always decides, and "do
98
+ * not scroll" is a decision (`<Link scroll={false}>`, an intercept): it
99
+ * replaces a pending one. A commit that is not a navigation decides nothing:
100
+ * an action refetch into the entry on screen (`storeOnly`) and a cache-only
101
+ * commit return no `scroll`, and their update leaves a pending decision alone
102
+ * (react/NavigationProvider.tsx).
103
+ *
104
+ * Scar tissue: those two used to answer `scroll: false`. An action refetch
105
+ * that landed before React committed a navigation then replaced the
106
+ * navigation's pending scroll with "do not scroll".
107
+ */
108
+ export interface CommitResult {
109
+ scroll?: { enabled: boolean | undefined };
110
+ }
111
+
95
112
  /**
96
113
  * Bound transaction with pre-configured commit options (without segmentIds/segments)
97
114
  */
@@ -107,12 +124,12 @@ export interface BoundTransaction {
107
124
  readonly transitionGatedOff?: boolean;
108
125
  /** Start streaming and get a token to end it when the stream completes */
109
126
  startStreaming(): StreamingToken;
110
- /** Commit the navigation. Returns the effective scroll option for the caller to handle. */
127
+ /** Commit the navigation. Returns its scroll decision, for the update. */
111
128
  commit(
112
129
  segmentIds: string[],
113
130
  segments: ResolvedSegment[],
114
131
  overrides?: BoundCommitOverrides,
115
- ): { scroll?: boolean };
132
+ ): CommitResult;
116
133
  }
117
134
 
118
135
  /**
@@ -120,7 +137,7 @@ export interface BoundTransaction {
120
137
  * Uses the event controller handle for lifecycle management
121
138
  */
122
139
  interface NavigationTransaction extends Disposable {
123
- commit(options: CommitOptions): { scroll?: boolean };
140
+ commit(options: CommitOptions): CommitResult;
124
141
  with(
125
142
  options: Omit<CommitOptions, "segmentIds" | "segments">,
126
143
  ): BoundTransaction;
@@ -146,7 +163,7 @@ export function createNavigationTransaction(
146
163
  /**
147
164
  * Commit the navigation - updates store and URL atomically
148
165
  */
149
- function commit(opts: CommitOptions): { scroll?: boolean } {
166
+ function commit(opts: CommitOptions): CommitResult {
150
167
  committed = true;
151
168
 
152
169
  const {
@@ -172,7 +189,7 @@ export function createNavigationTransaction(
172
189
  store.cacheSegmentsForHistory(historyKey, segments, currentHandleData);
173
190
  handle.complete(parsedUrl);
174
191
  debugLog("[Browser] Cache-only commit, historyKey:", historyKey);
175
- return { scroll: false };
192
+ return {};
176
193
  }
177
194
 
178
195
  if (!traversal) handleNavigationStart();
@@ -196,7 +213,7 @@ export function createNavigationTransaction(
196
213
  store.rememberDisplayedEntry(opts.routeName);
197
214
  debugLog("[Browser] Store updated (action)");
198
215
  handle.complete(parsedUrl);
199
- return { scroll: false };
216
+ return {};
200
217
  }
201
218
 
202
219
  if (traversal) {
@@ -209,7 +226,7 @@ export function createNavigationTransaction(
209
226
  eventController.commitLocationState(entryState, opts.treeless);
210
227
  handle.complete(parsedUrl);
211
228
  debugLog("[Browser] Traversal committed, historyKey:", historyKey);
212
- return { scroll };
229
+ return { scroll: { enabled: scroll } };
213
230
  }
214
231
 
215
232
  const historyState = buildHistoryState(
@@ -231,7 +248,7 @@ export function createNavigationTransaction(
231
248
  intercept ? "(intercept)" : "",
232
249
  );
233
250
 
234
- return { scroll };
251
+ return { scroll: { enabled: scroll } };
235
252
  }
236
253
 
237
254
  return {