@rangojs/router 0.22.1 → 0.24.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 (259) hide show
  1. package/dist/bin/rango.js +10 -4
  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/merge-segment-loaders.d.ts +5 -2
  7. package/dist/types/browser/navigation-transaction.d.ts +21 -7
  8. package/dist/types/browser/partial-update.d.ts +39 -8
  9. package/dist/types/browser/react/NavigationProvider.d.ts +19 -5
  10. package/dist/types/browser/react/context.d.ts +3 -2
  11. package/dist/types/browser/react/filter-segment-order.d.ts +7 -0
  12. package/dist/types/browser/response-adapter.d.ts +7 -0
  13. package/dist/types/browser/types.d.ts +24 -10
  14. package/dist/types/browser/validate-redirect-origin.d.ts +11 -0
  15. package/dist/types/build/generate-route-types.d.ts +1 -1
  16. package/dist/types/build/route-types/router-processing.d.ts +1 -1
  17. package/dist/types/build/route-types/scan-filter.d.ts +22 -2
  18. package/dist/types/cache/cf/cf-cache-store.d.ts +16 -1
  19. package/dist/types/cache/cf/cf-cache-types.d.ts +23 -2
  20. package/dist/types/cache/document-cache-policy.d.ts +24 -0
  21. package/dist/types/cache/document-cache.d.ts +2 -10
  22. package/dist/types/client-urls/client-urls.d.ts +1 -1
  23. package/dist/types/client-urls/hoisted-revalidate.d.ts +13 -0
  24. package/dist/types/client-urls/navigation.d.ts +0 -37
  25. package/dist/types/client-urls/revalidate-registry.d.ts +4 -0
  26. package/dist/types/client-urls/server-projection.d.ts +29 -1
  27. package/dist/types/client-urls/types.d.ts +34 -69
  28. package/dist/types/client.d.ts +0 -1
  29. package/dist/types/context-var.d.ts +12 -0
  30. package/dist/types/errors.d.ts +15 -0
  31. package/dist/types/handles/deferred-resolution.d.ts +2 -1
  32. package/dist/types/handles/pushed-by.d.ts +31 -0
  33. package/dist/types/index.d.ts +4 -0
  34. package/dist/types/index.rsc.d.ts +2 -0
  35. package/dist/types/loader-store.d.ts +8 -0
  36. package/dist/types/prefetch-markers.d.ts +11 -0
  37. package/dist/types/response-utils.d.ts +7 -0
  38. package/dist/types/reverse.d.ts +8 -8
  39. package/dist/types/route-definition/helpers-types.d.ts +27 -0
  40. package/dist/types/route-definition/rewrite.d.ts +56 -0
  41. package/dist/types/route-map-builder.d.ts +7 -2
  42. package/dist/types/router/basename.d.ts +7 -0
  43. package/dist/types/router/handler-context.d.ts +1 -5
  44. package/dist/types/router/internal-params.d.ts +8 -0
  45. package/dist/types/router/is-action.d.ts +2 -1
  46. package/dist/types/router/middleware-types.d.ts +15 -0
  47. package/dist/types/router/revalidation.d.ts +1 -2
  48. package/dist/types/router/route-trie-builder.d.ts +10 -0
  49. package/dist/types/router/segment-resolution/loader-mask.d.ts +3 -2
  50. package/dist/types/router/segment-resolution/mask-nested.d.ts +4 -2
  51. package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +264 -0
  52. package/dist/types/rsc/full-payload.d.ts +11 -0
  53. package/dist/types/rsc/handler.d.ts +33 -0
  54. package/dist/types/rsc/helpers.d.ts +8 -0
  55. package/dist/types/rsc/loader-fetch.d.ts +16 -0
  56. package/dist/types/rsc/rewrite-passes.d.ts +74 -0
  57. package/dist/types/rsc/ssr-setup.d.ts +3 -1
  58. package/dist/types/server/context.d.ts +21 -1
  59. package/dist/types/server/handle-store.d.ts +9 -1
  60. package/dist/types/server/request-context.d.ts +52 -2
  61. package/dist/types/server/rewrite-trail.d.ts +49 -0
  62. package/dist/types/server/slot-accessors.d.ts +29 -0
  63. package/dist/types/server.d.ts +1 -0
  64. package/dist/types/testing/flight.entry.d.ts +1 -1
  65. package/dist/types/testing/index.d.ts +4 -4
  66. package/dist/types/testing/internal/action-id.d.ts +10 -1
  67. package/dist/types/testing/internal/client-urls-projection.d.ts +16 -0
  68. package/dist/types/testing/internal/test-action.d.ts +8 -0
  69. package/dist/types/testing/render-route.d.ts +8 -0
  70. package/dist/types/testing/run-loader.d.ts +2 -1
  71. package/dist/types/testing/run-middleware.d.ts +20 -0
  72. package/dist/types/testing/run-revalidate.d.ts +74 -0
  73. package/dist/types/testing/run-transition-when.d.ts +2 -3
  74. package/dist/types/testing/serve-shell-request.d.ts +40 -1
  75. package/dist/types/testing/vitest.d.ts +11 -0
  76. package/dist/types/thenable-status.d.ts +6 -0
  77. package/dist/types/types/boundaries.d.ts +9 -0
  78. package/dist/types/types/handler-context.d.ts +14 -0
  79. package/dist/types/types/loader-types.d.ts +30 -0
  80. package/dist/types/types/request-scope.d.ts +7 -0
  81. package/dist/types/types/segments.d.ts +21 -4
  82. package/dist/types/urls/path-helper-types.d.ts +25 -4
  83. package/dist/types/urls/type-extraction.d.ts +6 -2
  84. package/dist/types/vite/discovery/discover-routers.d.ts +9 -0
  85. package/dist/types/vite/discovery/state.d.ts +14 -4
  86. package/dist/types/vite/plugins/client-urls-revalidate-hoist.d.ts +54 -0
  87. package/dist/types/vite/plugins/hoist/literal.d.ts +83 -0
  88. package/dist/types/vite/plugins/hoist/scope.d.ts +40 -0
  89. package/dist/types/vite/plugins/hoist/source.d.ts +41 -0
  90. package/dist/types/vite/plugins/transition-when-hoist.d.ts +8 -11
  91. package/dist/types/vite/router-discovery.d.ts +10 -0
  92. package/dist/vite/index.js +974 -379
  93. package/package.json +1 -1
  94. package/skills/caching/SKILL.md +54 -5
  95. package/skills/catalog.json +3 -3
  96. package/skills/client-urls/SKILL.md +197 -24
  97. package/skills/cloudflare/SKILL.md +14 -9
  98. package/skills/hooks/SKILL.md +16 -3
  99. package/skills/hooks/data.md +3 -1
  100. package/skills/links/SKILL.md +24 -0
  101. package/skills/loader/SKILL.md +199 -2
  102. package/skills/middleware/SKILL.md +183 -2
  103. package/skills/migrate-nextjs/SKILL.md +2 -2
  104. package/skills/migrate-react-router/SKILL.md +10 -7
  105. package/skills/migrate-react-router/data-and-actions.md +1 -1
  106. package/skills/ppr/SKILL.md +22 -0
  107. package/skills/prerender/SKILL.md +22 -8
  108. package/skills/rango/SKILL.md +13 -7
  109. package/skills/route/SKILL.md +139 -0
  110. package/skills/server-actions/SKILL.md +27 -0
  111. package/skills/testing/SKILL.md +3 -2
  112. package/skills/testing/client-components.md +2 -1
  113. package/skills/testing/middleware.md +31 -10
  114. package/skills/testing/predicates.md +67 -32
  115. package/skills/testing/response-routes.md +1 -0
  116. package/skills/testing/serve-shell-request.md +107 -19
  117. package/skills/testing/setup.md +2 -2
  118. package/skills/typesafety/route-types.md +2 -2
  119. package/skills/use-cache/SKILL.md +4 -2
  120. package/skills/view-transitions/SKILL.md +26 -0
  121. package/src/__augment-tests__/augmented.check.ts +20 -0
  122. package/src/browser/defer.ts +82 -0
  123. package/src/browser/document-revealed.ts +52 -0
  124. package/src/browser/event-controller.ts +46 -32
  125. package/src/browser/merge-segment-loaders.ts +31 -18
  126. package/src/browser/navigation-bridge.ts +82 -16
  127. package/src/browser/navigation-client.ts +48 -36
  128. package/src/browser/navigation-store.ts +13 -0
  129. package/src/browser/navigation-transaction.ts +25 -8
  130. package/src/browser/partial-update.ts +554 -146
  131. package/src/browser/prefetch/fetch.ts +2 -1
  132. package/src/browser/react/Link.tsx +7 -9
  133. package/src/browser/react/NavigationProvider.tsx +88 -49
  134. package/src/browser/react/context.ts +3 -2
  135. package/src/browser/react/filter-segment-order.ts +13 -2
  136. package/src/browser/react/use-router.ts +2 -4
  137. package/src/browser/response-adapter.ts +20 -0
  138. package/src/browser/rsc-router.tsx +17 -45
  139. package/src/browser/server-action-bridge.ts +25 -63
  140. package/src/browser/types.ts +24 -10
  141. package/src/browser/validate-redirect-origin.ts +16 -0
  142. package/src/build/generate-route-types.ts +1 -0
  143. package/src/build/route-types/router-processing.ts +3 -6
  144. package/src/build/route-types/scan-filter.ts +45 -11
  145. package/src/cache/cf/cf-cache-store.ts +59 -21
  146. package/src/cache/cf/cf-cache-types.ts +24 -2
  147. package/src/cache/document-cache-policy.ts +131 -0
  148. package/src/cache/document-cache.ts +21 -107
  149. package/src/client-urls/client-urls.ts +22 -6
  150. package/src/client-urls/hoisted-revalidate.ts +25 -0
  151. package/src/client-urls/navigation.ts +0 -96
  152. package/src/client-urls/revalidate-registry.ts +30 -0
  153. package/src/client-urls/server-projection.ts +151 -65
  154. package/src/client-urls/types.ts +39 -71
  155. package/src/client.tsx +9 -5
  156. package/src/context-var.ts +32 -2
  157. package/src/errors.ts +34 -0
  158. package/src/handles/deferred-resolution.ts +28 -5
  159. package/src/handles/pushed-by.ts +148 -0
  160. package/src/index.rsc.ts +8 -0
  161. package/src/index.ts +7 -0
  162. package/src/loader-store.ts +18 -7
  163. package/src/prefetch-markers.ts +13 -0
  164. package/src/response-utils.ts +19 -0
  165. package/src/reverse.ts +17 -14
  166. package/src/route-content-wrapper.tsx +18 -4
  167. package/src/route-definition/dsl-helpers.ts +27 -4
  168. package/src/route-definition/helpers-types.ts +27 -1
  169. package/src/route-definition/redirect.ts +2 -15
  170. package/src/route-definition/rewrite.ts +146 -0
  171. package/src/route-map-builder.ts +14 -11
  172. package/src/router/basename.ts +20 -0
  173. package/src/router/handler-context.ts +3 -13
  174. package/src/router/intercept-resolution.ts +35 -16
  175. package/src/router/internal-params.ts +16 -0
  176. package/src/router/is-action.ts +2 -1
  177. package/src/router/loader-resolution.ts +85 -4
  178. package/src/router/match-api.ts +36 -1
  179. package/src/router/match-middleware/background-revalidation.ts +16 -4
  180. package/src/router/match-middleware/cache-lookup.ts +35 -7
  181. package/src/router/match-middleware/cache-store.ts +15 -1
  182. package/src/router/match-middleware/intercept-resolution.ts +4 -2
  183. package/src/router/match-result.ts +14 -2
  184. package/src/router/middleware-types.ts +16 -0
  185. package/src/router/middleware.ts +127 -49
  186. package/src/router/navigation-snapshot.ts +2 -1
  187. package/src/router/revalidation.ts +1 -1
  188. package/src/router/route-trie-builder.ts +28 -1
  189. package/src/router/segment-resolution/fresh.ts +22 -18
  190. package/src/router/segment-resolution/loader-cache.ts +2 -1
  191. package/src/router/segment-resolution/loader-mask.ts +5 -3
  192. package/src/router/segment-resolution/loader-snapshot.ts +18 -4
  193. package/src/router/segment-resolution/mask-nested.ts +8 -4
  194. package/src/router/segment-resolution/prefetch-deferral.ts +583 -0
  195. package/src/router/segment-resolution/revalidation.ts +215 -82
  196. package/src/rsc/full-payload.ts +37 -5
  197. package/src/rsc/handler.ts +82 -55
  198. package/src/rsc/helpers.ts +24 -0
  199. package/src/rsc/loader-fetch.ts +79 -24
  200. package/src/rsc/response-cache-serve.ts +18 -2
  201. package/src/rsc/response-route-handler.ts +4 -7
  202. package/src/rsc/rewrite-passes.ts +203 -0
  203. package/src/rsc/rsc-rendering.ts +54 -9
  204. package/src/rsc/shell-capture.ts +2 -1
  205. package/src/rsc/ssr-setup.ts +11 -5
  206. package/src/segment-loader-promise.ts +23 -20
  207. package/src/segment-system.tsx +60 -26
  208. package/src/server/context.ts +39 -5
  209. package/src/server/cookie-parse.ts +36 -12
  210. package/src/server/cookie-store.ts +7 -3
  211. package/src/server/handle-store.ts +152 -25
  212. package/src/server/request-context.ts +175 -64
  213. package/src/server/request-headers.ts +4 -14
  214. package/src/server/rewrite-trail.ts +190 -0
  215. package/src/server/slot-accessors.ts +57 -0
  216. package/src/server.ts +4 -0
  217. package/src/ssr/index.tsx +118 -16
  218. package/src/testing/action-form.ts +2 -11
  219. package/src/testing/dispatch.ts +138 -67
  220. package/src/testing/e2e/page-helpers.ts +3 -1
  221. package/src/testing/flight.entry.ts +1 -0
  222. package/src/testing/index.ts +7 -3
  223. package/src/testing/internal/action-id.ts +16 -1
  224. package/src/testing/internal/client-urls-projection.ts +70 -0
  225. package/src/testing/internal/test-action.ts +25 -0
  226. package/src/testing/render-route.tsx +16 -3
  227. package/src/testing/run-loader.ts +4 -1
  228. package/src/testing/run-middleware.ts +56 -11
  229. package/src/testing/run-revalidate.ts +142 -0
  230. package/src/testing/run-transition-when.ts +7 -8
  231. package/src/testing/serve-shell-request.ts +131 -8
  232. package/src/testing/vitest.ts +67 -1
  233. package/src/thenable-status.ts +11 -0
  234. package/src/types/boundaries.ts +12 -0
  235. package/src/types/handler-context.ts +14 -0
  236. package/src/types/loader-types.ts +30 -0
  237. package/src/types/request-scope.ts +8 -0
  238. package/src/types/segments.ts +26 -4
  239. package/src/urls/path-helper-types.ts +27 -5
  240. package/src/urls/type-extraction.ts +30 -10
  241. package/src/use-loader.tsx +18 -13
  242. package/src/vite/discovery/client-urls-projection.ts +20 -21
  243. package/src/vite/discovery/discover-routers.ts +94 -26
  244. package/src/vite/discovery/state.ts +28 -4
  245. package/src/vite/discovery/virtual-module-codegen.ts +22 -17
  246. package/src/vite/plugins/client-urls-revalidate-hoist.ts +564 -0
  247. package/src/vite/plugins/expose-internal-ids.ts +2 -3
  248. package/src/vite/plugins/hoist/literal.ts +417 -0
  249. package/src/vite/plugins/hoist/scope.ts +370 -0
  250. package/src/vite/plugins/hoist/source.ts +84 -0
  251. package/src/vite/plugins/transition-when-hoist.ts +113 -779
  252. package/src/vite/rango.ts +2 -0
  253. package/src/vite/router-discovery.ts +116 -53
  254. package/dist/types/client-urls/revalidate-chain.d.ts +0 -33
  255. package/dist/types/client-urls/revalidation-protocol.d.ts +0 -25
  256. package/dist/types/testing/run-client-revalidate.d.ts +0 -43
  257. package/src/client-urls/revalidate-chain.ts +0 -83
  258. package/src/client-urls/revalidation-protocol.ts +0 -56
  259. package/src/testing/run-client-revalidate.ts +0 -92
