@rangojs/router 0.0.0-experimental.3 → 0.0.0-experimental.30

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