@rangojs/router 0.0.0-experimental.13 → 0.0.0-experimental.13221847

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 (298) hide show
  1. package/AGENTS.md +9 -0
  2. package/README.md +884 -4
  3. package/dist/bin/rango.js +1531 -212
  4. package/dist/vite/index.js +3995 -2489
  5. package/package.json +57 -52
  6. package/skills/breadcrumbs/SKILL.md +250 -0
  7. package/skills/cache-guide/SKILL.md +262 -0
  8. package/skills/caching/SKILL.md +85 -23
  9. package/skills/composability/SKILL.md +172 -0
  10. package/skills/debug-manifest/SKILL.md +12 -8
  11. package/skills/document-cache/SKILL.md +18 -16
  12. package/skills/fonts/SKILL.md +6 -4
  13. package/skills/hooks/SKILL.md +328 -70
  14. package/skills/host-router/SKILL.md +218 -0
  15. package/skills/intercept/SKILL.md +131 -8
  16. package/skills/layout/SKILL.md +100 -3
  17. package/skills/links/SKILL.md +62 -15
  18. package/skills/loader/SKILL.md +368 -42
  19. package/skills/middleware/SKILL.md +171 -34
  20. package/skills/mime-routes/SKILL.md +14 -10
  21. package/skills/parallel/SKILL.md +137 -1
  22. package/skills/prerender/SKILL.md +366 -28
  23. package/skills/rango/SKILL.md +85 -21
  24. package/skills/response-routes/SKILL.md +136 -83
  25. package/skills/route/SKILL.md +195 -21
  26. package/skills/router-setup/SKILL.md +123 -30
  27. package/skills/theme/SKILL.md +9 -8
  28. package/skills/typesafety/SKILL.md +240 -102
  29. package/skills/use-cache/SKILL.md +324 -0
  30. package/src/__internal.ts +102 -4
  31. package/src/bin/rango.ts +312 -15
  32. package/src/browser/action-coordinator.ts +97 -0
  33. package/src/browser/action-response-classifier.ts +99 -0
  34. package/src/browser/event-controller.ts +92 -64
  35. package/src/browser/history-state.ts +80 -0
  36. package/src/browser/intercept-utils.ts +52 -0
  37. package/src/browser/link-interceptor.ts +24 -4
  38. package/src/browser/logging.ts +11 -0
  39. package/src/browser/merge-segment-loaders.ts +20 -12
  40. package/src/browser/navigation-bridge.ts +266 -558
  41. package/src/browser/navigation-client.ts +132 -75
  42. package/src/browser/navigation-store.ts +33 -50
  43. package/src/browser/navigation-transaction.ts +297 -0
  44. package/src/browser/network-error-handler.ts +61 -0
  45. package/src/browser/partial-update.ts +303 -309
  46. package/src/browser/prefetch/cache.ts +206 -0
  47. package/src/browser/prefetch/fetch.ts +144 -0
  48. package/src/browser/prefetch/observer.ts +65 -0
  49. package/src/browser/prefetch/policy.ts +48 -0
  50. package/src/browser/prefetch/queue.ts +128 -0
  51. package/src/browser/rango-state.ts +112 -0
  52. package/src/browser/react/Link.tsx +190 -70
  53. package/src/browser/react/NavigationProvider.tsx +78 -11
  54. package/src/browser/react/context.ts +6 -0
  55. package/src/browser/react/filter-segment-order.ts +11 -0
  56. package/src/browser/react/index.ts +12 -12
  57. package/src/browser/react/location-state-shared.ts +95 -53
  58. package/src/browser/react/location-state.ts +60 -15
  59. package/src/browser/react/mount-context.ts +6 -1
  60. package/src/browser/react/nonce-context.ts +23 -0
  61. package/src/browser/react/shallow-equal.ts +27 -0
  62. package/src/browser/react/use-action.ts +29 -51
  63. package/src/browser/react/use-client-cache.ts +5 -3
  64. package/src/browser/react/use-handle.ts +29 -70
  65. package/src/browser/react/use-link-status.ts +6 -5
  66. package/src/browser/react/use-navigation.ts +22 -63
  67. package/src/browser/react/use-params.ts +65 -0
  68. package/src/browser/react/use-pathname.ts +47 -0
  69. package/src/browser/react/use-router.ts +63 -0
  70. package/src/browser/react/use-search-params.ts +56 -0
  71. package/src/browser/react/use-segments.ts +80 -97
  72. package/src/browser/response-adapter.ts +73 -0
  73. package/src/browser/rsc-router.tsx +188 -57
  74. package/src/browser/scroll-restoration.ts +117 -44
  75. package/src/browser/segment-reconciler.ts +221 -0
  76. package/src/browser/segment-structure-assert.ts +16 -0
  77. package/src/browser/server-action-bridge.ts +488 -606
  78. package/src/browser/shallow.ts +6 -1
  79. package/src/browser/types.ts +116 -47
  80. package/src/browser/validate-redirect-origin.ts +29 -0
  81. package/src/build/generate-manifest.ts +63 -21
  82. package/src/build/generate-route-types.ts +36 -1038
  83. package/src/build/index.ts +2 -5
  84. package/src/build/route-trie.ts +38 -12
  85. package/src/build/route-types/ast-helpers.ts +25 -0
  86. package/src/build/route-types/ast-route-extraction.ts +98 -0
  87. package/src/build/route-types/codegen.ts +102 -0
  88. package/src/build/route-types/include-resolution.ts +411 -0
  89. package/src/build/route-types/param-extraction.ts +48 -0
  90. package/src/build/route-types/per-module-writer.ts +128 -0
  91. package/src/build/route-types/router-processing.ts +479 -0
  92. package/src/build/route-types/scan-filter.ts +78 -0
  93. package/src/build/runtime-discovery.ts +231 -0
  94. package/src/cache/background-task.ts +34 -0
  95. package/src/cache/cache-key-utils.ts +44 -0
  96. package/src/cache/cache-policy.ts +125 -0
  97. package/src/cache/cache-runtime.ts +342 -0
  98. package/src/cache/cache-scope.ts +122 -303
  99. package/src/cache/cf/cf-cache-store.ts +571 -17
  100. package/src/cache/cf/index.ts +13 -3
  101. package/src/cache/document-cache.ts +116 -77
  102. package/src/cache/handle-capture.ts +81 -0
  103. package/src/cache/handle-snapshot.ts +41 -0
  104. package/src/cache/index.ts +1 -15
  105. package/src/cache/memory-segment-store.ts +191 -13
  106. package/src/cache/profile-registry.ts +73 -0
  107. package/src/cache/read-through-swr.ts +134 -0
  108. package/src/cache/segment-codec.ts +256 -0
  109. package/src/cache/taint.ts +98 -0
  110. package/src/cache/types.ts +72 -122
  111. package/src/client.rsc.tsx +3 -1
  112. package/src/client.tsx +84 -126
  113. package/src/component-utils.ts +4 -4
  114. package/src/components/DefaultDocument.tsx +5 -1
  115. package/src/context-var.ts +86 -0
  116. package/src/debug.ts +19 -9
  117. package/src/errors.ts +77 -7
  118. package/src/handle.ts +12 -7
  119. package/src/handles/MetaTags.tsx +73 -20
  120. package/src/handles/breadcrumbs.ts +66 -0
  121. package/src/handles/index.ts +1 -0
  122. package/src/handles/meta.ts +30 -13
  123. package/src/host/cookie-handler.ts +21 -15
  124. package/src/host/errors.ts +8 -8
  125. package/src/host/index.ts +4 -7
  126. package/src/host/pattern-matcher.ts +27 -27
  127. package/src/host/router.ts +61 -39
  128. package/src/host/testing.ts +8 -8
  129. package/src/host/types.ts +15 -7
  130. package/src/host/utils.ts +1 -1
  131. package/src/href-client.ts +65 -45
  132. package/src/index.rsc.ts +104 -40
  133. package/src/index.ts +122 -67
  134. package/src/internal-debug.ts +9 -3
  135. package/src/loader.rsc.ts +18 -93
  136. package/src/loader.ts +26 -9
  137. package/src/network-error-thrower.tsx +3 -1
  138. package/src/outlet-provider.tsx +45 -0
  139. package/src/prerender/param-hash.ts +4 -2
  140. package/src/prerender/store.ts +121 -17
  141. package/src/prerender.ts +325 -20
  142. package/src/reverse.ts +144 -124
  143. package/src/root-error-boundary.tsx +41 -29
  144. package/src/route-content-wrapper.tsx +7 -4
  145. package/src/route-definition/dsl-helpers.ts +959 -0
  146. package/src/route-definition/helper-factories.ts +200 -0
  147. package/src/route-definition/helpers-types.ts +430 -0
  148. package/src/route-definition/index.ts +52 -0
  149. package/src/route-definition/redirect.ts +93 -0
  150. package/src/route-definition.ts +1 -1450
  151. package/src/route-map-builder.ts +87 -133
  152. package/src/route-name.ts +53 -0
  153. package/src/route-types.ts +41 -6
  154. package/src/router/content-negotiation.ts +116 -0
  155. package/src/router/debug-manifest.ts +72 -0
  156. package/src/router/error-handling.ts +9 -9
  157. package/src/router/find-match.ts +160 -0
  158. package/src/router/handler-context.ts +324 -116
  159. package/src/router/intercept-resolution.ts +11 -4
  160. package/src/router/lazy-includes.ts +237 -0
  161. package/src/router/loader-resolution.ts +179 -133
  162. package/src/router/logging.ts +112 -6
  163. package/src/router/manifest.ts +58 -19
  164. package/src/router/match-api.ts +89 -88
  165. package/src/router/match-context.ts +4 -2
  166. package/src/router/match-handlers.ts +440 -0
  167. package/src/router/match-middleware/background-revalidation.ts +86 -89
  168. package/src/router/match-middleware/cache-lookup.ts +295 -49
  169. package/src/router/match-middleware/cache-store.ts +56 -13
  170. package/src/router/match-middleware/intercept-resolution.ts +45 -22
  171. package/src/router/match-middleware/segment-resolution.ts +20 -9
  172. package/src/router/match-pipelines.ts +10 -45
  173. package/src/router/match-result.ts +44 -21
  174. package/src/router/metrics.ts +240 -15
  175. package/src/router/middleware-cookies.ts +55 -0
  176. package/src/router/middleware-types.ts +222 -0
  177. package/src/router/middleware.ts +327 -369
  178. package/src/router/pattern-matching.ts +169 -31
  179. package/src/router/prerender-match.ts +402 -0
  180. package/src/router/preview-match.ts +170 -0
  181. package/src/router/revalidation.ts +105 -14
  182. package/src/router/router-context.ts +40 -21
  183. package/src/router/router-interfaces.ts +452 -0
  184. package/src/router/router-options.ts +592 -0
  185. package/src/router/router-registry.ts +24 -0
  186. package/src/router/segment-resolution/fresh.ts +677 -0
  187. package/src/router/segment-resolution/helpers.ts +263 -0
  188. package/src/router/segment-resolution/loader-cache.ts +199 -0
  189. package/src/router/segment-resolution/revalidation.ts +1296 -0
  190. package/src/router/segment-resolution/static-store.ts +67 -0
  191. package/src/router/segment-resolution.ts +21 -1354
  192. package/src/router/segment-wrappers.ts +291 -0
  193. package/src/router/telemetry-otel.ts +299 -0
  194. package/src/router/telemetry.ts +300 -0
  195. package/src/router/timeout.ts +148 -0
  196. package/src/router/trie-matching.ts +96 -29
  197. package/src/router/types.ts +15 -9
  198. package/src/router.ts +642 -2366
  199. package/src/rsc/handler-context.ts +45 -0
  200. package/src/rsc/handler.ts +639 -1027
  201. package/src/rsc/helpers.ts +140 -6
  202. package/src/rsc/index.ts +0 -20
  203. package/src/rsc/loader-fetch.ts +209 -0
  204. package/src/rsc/manifest-init.ts +86 -0
  205. package/src/rsc/nonce.ts +14 -0
  206. package/src/rsc/origin-guard.ts +141 -0
  207. package/src/rsc/progressive-enhancement.ts +379 -0
  208. package/src/rsc/response-error.ts +37 -0
  209. package/src/rsc/response-route-handler.ts +347 -0
  210. package/src/rsc/rsc-rendering.ts +237 -0
  211. package/src/rsc/runtime-warnings.ts +42 -0
  212. package/src/rsc/server-action.ts +348 -0
  213. package/src/rsc/ssr-setup.ts +128 -0
  214. package/src/rsc/types.ts +38 -11
  215. package/src/search-params.ts +66 -54
  216. package/src/segment-system.tsx +165 -17
  217. package/src/server/context.ts +237 -54
  218. package/src/server/cookie-store.ts +190 -0
  219. package/src/server/fetchable-loader-store.ts +11 -6
  220. package/src/server/handle-store.ts +94 -15
  221. package/src/server/loader-registry.ts +15 -56
  222. package/src/server/request-context.ts +438 -71
  223. package/src/server.ts +26 -164
  224. package/src/ssr/index.tsx +101 -31
  225. package/src/static-handler.ts +22 -4
  226. package/src/theme/ThemeProvider.tsx +21 -15
  227. package/src/theme/ThemeScript.tsx +5 -5
  228. package/src/theme/constants.ts +5 -2
  229. package/src/theme/index.ts +4 -14
  230. package/src/theme/theme-context.ts +4 -30
  231. package/src/theme/theme-script.ts +21 -18
  232. package/src/types/boundaries.ts +158 -0
  233. package/src/types/cache-types.ts +198 -0
  234. package/src/types/error-types.ts +192 -0
  235. package/src/types/global-namespace.ts +100 -0
  236. package/src/types/handler-context.ts +773 -0
  237. package/src/types/index.ts +88 -0
  238. package/src/types/loader-types.ts +183 -0
  239. package/src/types/route-config.ts +170 -0
  240. package/src/types/route-entry.ts +109 -0
  241. package/src/types/segments.ts +150 -0
  242. package/src/types.ts +1 -1795
  243. package/src/urls/include-helper.ts +197 -0
  244. package/src/urls/index.ts +53 -0
  245. package/src/urls/path-helper-types.ts +339 -0
  246. package/src/urls/path-helper.ts +329 -0
  247. package/src/urls/pattern-types.ts +95 -0
  248. package/src/urls/response-types.ts +106 -0
  249. package/src/urls/type-extraction.ts +372 -0
  250. package/src/urls/urls-function.ts +98 -0
  251. package/src/urls.ts +1 -1323
  252. package/src/use-loader.tsx +85 -77
  253. package/src/vite/discovery/bundle-postprocess.ts +184 -0
  254. package/src/vite/discovery/discover-routers.ts +344 -0
  255. package/src/vite/discovery/prerender-collection.ts +385 -0
  256. package/src/vite/discovery/route-types-writer.ts +258 -0
  257. package/src/vite/discovery/self-gen-tracking.ts +47 -0
  258. package/src/vite/discovery/state.ts +108 -0
  259. package/src/vite/discovery/virtual-module-codegen.ts +203 -0
  260. package/src/vite/index.ts +11 -2259
  261. package/src/vite/plugin-types.ts +48 -0
  262. package/src/vite/plugins/cjs-to-esm.ts +93 -0
  263. package/src/vite/plugins/client-ref-dedup.ts +115 -0
  264. package/src/vite/plugins/client-ref-hashing.ts +105 -0
  265. package/src/vite/{expose-action-id.ts → plugins/expose-action-id.ts} +72 -47
  266. package/src/vite/{expose-id-utils.ts → plugins/expose-id-utils.ts} +8 -43
  267. package/src/vite/plugins/expose-ids/export-analysis.ts +296 -0
  268. package/src/vite/plugins/expose-ids/handler-transform.ts +179 -0
  269. package/src/vite/plugins/expose-ids/loader-transform.ts +74 -0
  270. package/src/vite/plugins/expose-ids/router-transform.ts +110 -0
  271. package/src/vite/plugins/expose-ids/types.ts +45 -0
  272. package/src/vite/plugins/expose-internal-ids.ts +569 -0
  273. package/src/vite/plugins/refresh-cmd.ts +65 -0
  274. package/src/vite/plugins/use-cache-transform.ts +323 -0
  275. package/src/vite/plugins/version-injector.ts +83 -0
  276. package/src/vite/plugins/version-plugin.ts +266 -0
  277. package/src/vite/{virtual-entries.ts → plugins/virtual-entries.ts} +23 -14
  278. package/src/vite/plugins/virtual-stub-plugin.ts +29 -0
  279. package/src/vite/rango.ts +445 -0
  280. package/src/vite/router-discovery.ts +777 -0
  281. package/src/vite/{ast-handler-extract.ts → utils/ast-handler-extract.ts} +181 -9
  282. package/src/vite/utils/banner.ts +36 -0
  283. package/src/vite/utils/bundle-analysis.ts +137 -0
  284. package/src/vite/utils/manifest-utils.ts +70 -0
  285. package/src/vite/{package-resolution.ts → utils/package-resolution.ts} +25 -29
  286. package/src/vite/utils/prerender-utils.ts +189 -0
  287. package/src/vite/utils/shared-utils.ts +169 -0
  288. package/CLAUDE.md +0 -43
  289. package/dist/vite/index.named-routes.gen.ts +0 -103
  290. package/src/browser/lru-cache.ts +0 -69
  291. package/src/browser/request-controller.ts +0 -164
  292. package/src/cache/memory-store.ts +0 -253
  293. package/src/href-context.ts +0 -33
  294. package/src/router.gen.ts +0 -6
  295. package/src/static-handler.gen.ts +0 -5
  296. package/src/urls.gen.ts +0 -8
  297. package/src/vite/expose-internal-ids.ts +0 -1167
  298. /package/src/vite/{version.d.ts → plugins/version.d.ts} +0 -0
