@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
@@ -11,9 +11,15 @@ import { startTransition } from "react";
11
11
  // addTransitionType is only available in React experimental
12
12
  const addTransitionType: ((type: string) => void) | undefined =
13
13
  "addTransitionType" in React ? (React as any).addTransitionType : undefined;
14
+
14
15
  import type { RenderSegmentsOptions } from "../segment-system.js";
15
16
  import { reconcileSegments } from "./segment-reconciler.js";
16
17
  import type { ReconcileActor } from "./segment-reconciler.js";
18
+ import { Defer } from "./defer.js";
19
+ import {
20
+ emitNavigationError,
21
+ toNetworkError,
22
+ } from "./network-error-handler.js";
17
23
  import {
18
24
  hasActiveIntercept as hasActiveInterceptSlots,
19
25
  isInterceptSegment,
@@ -25,17 +31,11 @@ import {
25
31
  isBrowserDebugEnabled,
26
32
  IS_BROWSER_DEBUG,
27
33
  } from "./logging.js";
28
- import {
29
- validateRedirectOrigin,
30
- validateExternalRedirect,
31
- } from "./validate-redirect-origin.js";
32
- import type { NavigationUpdate } from "./types.js";
34
+ import { validatePayloadRedirect } from "./validate-redirect-origin.js";
35
+ import type { HandleData, NavigationUpdate, RscMetadata } from "./types.js";
33
36
  import { OPTIMISTIC_COMMIT_TRANSITION_TYPE } from "./optimistic-commit.js";
34
37
  import type { LoaderStore } from "../loader-store.js";
35
- import {
36
- collectClientRevalidationDecisions,
37
- setActiveInterceptTargets,
38
- } from "../client-urls/navigation.js";
38
+ import { setActiveInterceptTargets } from "../client-urls/navigation.js";
39
39
  import {
40
40
  decideCommitGatedOff,
41
41
  mergeSegmentParams,
@@ -43,15 +43,9 @@ import {
43
43
  } from "./transition-when.js";
44
44
  import { buildHistoryState } from "./history-state.js";
45
45
  import { addLocationState } from "./react/location-state-shared.js";
46
- import type { TransitionWhenKind } from "../types/segments.js";
46
+ import { isPlaceholder, type TransitionWhenKind } from "../types/segments.js";
47
47
  import { auditTreeCause, type TreeUpdateCause } from "../suspense-audit.js";
48
48
 
49
- function toScrollPayload(
50
- scroll: boolean | undefined,
51
- ): NonNullable<NavigationUpdate["scroll"]> {
52
- return { enabled: scroll !== false ? scroll : false };
53
- }
54
-
55
49
  /**
56
50
  * Hold/animate decision for a commit: a non-intercept segment carries a
57
51
  * transition, no intercept is presented, and this navigation's
@@ -115,6 +109,66 @@ export interface PartialUpdateConfig {
115
109
  ) => Promise<ReactNode> | ReactNode;
116
110
  /** RSC version getter — returns the current version (may change after HMR) */
117
111
  getVersion?: () => string | undefined;
112
+ /**
113
+ * A fill (`prefetch: false`) lands after its navigation has committed: the
114
+ * transaction has recorded the entry and handed React the tree, and the
115
+ * fill hands React no update that would carry what its response holds to
116
+ * the stores. The bridge that owns navigation supplies the writes, the
117
+ * ones its own updates make. Without them a redirect is a document
118
+ * navigation and the rest is dropped.
119
+ */
120
+ fill?: {
121
+ redirect(url: string, state?: Record<string, unknown>): void;
122
+ /** The location state the server set, to the entry and its readers. */
123
+ locationState(state: Record<string, unknown>): void;
124
+ /** What the response's handlers pushed, to the handle store. */
125
+ handles(metadata: RscMetadata): void;
126
+ /**
127
+ * Deliver the navigation state to its readers now. A page built with
128
+ * holes commits in the task of its click, before the notification of the
129
+ * click has gone out (event-controller.ts makeDebouncedNotifier), and
130
+ * React holds that commit where a hole is read with no boundary: without
131
+ * this useNavigation() reads idle, on the page being left, for the whole
132
+ * wait. Delivered first, `loading` is on screen when the commit is
133
+ * handed over, and stays while React holds it, as for a plain click.
134
+ */
135
+ flush(): void;
136
+ };
137
+ }
138
+
139
+ const FILL_REDIRECT_NOT_FOLLOWED = "[rango] fill: redirect not followed";
140
+
141
+ /** Rewrite `into` in place: its holders keep the array. */
142
+ function replace<T>(into: T[], items: readonly T[]): void {
143
+ into.splice(0, into.length, ...items);
144
+ }
145
+
146
+ /** The ids of `matched` that `segments` lacks. */
147
+ function missingIds(
148
+ matched: readonly string[],
149
+ segments: readonly ResolvedSegment[],
150
+ ): string[] {
151
+ const ids = new Set(segments.map((s) => s.id));
152
+ return matched.filter((id) => !ids.has(id));
153
+ }
154
+
155
+ /**
156
+ * A tree built with holes: one defer per placeholder segment, standing for
157
+ * the value its work did not produce. The segment is always new to the page:
158
+ * a payload that defers is adopted only on the page it was prefetched from,
159
+ * with the segments it was prefetched with (rsc/rsc-rendering.ts
160
+ * sourceScoped, prefetch/cache.ts buildSourceKey), and the server defers
161
+ * nothing that request listed as held. The navigation that commits the tree
162
+ * fills it from its fill request (fillHoles) or, when it is left first,
163
+ * releases it (docs/design/prefetch-false.md, "The browser").
164
+ */
165
+ interface Holes {
166
+ /** Placeholder segment (the payload's object) -> its defer. */
167
+ defers: Map<ResolvedSegment, Defer<unknown>>;
168
+ /** The `matched` the tree was built for. */
169
+ matched: string[];
170
+ /** The answer to the fill request this navigation sent for them. */
171
+ answer: ReturnType<NavigationClient["fetchPartial"]>;
118
172
  }
119
173
 
120
174
  /**
@@ -146,18 +200,22 @@ export type UpdateMode =
146
200
  * when we send cached segment IDs to the server - if the server returns empty diff,
147
201
  * we use the same segments we told the server we have. */
148
202
  targetCacheSegments?: ResolvedSegment[];
149
- /** Cached handle data for the target URL. When server returns empty diff and we're
150
- * rendering from cache, this is passed to the UI to restore breadcrumbs etc. */
151
- targetCacheHandleData?: Record<string, Record<string, unknown[]>>;
203
+ /** The handle state of `targetCacheSegments`: the entry's pushes, and
204
+ * the live pushes of the page on screen for each segment taken from it.
205
+ * Read at commit; the page starts from it and the response's handles,
206
+ * when it has any, merge onto it (NavigationProvider). */
207
+ targetCacheHandleData?: () => HandleData;
152
208
  /** Source URL for intercept restore (popstate cache miss) */
153
209
  interceptSourceUrl?: string;
154
210
  /**
155
- * The bridge already presented an optimistic clientUrls() destination
156
- * for this navigation: transition-lane commits add
211
+ * The optimistic clientUrls() destination the bridge presented for
212
+ * this navigation. Transition-lane commits add
157
213
  * OPTIMISTIC_COMMIT_TRANSITION_TYPE so router <ViewTransition>
158
- * boundaries do not animate the identical repaint.
214
+ * boundaries do not animate the identical repaint, and `clear()` runs
215
+ * once the canonical tree is handed to React: `useOutlet().pending`
216
+ * ends with that commit, not with a `prefetch: false` fill after it.
159
217
  */
160
- optimisticPresented?: boolean;
218
+ presentation?: { clear(): void };
161
219
  /**
162
220
  * A router.refresh() (or cross-tab refresh) of the current URL:
163
221
  * transition({ when }) sees kind "revalidate", not "replace".
@@ -197,10 +255,287 @@ export function createPartialUpdater(
197
255
  getVersion = () => undefined,
198
256
  } = config;
199
257
 
258
+ /**
259
+ * The segments the page on screen holds. A placeholder (`prefetch: false`,
260
+ * waiting for its fill) is not a copy of its segment: it never stands in
261
+ * for one in a reconcile, and its id is never sent as held (tx.commit below
262
+ * leaves it out of the store's ids).
263
+ */
200
264
  function getCurrentCachedSegments(): ResolvedSegment[] {
201
- const currentKey = store.getHistoryKey();
202
- const cached = store.getCachedSegments(currentKey);
203
- return cached?.segments || [];
265
+ return (entryOnScreen() || []).filter((s) => !isPlaceholder(s));
266
+ }
267
+
268
+ /**
269
+ * One defer per placeholder: a loader's is its `loaderData`, any other
270
+ * segment's is its `component`. Every reader of a hole (Suspender,
271
+ * useLoader, React itself where a segment has no loading()) reads it as a
272
+ * stream that has not arrived yet, and all of them wait on its one promise
273
+ * (browser/defer.ts readDefer, asStream).
274
+ */
275
+ function armHoles(
276
+ placeholders: ResolvedSegment[],
277
+ ): Map<ResolvedSegment, Defer<unknown>> {
278
+ const defers = new Map<ResolvedSegment, Defer<unknown>>();
279
+ for (const segment of placeholders) {
280
+ const hole = new Defer<unknown>();
281
+ if (segment.type === "loader") segment.loaderData = hole;
282
+ else segment.component = hole as unknown as ReactNode;
283
+ defers.set(segment, hole);
284
+ }
285
+ return defers;
286
+ }
287
+
288
+ /**
289
+ * A defer that will not be resolved must not stay a pending stream. It is
290
+ * never rejected either: a rejection is unhandled in every aggregate built
291
+ * over it (segment-loader-promise.ts).
292
+ */
293
+ function releaseHoles(holes: Holes): void {
294
+ for (const [placeholder, hole] of holes.defers) {
295
+ if (placeholder.type === "loader") {
296
+ store.loaders.releasePendingStream(placeholder.loaderId!, hole.promise);
297
+ }
298
+ }
299
+ }
300
+
301
+ /**
302
+ * The entry on screen, while it still holds these placeholders. By object,
303
+ * not by history key: a shallow navigation that copied the entry to a new
304
+ * key is still the page the answer is for, and an action refetch that
305
+ * already rendered the missing segments is not.
306
+ */
307
+ function entryToFill(holes: Holes): ResolvedSegment[] | undefined {
308
+ const live = entryOnScreen();
309
+ return live?.some((s) => holes.defers.has(s)) ? live : undefined;
310
+ }
311
+
312
+ /** The page on screen holds every one of `segments` (by id). */
313
+ function holdsAll(segments: ResolvedSegment[]): boolean {
314
+ const onScreenIds = new Set(getCurrentCachedSegments().map((s) => s.id));
315
+ return segments.every((s) => onScreenIds.has(s.id));
316
+ }
317
+
318
+ function entryOnScreen(): ResolvedSegment[] | undefined {
319
+ return store.getCachedSegments(store.getHistoryKey())?.segments;
320
+ }
321
+
322
+ /**
323
+ * Act on the redirect a navigation was answered with. True when `metadata`
324
+ * carried one: the navigation is done here. A target in the app is thrown
325
+ * for the bridge to follow. An explicit off-host one
326
+ * (redirect(url, { external: true })) is a hard navigation, still
327
+ * scheme-validated (http/https only): `external` waives the same-origin
328
+ * check the app opted out of, not scheme safety, so a forged payload
329
+ * carrying a javascript: or data: URL cannot script via location.assign. A
330
+ * target the client refuses is dropped.
331
+ */
332
+ function redirected(metadata: RscMetadata | undefined): boolean {
333
+ const redirect = metadata?.redirect;
334
+ if (!redirect) return false;
335
+ const target = validatePayloadRedirect(redirect);
336
+ if (!target) {
337
+ debugLog("[Browser] Ignoring blocked redirect payload");
338
+ } else if (redirect.external) {
339
+ debugLog("[Browser] External redirect (hard navigation)");
340
+ window.location.assign(target);
341
+ } else {
342
+ throw new ServerRedirect(target, metadata.locationState);
343
+ }
344
+ return true;
345
+ }
346
+
347
+ /**
348
+ * Follow a redirect a fill was answered with: a replace navigation through
349
+ * the bridge, a document navigation for an external target or with no
350
+ * bridge. False when the client refuses the target.
351
+ */
352
+ function followFillRedirect(
353
+ redirect: { url: string; external?: boolean },
354
+ state: Record<string, unknown> | undefined,
355
+ ): boolean {
356
+ const target = validatePayloadRedirect(redirect);
357
+ if (!target) return false;
358
+ if (redirect.external || !config.fill) window.location.assign(target);
359
+ else config.fill.redirect(target, state);
360
+ return true;
361
+ }
362
+
363
+ /**
364
+ * Fill the holes of the tree this navigation committed with its fill's
365
+ * answer (`holes.answer`): a partial payload that carries the segment each placeholder
366
+ * stands for. Each defer resolves with that segment's value, the stored
367
+ * entry is rewritten with the segments themselves, and what the answer's
368
+ * handlers pushed and the location state they set go to the stores their
369
+ * readers subscribe to. React is handed nothing: the tree that reads the
370
+ * defers is the page, and what resolves reaches it as a stream that
371
+ * arrived.
372
+ *
373
+ * Called after tx.commit(), so the answer can never land before the tree it
374
+ * fills. The navigation's signal is the one thing that cancels it: a newer
375
+ * navigation or a back/forward aborts the request while it waits for its
376
+ * response (navigation-client.ts), and an answer that arrives after the
377
+ * abort is dropped. Either way the defers are released, not resolved.
378
+ *
379
+ * One answer does replace the page: one whose segments are not the ones the
380
+ * tree was built for. A handler that was awaited threw or called
381
+ * notFound(), and its entry's segments are one error segment instead
382
+ * (router/segment-resolution/helpers.ts catchSegmentError). That is the
383
+ * outcome of the navigation, and the segments are rendered as a
384
+ * navigation's are. `gatedOff` is the tree's transition({ when }) decision,
385
+ * for that render.
386
+ *
387
+ * Never rejects.
388
+ */
389
+ async function fillHoles(
390
+ holes: Holes,
391
+ signal: AbortSignal | undefined,
392
+ url: string,
393
+ gatedOff: boolean,
394
+ ): Promise<void> {
395
+ let filled = false;
396
+ try {
397
+ const result = await holes.answer;
398
+ // A page that no longer waits for the answer (an action refetch
399
+ // rendered what the placeholders stand for) takes nothing of it, its
400
+ // redirect included.
401
+ const live = entryToFill(holes);
402
+ if (signal?.aborted || !live) return;
403
+
404
+ const metadata = result.payload.metadata;
405
+ if (metadata?.redirect) {
406
+ if (!followFillRedirect(metadata.redirect, metadata.locationState)) {
407
+ throw new Error(FILL_REDIRECT_NOT_FOLLOWED);
408
+ }
409
+ return;
410
+ }
411
+ if (!metadata?.isPartial) {
412
+ throw new Error("[rango] fill: not a partial payload");
413
+ }
414
+
415
+ const matched = metadata.matched || [];
416
+ const reconciled = reconcileSegments({
417
+ actor: "stale-revalidation",
418
+ matched,
419
+ diff: metadata.diff || [],
420
+ serverSegments: metadata.segments || [],
421
+ cachedSegments: live.filter((s) => !isPlaceholder(s)),
422
+ insertMissingDiff: true,
423
+ });
424
+ const missing = missingIds(matched, reconciled.segments);
425
+ if (missing.length > 0) {
426
+ throw new Error(
427
+ `[rango] fill: missing segments [${missing.join(", ")}]`,
428
+ );
429
+ }
430
+
431
+ // Nothing of the answer is handed over before this point: what an
432
+ // answer the client then refuses had set would stay under the error.
433
+ if (metadata.locationState) {
434
+ config.fill?.locationState(metadata.locationState);
435
+ }
436
+
437
+ const placeholders = [...holes.defers.keys()];
438
+ const answers = placeholders.map((placeholder) =>
439
+ reconciled.segments.find(
440
+ (s) => s.id === placeholder.id && s.type === placeholder.type,
441
+ ),
442
+ );
443
+ if (
444
+ answers.includes(undefined) ||
445
+ matched.join() !== holes.matched.join()
446
+ ) {
447
+ // The tree's handle stream (NavigationProvider processHandles) reads
448
+ // this array on every yield and drops the buckets of segments
449
+ // outside it.
450
+ replace(holes.matched, matched);
451
+ // Each place a hole stood is handed its defer again, resolved with
452
+ // what the answer put there, and its reader applies the read rule
453
+ // (browser/defer.ts): a reader that suspended on it while it mounted
454
+ // calls use() of the same promise, which React has marked; one React
455
+ // never read (a hole below another one) reads the value, so this
456
+ // urgent update shows no fallback the plain click does not show
457
+ // (navigation-adoption-loading.test.tsx, "a failed route's error").
458
+ const settled = new Map<ResolvedSegment, ResolvedSegment>();
459
+ for (const [placeholder, hole] of holes.defers) {
460
+ if (placeholder.type === "loader") continue;
461
+ // By id alone: an error segment takes its route's place.
462
+ const next = reconciled.mainSegments.find(
463
+ (s) => s.id === placeholder.id,
464
+ );
465
+ if (next && !(next.component instanceof Promise)) {
466
+ hole.resolve(next.component);
467
+ settled.set(next, {
468
+ ...next,
469
+ component: hole as unknown as ReactNode,
470
+ });
471
+ }
472
+ }
473
+ const main = reconciled.mainSegments.map((s) => settled.get(s) ?? s);
474
+ const root = await renderSegments(main, {
475
+ transitionGatedOff: gatedOff,
476
+ interceptSegments:
477
+ reconciled.interceptSegments.length > 0
478
+ ? reconciled.interceptSegments
479
+ : undefined,
480
+ });
481
+ if (signal?.aborted || entryOnScreen() !== live) return;
482
+ releaseHoles(holes);
483
+ // The navigation's outcome (see fillHoles), so its cause.
484
+ if (process.env.NODE_ENV !== "production") {
485
+ auditTreeCause("navigation");
486
+ }
487
+ // No `scroll`: the transaction decided it with the click's update.
488
+ // A decision here, even "do not scroll", would replace that pending
489
+ // one (NavigationProvider).
490
+ onUpdate({ root, metadata });
491
+ } else {
492
+ config.fill?.handles(metadata);
493
+ // All in one task, each with its own stream or node: a hole below
494
+ // another one is read in the render the outer one's resolution
495
+ // starts. Resolved by then with a node, it costs that render nothing
496
+ // where a router reader reads it (browser/defer.ts readDefer); with a
497
+ // stream, or where React reads the node itself, one microtask. Not a
498
+ // fallback.
499
+ placeholders.forEach((placeholder, index) => {
500
+ const next = answers[index]!;
501
+ holes.defers
502
+ .get(placeholder)!
503
+ .resolve(
504
+ placeholder.type === "loader" ? next.loaderData : next.component,
505
+ );
506
+ });
507
+ }
508
+
509
+ // The entry keeps the answer's own streams and nodes, not the defers:
510
+ // a defer belongs to the tree on screen, and the next render from this
511
+ // page builds on the entry. The answer belongs to the visit that built
512
+ // the tree: the entry is rewritten in place, not re-cached.
513
+ // cacheSegmentsForHistory would advance the store's nav instance and
514
+ // disown the tree's handle stream, and would reset the entry's stale
515
+ // flag.
516
+ replace(live, reconciled.segments);
517
+ store.setSegmentIds(matched);
518
+ filled = true;
519
+ } catch (thrown) {
520
+ // Only on a page that still waits for this answer.
521
+ if (signal?.aborted || !entryToFill(holes)) return;
522
+ let error = thrown;
523
+ if (error instanceof ServerRedirect) {
524
+ if (followFillRedirect({ url: error.url }, error.state)) return;
525
+ error = new Error(FILL_REDIRECT_NOT_FOLLOWED);
526
+ }
527
+ console.error("[rango] fill failed:", error);
528
+ // Shown the way a navigation the client cannot process is
529
+ // (navigation-bridge.ts): the error replaces the page, so an error
530
+ // boundary takes over and no fallback is left waiting.
531
+ emitNavigationError(
532
+ onUpdate,
533
+ toNetworkError(error, { url, operation: "navigation" }) ?? error,
534
+ url,
535
+ );
536
+ } finally {
537
+ if (!filled) releaseHoles(holes);
538
+ }
204
539
  }
205
540
 
206
541
  async function fetchPartialUpdate(
@@ -310,37 +645,22 @@ export function createPartialUpdater(
310
645
  : undefined;
311
646
  const cachedSegs = targetCache ?? getCurrentCachedSegments();
312
647
  const cachedSegsSource = targetCache ? "history-cache" : "current-page";
648
+ // Rendered from the history cache, the page starts from the entry's
649
+ // pushes whether or not the response re-renders. Read before tx.commit():
650
+ // it records the controller's handle state (the document snapshot during
651
+ // the hydration window) in the entry it commits, which for a link to the
652
+ // page on screen's own URL is the entry this reads. Written over that
653
+ // record after it.
654
+ const readCachedHandleData =
655
+ targetCache && mode.type === "navigate"
656
+ ? mode.targetCacheHandleData
657
+ : undefined;
313
658
  if (IS_BROWSER_DEBUG) {
314
659
  debugLog(
315
660
  `[Browser] cachedSegs source: ${cachedSegsSource} (${cachedSegs.length} segments: ${cachedSegs.map((s) => s.id).join(", ")})`,
316
661
  );
317
662
  }
318
663
 
319
- // Client-run per-loader revalidation: execute the held clientUrls route's
320
- // revalidate() predicates now and ship their decisions with the request.
321
- // Fails soft to null (locked server defaults) when no group is active or
322
- // URLs do not parse.
323
- let clientRevalidation: string | null = null;
324
- try {
325
- clientRevalidation = collectClientRevalidationDecisions({
326
- currentUrl: new URL(previousUrl, window.location.origin),
327
- nextUrl: new URL(url, window.location.origin),
328
- // This partial fetch is a GET the server evaluates WITHOUT
329
- // actionContext (navigation defaults) even when it is an
330
- // action-triggered refetch — so the decision baseline is never the
331
- // action default here. Predicates still see isAction()/actionId
332
- // truthfully for matching.
333
- actionRequest: false,
334
- isAction: mode.type === "action",
335
- ...(mode.type === "action" && mode.actionId !== undefined
336
- ? { actionId: mode.actionId }
337
- : {}),
338
- stale: mode.type === "stale-revalidation",
339
- });
340
- } catch {
341
- clientRevalidation = null;
342
- }
343
-
344
664
  let fetchResult: Awaited<ReturnType<NavigationClient["fetchPartial"]>>;
345
665
  fetchResult = await client.fetchPartial({
346
666
  targetUrl: url,
@@ -348,7 +668,6 @@ export function createPartialUpdater(
348
668
  previousUrl,
349
669
  staleRevalidation:
350
670
  mode.type === "stale-revalidation" || segments.length === 0,
351
- clientRevalidation,
352
671
  version: getVersion(),
353
672
  routerId: store.getRouterId?.(),
354
673
  });
@@ -363,7 +682,26 @@ export function createPartialUpdater(
363
682
  // Side effect only: end the streaming token once the stream settles.
364
683
  // The wrapped promise was never read as a value; only the .end() matters.
365
684
  // The .catch keeps an unhandled rejection from leaking if the stream errors.
366
- rawStreamComplete.then(() => streamingToken.end()).catch(() => {});
685
+ // A payload with deferred segments is still streaming until its fill has
686
+ // (`filled` is set below, before this function first awaits). A
687
+ // navigation that is left ignores its response, which is never cancelled
688
+ // (navigation-client.ts): it streams to its end, and stops counting as
689
+ // streaming at the abort.
690
+ let filled: Promise<unknown> | undefined;
691
+ const endStream = (): void => {
692
+ signal?.removeEventListener("abort", endStream);
693
+ streamingToken.end();
694
+ };
695
+ signal?.addEventListener("abort", endStream);
696
+ rawStreamComplete
697
+ .then(() => (filled ? filled.then(endStream) : endStream()))
698
+ .catch(() => {});
699
+
700
+ if (signal?.aborted) {
701
+ endStream();
702
+ debugLog("[Browser] Ignoring stale navigation (aborted)");
703
+ return;
704
+ }
367
705
 
368
706
  const currentRouterId = store.getRouterId?.();
369
707
  if (
@@ -379,49 +717,12 @@ export function createPartialUpdater(
379
717
  return;
380
718
  }
381
719
 
382
- if (payload.metadata?.redirect) {
383
- if (signal?.aborted) {
384
- debugLog("[Browser] Ignoring stale redirect (aborted)");
385
- return;
386
- }
387
- // Explicit off-host redirect (redirect(url, { external: true })):
388
- // hard-navigate, but still scheme-validate (http/https only). external
389
- // waives the same-origin check the app opted out of, NOT scheme safety, so
390
- // a forged payload carrying a javascript:/data: URL cannot script via
391
- // location.assign.
392
- if (payload.metadata.redirect.external) {
393
- const externalUrl = validateExternalRedirect(
394
- payload.metadata.redirect.url,
395
- window.location.origin,
396
- );
397
- if (!externalUrl) {
398
- debugLog("[Browser] Ignoring blocked external redirect payload");
399
- return;
400
- }
401
- debugLog("[Browser] External redirect (hard navigation)");
402
- window.location.assign(externalUrl);
403
- return;
404
- }
405
- const redirectUrl = validateRedirectOrigin(
406
- payload.metadata.redirect.url,
407
- window.location.origin,
408
- );
409
- if (!redirectUrl) {
410
- debugLog("[Browser] Ignoring blocked redirect payload");
411
- return;
412
- }
413
- const serverState = payload.metadata.locationState;
414
- throw new ServerRedirect(redirectUrl, serverState);
415
- }
720
+ if (redirected(payload.metadata)) return;
416
721
 
417
722
  if (payload.metadata?.isPartial) {
418
723
  const { segments: newSegments, matched, diff } = payload.metadata;
419
-
420
- // Check if this navigation is stale (a newer one started)
421
- if (signal?.aborted) {
422
- debugLog("[Browser] Ignoring stale navigation (aborted)");
423
- return;
424
- }
724
+ let serverSegments = newSegments || [];
725
+ let sent = diff || [];
425
726
 
426
727
  if (IS_BROWSER_DEBUG) {
427
728
  debugLog(`[Browser] Partial update - matched: ${matched?.join(", ")}`);
@@ -448,17 +749,18 @@ export function createPartialUpdater(
448
749
  forceAwait: true,
449
750
  transitionGatedOff: cachedGatedOff,
450
751
  });
752
+ if (signal?.aborted) return;
451
753
 
754
+ const cachedHandleData = readCachedHandleData?.();
452
755
  const { scroll: commitScroll } = tx.commit(
453
756
  matchedIds,
454
757
  existingSegments,
455
758
  { routeName: payload.metadata.routeName },
456
759
  );
457
-
458
- if (mode.targetCacheHandleData) {
760
+ if (cachedHandleData) {
459
761
  store.updateCacheHandleData(
460
762
  store.getHistoryKey(),
461
- mode.targetCacheHandleData,
763
+ cachedHandleData,
462
764
  );
463
765
  }
464
766
 
@@ -466,11 +768,8 @@ export function createPartialUpdater(
466
768
  payload.metadata!;
467
769
  const cachedUpdate = {
468
770
  root: newTree,
469
- metadata: {
470
- ...metadataWithoutHandles,
471
- cachedHandleData: mode.targetCacheHandleData,
472
- },
473
- scroll: toScrollPayload(commitScroll),
771
+ metadata: { ...metadataWithoutHandles, cachedHandleData },
772
+ ...(commitScroll && { scroll: commitScroll }),
474
773
  };
475
774
 
476
775
  if (process.env.NODE_ENV !== "production") {
@@ -500,6 +799,7 @@ export function createPartialUpdater(
500
799
  const newTree = await renderSegments(existingSegments, {
501
800
  forceAwait: true,
502
801
  });
802
+ if (signal?.aborted) return;
503
803
 
504
804
  const { scroll: leaveScroll } = tx.commit(
505
805
  matchedIds,
@@ -513,7 +813,7 @@ export function createPartialUpdater(
513
813
  onUpdate({
514
814
  root: newTree,
515
815
  metadata: payload.metadata,
516
- scroll: toScrollPayload(leaveScroll),
816
+ ...(leaveScroll && { scroll: leaveScroll }),
517
817
  });
518
818
 
519
819
  debugLog("[Browser] Navigation complete (left intercept)");
@@ -531,33 +831,49 @@ export function createPartialUpdater(
531
831
  return;
532
832
  }
533
833
 
534
- const matchedIds = matched || [];
834
+ let matchedIds = matched || [];
535
835
  const actor: ReconcileActor =
536
836
  mode.type === "stale-revalidation" || mode.type === "action"
537
837
  ? "stale-revalidation"
538
838
  : "navigation";
539
839
 
540
- const reconciled = reconcileSegments({
541
- actor,
542
- matched: matchedIds,
543
- diff: diff || [],
544
- serverSegments: newSegments || [],
545
- cachedSegments: cachedSegs,
546
- insertMissingDiff: true,
547
- });
840
+ // prefetch: false. A payload that carries deferred segments (an adopted
841
+ // prefetch, whatever answered it) is the page with holes in it: one
842
+ // placeholder per segment whose work did not run, each with what its
843
+ // wrapper chain is built from. The tree is built once, with a deferred
844
+ // in place of each missing value, and this navigation fills them from
845
+ // one fill request once it has committed the tree.
846
+ let defers: Holes["defers"] | undefined;
847
+ let heldIds = matchedIds;
848
+ const placeholders = serverSegments.filter(isPlaceholder);
849
+ if (placeholders.length > 0) {
850
+ defers = armHoles(placeholders);
851
+ // A placeholder is not held: every later request from this page
852
+ // leaves its id out, so the server renders it.
853
+ const deferredIds = new Set(placeholders.map((s) => s.id));
854
+ heldIds = matchedIds.filter((id) => !deferredIds.has(id));
855
+ }
548
856
 
549
- const reconciledIdSet = new Set(reconciled.segments.map((s) => s.id));
550
- const missingIds = matchedIds.filter(
551
- (id: string) => !reconciledIdSet.has(id),
552
- );
857
+ const reconcile = () =>
858
+ reconcileSegments({
859
+ actor,
860
+ matched: matchedIds,
861
+ diff: sent,
862
+ serverSegments,
863
+ cachedSegments: cachedSegs,
864
+ insertMissingDiff: true,
865
+ });
866
+ let reconciled = reconcile();
553
867
 
554
- if (missingIds.length > 0) {
555
- const missingCount = missingIds.length;
868
+ const missing = missingIds(matchedIds, reconciled.segments);
869
+
870
+ if (missing.length > 0) {
871
+ const missingCount = missing.length;
556
872
 
557
873
  if (isRetry) {
558
- console.warn("Missing ids", { missingIds });
874
+ console.warn("Missing ids", { missingIds: missing });
559
875
  throw new Error(
560
- `[Browser] Failed to fetch segments after retry. Missing: [${missingIds.join(", ")}]`,
876
+ `[Browser] Failed to fetch segments after retry. Missing: [${missing.join(", ")}]`,
561
877
  );
562
878
  }
563
879
  if (signal?.aborted) {
@@ -593,6 +909,81 @@ export function createPartialUpdater(
593
909
  // reach renderSegments. The response carries no decision, so a reused
594
910
  // prefetch is decided against the real source.
595
911
  const gatedOff = decideGatedOff(reconciled.segments, payload.metadata);
912
+
913
+ // The fill: one request for what the prefetch deferred, listing what
914
+ // the client holds once this payload commits. It leaves now, before
915
+ // the tree is rendered, with this navigation's signal.
916
+ let holes: Holes | undefined;
917
+ if (defers) {
918
+ holes = {
919
+ defers,
920
+ matched: matchedIds,
921
+ answer: client.fetchPartial({
922
+ targetUrl: url,
923
+ segmentIds: heldIds,
924
+ // The page the fill completes is the page it is on.
925
+ previousUrl: url,
926
+ fill: true,
927
+ signal,
928
+ version: getVersion(),
929
+ routerId: store.getRouterId?.(),
930
+ }),
931
+ };
932
+ filled = holes.answer
933
+ .then((answer) => answer.streamComplete)
934
+ .catch(() => {});
935
+ }
936
+
937
+ // What a fill brought for a page that waited for it (below).
938
+ let waited: RscMetadata | undefined;
939
+ if (
940
+ holes &&
941
+ placeholders.some((s) => s.type !== "loader") &&
942
+ shouldStartViewTransition(reconciled.segments, gatedOff)
943
+ ) {
944
+ // A deferred handler on a page that commits in a transition. React
945
+ // renders such a page's content in the commit that shows its
946
+ // fallback, in a render that can wait: a plain click commits it with
947
+ // its response, URL, hooks and page together. Committed with the
948
+ // click and filled afterwards, the URL moved a round trip before the
949
+ // page, and no later commit could bring the content in without a
950
+ // view transition the plain click does not start (measured: three
951
+ // for two, the content up to 280 ms late). So this click is a
952
+ // navigation that waits for its fill: nothing commits until the
953
+ // fill's first chunk, and then the page commits once, from the
954
+ // prefetched segments and the fill's, with no hole in it.
955
+ const { answer } = holes;
956
+ holes = undefined;
957
+ waited = (await answer).payload.metadata;
958
+ if (signal?.aborted || redirected(waited)) return;
959
+ if (waited?.isPartial) {
960
+ matchedIds = heldIds = waited.matched || [];
961
+ serverSegments = [
962
+ ...serverSegments.filter((s) => !isPlaceholder(s)),
963
+ ...(waited.segments || []),
964
+ ];
965
+ sent = [...sent, ...(waited.diff || [])];
966
+ reconciled = reconcile();
967
+ if (waited.locationState) {
968
+ payload.metadata.locationState = {
969
+ ...payload.metadata.locationState,
970
+ ...waited.locationState,
971
+ };
972
+ }
973
+ }
974
+ // The two answers do not make the page (the routes changed between
975
+ // the prefetch and the click): start over with nothing held, as a
976
+ // navigation whose own response misses segments does above. That
977
+ // request is never answered from a prefetch, so it defers nothing.
978
+ if (
979
+ !waited?.isPartial ||
980
+ missingIds(matchedIds, reconciled.segments).length > 0
981
+ ) {
982
+ return fetchPartialUpdate(url, [], true, signal, tx, mode);
983
+ }
984
+ } else if (holes) {
985
+ config.fill?.flush();
986
+ }
596
987
  const renderOptions = {
597
988
  transitionGatedOff: gatedOff,
598
989
  isAction: mode.type === "action",
@@ -600,8 +991,10 @@ export function createPartialUpdater(
600
991
  // land without a loading()/fallback frame. A fully-prefetched nav has
601
992
  // its router data already resolved (the prefetch stream drained), so
602
993
  // awaiting it here is free; the commit below then runs in a transition
603
- // (fullyPrefetched branch) so nothing router-owned can flash.
604
- forceAwait: mode.type === "stale-revalidation" || fullyPrefetched,
994
+ // (fullyPrefetched branch) so nothing router-owned can flash. A page
995
+ // that waited for its fill is still streaming it.
996
+ forceAwait:
997
+ mode.type === "stale-revalidation" || (fullyPrefetched && !waited),
605
998
  interceptSegments:
606
999
  reconciled.interceptSegments.length > 0
607
1000
  ? reconciled.interceptSegments
@@ -657,10 +1050,8 @@ export function createPartialUpdater(
657
1050
  // cache, which says nothing about what is visible now. Restricted to
658
1051
  // plain navigations — leave-intercept must close its modal urgently,
659
1052
  // and action/stale modes have their own transition branch.
660
- const onScreenIds = new Set(getCurrentCachedSegments().map((s) => s.id));
661
1053
  const isSameStructureNav =
662
- mode.type === "navigate" &&
663
- reconciled.segments.every((s) => onScreenIds.has(s.id));
1054
+ mode.type === "navigate" && holdsAll(reconciled.segments);
664
1055
 
665
1056
  const effectiveInterceptSource =
666
1057
  interceptSourceUrl || segmentState.currentUrl;
@@ -672,7 +1063,6 @@ export function createPartialUpdater(
672
1063
  }
673
1064
  }
674
1065
 
675
- const allSegmentIds = matchedIds;
676
1066
  const serverLocationState = payload.metadata?.locationState;
677
1067
  const overrides: CommitOverrides = {
678
1068
  routeName: payload.metadata?.routeName,
@@ -683,11 +1073,21 @@ export function createPartialUpdater(
683
1073
  }),
684
1074
  ...(serverLocationState && { serverState: serverLocationState }),
685
1075
  };
1076
+ const cachedHandleData = readCachedHandleData?.();
686
1077
  const { scroll: navScroll } = tx.commit(
687
- allSegmentIds,
1078
+ heldIds,
688
1079
  reconciled.segments,
689
1080
  overrides,
690
1081
  );
1082
+ if (cachedHandleData) {
1083
+ store.updateCacheHandleData(store.getHistoryKey(), cachedHandleData);
1084
+ }
1085
+ const hasTransition = shouldStartViewTransition(
1086
+ reconciled.segments,
1087
+ gatedOff,
1088
+ );
1089
+ // After tx.commit(): the handle stream belongs to the entry it made.
1090
+ if (waited) config.fill?.handles(waited);
691
1091
 
692
1092
  if (mode.type === "stale-revalidation") {
693
1093
  const historyKeyNow = store.getHistoryKey();
@@ -701,12 +1101,9 @@ export function createPartialUpdater(
701
1101
 
702
1102
  debugLog("[partial-update] updating document");
703
1103
 
704
- const hasTransition = shouldStartViewTransition(
705
- reconciled.segments,
706
- gatedOff,
707
- );
708
- const optimisticPresented =
709
- mode.type === "navigate" && mode.optimisticPresented === true;
1104
+ const presentation =
1105
+ mode.type === "navigate" ? mode.presentation : undefined;
1106
+ const optimisticPresented = presentation !== undefined;
710
1107
  // [VT-DIAG] Gated behind INTERNAL_RANGO_DEBUG. Reports which reconciled
711
1108
  // segment still carries a transition after the server-side when-gate, and
712
1109
  // whether the commit will be held in a startTransition. If `withTransition`
@@ -730,12 +1127,14 @@ export function createPartialUpdater(
730
1127
  // metadata); popstate navigations get no optimistic presentation anyway.
731
1128
  setActiveInterceptTargets(payload.metadata?.interceptTargets);
732
1129
 
733
- const scrollPayload = toScrollPayload(navScroll);
734
-
1130
+ // No scroll for a commit that is not a navigation (an action refetch):
1131
+ // navigation-transaction.ts CommitResult.
735
1132
  const update: NavigationUpdate = {
736
1133
  root: newTree,
737
- metadata: payload.metadata!,
738
- scroll: scrollPayload,
1134
+ metadata: cachedHandleData
1135
+ ? { ...payload.metadata, cachedHandleData }
1136
+ : payload.metadata!,
1137
+ ...(navScroll && { scroll: navScroll }),
739
1138
  };
740
1139
  if (process.env.NODE_ENV !== "production") {
741
1140
  auditTreeCause(treeCause(mode, tx.traversal));
@@ -763,11 +1162,19 @@ export function createPartialUpdater(
763
1162
  );
764
1163
  } else if (
765
1164
  !gatedOff &&
766
- (fullyPrefetched || isSameStructureNav || optimisticPresented)
1165
+ ((fullyPrefetched && !holes) ||
1166
+ isSameStructureNav ||
1167
+ optimisticPresented)
767
1168
  ) {
768
1169
  // Content-hold commit, two triggers. Fully-prefetched nav: the payload
769
1170
  // is fully resolved (forceAwait above), so the transition commits
770
- // synchronously — no fallback flash. Same-structure nav: the re-run
1171
+ // synchronously — no fallback flash. Not a payload with holes: its
1172
+ // page is not whole, and a plain click to it commits urgently. In a
1173
+ // transition React starts a view transition for the commit wherever
1174
+ // a <ViewTransition> is mounted on the page being left, which the
1175
+ // plain click does not start, and what fills the holes is revealed
1176
+ // behind it (measured with a 600 ms view transition: the page
1177
+ // complete at 641 ms against 307). Same-structure nav: the re-run
771
1178
  // loaders are still streaming, and an urgent commit would re-suspend
772
1179
  // the ALREADY-REVEALED boundaries — replacing visible content with its
773
1180
  // own fallback for the loader's full duration (the PLP filter-change
@@ -806,6 +1213,14 @@ export function createPartialUpdater(
806
1213
  // branch above (the opt-in).
807
1214
  onUpdate(update);
808
1215
  }
1216
+ // In a transition, so where React holds the tree the clear is held
1217
+ // with it (client-urls/navigation.ts).
1218
+ presentation?.clear();
1219
+
1220
+ // The tree is committed and handed to React, which may hold it on a
1221
+ // hole read with no boundary: this navigation fills it now, from the
1222
+ // request that left before the render.
1223
+ if (holes) await fillHoles(holes, signal, url, gatedOff);
809
1224
 
810
1225
  debugLog("[Browser] Navigation complete");
811
1226
  return;
@@ -813,12 +1228,6 @@ export function createPartialUpdater(
813
1228
  console.warn(`[Browser] Full update (fallback)`);
814
1229
 
815
1230
  const segments = payload.metadata?.segments || [];
816
-
817
- if (signal?.aborted) {
818
- debugLog("[Browser] Ignoring stale navigation (aborted)");
819
- return;
820
- }
821
-
822
1231
  const segmentIds = segments.map((s: ResolvedSegment) => s.id);
823
1232
 
824
1233
  const fullGatedOff = decideGatedOff(segments, payload.metadata ?? {});
@@ -841,11 +1250,10 @@ export function createPartialUpdater(
841
1250
  segments,
842
1251
  fullGatedOff,
843
1252
  );
844
- const fullScrollPayload = toScrollPayload(fullScroll);
845
1253
  const fullUpdate: NavigationUpdate = {
846
1254
  root: newTree,
847
1255
  metadata: payload.metadata!,
848
- scroll: fullScrollPayload,
1256
+ ...(fullScroll && { scroll: fullScroll }),
849
1257
  };
850
1258
 
851
1259
  if (process.env.NODE_ENV !== "production") {