@rangojs/router 0.22.1 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/dist/bin/rango.js +8 -3
  2. package/dist/testing/vitest.js +31 -1
  3. package/dist/types/browser/defer.d.ts +48 -0
  4. package/dist/types/browser/document-revealed.d.ts +17 -0
  5. package/dist/types/browser/event-controller.d.ts +7 -1
  6. package/dist/types/browser/navigation-transaction.d.ts +21 -7
  7. package/dist/types/browser/partial-update.d.ts +34 -5
  8. package/dist/types/browser/react/NavigationProvider.d.ts +13 -3
  9. package/dist/types/browser/react/filter-segment-order.d.ts +7 -0
  10. package/dist/types/browser/response-adapter.d.ts +7 -0
  11. package/dist/types/browser/types.d.ts +19 -9
  12. package/dist/types/browser/validate-redirect-origin.d.ts +11 -0
  13. package/dist/types/build/generate-route-types.d.ts +1 -1
  14. package/dist/types/build/route-types/router-processing.d.ts +1 -1
  15. package/dist/types/build/route-types/scan-filter.d.ts +16 -2
  16. package/dist/types/cache/cf/cf-cache-store.d.ts +16 -1
  17. package/dist/types/cache/cf/cf-cache-types.d.ts +23 -2
  18. package/dist/types/client-urls/client-urls.d.ts +1 -1
  19. package/dist/types/client-urls/hoisted-revalidate.d.ts +13 -0
  20. package/dist/types/client-urls/navigation.d.ts +0 -37
  21. package/dist/types/client-urls/revalidate-registry.d.ts +4 -0
  22. package/dist/types/client-urls/server-projection.d.ts +29 -1
  23. package/dist/types/client-urls/types.d.ts +34 -69
  24. package/dist/types/client.d.ts +0 -1
  25. package/dist/types/errors.d.ts +5 -0
  26. package/dist/types/handles/deferred-resolution.d.ts +2 -1
  27. package/dist/types/handles/pushed-by.d.ts +23 -0
  28. package/dist/types/loader-store.d.ts +8 -0
  29. package/dist/types/prefetch-markers.d.ts +11 -0
  30. package/dist/types/route-definition/helpers-types.d.ts +27 -0
  31. package/dist/types/router/is-action.d.ts +2 -1
  32. package/dist/types/router/segment-resolution/loader-mask.d.ts +3 -2
  33. package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +241 -0
  34. package/dist/types/server/context.d.ts +21 -1
  35. package/dist/types/server/handle-store.d.ts +9 -1
  36. package/dist/types/server/request-context.d.ts +14 -1
  37. package/dist/types/server.d.ts +1 -0
  38. package/dist/types/testing/flight.entry.d.ts +1 -1
  39. package/dist/types/testing/index.d.ts +3 -3
  40. package/dist/types/testing/internal/action-id.d.ts +10 -1
  41. package/dist/types/testing/internal/client-urls-projection.d.ts +16 -0
  42. package/dist/types/testing/internal/test-action.d.ts +8 -0
  43. package/dist/types/testing/run-loader.d.ts +2 -1
  44. package/dist/types/testing/run-revalidate.d.ts +74 -0
  45. package/dist/types/testing/run-transition-when.d.ts +2 -3
  46. package/dist/types/testing/serve-shell-request.d.ts +38 -0
  47. package/dist/types/testing/vitest.d.ts +11 -0
  48. package/dist/types/types/boundaries.d.ts +9 -0
  49. package/dist/types/types/handler-context.d.ts +14 -0
  50. package/dist/types/types/loader-types.d.ts +30 -0
  51. package/dist/types/types/segments.d.ts +21 -4
  52. package/dist/types/urls/path-helper-types.d.ts +25 -4
  53. package/dist/types/urls/type-extraction.d.ts +6 -2
  54. package/dist/types/vite/discovery/discover-routers.d.ts +9 -0
  55. package/dist/types/vite/discovery/state.d.ts +11 -4
  56. package/dist/types/vite/plugins/client-urls-revalidate-hoist.d.ts +54 -0
  57. package/dist/types/vite/plugins/hoist/literal.d.ts +83 -0
  58. package/dist/types/vite/plugins/hoist/scope.d.ts +40 -0
  59. package/dist/types/vite/plugins/hoist/source.d.ts +41 -0
  60. package/dist/types/vite/plugins/transition-when-hoist.d.ts +8 -11
  61. package/dist/types/vite/router-discovery.d.ts +10 -0
  62. package/dist/vite/index.js +842 -308
  63. package/package.json +1 -1
  64. package/skills/caching/SKILL.md +42 -5
  65. package/skills/catalog.json +2 -2
  66. package/skills/client-urls/SKILL.md +113 -24
  67. package/skills/cloudflare/SKILL.md +14 -9
  68. package/skills/hooks/SKILL.md +11 -3
  69. package/skills/links/SKILL.md +24 -0
  70. package/skills/loader/SKILL.md +192 -1
  71. package/skills/migrate-nextjs/SKILL.md +2 -2
  72. package/skills/migrate-react-router/SKILL.md +10 -7
  73. package/skills/migrate-react-router/data-and-actions.md +1 -1
  74. package/skills/ppr/SKILL.md +13 -0
  75. package/skills/prerender/SKILL.md +22 -8
  76. package/skills/rango/SKILL.md +12 -6
  77. package/skills/route/SKILL.md +139 -0
  78. package/skills/testing/SKILL.md +3 -2
  79. package/skills/testing/client-components.md +1 -1
  80. package/skills/testing/predicates.md +67 -32
  81. package/skills/testing/serve-shell-request.md +88 -18
  82. package/skills/testing/setup.md +2 -2
  83. package/skills/typesafety/route-types.md +2 -2
  84. package/skills/view-transitions/SKILL.md +26 -0
  85. package/src/browser/defer.ts +82 -0
  86. package/src/browser/document-revealed.ts +52 -0
  87. package/src/browser/event-controller.ts +43 -32
  88. package/src/browser/navigation-bridge.ts +64 -14
  89. package/src/browser/navigation-client.ts +48 -36
  90. package/src/browser/navigation-store.ts +13 -0
  91. package/src/browser/navigation-transaction.ts +25 -8
  92. package/src/browser/partial-update.ts +528 -135
  93. package/src/browser/prefetch/fetch.ts +2 -1
  94. package/src/browser/react/NavigationProvider.tsx +47 -28
  95. package/src/browser/react/filter-segment-order.ts +13 -2
  96. package/src/browser/response-adapter.ts +20 -0
  97. package/src/browser/rsc-router.tsx +17 -45
  98. package/src/browser/server-action-bridge.ts +19 -63
  99. package/src/browser/types.ts +19 -9
  100. package/src/browser/validate-redirect-origin.ts +16 -0
  101. package/src/build/generate-route-types.ts +1 -0
  102. package/src/build/route-types/router-processing.ts +3 -6
  103. package/src/build/route-types/scan-filter.ts +37 -10
  104. package/src/cache/cf/cf-cache-store.ts +59 -21
  105. package/src/cache/cf/cf-cache-types.ts +24 -2
  106. package/src/cache/document-cache.ts +22 -1
  107. package/src/client-urls/client-urls.ts +22 -6
  108. package/src/client-urls/hoisted-revalidate.ts +25 -0
  109. package/src/client-urls/navigation.ts +0 -96
  110. package/src/client-urls/revalidate-registry.ts +30 -0
  111. package/src/client-urls/server-projection.ts +151 -65
  112. package/src/client-urls/types.ts +39 -71
  113. package/src/client.tsx +9 -5
  114. package/src/errors.ts +15 -0
  115. package/src/handles/deferred-resolution.ts +28 -5
  116. package/src/handles/pushed-by.ts +122 -0
  117. package/src/loader-store.ts +18 -7
  118. package/src/prefetch-markers.ts +13 -0
  119. package/src/route-content-wrapper.tsx +18 -4
  120. package/src/route-definition/dsl-helpers.ts +27 -4
  121. package/src/route-definition/helpers-types.ts +27 -1
  122. package/src/router/intercept-resolution.ts +6 -4
  123. package/src/router/is-action.ts +2 -1
  124. package/src/router/loader-resolution.ts +83 -4
  125. package/src/router/match-api.ts +36 -1
  126. package/src/router/match-middleware/background-revalidation.ts +11 -1
  127. package/src/router/match-middleware/cache-lookup.ts +29 -6
  128. package/src/router/match-middleware/cache-store.ts +17 -1
  129. package/src/router/match-result.ts +14 -2
  130. package/src/router/navigation-snapshot.ts +2 -1
  131. package/src/router/segment-resolution/fresh.ts +22 -18
  132. package/src/router/segment-resolution/loader-cache.ts +2 -1
  133. package/src/router/segment-resolution/loader-mask.ts +5 -3
  134. package/src/router/segment-resolution/prefetch-deferral.ts +543 -0
  135. package/src/router/segment-resolution/revalidation.ts +169 -60
  136. package/src/rsc/rsc-rendering.ts +50 -7
  137. package/src/rsc/shell-capture.ts +2 -1
  138. package/src/segment-loader-promise.ts +23 -20
  139. package/src/segment-system.tsx +60 -26
  140. package/src/server/context.ts +39 -5
  141. package/src/server/handle-store.ts +104 -8
  142. package/src/server/request-context.ts +21 -1
  143. package/src/server.ts +4 -0
  144. package/src/testing/action-form.ts +2 -11
  145. package/src/testing/e2e/page-helpers.ts +3 -1
  146. package/src/testing/flight.entry.ts +1 -0
  147. package/src/testing/index.ts +6 -3
  148. package/src/testing/internal/action-id.ts +16 -1
  149. package/src/testing/internal/client-urls-projection.ts +70 -0
  150. package/src/testing/internal/test-action.ts +25 -0
  151. package/src/testing/run-loader.ts +2 -1
  152. package/src/testing/run-revalidate.ts +142 -0
  153. package/src/testing/run-transition-when.ts +7 -8
  154. package/src/testing/serve-shell-request.ts +116 -6
  155. package/src/testing/vitest.ts +67 -1
  156. package/src/types/boundaries.ts +12 -0
  157. package/src/types/handler-context.ts +14 -0
  158. package/src/types/loader-types.ts +30 -0
  159. package/src/types/segments.ts +26 -4
  160. package/src/urls/path-helper-types.ts +27 -5
  161. package/src/urls/type-extraction.ts +30 -10
  162. package/src/use-loader.tsx +18 -13
  163. package/src/vite/discovery/client-urls-projection.ts +20 -21
  164. package/src/vite/discovery/discover-routers.ts +94 -26
  165. package/src/vite/discovery/state.ts +20 -4
  166. package/src/vite/discovery/virtual-module-codegen.ts +22 -17
  167. package/src/vite/plugins/client-urls-revalidate-hoist.ts +564 -0
  168. package/src/vite/plugins/hoist/literal.ts +417 -0
  169. package/src/vite/plugins/hoist/scope.ts +370 -0
  170. package/src/vite/plugins/hoist/source.ts +84 -0
  171. package/src/vite/plugins/transition-when-hoist.ts +113 -779
  172. package/src/vite/rango.ts +2 -0
  173. package/src/vite/router-discovery.ts +93 -32
  174. package/dist/types/client-urls/revalidate-chain.d.ts +0 -33
  175. package/dist/types/client-urls/revalidation-protocol.d.ts +0 -25
  176. package/dist/types/testing/run-client-revalidate.d.ts +0 -43
  177. package/src/client-urls/revalidate-chain.ts +0 -83
  178. package/src/client-urls/revalidation-protocol.ts +0 -56
  179. 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 { 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
  /**
@@ -152,12 +206,14 @@ export type UpdateMode =
152
206
  /** Source URL for intercept restore (popstate cache miss) */
153
207
  interceptSourceUrl?: string;
154
208
  /**
155
- * The bridge already presented an optimistic clientUrls() destination
156
- * for this navigation: transition-lane commits add
209
+ * The optimistic clientUrls() destination the bridge presented for
210
+ * this navigation. Transition-lane commits add
157
211
  * OPTIMISTIC_COMMIT_TRANSITION_TYPE so router <ViewTransition>
158
- * boundaries do not animate the identical repaint.
212
+ * boundaries do not animate the identical repaint, and `clear()` runs
213
+ * once the canonical tree is handed to React: `useOutlet().pending`
214
+ * ends with that commit, not with a `prefetch: false` fill after it.
159
215
  */
160
- optimisticPresented?: boolean;
216
+ presentation?: { clear(): void };
161
217
  /**
162
218
  * A router.refresh() (or cross-tab refresh) of the current URL:
163
219
  * transition({ when }) sees kind "revalidate", not "replace".
@@ -197,10 +253,287 @@ export function createPartialUpdater(
197
253
  getVersion = () => undefined,
198
254
  } = config;
199
255
 
256
+ /**
257
+ * The segments the page on screen holds. A placeholder (`prefetch: false`,
258
+ * waiting for its fill) is not a copy of its segment: it never stands in
259
+ * for one in a reconcile, and its id is never sent as held (tx.commit below
260
+ * leaves it out of the store's ids).
261
+ */
200
262
  function getCurrentCachedSegments(): ResolvedSegment[] {
201
- const currentKey = store.getHistoryKey();
202
- const cached = store.getCachedSegments(currentKey);
203
- return cached?.segments || [];
263
+ return (entryOnScreen() || []).filter((s) => !isPlaceholder(s));
264
+ }
265
+
266
+ /**
267
+ * One defer per placeholder: a loader's is its `loaderData`, any other
268
+ * segment's is its `component`. Every reader of a hole (Suspender,
269
+ * useLoader, React itself where a segment has no loading()) reads it as a
270
+ * stream that has not arrived yet, and all of them wait on its one promise
271
+ * (browser/defer.ts readDefer, asStream).
272
+ */
273
+ function armHoles(
274
+ placeholders: ResolvedSegment[],
275
+ ): Map<ResolvedSegment, Defer<unknown>> {
276
+ const defers = new Map<ResolvedSegment, Defer<unknown>>();
277
+ for (const segment of placeholders) {
278
+ const hole = new Defer<unknown>();
279
+ if (segment.type === "loader") segment.loaderData = hole;
280
+ else segment.component = hole as unknown as ReactNode;
281
+ defers.set(segment, hole);
282
+ }
283
+ return defers;
284
+ }
285
+
286
+ /**
287
+ * A defer that will not be resolved must not stay a pending stream. It is
288
+ * never rejected either: a rejection is unhandled in every aggregate built
289
+ * over it (segment-loader-promise.ts).
290
+ */
291
+ function releaseHoles(holes: Holes): void {
292
+ for (const [placeholder, hole] of holes.defers) {
293
+ if (placeholder.type === "loader") {
294
+ store.loaders.releasePendingStream(placeholder.loaderId!, hole.promise);
295
+ }
296
+ }
297
+ }
298
+
299
+ /**
300
+ * The entry on screen, while it still holds these placeholders. By object,
301
+ * not by history key: a shallow navigation that copied the entry to a new
302
+ * key is still the page the answer is for, and an action refetch that
303
+ * already rendered the missing segments is not.
304
+ */
305
+ function entryToFill(holes: Holes): ResolvedSegment[] | undefined {
306
+ const live = entryOnScreen();
307
+ return live?.some((s) => holes.defers.has(s)) ? live : undefined;
308
+ }
309
+
310
+ /** The page on screen holds every one of `segments` (by id). */
311
+ function holdsAll(segments: ResolvedSegment[]): boolean {
312
+ const onScreenIds = new Set(getCurrentCachedSegments().map((s) => s.id));
313
+ return segments.every((s) => onScreenIds.has(s.id));
314
+ }
315
+
316
+ function entryOnScreen(): ResolvedSegment[] | undefined {
317
+ return store.getCachedSegments(store.getHistoryKey())?.segments;
318
+ }
319
+
320
+ /**
321
+ * Act on the redirect a navigation was answered with. True when `metadata`
322
+ * carried one: the navigation is done here. A target in the app is thrown
323
+ * for the bridge to follow. An explicit off-host one
324
+ * (redirect(url, { external: true })) is a hard navigation, still
325
+ * scheme-validated (http/https only): `external` waives the same-origin
326
+ * check the app opted out of, not scheme safety, so a forged payload
327
+ * carrying a javascript: or data: URL cannot script via location.assign. A
328
+ * target the client refuses is dropped.
329
+ */
330
+ function redirected(metadata: RscMetadata | undefined): boolean {
331
+ const redirect = metadata?.redirect;
332
+ if (!redirect) return false;
333
+ const target = validatePayloadRedirect(redirect);
334
+ if (!target) {
335
+ debugLog("[Browser] Ignoring blocked redirect payload");
336
+ } else if (redirect.external) {
337
+ debugLog("[Browser] External redirect (hard navigation)");
338
+ window.location.assign(target);
339
+ } else {
340
+ throw new ServerRedirect(target, metadata.locationState);
341
+ }
342
+ return true;
343
+ }
344
+
345
+ /**
346
+ * Follow a redirect a fill was answered with: a replace navigation through
347
+ * the bridge, a document navigation for an external target or with no
348
+ * bridge. False when the client refuses the target.
349
+ */
350
+ function followFillRedirect(
351
+ redirect: { url: string; external?: boolean },
352
+ state: Record<string, unknown> | undefined,
353
+ ): boolean {
354
+ const target = validatePayloadRedirect(redirect);
355
+ if (!target) return false;
356
+ if (redirect.external || !config.fill) window.location.assign(target);
357
+ else config.fill.redirect(target, state);
358
+ return true;
359
+ }
360
+
361
+ /**
362
+ * Fill the holes of the tree this navigation committed with its fill's
363
+ * answer (`holes.answer`): a partial payload that carries the segment each placeholder
364
+ * stands for. Each defer resolves with that segment's value, the stored
365
+ * entry is rewritten with the segments themselves, and what the answer's
366
+ * handlers pushed and the location state they set go to the stores their
367
+ * readers subscribe to. React is handed nothing: the tree that reads the
368
+ * defers is the page, and what resolves reaches it as a stream that
369
+ * arrived.
370
+ *
371
+ * Called after tx.commit(), so the answer can never land before the tree it
372
+ * fills. The navigation's signal is the one thing that cancels it: a newer
373
+ * navigation or a back/forward aborts the request while it waits for its
374
+ * response (navigation-client.ts), and an answer that arrives after the
375
+ * abort is dropped. Either way the defers are released, not resolved.
376
+ *
377
+ * One answer does replace the page: one whose segments are not the ones the
378
+ * tree was built for. A handler that was awaited threw or called
379
+ * notFound(), and its entry's segments are one error segment instead
380
+ * (router/segment-resolution/helpers.ts catchSegmentError). That is the
381
+ * outcome of the navigation, and the segments are rendered as a
382
+ * navigation's are. `gatedOff` is the tree's transition({ when }) decision,
383
+ * for that render.
384
+ *
385
+ * Never rejects.
386
+ */
387
+ async function fillHoles(
388
+ holes: Holes,
389
+ signal: AbortSignal | undefined,
390
+ url: string,
391
+ gatedOff: boolean,
392
+ ): Promise<void> {
393
+ let filled = false;
394
+ try {
395
+ const result = await holes.answer;
396
+ // A page that no longer waits for the answer (an action refetch
397
+ // rendered what the placeholders stand for) takes nothing of it, its
398
+ // redirect included.
399
+ const live = entryToFill(holes);
400
+ if (signal?.aborted || !live) return;
401
+
402
+ const metadata = result.payload.metadata;
403
+ if (metadata?.redirect) {
404
+ if (!followFillRedirect(metadata.redirect, metadata.locationState)) {
405
+ throw new Error(FILL_REDIRECT_NOT_FOLLOWED);
406
+ }
407
+ return;
408
+ }
409
+ if (!metadata?.isPartial) {
410
+ throw new Error("[rango] fill: not a partial payload");
411
+ }
412
+
413
+ const matched = metadata.matched || [];
414
+ const reconciled = reconcileSegments({
415
+ actor: "stale-revalidation",
416
+ matched,
417
+ diff: metadata.diff || [],
418
+ serverSegments: metadata.segments || [],
419
+ cachedSegments: live.filter((s) => !isPlaceholder(s)),
420
+ insertMissingDiff: true,
421
+ });
422
+ const missing = missingIds(matched, reconciled.segments);
423
+ if (missing.length > 0) {
424
+ throw new Error(
425
+ `[rango] fill: missing segments [${missing.join(", ")}]`,
426
+ );
427
+ }
428
+
429
+ // Nothing of the answer is handed over before this point: what an
430
+ // answer the client then refuses had set would stay under the error.
431
+ if (metadata.locationState) {
432
+ config.fill?.locationState(metadata.locationState);
433
+ }
434
+
435
+ const placeholders = [...holes.defers.keys()];
436
+ const answers = placeholders.map((placeholder) =>
437
+ reconciled.segments.find(
438
+ (s) => s.id === placeholder.id && s.type === placeholder.type,
439
+ ),
440
+ );
441
+ if (
442
+ answers.includes(undefined) ||
443
+ matched.join() !== holes.matched.join()
444
+ ) {
445
+ // The tree's handle stream (NavigationProvider processHandles) reads
446
+ // this array on every yield and drops the buckets of segments
447
+ // outside it.
448
+ replace(holes.matched, matched);
449
+ // Each place a hole stood is handed its defer again, resolved with
450
+ // what the answer put there, and its reader applies the read rule
451
+ // (browser/defer.ts): a reader that suspended on it while it mounted
452
+ // calls use() of the same promise, which React has marked; one React
453
+ // never read (a hole below another one) reads the value, so this
454
+ // urgent update shows no fallback the plain click does not show
455
+ // (navigation-adoption-loading.test.tsx, "a failed route's error").
456
+ const settled = new Map<ResolvedSegment, ResolvedSegment>();
457
+ for (const [placeholder, hole] of holes.defers) {
458
+ if (placeholder.type === "loader") continue;
459
+ // By id alone: an error segment takes its route's place.
460
+ const next = reconciled.mainSegments.find(
461
+ (s) => s.id === placeholder.id,
462
+ );
463
+ if (next && !(next.component instanceof Promise)) {
464
+ hole.resolve(next.component);
465
+ settled.set(next, {
466
+ ...next,
467
+ component: hole as unknown as ReactNode,
468
+ });
469
+ }
470
+ }
471
+ const main = reconciled.mainSegments.map((s) => settled.get(s) ?? s);
472
+ const root = await renderSegments(main, {
473
+ transitionGatedOff: gatedOff,
474
+ interceptSegments:
475
+ reconciled.interceptSegments.length > 0
476
+ ? reconciled.interceptSegments
477
+ : undefined,
478
+ });
479
+ if (signal?.aborted || entryOnScreen() !== live) return;
480
+ releaseHoles(holes);
481
+ // The navigation's outcome (see fillHoles), so its cause.
482
+ if (process.env.NODE_ENV !== "production") {
483
+ auditTreeCause("navigation");
484
+ }
485
+ // No `scroll`: the transaction decided it with the click's update.
486
+ // A decision here, even "do not scroll", would replace that pending
487
+ // one (NavigationProvider).
488
+ onUpdate({ root, metadata });
489
+ } else {
490
+ config.fill?.handles(metadata);
491
+ // All in one task, each with its own stream or node: a hole below
492
+ // another one is read in the render the outer one's resolution
493
+ // starts. Resolved by then with a node, it costs that render nothing
494
+ // where a router reader reads it (browser/defer.ts readDefer); with a
495
+ // stream, or where React reads the node itself, one microtask. Not a
496
+ // fallback.
497
+ placeholders.forEach((placeholder, index) => {
498
+ const next = answers[index]!;
499
+ holes.defers
500
+ .get(placeholder)!
501
+ .resolve(
502
+ placeholder.type === "loader" ? next.loaderData : next.component,
503
+ );
504
+ });
505
+ }
506
+
507
+ // The entry keeps the answer's own streams and nodes, not the defers:
508
+ // a defer belongs to the tree on screen, and the next render from this
509
+ // page builds on the entry. The answer belongs to the visit that built
510
+ // the tree: the entry is rewritten in place, not re-cached.
511
+ // cacheSegmentsForHistory would advance the store's nav instance and
512
+ // disown the tree's handle stream, and would reset the entry's stale
513
+ // flag.
514
+ replace(live, reconciled.segments);
515
+ store.setSegmentIds(matched);
516
+ filled = true;
517
+ } catch (thrown) {
518
+ // Only on a page that still waits for this answer.
519
+ if (signal?.aborted || !entryToFill(holes)) return;
520
+ let error = thrown;
521
+ if (error instanceof ServerRedirect) {
522
+ if (followFillRedirect({ url: error.url }, error.state)) return;
523
+ error = new Error(FILL_REDIRECT_NOT_FOLLOWED);
524
+ }
525
+ console.error("[rango] fill failed:", error);
526
+ // Shown the way a navigation the client cannot process is
527
+ // (navigation-bridge.ts): the error replaces the page, so an error
528
+ // boundary takes over and no fallback is left waiting.
529
+ emitNavigationError(
530
+ onUpdate,
531
+ toNetworkError(error, { url, operation: "navigation" }) ?? error,
532
+ url,
533
+ );
534
+ } finally {
535
+ if (!filled) releaseHoles(holes);
536
+ }
204
537
  }
205
538
 
206
539
  async function fetchPartialUpdate(
@@ -316,31 +649,6 @@ export function createPartialUpdater(
316
649
  );
317
650
  }
318
651
 
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
652
  let fetchResult: Awaited<ReturnType<NavigationClient["fetchPartial"]>>;
345
653
  fetchResult = await client.fetchPartial({
346
654
  targetUrl: url,
@@ -348,7 +656,6 @@ export function createPartialUpdater(
348
656
  previousUrl,
349
657
  staleRevalidation:
350
658
  mode.type === "stale-revalidation" || segments.length === 0,
351
- clientRevalidation,
352
659
  version: getVersion(),
353
660
  routerId: store.getRouterId?.(),
354
661
  });
@@ -363,7 +670,26 @@ export function createPartialUpdater(
363
670
  // Side effect only: end the streaming token once the stream settles.
364
671
  // The wrapped promise was never read as a value; only the .end() matters.
365
672
  // The .catch keeps an unhandled rejection from leaking if the stream errors.
366
- rawStreamComplete.then(() => streamingToken.end()).catch(() => {});
673
+ // A payload with deferred segments is still streaming until its fill has
674
+ // (`filled` is set below, before this function first awaits). A
675
+ // navigation that is left ignores its response, which is never cancelled
676
+ // (navigation-client.ts): it streams to its end, and stops counting as
677
+ // streaming at the abort.
678
+ let filled: Promise<unknown> | undefined;
679
+ const endStream = (): void => {
680
+ signal?.removeEventListener("abort", endStream);
681
+ streamingToken.end();
682
+ };
683
+ signal?.addEventListener("abort", endStream);
684
+ rawStreamComplete
685
+ .then(() => (filled ? filled.then(endStream) : endStream()))
686
+ .catch(() => {});
687
+
688
+ if (signal?.aborted) {
689
+ endStream();
690
+ debugLog("[Browser] Ignoring stale navigation (aborted)");
691
+ return;
692
+ }
367
693
 
368
694
  const currentRouterId = store.getRouterId?.();
369
695
  if (
@@ -379,49 +705,12 @@ export function createPartialUpdater(
379
705
  return;
380
706
  }
381
707
 
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
- }
708
+ if (redirected(payload.metadata)) return;
416
709
 
417
710
  if (payload.metadata?.isPartial) {
418
711
  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
- }
712
+ let serverSegments = newSegments || [];
713
+ let sent = diff || [];
425
714
 
426
715
  if (IS_BROWSER_DEBUG) {
427
716
  debugLog(`[Browser] Partial update - matched: ${matched?.join(", ")}`);
@@ -448,6 +737,7 @@ export function createPartialUpdater(
448
737
  forceAwait: true,
449
738
  transitionGatedOff: cachedGatedOff,
450
739
  });
740
+ if (signal?.aborted) return;
451
741
 
452
742
  const { scroll: commitScroll } = tx.commit(
453
743
  matchedIds,
@@ -470,7 +760,7 @@ export function createPartialUpdater(
470
760
  ...metadataWithoutHandles,
471
761
  cachedHandleData: mode.targetCacheHandleData,
472
762
  },
473
- scroll: toScrollPayload(commitScroll),
763
+ ...(commitScroll && { scroll: commitScroll }),
474
764
  };
475
765
 
476
766
  if (process.env.NODE_ENV !== "production") {
@@ -500,6 +790,7 @@ export function createPartialUpdater(
500
790
  const newTree = await renderSegments(existingSegments, {
501
791
  forceAwait: true,
502
792
  });
793
+ if (signal?.aborted) return;
503
794
 
504
795
  const { scroll: leaveScroll } = tx.commit(
505
796
  matchedIds,
@@ -513,7 +804,7 @@ export function createPartialUpdater(
513
804
  onUpdate({
514
805
  root: newTree,
515
806
  metadata: payload.metadata,
516
- scroll: toScrollPayload(leaveScroll),
807
+ ...(leaveScroll && { scroll: leaveScroll }),
517
808
  });
518
809
 
519
810
  debugLog("[Browser] Navigation complete (left intercept)");
@@ -531,33 +822,49 @@ export function createPartialUpdater(
531
822
  return;
532
823
  }
533
824
 
534
- const matchedIds = matched || [];
825
+ let matchedIds = matched || [];
535
826
  const actor: ReconcileActor =
536
827
  mode.type === "stale-revalidation" || mode.type === "action"
537
828
  ? "stale-revalidation"
538
829
  : "navigation";
539
830
 
540
- const reconciled = reconcileSegments({
541
- actor,
542
- matched: matchedIds,
543
- diff: diff || [],
544
- serverSegments: newSegments || [],
545
- cachedSegments: cachedSegs,
546
- insertMissingDiff: true,
547
- });
831
+ // prefetch: false. A payload that carries deferred segments (an adopted
832
+ // prefetch, whatever answered it) is the page with holes in it: one
833
+ // placeholder per segment whose work did not run, each with what its
834
+ // wrapper chain is built from. The tree is built once, with a deferred
835
+ // in place of each missing value, and this navigation fills them from
836
+ // one fill request once it has committed the tree.
837
+ let defers: Holes["defers"] | undefined;
838
+ let heldIds = matchedIds;
839
+ const placeholders = serverSegments.filter(isPlaceholder);
840
+ if (placeholders.length > 0) {
841
+ defers = armHoles(placeholders);
842
+ // A placeholder is not held: every later request from this page
843
+ // leaves its id out, so the server renders it.
844
+ const deferredIds = new Set(placeholders.map((s) => s.id));
845
+ heldIds = matchedIds.filter((id) => !deferredIds.has(id));
846
+ }
548
847
 
549
- const reconciledIdSet = new Set(reconciled.segments.map((s) => s.id));
550
- const missingIds = matchedIds.filter(
551
- (id: string) => !reconciledIdSet.has(id),
552
- );
848
+ const reconcile = () =>
849
+ reconcileSegments({
850
+ actor,
851
+ matched: matchedIds,
852
+ diff: sent,
853
+ serverSegments,
854
+ cachedSegments: cachedSegs,
855
+ insertMissingDiff: true,
856
+ });
857
+ let reconciled = reconcile();
553
858
 
554
- if (missingIds.length > 0) {
555
- const missingCount = missingIds.length;
859
+ const missing = missingIds(matchedIds, reconciled.segments);
860
+
861
+ if (missing.length > 0) {
862
+ const missingCount = missing.length;
556
863
 
557
864
  if (isRetry) {
558
- console.warn("Missing ids", { missingIds });
865
+ console.warn("Missing ids", { missingIds: missing });
559
866
  throw new Error(
560
- `[Browser] Failed to fetch segments after retry. Missing: [${missingIds.join(", ")}]`,
867
+ `[Browser] Failed to fetch segments after retry. Missing: [${missing.join(", ")}]`,
561
868
  );
562
869
  }
563
870
  if (signal?.aborted) {
@@ -593,6 +900,81 @@ export function createPartialUpdater(
593
900
  // reach renderSegments. The response carries no decision, so a reused
594
901
  // prefetch is decided against the real source.
595
902
  const gatedOff = decideGatedOff(reconciled.segments, payload.metadata);
903
+
904
+ // The fill: one request for what the prefetch deferred, listing what
905
+ // the client holds once this payload commits. It leaves now, before
906
+ // the tree is rendered, with this navigation's signal.
907
+ let holes: Holes | undefined;
908
+ if (defers) {
909
+ holes = {
910
+ defers,
911
+ matched: matchedIds,
912
+ answer: client.fetchPartial({
913
+ targetUrl: url,
914
+ segmentIds: heldIds,
915
+ // The page the fill completes is the page it is on.
916
+ previousUrl: url,
917
+ fill: true,
918
+ signal,
919
+ version: getVersion(),
920
+ routerId: store.getRouterId?.(),
921
+ }),
922
+ };
923
+ filled = holes.answer
924
+ .then((answer) => answer.streamComplete)
925
+ .catch(() => {});
926
+ }
927
+
928
+ // What a fill brought for a page that waited for it (below).
929
+ let waited: RscMetadata | undefined;
930
+ if (
931
+ holes &&
932
+ placeholders.some((s) => s.type !== "loader") &&
933
+ shouldStartViewTransition(reconciled.segments, gatedOff)
934
+ ) {
935
+ // A deferred handler on a page that commits in a transition. React
936
+ // renders such a page's content in the commit that shows its
937
+ // fallback, in a render that can wait: a plain click commits it with
938
+ // its response, URL, hooks and page together. Committed with the
939
+ // click and filled afterwards, the URL moved a round trip before the
940
+ // page, and no later commit could bring the content in without a
941
+ // view transition the plain click does not start (measured: three
942
+ // for two, the content up to 280 ms late). So this click is a
943
+ // navigation that waits for its fill: nothing commits until the
944
+ // fill's first chunk, and then the page commits once, from the
945
+ // prefetched segments and the fill's, with no hole in it.
946
+ const { answer } = holes;
947
+ holes = undefined;
948
+ waited = (await answer).payload.metadata;
949
+ if (signal?.aborted || redirected(waited)) return;
950
+ if (waited?.isPartial) {
951
+ matchedIds = heldIds = waited.matched || [];
952
+ serverSegments = [
953
+ ...serverSegments.filter((s) => !isPlaceholder(s)),
954
+ ...(waited.segments || []),
955
+ ];
956
+ sent = [...sent, ...(waited.diff || [])];
957
+ reconciled = reconcile();
958
+ if (waited.locationState) {
959
+ payload.metadata.locationState = {
960
+ ...payload.metadata.locationState,
961
+ ...waited.locationState,
962
+ };
963
+ }
964
+ }
965
+ // The two answers do not make the page (the routes changed between
966
+ // the prefetch and the click): start over with nothing held, as a
967
+ // navigation whose own response misses segments does above. That
968
+ // request is never answered from a prefetch, so it defers nothing.
969
+ if (
970
+ !waited?.isPartial ||
971
+ missingIds(matchedIds, reconciled.segments).length > 0
972
+ ) {
973
+ return fetchPartialUpdate(url, [], true, signal, tx, mode);
974
+ }
975
+ } else if (holes) {
976
+ config.fill?.flush();
977
+ }
596
978
  const renderOptions = {
597
979
  transitionGatedOff: gatedOff,
598
980
  isAction: mode.type === "action",
@@ -600,8 +982,10 @@ export function createPartialUpdater(
600
982
  // land without a loading()/fallback frame. A fully-prefetched nav has
601
983
  // its router data already resolved (the prefetch stream drained), so
602
984
  // 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,
985
+ // (fullyPrefetched branch) so nothing router-owned can flash. A page
986
+ // that waited for its fill is still streaming it.
987
+ forceAwait:
988
+ mode.type === "stale-revalidation" || (fullyPrefetched && !waited),
605
989
  interceptSegments:
606
990
  reconciled.interceptSegments.length > 0
607
991
  ? reconciled.interceptSegments
@@ -657,10 +1041,8 @@ export function createPartialUpdater(
657
1041
  // cache, which says nothing about what is visible now. Restricted to
658
1042
  // plain navigations — leave-intercept must close its modal urgently,
659
1043
  // and action/stale modes have their own transition branch.
660
- const onScreenIds = new Set(getCurrentCachedSegments().map((s) => s.id));
661
1044
  const isSameStructureNav =
662
- mode.type === "navigate" &&
663
- reconciled.segments.every((s) => onScreenIds.has(s.id));
1045
+ mode.type === "navigate" && holdsAll(reconciled.segments);
664
1046
 
665
1047
  const effectiveInterceptSource =
666
1048
  interceptSourceUrl || segmentState.currentUrl;
@@ -672,7 +1054,6 @@ export function createPartialUpdater(
672
1054
  }
673
1055
  }
674
1056
 
675
- const allSegmentIds = matchedIds;
676
1057
  const serverLocationState = payload.metadata?.locationState;
677
1058
  const overrides: CommitOverrides = {
678
1059
  routeName: payload.metadata?.routeName,
@@ -684,10 +1065,16 @@ export function createPartialUpdater(
684
1065
  ...(serverLocationState && { serverState: serverLocationState }),
685
1066
  };
686
1067
  const { scroll: navScroll } = tx.commit(
687
- allSegmentIds,
1068
+ heldIds,
688
1069
  reconciled.segments,
689
1070
  overrides,
690
1071
  );
1072
+ const hasTransition = shouldStartViewTransition(
1073
+ reconciled.segments,
1074
+ gatedOff,
1075
+ );
1076
+ // After tx.commit(): the handle stream belongs to the entry it made.
1077
+ if (waited) config.fill?.handles(waited);
691
1078
 
692
1079
  if (mode.type === "stale-revalidation") {
693
1080
  const historyKeyNow = store.getHistoryKey();
@@ -701,12 +1088,9 @@ export function createPartialUpdater(
701
1088
 
702
1089
  debugLog("[partial-update] updating document");
703
1090
 
704
- const hasTransition = shouldStartViewTransition(
705
- reconciled.segments,
706
- gatedOff,
707
- );
708
- const optimisticPresented =
709
- mode.type === "navigate" && mode.optimisticPresented === true;
1091
+ const presentation =
1092
+ mode.type === "navigate" ? mode.presentation : undefined;
1093
+ const optimisticPresented = presentation !== undefined;
710
1094
  // [VT-DIAG] Gated behind INTERNAL_RANGO_DEBUG. Reports which reconciled
711
1095
  // segment still carries a transition after the server-side when-gate, and
712
1096
  // whether the commit will be held in a startTransition. If `withTransition`
@@ -730,12 +1114,12 @@ export function createPartialUpdater(
730
1114
  // metadata); popstate navigations get no optimistic presentation anyway.
731
1115
  setActiveInterceptTargets(payload.metadata?.interceptTargets);
732
1116
 
733
- const scrollPayload = toScrollPayload(navScroll);
734
-
1117
+ // No scroll for a commit that is not a navigation (an action refetch):
1118
+ // navigation-transaction.ts CommitResult.
735
1119
  const update: NavigationUpdate = {
736
1120
  root: newTree,
737
1121
  metadata: payload.metadata!,
738
- scroll: scrollPayload,
1122
+ ...(navScroll && { scroll: navScroll }),
739
1123
  };
740
1124
  if (process.env.NODE_ENV !== "production") {
741
1125
  auditTreeCause(treeCause(mode, tx.traversal));
@@ -763,11 +1147,19 @@ export function createPartialUpdater(
763
1147
  );
764
1148
  } else if (
765
1149
  !gatedOff &&
766
- (fullyPrefetched || isSameStructureNav || optimisticPresented)
1150
+ ((fullyPrefetched && !holes) ||
1151
+ isSameStructureNav ||
1152
+ optimisticPresented)
767
1153
  ) {
768
1154
  // Content-hold commit, two triggers. Fully-prefetched nav: the payload
769
1155
  // is fully resolved (forceAwait above), so the transition commits
770
- // synchronously — no fallback flash. Same-structure nav: the re-run
1156
+ // synchronously — no fallback flash. Not a payload with holes: its
1157
+ // page is not whole, and a plain click to it commits urgently. In a
1158
+ // transition React starts a view transition for the commit wherever
1159
+ // a <ViewTransition> is mounted on the page being left, which the
1160
+ // plain click does not start, and what fills the holes is revealed
1161
+ // behind it (measured with a 600 ms view transition: the page
1162
+ // complete at 641 ms against 307). Same-structure nav: the re-run
771
1163
  // loaders are still streaming, and an urgent commit would re-suspend
772
1164
  // the ALREADY-REVEALED boundaries — replacing visible content with its
773
1165
  // own fallback for the loader's full duration (the PLP filter-change
@@ -806,6 +1198,14 @@ export function createPartialUpdater(
806
1198
  // branch above (the opt-in).
807
1199
  onUpdate(update);
808
1200
  }
1201
+ // In a transition, so where React holds the tree the clear is held
1202
+ // with it (client-urls/navigation.ts).
1203
+ presentation?.clear();
1204
+
1205
+ // The tree is committed and handed to React, which may hold it on a
1206
+ // hole read with no boundary: this navigation fills it now, from the
1207
+ // request that left before the render.
1208
+ if (holes) await fillHoles(holes, signal, url, gatedOff);
809
1209
 
810
1210
  debugLog("[Browser] Navigation complete");
811
1211
  return;
@@ -813,12 +1213,6 @@ export function createPartialUpdater(
813
1213
  console.warn(`[Browser] Full update (fallback)`);
814
1214
 
815
1215
  const segments = payload.metadata?.segments || [];
816
-
817
- if (signal?.aborted) {
818
- debugLog("[Browser] Ignoring stale navigation (aborted)");
819
- return;
820
- }
821
-
822
1216
  const segmentIds = segments.map((s: ResolvedSegment) => s.id);
823
1217
 
824
1218
  const fullGatedOff = decideGatedOff(segments, payload.metadata ?? {});
@@ -841,11 +1235,10 @@ export function createPartialUpdater(
841
1235
  segments,
842
1236
  fullGatedOff,
843
1237
  );
844
- const fullScrollPayload = toScrollPayload(fullScroll);
845
1238
  const fullUpdate: NavigationUpdate = {
846
1239
  root: newTree,
847
1240
  metadata: payload.metadata!,
848
- scroll: fullScrollPayload,
1241
+ ...(fullScroll && { scroll: fullScroll }),
849
1242
  };
850
1243
 
851
1244
  if (process.env.NODE_ENV !== "production") {