@@ -5,15 +5,28 @@ import type {
5
5
  ResolvedSegment,
6
6
  } from "./types.js";
7
7
  import type { ReactNode } from "react";
8
+ import * as React from "react";
8
9
  import { startTransition } from "react";
10
+
11
+ // addTransitionType is only available in React experimental
12
+ const addTransitionType: ((type: string) => void) | undefined =
13
+ "addTransitionType" in React ? (React as any).addTransitionType : undefined;
9
14
  import type { RenderSegmentsOptions } from "../segment-system.js";
10
- import {
11
- mergeSegmentLoaders,
12
- needsLoaderMerge,
13
- insertMissingDiffSegments,
14
- } from "./merge-segment-loaders.js";
15
- import { assertSegmentStructure } from "./segment-structure-assert.js";
16
- import type { BoundTransaction } from "./navigation-bridge.js";
15
+ import { reconcileSegments } from "./segment-reconciler.js";
16
+ import type { ReconcileActor } from "./segment-reconciler.js";
17
+ import { hasActiveIntercept as hasActiveInterceptSlots } from "./intercept-utils.js";
18
+ import type { BoundTransaction } from "./navigation-transaction.js";
19
+ import { ServerRedirect } from "../errors.js";
20
+ import { debugLog } from "./logging.js";
21
+ import { validateRedirectOrigin } from "./validate-redirect-origin.js";
22
+ import type { NavigationUpdate } from "./types.js";
23
+
24
+ /** Build a scroll payload from the commit's scroll option */
25
+ function toScrollPayload(
26
+ scroll: boolean | undefined,
27
+ ): NonNullable<NavigationUpdate["scroll"]> {
28
+ return { enabled: scroll !== false ? scroll : false };
29
+ }
17
30
 