@@ -1,12 +1,16 @@
1
1
  import type { ResolvedSegment } from "./types.js";
2
2
  import { debugLog, IS_BROWSER_DEBUG } from "./logging.js";
3
+ import { settledValue } from "../thenable-status.js";
3
4
 
4
5
  /**
5
6
  * Merge partial loader data from server with cached loader data.
6
7
  *
7
8
  * During partial revalidation (stale or action), the server may return only
8
- * some loaders that pass the revalidation check. The component still needs
9
- * all loader data, so we merge fresh data with cached data.
9
+ * some loaders that pass the revalidation check, none when every one is held.
10
+ * The component still needs all loader data, so we merge fresh data with
11
+ * cached data. Two settled aggregates merge into the array itself: a fresh
12
+ * Promise.all over settled parts is a promise React has not read, and content
13
+ * on screen handed one suspends (docs/internal/suspense-contract.md, rule 2).
10
14
  *
11
15
  * @param fromServer - Segment returned from server with partial loaders
12
16
  * @param fromCache - Cached segment with full loader data
@@ -25,24 +29,33 @@ export function mergeSegmentLoaders(
25
29
  );
26
30
  }
27
31
 
32
+ // Fresh data for the loaders the server re-ran, cached data for the rest.
33
+ const merge = (newData: any[], cachedData: any[]): any[] =>
34
+ cachedLoaderIds.map((id: string, i: number) => {
35
+ const newIndex = serverLoaderIds.indexOf(id);
36
+ return newIndex !== -1 ? newData[newIndex] : cachedData[i];
37
+ });
38
+ const settledNew = settledValue(fromServer.loaderDataPromise!);
39
+ const settledCached = settledValue(fromCache.loaderDataPromise!);
40
+
28
41
  return {
29
42
  ...fromCache,
30
- // Keep cached component (server's might be a fresh Promise that needs the loaders)
31
- component: fromCache.component,
32
- // Merge loader data - await both and combine
33
- loaderDataPromise: Promise.all([
34
- fromServer.loaderDataPromise!,
35
- fromCache.loaderDataPromise!,
36
- ]).then(([newData, cachedData]) => {
37
- // Build merged array: use new data for updated loaders, cached for rest
38
- return cachedLoaderIds.map((id: string, i: number) => {
39
- const newIndex = serverLoaderIds.indexOf(id);
40
- if (newIndex !== -1) {
41
- return (newData as any[])[newIndex]; // Use fresh data
42
- }
43
- return (cachedData as any[])[i]; // Use cached data
44
- });
45
- }),
43
+ // The server re-ran the slot's handler and layout: their render applies,
44
+ // as it does when every loader re-runs. Only the loaders it held keep
45
+ // their cached data.
46
+ component: fromServer.component,
47
+ layout: fromServer.layout,
48
+ // Every loader held: the cached aggregate itself, so a boundary still
49
+ // waiting on it keeps its one thenable (suspense-contract.md, rule 1).
50
+ loaderDataPromise:
51
+ serverLoaderIds.length === 0
52
+ ? fromCache.loaderDataPromise
53
+ : settledNew && settledCached
54
+ ? merge(settledNew, settledCached)
55
+ : Promise.all([
56
+ fromServer.loaderDataPromise!,
57
+ fromCache.loaderDataPromise!,
58
+ ]).then(([newData, cachedData]) => merge(newData, cachedData)),
46
59
  // Keep all loader IDs from cache
47
60
  loaderIds: fromCache.loaderIds,
48
61
  // The rebuilt aggregate is the source of truth. Cached per-loader
@@ -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,13 @@ 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";
24
+ import { withSegmentHandles } from "../handles/pushed-by.js";
18
25
  import {
19
26
  handleNavigationStart,
20
27
  handleNavigationEnd,
@@ -120,6 +127,21 @@ export function setupNavigationBridgeDelegatedPrefetch(
120
127
  );
121
128
  }
122
129
 
130
+ /**
131
+ * A history entry's segments, when they can be shown. An entry left before
132
+ * its fill landed (`prefetch: false`) holds placeholders, not segments:
133
+ * restoring it would suspend on holes nobody resolves, so it is fetched.
134
+ */
135
+ function isRestorable(
136
+ segments: ResolvedSegment[] | undefined,
137
+ ): segments is ResolvedSegment[] {
138
+ return (
139
+ segments !== undefined &&
140
+ segments.length > 0 &&
141
+ !segments.some(isPlaceholder)
142
+ );
143
+ }
144
+
123
145
  /**
124
146
  * Create a navigation bridge for handling client-side navigation
125
147
  *
@@ -154,6 +176,20 @@ export function createNavigationBridge(
154
176
  onUpdate,
155
177
  renderSegments,
156
178
  getVersion: () => version,
179
+ fill: {
180
+ // A fill follows a redirect as a navigation (`bridge` is below).
181
+ redirect: (url, state) =>
182
+ void bridge.navigate(url, { state, replace: true, _skipCache: true }),
183
+ // No payload follows (`treeless`): the entry's readers are told now.
184
+ locationState: (state) => {
185
+ eventController.commitLocationState(mergeLocationState(state), true);
186
+ eventController.flushRouteState();
187
+ },
188
+ handles: (metadata) => {
189
+ if (metadata.handles) consumeHandles(metadata, eventController, store);
190
+ },
191
+ flush: eventController.flushRouteState,
192
+ },
157
193
  });
158
194
 
159
195
  /**
@@ -195,7 +231,7 @@ export function createNavigationBridge(
195
231
  });
196
232
  }
197
233
 
198
- return {
234
+ const bridge: NavigationBridge = {
199
235
  /**
200
236
  * Navigate to a URL
201
237
  * Uses cached segments for SWR revalidation when available
@@ -247,7 +283,9 @@ export function createNavigationBridge(
247
283
 
248
284
  const historyKey = generateHistoryKey(url, { intercept: isIntercept });
249
285
 
250
- // Copy current segments to the new history key so back/forward restores instantly
286
+ // Copy current segments to the new history key so back/forward restores instantly.
287
+ // The array itself, not a clone: getPageHistoryKeys reads a shared
288
+ // array as the same page (the document's late handle pushes).
251
289
  const currentKey = store.getHistoryKey();
252
290
  const currentCache = store.getCachedSegments(currentKey);
253
291
  if (currentCache?.segments) {
@@ -349,15 +387,21 @@ export function createNavigationBridge(
349
387
  // since it may have fresher data after an action revalidation.
350
388
  // This avoids unnecessary server round-trips for shared layout loaders.
351
389
  let cachedSegments = cached?.segments;
352
- const cachedHandleData = cached?.handleData;
390
+ // The segments taken from the page on screen: their pushes come from
391
+ // it too (targetCacheHandleData below).
392
+ const fromSource = new Set<string>();
353
393
  if (cachedSegments && sourceCached?.segments) {
354
394
  const sourceSegmentMap = new Map(
355
395
  sourceCached.segments.map((s) => [s.id, s]),
356
396
  );
357
397
  cachedSegments = cachedSegments.map((targetSeg) => {
358
398
  const sourceSeg = sourceSegmentMap.get(targetSeg.id);
359
- // Use source (current page) version for shared segments - it's fresher
360
- return sourceSeg || targetSeg;
399
+ // Use source (current page) version for shared segments - it's
400
+ // fresher. A placeholder waiting for its fill (prefetch: false) is
401
+ // not a copy of the segment: the target keeps its own.
402
+ if (!sourceSeg || isPlaceholder(sourceSeg)) return targetSeg;
403
+ fromSource.add(targetSeg.id);
404
+ return sourceSeg;
361
405
  });
362
406
  }
363
407
 
@@ -374,8 +418,7 @@ export function createNavigationBridge(
374
418
  // 4. redirect-with-state - force re-render so hooks read fresh state
375
419
  // 5. stale cache - server action invalidated it, need fresh data with loading state
376
420
  const hasUsableCache =
377
- cachedSegments &&
378
- cachedSegments.length > 0 &&
421
+ isRestorable(cachedSegments) &&
379
422
  !isInterceptOnlyCache(cachedSegments) &&
380
423
  !hasInterceptCache &&
381
424
  !isLeavingIntercept &&
@@ -460,10 +503,23 @@ export function createNavigationBridge(
460
503
  ...(hasUsableCache
461
504
  ? {
462
505
  targetCacheSegments: cachedSegments,
463
- targetCacheHandleData: cachedHandleData,
506
+ // Read at commit: a push the page on screen gets while
507
+ // the request is in flight is in it.
508
+ targetCacheHandleData: () =>
509
+ withSegmentHandles(
510
+ cached?.handleData ?? {},
511
+ liveHandleDataForCache(
512
+ eventController,
513
+ store,
514
+ sourceHistoryKey,
515
+ ),
516
+ fromSource,
517
+ ),
464
518
  }
465
519
  : {}),
466
- optimisticPresented: clientUrlPresentation !== null,
520
+ ...(clientUrlPresentation && {
521
+ presentation: clientUrlPresentation,
522
+ }),
467
523
  },
468
524
  );
469
525
  } catch (error) {
@@ -580,7 +636,9 @@ export function createNavigationBridge(
580
636
  * Uses cached segments when available for instant restoration
581
637
  */