18
31
  /**
19
32
  * Configuration for creating a partial updater
@@ -42,12 +55,30 @@ export interface CommitOverrides {
42
55
  intercept?: boolean;
43
56
  /** Source URL where intercept was triggered from */
44
57
  interceptSourceUrl?: string;
58
+ /** Server-set location state to merge into history.pushState */
59
+ serverState?: Record<string, unknown>;
45
60
  }
46
61
 
47
62
  /**
48
- * Commit context passed to partial updater for URL updates
49
- * Transaction encapsulates all store mutations for atomic commit
63
+ * Discriminated update mode for partial updates.
50
64
  */
65
+ export type UpdateMode =
66
+ | {
67
+ type: "navigate";
68
+ /** Cached segments for the target URL. When provided, these are used to build
69
+ * the segment map instead of the current page's segments. This ensures consistency
70
+ * when we send cached segment IDs to the server - if the server returns empty diff,
71
+ * we use the same segments we told the server we have. */
72
+ targetCacheSegments?: ResolvedSegment[];
73
+ /** Cached handle data for the target URL. When server returns empty diff and we're
74
+ * rendering from cache, this is passed to the UI to restore breadcrumbs etc. */
75
+ targetCacheHandleData?: Record<string, Record<string, unknown[]>>;
76
+ /** Source URL for intercept restore (popstate cache miss) */
77
+ interceptSourceUrl?: string;
78
+ }
79
+ | { type: "leave-intercept" }
80
+ | { type: "stale-revalidation"; interceptSourceUrl?: string }
81
+ | { type: "action"; interceptSourceUrl?: string };
51
82
 
52
83
  /**
53
84
  * Type for the fetchPartialUpdate function
@@ -57,24 +88,9 @@ export type PartialUpdater = (
57
88
  segmentIds: string[] | undefined,
58
89
  isRetry: boolean,
59
90
  signal: AbortSignal | undefined,
60
- type: BoundTransaction,
61
- options?: {
62
- isAction?: boolean;
63
- staleRevalidation?: boolean;
64
- interceptSourceUrl?: string;
65
- /** Cached segments for the target URL. When provided, these are used to build
66
- * the segment map instead of the current page's segments. This ensures consistency
67
- * when we send cached segment IDs to the server - if the server returns empty diff,
68
- * we use the same segments we told the server we have. */
69
- targetCacheSegments?: ResolvedSegment[];
70
- /** Cached handle data for the target URL. When server returns empty diff and we're
71
- * rendering from cache, this is passed to the UI to restore breadcrumbs etc. */
72
- targetCacheHandleData?: Record<string, Record<string, unknown[]>>;
73
- /** When true, we're leaving an intercept state - don't use current segment IDs
74
- * as fallback and force a fresh render from server */
75
- leavingIntercept?: boolean;
76
- },
77
- ) => Promise<Promise<void>>;
91
+ tx: BoundTransaction,
92
+ mode?: UpdateMode,
93
+ ) => Promise<void>;
78
94
 
79
95
  /**
80
96
  * Create a partial updater for fetching and applying RSC partial updates
@@ -84,18 +100,6 @@ export type PartialUpdater = (
84
100
  *
85
101
  * @param config - Partial update configuration
86
102
  * @returns fetchPartialUpdate function
87
- *
88
- * @example
89
- * ```typescript
90
- * const fetchPartialUpdate = createPartialUpdater({
91
- * store,
92
- * client,
93
- * onUpdate: (update) => store.emit(update),
94
- * renderSegments,
95
- * });
96
- *
97
- * await fetchPartialUpdate('/new-page');
98
- * ```
99
103
  */