582
638
  async handlePopstate(): Promise<void> {
583
- // Abort any pending navigation to prevent race conditions
639
+ // Abort any pending navigation to prevent race conditions, and the
640
+ // fill of the page being left (prefetch: false), which its committed
641
+ // navigation still owns.
584
642
  eventController.abortNavigation();
585
643
  handleTraversalStart();
586
644
 
@@ -648,7 +706,7 @@ export function createNavigationBridge(
648
706
  // revalidates (SWR) instead of serving it as fresh.
649
707
  const isStale = (cached?.stale ?? false) || isActionFenceActive();
650
708
 
651
- if (cachedSegments && cachedSegments.length > 0) {
709
+ if (isRestorable(cachedSegments)) {
652
710
  // transition({ when }) decides the restore (kind "pop") against the
653
711
  // entry being left, so before the store moves to the restored entry.
654
712
  // The decision the entry was committed with is not reused: it was
@@ -818,8 +876,12 @@ export function createNavigationBridge(
818
876
  ? { type: "leave-intercept" }
819
877
  : undefined,
820
878
  );
821
- // Restore scroll position after fetch completes
822
- handleNavigationEnd({ restore: true, isStreaming });
879
+ // Restore scroll position after fetch completes. A traversal that
880
+ // was left returns here too, its response ignored: it restores
881
+ // nothing.
882
+ if (!tx.handle.signal.aborted) {
883
+ handleNavigationEnd({ restore: true, isStreaming });
884
+ }
823
885
  } catch (error) {
824
886
  // Aborted or superseded by a newer navigation: bail without clobbering
825
887
  // content with a boundary (see navigate()).
@@ -888,13 +950,16 @@ export function createNavigationBridge(
888
950
  // 3. The user presses back and the browser restores from bfcache
889
951
  // At that point, currentNavigation is still set from step 1, so
890
952
  // getState() returns "loading" and the progress bar shows.
891
- // Abort the stale navigation to reset state to idle.
953
+ // Abort the stale navigation to reset state to idle. Only that one: the
954
+ // page restored is not being left, so a committed navigation's
955
+ // prefetch: false fill keeps its signal, and fails as a navigation if
956
+ // the browser dropped its request while the page was frozen.
892
957
  const handlePageShow = (event: PageTransitionEvent) => {
893
958
  if (event.persisted) {
894
959
  debugLog(
895
960
  "[Browser] Page restored from bfcache, resetting navigation state",
896
961
  );
897
- eventController.abortNavigation();
962
+ eventController.abortNavigation(true);
898
963
  }
899
964
  };
900
965
 
@@ -933,6 +998,7 @@ export function createNavigationBridge(
933
998
  store.clearHistoryCache();
934
999
  },
935
1000
  };
1001
+ return bridge;
936
1002
  }
937
1003
 
938
1004
  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 {