100
104
  export function createPartialUpdater(
101
105
  config: PartialUpdateConfig,
@@ -103,20 +107,16 @@ export function createPartialUpdater(
103
107
  const { store, client, onUpdate, renderSegments, version } = config;
104
108
 
105
109
  /**
106
- * Build a lookup map from current page's cached segments
110
+ * Get current page's cached segments as an array
107
111
  */
108
- function getCurrentSegmentMap(): Map<string, ResolvedSegment> {
112
+ function getCurrentCachedSegments(): ResolvedSegment[] {
109
113
  const currentKey = store.getHistoryKey();
110
114
  const cached = store.getCachedSegments(currentKey);
111
- const cachedSegments = cached?.segments || [];
112
- const map = new Map<string, ResolvedSegment>();
113
- cachedSegments.forEach((s) => map.set(s.id, s));
114
- return map;
115
+ return cached?.segments || [];
115
116
  }
116
117
 
117
118
  /**
118
119
  * Fetch partial update and trigger UI update
119
- * Returns a promise that resolves when the RSC stream is fully consumed
120
120
  *
121
121
  * @param tx - Transaction for committing segment state (required)
122
122
  * @param signal - AbortSignal to check if navigation is stale (not for aborting fetch)
@@ -127,40 +127,34 @@ export function createPartialUpdater(
127
127
  isRetry: boolean,
128
128
  signal: AbortSignal | undefined,
129
129
  tx: BoundTransaction,
130
- options?: {
131
- isAction?: boolean;
132
- staleRevalidation?: boolean;
133
- interceptSourceUrl?: string;
134
- targetCacheSegments?: ResolvedSegment[];
135
- targetCacheHandleData?: Record<string, Record<string, unknown[]>>;
136
- leavingIntercept?: boolean;
137
- },
138
- ): Promise<Promise<void>> {
139
- const {
140
- isAction = false,
141
- staleRevalidation = false,
142
- interceptSourceUrl,
143
- targetCacheSegments,
144
- targetCacheHandleData,
145
- leavingIntercept = false,
146
- } = options || {};
130
+ mode: UpdateMode = { type: "navigate" },
131
+ ): Promise<void> {
147
132
  const segmentState = store.getSegmentState();
148
133
  const url = targetUrl || window.location.href;
149
134
 
150
135
  // Capture history key at start for stale revalidation consistency check
151
136
  const historyKeyAtStart = store.getHistoryKey();
152
137
 
153
- // When leaving intercept, don't send current segment IDs - we need fresh non-intercept segments
154
- // Filter out intercept-related segments (parallel slots like @modal) from current segments
138
+ // Derive interceptSourceUrl from modes that carry it
139
+ const interceptSourceUrl =
140
+ mode.type === "stale-revalidation" ||
141
+ mode.type === "action" ||
142
+ mode.type === "navigate"
143
+ ? mode.interceptSourceUrl
144
+ : undefined;
145
+
146
+ // When leaving intercept, filter out intercept-specific segments
155
147
  let segments: string[];
156
- if (leavingIntercept) {
157
- // When leaving intercept, only send segments that aren't intercept-specific
158
- // The server will return the non-intercept version of the route
148
+ if (mode.type === "leave-intercept") {
159
149
  const currentSegments = segmentIds ?? segmentState.currentSegmentIds;
160
- // Filter out intercept-specific parallel slots (containing ".@") so the
161
- // server resolves the base route instead of the modal overlay.
162
- segments = currentSegments.filter((id) => !id.includes(".@"));
163
- console.log(
150
+ const currentCached = getCurrentCachedSegments();
151
+ const interceptIds = new Set(
152
+ currentCached
153
+ .filter((s) => s.namespace?.startsWith("intercept:"))
154
+ .map((s) => s.id),
155
+ );
156
+ segments = currentSegments.filter((id) => !interceptIds.has(id));
157
+ debugLog(
164
158
  `[Browser] Leaving intercept - filtered segments: ${segments.join(", ")}`,
165
159
  );
166
160
  } else {
@@ -168,209 +162,201 @@ export function createPartialUpdater(
168
162
  }
169
163
 
170
164
  // For intercept revalidation, use the intercept source URL as previousUrl
171
- // This tells the server the route should be treated as an intercept
172
165
  const previousUrl =
173
166
  interceptSourceUrl || tx.currentUrl || segmentState.currentUrl;
174
167
 
175
- console.log(`\n[Browser] >>> NAVIGATION`);
176
- console.log(`[Browser] From: ${previousUrl}`);
177
- console.log(`[Browser] To: ${url}`);
178
- console.log(`[Browser] Segments to send: ${segments.join(", ")}`);
168
+ debugLog(`\n[Browser] >>> NAVIGATION`);
169
+ debugLog(`[Browser] From: ${previousUrl}`);
170
+ debugLog(`[Browser] To: ${url}`);
171
+ debugLog(`[Browser] Segments to send: ${segments.join(", ")}`);
179
172
  if (interceptSourceUrl) {
180
- console.log(`[Browser] Intercept context from: ${interceptSourceUrl}`);
173
+ debugLog(`[Browser] Intercept context from: ${interceptSourceUrl}`);
181
174
  }
182
175
 
183
- // Build segment map for merging with server diff.
184
- // When targetCacheSegments is provided (navigating to a cached route), use those
185
- // to ensure consistency - we use the same segments we told the server we have.
176
+ // Get cached segments for merging with server diff.
177
+ // When navigating with targetCacheSegments, use those for consistency.
186
178
  // Otherwise fall back to current page's segments (for same-route revalidation).
187
- let currentSegmentMap: Map<string, ResolvedSegment>;
188
- if (targetCacheSegments && targetCacheSegments.length > 0) {
189
- currentSegmentMap = new Map();
190
- targetCacheSegments.forEach((s) => currentSegmentMap.set(s.id, s));
191
- } else {
192
- currentSegmentMap = getCurrentSegmentMap();
193
- }
194
- // Mark navigation as streaming (response received, now parsing RSC)
195
- // The token is ended when the stream completes
196
- const streamingToken = tx.startStreaming();
179
+ const targetCache =
180
+ mode.type === "navigate" ? mode.targetCacheSegments : undefined;
181
+ const cachedSegs =
182
+ targetCache && targetCache.length > 0
183
+ ? targetCache
184
+ : getCurrentCachedSegments();
185
+
197
186
  // Fetch partial payload (no abort signal - RSC doesn't support it well)
198
- const { payload, streamComplete: rawStreamComplete } =
199
- await client.fetchPartial({
200
- targetUrl: url,
201
- segmentIds: segments,
202
- previousUrl,
203
- staleRevalidation,
204
- version,
205
- });
206
- console.log("payload.metadata", payload.metadata);
187
+ let fetchResult: Awaited<ReturnType<NavigationClient["fetchPartial"]>>;
188
+ fetchResult = await client.fetchPartial({
189
+ targetUrl: url,
190
+ segmentIds: segments,
191
+ previousUrl,
192
+ // Mark stale when explicitly requested OR when no segments are sent
193
+ // (action redirect sends empty segments for a fresh render).
194
+ staleRevalidation:
195
+ mode.type === "stale-revalidation" || segments.length === 0,
196
+ version,
197
+ });
198
+ // Mark navigation as streaming (response received, now parsing RSC).
199
+ // Called after fetchPartial so pendingUrl stays set during the network wait,
200
+ // allowing useLinkStatus to show per-link pending indicators.
201
+ const streamingToken = tx.startStreaming();
202
+ const { payload, streamComplete: rawStreamComplete } = fetchResult;
203
+ debugLog("payload.metadata", payload.metadata);
207
204
 
208
205
  const streamComplete = rawStreamComplete.then(() => {
209
206
  streamingToken.end();
210
207
  });
211
208
 
209
+ // Handle server-side redirect with state
210
+ if (payload.metadata?.redirect) {
211
+ if (signal?.aborted) {
212
+ debugLog("[Browser] Ignoring stale redirect (aborted)");
213
+ return;
214
+ }
215
+ const redirectUrl = validateRedirectOrigin(
216
+ payload.metadata.redirect.url,
217
+ window.location.origin,
218
+ );
219
+ if (!redirectUrl) {
220
+ debugLog("[Browser] Ignoring blocked redirect payload");
221
+ return;
222
+ }
223
+ const serverState = payload.metadata.locationState;
224
+ throw new ServerRedirect(redirectUrl, serverState);
225
+ }
226
+
212
227
  if (payload.metadata?.isPartial) {
213
228
  const { segments: newSegments, matched, diff } = payload.metadata;
214
229
 
215
230
  // Check if this navigation is stale (a newer one started)
216
231
  if (signal?.aborted) {
217
- console.log(`[Browser] Ignoring stale navigation (aborted)`);
218
- return streamComplete;
232
+ debugLog("[Browser] Ignoring stale navigation (aborted)");
233
+ return;
219
234
  }
220
235
 
221
- console.log(`[Browser] Partial update - matched: ${matched?.join(", ")}`);
222
- console.log(`[Browser] Diff: ${diff?.join(", ")}`);
223
-
224
- // Create lookup for new segments from server
225
- const newSegmentMap = new Map<string, ResolvedSegment>();
226
- (newSegments || []).forEach((s: ResolvedSegment) =>
227
- newSegmentMap.set(s.id, s),
228
- );
236
+ debugLog(`[Browser] Partial update - matched: ${matched?.join(", ")}`);
237
+ debugLog(`[Browser] Diff: ${diff?.join(", ")}`);
229
238
 
230
239
  // If diff is empty, nothing changed on server side.
231
- // However, if we're navigating with targetCacheSegments (to a different route),
232
- // we still need to render those segments since the UI is showing the old route.
233
240
  if (!diff || diff.length === 0) {
234
241
  const matchedIds = matched || [];
242
+ const cacheMap = new Map(cachedSegs.map((s) => [s.id, s]));
235
243
  const existingSegments = matchedIds
236
- .map((id: string) => currentSegmentMap.get(id))
244
+ .map((id: string) => cacheMap.get(id))
237
245
  .filter(Boolean) as ResolvedSegment[];
238
246
 
239
247
  // When navigating with cached segments to a different route, render them.
240
- // targetCacheSegments being provided means we're navigating to a cached route.
241
- if (targetCacheSegments && targetCacheSegments.length > 0) {
242
- console.log(
243
- `[Browser] No diff but navigating with cached segments - rendering target route`,
248
+ if (mode.type === "navigate" && targetCache && targetCache.length > 0) {
249
+ debugLog(
250
+ "[Browser] No diff but navigating with cached segments - rendering target route",
244
251
  );
245
252
 
246
253
  const newTree = await renderSegments(existingSegments, {
247
254
  forceAwait: true,
248
255
  });
249
256
 
250
- tx.commit(matchedIds, existingSegments);
257
+ const { scroll: commitScroll } = tx.commit(
258
+ matchedIds,
259
+ existingSegments,
260
+ );
261
+
262
+ // Fix: tx.commit() cached the source page's handleData because
263
+ // eventController hasn't been updated yet. Overwrite with the
264
+ // correct cached handleData to prevent cache corruption on
265
+ // subsequent navigations to this same URL.
266
+ if (mode.targetCacheHandleData) {
267
+ store.updateCacheHandleData(
268
+ store.getHistoryKey(),
269
+ mode.targetCacheHandleData,
270
+ );
271
+ }
251
272
 
252
273
  // Include cachedHandleData in metadata so NavigationProvider can restore
253
274
  // breadcrumbs and other handle data from cache.
254
- // IMPORTANT: Remove `handles` from metadata to prevent NavigationProvider from
275
+ // Remove `handles` from metadata to prevent NavigationProvider from
255
276
  // processing an empty handles stream, which would clear the cached breadcrumbs.
256
- // When rendering from cache with empty diff, we want to use cachedHandleData instead.
257
277
  const { handles: _unusedHandles, ...metadataWithoutHandles } =
258
278
  payload.metadata!;
259
- onUpdate({
279
+ const cachedUpdate = {
260
280
  root: newTree,
261
281
  metadata: {
262
282
  ...metadataWithoutHandles,
263
- cachedHandleData: targetCacheHandleData,
283
+ cachedHandleData: mode.targetCacheHandleData,
264
284
  },
265
- });
285
+ scroll: toScrollPayload(commitScroll),
286
+ };
266
287
 
267
- console.log(`[Browser] Navigation complete (rendered from cache)\n`);
268
- return streamComplete;
288
+ const cachedHasTransition = existingSegments.some(
289
+ (s) => s.transition,
290
+ );
291
+ if (cachedHasTransition) {
292
+ startTransition(() => {
293
+ if (addTransitionType) {
294
+ addTransitionType("navigation");
295
+ }
296
+ onUpdate(cachedUpdate);
297
+ });
298
+ } else {
299
+ onUpdate(cachedUpdate);
300
+ }
301
+
302
+ debugLog("[Browser] Navigation complete (rendered from cache)");
303
+ return;
269
304
  }
270
305
 
271
306
  // When leaving intercept, force re-render even with empty diff
272
- // The matched segments are the non-intercept segments, which we need to render
273
- // to remove the modal from the UI
274
- if (leavingIntercept) {
275
- console.log(
276
- `[Browser] Leaving intercept - forcing re-render to remove modal`,
307
+ if (mode.type === "leave-intercept") {
308
+ debugLog(
309
+ "[Browser] Leaving intercept - forcing re-render to remove modal",
277
310
  );
278
311
 
279
312
  const newTree = await renderSegments(existingSegments, {
280
313
  forceAwait: true,
281
314
  });
282
315
 
283
- tx.commit(matchedIds, existingSegments);
316
+ const { scroll: leaveScroll } = tx.commit(
317
+ matchedIds,
318
+ existingSegments,
319
+ );
284
320
 
285
321
  onUpdate({
286
322
  root: newTree,
287
323
  metadata: payload.metadata,
324
+ scroll: toScrollPayload(leaveScroll),
288
325
  });
289
326
 
290
- console.log(`[Browser] Navigation complete (left intercept)\n`);
291
- return streamComplete;
327
+ debugLog("[Browser] Navigation complete (left intercept)");
328
+ return;
292
329
  }
293
330
 
294
331
  // Same route revalidation with no changes - skip UI update
295
- console.log(
296
- `[Browser] No changes - all revalidations returned false, keeping existing UI`,
332
+ debugLog(
333
+ "[Browser] No changes - all revalidations returned false, keeping existing UI",
297
334
  );
298
335
  tx.commit(matchedIds, existingSegments);
299
- console.log(`[Browser] Navigation complete (no re-render)\n`);
300
- return streamComplete;
336
+ debugLog("[Browser] Navigation complete (no re-render)");
337
+ return;
301
338
  }
302
339
 
303
- // Build full segment list by merging:
304
- // - New/changed segments from server response (diff)
305
- // - Unchanged segments from current page's cache
340
+ // Reconcile server segments with cached segments (single source of truth)
306
341
  const matchedIds = matched || [];
307
- console.log(`[Browser] matchedIds: ${matchedIds.join(", ")}`);
308
- console.log(
309
- `[Browser] currentSegmentMap keys: ${[...currentSegmentMap.keys()].join(", ")}`,
310
- );
311
- console.log(
312
- `[Browser] newSegmentMap keys: ${[...newSegmentMap.keys()].join(", ")}`,
313
- newSegmentMap,
314
- );
315
-
316
- // First pass: build segments from matched IDs
317
- const matchedIdSet = new Set(matchedIds);
318
- const allSegments = matchedIds
319
- .map((id: string) => {
320
- // First check server response (new/updated segments)
321
- const fromServer = newSegmentMap.get(id);
322
- if (fromServer) {
323
- // For partial revalidation (stale or action), merge server's new loader data
324
- // with cached loader data when server returns fewer loaders than cached
325
- const fromCache = currentSegmentMap.get(id);
326
- // Dev-mode assertion: warn if tree structure would change
327
- if (fromCache) {
328
- assertSegmentStructure(fromCache, fromServer, "partial-update");
329
- }
330
- if (
331
- (staleRevalidation || isAction) &&
332
- needsLoaderMerge(fromServer, fromCache)
333
- ) {
334
- return mergeSegmentLoaders(fromServer, fromCache);
335
- }
336
- // When server returns component: null for a layout segment, it means
337
- // "this segment doesn't need re-rendering" - preserve the cached component
338
- // to maintain the outlet chain and prevent React tree changes
339
- if (
340
- fromServer.component === null &&
341
- fromServer.type === "layout" &&
342
- fromCache?.component != null
343
- ) {
344
- console.log(
345
- `[Browser] Preserving cached component for layout ${id} (server returned null)`,
346
- );
347
- return { ...fromServer, component: fromCache.component };
348
- }
349
- return fromServer;
350
- }
351
- // Fall back to current page's cached segments
352
- const fromCache = currentSegmentMap.get(id);
353
- if (!fromCache) {
354
- console.warn(`[Browser] Missing segment: ${id}`);
355
- return fromCache;
356
- }
357
- // Clear loading for cached segments to prevent suspense - server decided
358
- // this segment doesn't need re-rendering, so show content as-is
359
- if (fromCache.loading !== undefined) {
360
- return { ...fromCache, loading: undefined };
361
- }
362
- return fromCache;
363
- })
364
- .filter(Boolean) as ResolvedSegment[];
365
-
366
- // Insert diff segments not in matchedIds (e.g., loader segments from consolidation fetch)
367
- insertMissingDiffSegments(allSegments, diff, matchedIdSet, newSegmentMap);
342
+ const actor: ReconcileActor =
343
+ mode.type === "stale-revalidation" || mode.type === "action"
344
+ ? "stale-revalidation"
345
+ : "navigation";
346
+
347
+ const reconciled = reconcileSegments({
348
+ actor,
349
+ matched: matchedIds,
350
+ diff: diff || [],
351
+ serverSegments: newSegments || [],
352
+ cachedSegments: cachedSegs,
353
+ insertMissingDiff: true,
354
+ });
368
355
 
369
356
  // HMR RESILIENCE: Check if we're missing any matched segments
370
- // Note: allSegments may include additional diff segments, so we check matchedIds specifically
371
- const allSegmentIdSet = new Set(allSegments.map((s) => s.id));
357
+ const reconciledIdSet = new Set(reconciled.segments.map((s) => s.id));
372
358
  const missingIds = matchedIds.filter(
373
- (id: string) => !allSegmentIdSet.has(id),
359
+ (id: string) => !reconciledIdSet.has(id),
374
360
  );
375
361
 
376
362
  if (missingIds.length > 0) {
@@ -383,52 +369,39 @@ export function createPartialUpdater(
383
369
  );
384
370
  }
385
371
  if (signal?.aborted) {
386
- console.log(
387
- `[Browser] Ignoring stale navigation (aborted during HMR retry)`,
372
+ debugLog(
373
+ "[Browser] Ignoring stale navigation (aborted during HMR retry)",
388
374
  );
389
- return streamComplete;
375
+ return;
390
376
  }
391
- if (isAction) {
392
- return streamComplete;
377
+ if (mode.type === "action") {
378
+ return;
393
379
  }
394
380
  console.warn(
395
381
  `[Browser] HMR detected: Missing ${missingCount} segments. Refetching all...`,
396
382
  );
397
383
 
398
384
  // Refetch with empty segments = server sends everything
399
- return fetchPartialUpdate(url, [], true, signal, tx, { isAction });
385
+ return fetchPartialUpdate(url, [], true, signal, tx, mode);
400
386
  }
401
387
 
402
- // INTERCEPT HANDLING: Separate intercept segments for explicit injection
403
- // Intercept segments have namespace starting with "intercept:" or ID containing .@
404
- // This makes the flow clearer and easier to debug
405
- const isInterceptSegment = (s: ResolvedSegment) =>
406
- s.namespace?.startsWith("intercept:") ||
407
- (s.type === "parallel" && s.id.includes(".@"));
408
-
409
- const interceptSegments = allSegments.filter(isInterceptSegment);
410
- const mainSegments = allSegments.filter((s) => !isInterceptSegment(s));
411
-
412
388
  if (signal?.aborted) {
413
- console.log(
414
- `[Browser] Ignoring stale navigation (aborted before render)`,
415
- );
416
- return streamComplete;
389
+ debugLog("[Browser] Ignoring stale navigation (aborted before render)");
390
+ return;
417
391
  }
418
392
 
419
393
  // Rebuild tree on client (await for loader data resolution)
420
- // Race against abort signal to allow cancellation during loader awaiting
421
- // Pass intercept segments separately for explicit handling
422
- // For stale revalidation, use forceAwait to ensure no loading fallbacks
423
394
  const renderOptions = {
424
- isAction,
425
- forceAwait: staleRevalidation,
395
+ isAction: mode.type === "action",
396
+ forceAwait: mode.type === "stale-revalidation",
426
397
  interceptSegments:
427
- interceptSegments.length > 0 ? interceptSegments : undefined,
398
+ reconciled.interceptSegments.length > 0
399
+ ? reconciled.interceptSegments
400
+ : undefined,
428
401
  };
429
402
  const newTree = await (signal
430
403
  ? Promise.race([
431
- renderSegments(mainSegments, renderOptions),
404
+ renderSegments(reconciled.mainSegments, renderOptions),
432
405
  new Promise<never>((_, reject) => {
433
406
  if (signal.aborted) {
434
407
  reject(new DOMException("Navigation aborted", "AbortError"));
@@ -438,158 +411,179 @@ export function createPartialUpdater(
438
411
  });
439
412
  }),
440
413
  ])
441
- : renderSegments(mainSegments, renderOptions));
414
+ : renderSegments(reconciled.mainSegments, renderOptions));
442
415
 
443
416
  // Final abort check before committing - another navigation may have started
444
417
  if (signal?.aborted) {
445
- console.log(
446
- `[Browser] Ignoring stale navigation (aborted before commit)`,
447
- );
448
- return streamComplete;
418
+ debugLog("[Browser] Ignoring stale navigation (aborted before commit)");
419
+ return;
449
420
  }
450
421
 
451
422
  // Check if this is an intercept response (any slot is active)
452
- // If so, disable scroll to keep the current scroll position
453
- const hasActiveIntercept = payload.metadata?.slots
454
- ? Object.values(payload.metadata.slots).some((slot) => slot.active)
455
- : false;
456
-
457
- // BUG FIX: When navigating with cached target segments but receiving an intercept response,
458
- // the background segments should come from the SOURCE page (where we navigated from),
459
- // not the TARGET cache. This happens when:
460
- // 1. User visits /product/xxx (detail page) - cached under key "/product/xxx"
461
- // 2. User navigates back to /
462
- // 3. User clicks product link → cache hit for "/product/xxx" (detail page)
463
- // 4. But server returns intercept response (modal with index background)
464
- // 5. Without this fix: background uses detail page segments (wrong!)
465
- // 6. With this fix: rebuild currentSegmentMap from source page
466
- if (hasActiveIntercept && targetCacheSegments) {
467
- console.log(
468
- `[Browser] Intercept response with target cache - rebuilding segment map from source page`,
469
- );
470
- currentSegmentMap = getCurrentSegmentMap();
471
- }
423
+ const isInterceptResponse = hasActiveInterceptSlots(
424
+ payload.metadata?.slots,
425
+ );
472
426
 
473
- // Track intercept context for action revalidation (only on navigation, not actions or stale revalidation)
474
- if (!isAction && !staleRevalidation) {
475
- if (hasActiveIntercept) {
476
- // Save the source URL for action revalidation to maintain intercept context
477
- store.setInterceptSourceUrl(segmentState.currentUrl);
427
+ // Track intercept context (only on navigation, not actions or stale revalidation)
428
+ // Use the authoritative source from mode/history state when restoring an
429
+ // intercept via popstate cache miss; fall back to the current URL for fresh
430
+ // intercept navigations.
431
+ const effectiveInterceptSource =
432
+ interceptSourceUrl || segmentState.currentUrl;
433
+ if (mode.type !== "action" && mode.type !== "stale-revalidation") {
434
+ if (isInterceptResponse) {
435
+ store.setInterceptSourceUrl(effectiveInterceptSource);
478
436
  } else {
479
- // Clear intercept context when navigating to a non-intercept route
480
437
  store.setInterceptSourceUrl(null);
481
438
  }
482
439
  }
483
440
 
484
- // Commit navigation - transaction handles all store mutations atomically
485
- // For intercept responses: disable scroll, mark as intercept, include source URL
486
- // Use allSegmentIds (derived from allSegments) instead of matchedIds because
487
- // we may have added diff segments (like loader segments) not in the matched array
488
- const allSegmentIds = allSegments.map((s) => s.id);
489
- tx.commit(
441
+ // Commit navigation - use server's matched as the authoritative segment ID list.
442
+ // reconciled.segments may be missing IDs (e.g., loader segments not in diff or cache)
443
+ // but the server's matched always includes all expected segment IDs.
444
+ const allSegmentIds = matchedIds;
445
+ const serverLocationState = payload.metadata?.locationState;
446
+ const overrides: CommitOverrides | undefined = isInterceptResponse
447
+ ? {
448
+ scroll: false,
449
+ intercept: true,
450
+ interceptSourceUrl: effectiveInterceptSource,
451
+ ...(serverLocationState && { serverState: serverLocationState }),
452
+ }
453
+ : serverLocationState
454
+ ? { serverState: serverLocationState }
455
+ : undefined;
456
+ const { scroll: navScroll } = tx.commit(
490
457
  allSegmentIds,
491
- allSegments,
492
- hasActiveIntercept
493
- ? {
494
- scroll: false,
495
- intercept: true,
496
- interceptSourceUrl: segmentState.currentUrl,
497
- }
498
- : undefined,
458
+ reconciled.segments,
459
+ overrides,
499
460
  );
500
461
 
501
462
  // For stale revalidation: verify history key hasn't changed before updating UI
502
- // If user navigated away, skip UI update to avoid corrupting current view
503
- if (staleRevalidation) {
463
+ if (mode.type === "stale-revalidation") {
504
464
  const historyKeyNow = store.getHistoryKey();
505
465
  if (historyKeyNow !== historyKeyAtStart) {
506
- console.log(
466
+ debugLog(
507
467
  `[Browser] Stale revalidation: history key changed (${historyKeyAtStart} -> ${historyKeyNow}), skipping UI update`,
508
468
  );
509
- return streamComplete;
469
+ return;
510
470
  }
511
471
  }
512
472
 
513
- console.log("[partial-update] updating document");
473
+ debugLog("[partial-update] updating document");
514
474
 
515
- // Emit update to trigger React render
516
- // For stale revalidation: wait for stream to complete (loaders resolved), then update
517
- // For actions: wrap in startTransition to avoid UI flickering
518
- if (isAction || staleRevalidation) {
475
+ // Emit update to trigger React render.
476
+ // Scroll info is included so NavigationProvider applies it after React commits.
477
+ const hasTransition = reconciled.mainSegments.some((s) => s.transition);
478
+ const scrollPayload = toScrollPayload(navScroll);
479
+
480
+ if (mode.type === "action" || mode.type === "stale-revalidation") {
481
+ startTransition(() => {
482
+ if (hasTransition && addTransitionType) {
483
+ addTransitionType("action");
484
+ }
485
+ onUpdate({
486
+ root: newTree,
487
+ metadata: payload.metadata!,
488
+ scroll: scrollPayload,
489
+ });
490
+ });
491
+ } else if (hasTransition) {
519
492
  startTransition(() => {
493
+ if (addTransitionType) {
494
+ addTransitionType("navigation");
495
+ }
520
496
  onUpdate({
521
497
  root: newTree,
522
498
  metadata: payload.metadata!,
499
+ scroll: scrollPayload,
523
500
  });
524
501
  });
525
502
  } else {
526
503
  onUpdate({
527
504
  root: newTree,
528
505
  metadata: payload.metadata!,
506
+ scroll: scrollPayload,
529
507
  });
530
508
  }
531
509
 
532
- console.log(`[Browser] Navigation complete\n`);
533
- return streamComplete;
510
+ debugLog("[Browser] Navigation complete");
511
+ return;
534
512
  } else {
535
513
  // Full update (fallback)
536
- // Use client-side renderSegments instead of payload.root to ensure
537
- // consistent component references with action revalidation.
538
- // Server-rendered RSC tree has different component references than
539
- // client-created tree, which causes React to remount LoaderBoundary
540
- // when actions trigger revalidation.
541
514
  console.warn(`[Browser] Full update (fallback)`);
542
515
 
543
516
  const segments = payload.metadata?.segments || [];
544
517
 
545
- // Check if this navigation is stale (a newer one started)
546
518
  if (signal?.aborted) {
547
- console.log(`[Browser] Ignoring stale navigation (aborted)`);
548
- return streamComplete;
519
+ debugLog("[Browser] Ignoring stale navigation (aborted)");
520
+ return;
549
521
  }
550
522
 
551
523
  const segmentIds = segments.map((s: ResolvedSegment) => s.id);
552
524
 
553
- // Render on client for consistent component references
554
525
  const newTree = await renderSegments(segments);
555
526
 
556
- // Final abort check before committing - another navigation may have started
557
527
  if (signal?.aborted) {
558
- console.log(
559
- `[Browser] Ignoring stale navigation (aborted before commit)`,
560
- );
561
- return streamComplete;
528
+ debugLog("[Browser] Ignoring stale navigation (aborted before commit)");
529
+ return;
562
530
  }
563
531
 
564
- // Commit navigation - transaction handles all store mutations atomically
565
- tx.commit(segmentIds, segments);
532
+ const fullUpdateServerState = payload.metadata?.locationState;
533
+ const { scroll: fullScroll } = fullUpdateServerState
534
+ ? tx.commit(segmentIds, segments, {
535
+ serverState: fullUpdateServerState,
536
+ })
537
+ : tx.commit(segmentIds, segments);
538
+
539
+ const fullHasTransition = segments.some(
540
+ (s: ResolvedSegment) => s.transition,
541
+ );
542
+ const fullScrollPayload = toScrollPayload(fullScroll);
566
543
 
567
- // Emit update to trigger React render
568
- // For stale revalidation: wait for stream to complete, then update
569
- // For actions: wrap in startTransition to avoid UI flickering
570
- if (staleRevalidation) {
544
+ if (mode.type === "stale-revalidation") {
571
545
  await rawStreamComplete;
572
546
  startTransition(() => {
547
+ if (fullHasTransition && addTransitionType) {
548
+ addTransitionType("action");
549
+ }
573
550
  onUpdate({
574
551
  root: newTree,
575
552
  metadata: payload.metadata!,
553
+ scroll: fullScrollPayload,
576
554
  });
577
555
  });
578
- } else if (isAction) {
556
+ } else if (mode.type === "action") {
579
557
  startTransition(async () => {
558
+ if (fullHasTransition && addTransitionType) {
559
+ addTransitionType("action");
560
+ }
561
+ onUpdate({
562
+ root: newTree,
563
+ metadata: payload.metadata!,
564
+ scroll: fullScrollPayload,
565
+ });
566
+ });
567
+ } else if (fullHasTransition) {
568
+ startTransition(() => {
569
+ if (addTransitionType) {
570
+ addTransitionType("navigation");
571
+ }
580
572
  onUpdate({
581
573
  root: newTree,
582
574
  metadata: payload.metadata!,
575
+ scroll: fullScrollPayload,
583
576
  });
584
577
  });
585
578
  } else {
586
579
  onUpdate({
587
580
  root: newTree,
588
581
  metadata: payload.metadata!,
582
+ scroll: fullScrollPayload,
589
583
  });
590
584
  }
591
585
 
592
- return streamComplete;
586
+ return;
593
587
  }
594
588
  }
595
589