@rangojs/router 0.0.0-experimental.19 → 0.0.0-experimental.1fa245e2

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 (160) hide show
  1. package/{CLAUDE.md → AGENTS.md} +4 -0
  2. package/README.md +122 -30
  3. package/dist/bin/rango.js +245 -63
  4. package/dist/vite/index.js +859 -418
  5. package/package.json +3 -3
  6. package/skills/breadcrumbs/SKILL.md +250 -0
  7. package/skills/cache-guide/SKILL.md +32 -0
  8. package/skills/caching/SKILL.md +49 -8
  9. package/skills/document-cache/SKILL.md +2 -2
  10. package/skills/hooks/SKILL.md +33 -31
  11. package/skills/host-router/SKILL.md +218 -0
  12. package/skills/links/SKILL.md +3 -1
  13. package/skills/loader/SKILL.md +72 -22
  14. package/skills/middleware/SKILL.md +2 -0
  15. package/skills/parallel/SKILL.md +126 -0
  16. package/skills/prerender/SKILL.md +112 -70
  17. package/skills/rango/SKILL.md +0 -1
  18. package/skills/route/SKILL.md +34 -4
  19. package/skills/router-setup/SKILL.md +95 -5
  20. package/skills/typesafety/SKILL.md +35 -23
  21. package/src/__internal.ts +92 -0
  22. package/src/bin/rango.ts +18 -0
  23. package/src/browser/app-version.ts +14 -0
  24. package/src/browser/event-controller.ts +5 -0
  25. package/src/browser/link-interceptor.ts +4 -0
  26. package/src/browser/navigation-bridge.ts +114 -18
  27. package/src/browser/navigation-client.ts +126 -44
  28. package/src/browser/navigation-store.ts +43 -8
  29. package/src/browser/navigation-transaction.ts +11 -9
  30. package/src/browser/partial-update.ts +80 -15
  31. package/src/browser/prefetch/cache.ts +166 -27
  32. package/src/browser/prefetch/fetch.ts +52 -39
  33. package/src/browser/prefetch/policy.ts +6 -0
  34. package/src/browser/prefetch/queue.ts +92 -20
  35. package/src/browser/prefetch/resource-ready.ts +77 -0
  36. package/src/browser/react/Link.tsx +70 -14
  37. package/src/browser/react/NavigationProvider.tsx +40 -4
  38. package/src/browser/react/context.ts +7 -2
  39. package/src/browser/react/use-handle.ts +9 -58
  40. package/src/browser/react/use-router.ts +21 -8
  41. package/src/browser/rsc-router.tsx +143 -59
  42. package/src/browser/scroll-restoration.ts +41 -42
  43. package/src/browser/segment-reconciler.ts +6 -1
  44. package/src/browser/server-action-bridge.ts +454 -436
  45. package/src/browser/types.ts +60 -5
  46. package/src/build/generate-manifest.ts +6 -6
  47. package/src/build/generate-route-types.ts +5 -0
  48. package/src/build/route-trie.ts +19 -3
  49. package/src/build/route-types/include-resolution.ts +8 -1
  50. package/src/build/route-types/router-processing.ts +346 -87
  51. package/src/build/route-types/scan-filter.ts +8 -1
  52. package/src/cache/cache-runtime.ts +15 -11
  53. package/src/cache/cache-scope.ts +48 -7
  54. package/src/cache/cf/cf-cache-store.ts +453 -11
  55. package/src/cache/cf/index.ts +5 -1
  56. package/src/cache/document-cache.ts +17 -7
  57. package/src/cache/index.ts +1 -0
  58. package/src/cache/taint.ts +55 -0
  59. package/src/client.rsc.tsx +2 -1
  60. package/src/client.tsx +3 -102
  61. package/src/context-var.ts +72 -2
  62. package/src/debug.ts +2 -2
  63. package/src/handle.ts +40 -0
  64. package/src/handles/breadcrumbs.ts +66 -0
  65. package/src/handles/index.ts +1 -0
  66. package/src/host/index.ts +0 -3
  67. package/src/index.rsc.ts +8 -37
  68. package/src/index.ts +40 -66
  69. package/src/prerender/store.ts +57 -15
  70. package/src/prerender.ts +138 -77
  71. package/src/reverse.ts +22 -1
  72. package/src/route-definition/dsl-helpers.ts +73 -25
  73. package/src/route-definition/helpers-types.ts +10 -6
  74. package/src/route-definition/index.ts +3 -3
  75. package/src/route-definition/redirect.ts +11 -3
  76. package/src/route-definition/resolve-handler-use.ts +149 -0
  77. package/src/route-map-builder.ts +7 -1
  78. package/src/route-types.ts +11 -0
  79. package/src/router/content-negotiation.ts +100 -1
  80. package/src/router/find-match.ts +4 -2
  81. package/src/router/handler-context.ts +108 -25
  82. package/src/router/intercept-resolution.ts +11 -4
  83. package/src/router/lazy-includes.ts +4 -1
  84. package/src/router/loader-resolution.ts +123 -11
  85. package/src/router/logging.ts +5 -2
  86. package/src/router/manifest.ts +9 -3
  87. package/src/router/match-api.ts +125 -190
  88. package/src/router/match-middleware/background-revalidation.ts +30 -2
  89. package/src/router/match-middleware/cache-lookup.ts +88 -16
  90. package/src/router/match-middleware/cache-store.ts +53 -10
  91. package/src/router/match-middleware/intercept-resolution.ts +9 -7
  92. package/src/router/match-middleware/segment-resolution.ts +61 -5
  93. package/src/router/match-result.ts +22 -15
  94. package/src/router/metrics.ts +238 -13
  95. package/src/router/middleware-types.ts +53 -12
  96. package/src/router/middleware.ts +172 -85
  97. package/src/router/navigation-snapshot.ts +182 -0
  98. package/src/router/pattern-matching.ts +20 -5
  99. package/src/router/prerender-match.ts +114 -10
  100. package/src/router/preview-match.ts +30 -102
  101. package/src/router/request-classification.ts +310 -0
  102. package/src/router/revalidation.ts +27 -7
  103. package/src/router/route-snapshot.ts +245 -0
  104. package/src/router/router-context.ts +6 -1
  105. package/src/router/router-interfaces.ts +50 -5
  106. package/src/router/router-options.ts +50 -19
  107. package/src/router/segment-resolution/fresh.ts +200 -19
  108. package/src/router/segment-resolution/helpers.ts +30 -25
  109. package/src/router/segment-resolution/loader-cache.ts +1 -0
  110. package/src/router/segment-resolution/revalidation.ts +429 -301
  111. package/src/router/segment-wrappers.ts +2 -0
  112. package/src/router/trie-matching.ts +20 -2
  113. package/src/router/types.ts +1 -0
  114. package/src/router.ts +88 -15
  115. package/src/rsc/handler.ts +546 -359
  116. package/src/rsc/index.ts +0 -20
  117. package/src/rsc/manifest-init.ts +5 -1
  118. package/src/rsc/progressive-enhancement.ts +25 -8
  119. package/src/rsc/rsc-rendering.ts +35 -43
  120. package/src/rsc/server-action.ts +16 -10
  121. package/src/rsc/ssr-setup.ts +128 -0
  122. package/src/rsc/types.ts +10 -1
  123. package/src/search-params.ts +16 -13
  124. package/src/segment-system.tsx +140 -4
  125. package/src/server/context.ts +148 -16
  126. package/src/server/loader-registry.ts +9 -8
  127. package/src/server/request-context.ts +182 -34
  128. package/src/server.ts +6 -0
  129. package/src/ssr/index.tsx +4 -0
  130. package/src/static-handler.ts +18 -6
  131. package/src/theme/index.ts +4 -13
  132. package/src/types/cache-types.ts +4 -4
  133. package/src/types/handler-context.ts +149 -49
  134. package/src/types/loader-types.ts +36 -9
  135. package/src/types/route-config.ts +17 -8
  136. package/src/types/route-entry.ts +8 -1
  137. package/src/types/segments.ts +2 -5
  138. package/src/urls/path-helper-types.ts +9 -2
  139. package/src/urls/path-helper.ts +48 -13
  140. package/src/urls/pattern-types.ts +12 -0
  141. package/src/urls/response-types.ts +16 -6
  142. package/src/use-loader.tsx +73 -4
  143. package/src/vite/discovery/bundle-postprocess.ts +61 -89
  144. package/src/vite/discovery/discover-routers.ts +23 -5
  145. package/src/vite/discovery/prerender-collection.ts +48 -15
  146. package/src/vite/discovery/state.ts +17 -13
  147. package/src/vite/index.ts +8 -3
  148. package/src/vite/plugin-types.ts +51 -79
  149. package/src/vite/plugins/client-ref-dedup.ts +115 -0
  150. package/src/vite/plugins/expose-action-id.ts +1 -3
  151. package/src/vite/plugins/performance-tracks.ts +88 -0
  152. package/src/vite/plugins/refresh-cmd.ts +127 -0
  153. package/src/vite/plugins/version-plugin.ts +13 -1
  154. package/src/vite/rango.ts +174 -211
  155. package/src/vite/router-discovery.ts +169 -42
  156. package/src/vite/utils/banner.ts +3 -3
  157. package/src/vite/utils/prerender-utils.ts +78 -0
  158. package/src/vite/utils/shared-utils.ts +3 -2
  159. package/skills/testing/SKILL.md +0 -226
  160. package/src/route-definition/route-function.ts +0 -119
@@ -17,6 +17,11 @@ import {
17
17
  emptyResponse,
18
18
  teeWithCompletion,
19
19
  } from "./response-adapter.js";
20
+ import {
21
+ buildPrefetchKey,
22
+ consumeInflightPrefetch,
23
+ consumePrefetch,
24
+ } from "./prefetch/cache.js";
20
25
 
21
26
  /**
22
27
  * Create a navigation client for fetching RSC payloads
@@ -24,21 +29,12 @@ import {
24
29
  * The client handles building URLs with RSC parameters and
25
30
  * deserializing the response using the RSC runtime.
26
31
  *
32
+ * Checks the in-memory prefetch cache before making a network request.
33
+ * The cache key is source-dependent (includes the previous URL) so
34
+ * prefetch responses match the exact diff the server would produce.
35
+ *
27
36
  * @param deps - RSC browser dependencies (createFromFetch)
28
37
  * @returns NavigationClient instance
29
- *
30
- * @example
31
- * ```typescript
32
- * import { createFromFetch } from "@vitejs/plugin-rsc/browser";
33
- *
34
- * const client = createNavigationClient({ createFromFetch });
35
- *
36
- * const payload = await client.fetchPartial({
37
- * targetUrl: "/shop/products",
38
- * segmentIds: ["root", "shop"],
39
- * previousUrl: "/",
40
- * });
41
- * ```
42
38
  */
43
39
  export function createNavigationClient(
44
40
  deps: Pick<RscBrowserDependencies, "createFromFetch">,
@@ -47,8 +43,9 @@ export function createNavigationClient(
47
43
  /**
48
44
  * Fetch a partial RSC payload for navigation
49
45
  *
50
- * Sends current segment IDs to the server so it can determine
51
- * which segments need to be re-rendered (diff).
46
+ * First checks the in-memory prefetch cache for a matching entry.
47
+ * If found, uses the cached response instantly. Otherwise sends
48
+ * current segment IDs to the server for diff-based rendering.
52
49
  *
53
50
  * @param options - Fetch options
54
51
  * @returns RSC payload with segments and metadata, plus stream completion promise
@@ -64,6 +61,7 @@ export function createNavigationClient(
64
61
  staleRevalidation,
65
62
  interceptSourceUrl,
66
63
  version,
64
+ routerId,
67
65
  hmr,
68
66
  } = options;
69
67
 
@@ -80,7 +78,8 @@ export function createNavigationClient(
80
78
  });
81
79
  }
82
80
 
83
- // Build fetch URL with partial rendering params
81
+ // Build fetch URL with partial rendering params (used for both
82
+ // cache key lookup and actual fetch if cache misses)
84
83
  const fetchUrl = new URL(targetUrl, window.location.origin);
85
84
  fetchUrl.searchParams.set("_rsc_partial", "true");
86
85
  fetchUrl.searchParams.set("_rsc_segments", segmentIds.join(","));
@@ -90,32 +89,38 @@ export function createNavigationClient(
90
89
  if (version) {
91
90
  fetchUrl.searchParams.set("_rsc_v", version);
92
91
  }
93
- if (tx) {
94
- browserDebugLog(tx, "fetching", {
95
- path: `${fetchUrl.pathname}${fetchUrl.search}`,
96
- });
92
+ if (routerId) {
93
+ fetchUrl.searchParams.set("_rsc_rid", routerId);
97
94
  }
98
95
 
96
+ // Check completed in-memory prefetch cache before making a network request.
97
+ // The cache key includes the source URL (previousUrl) because the
98
+ // server's diff response depends on the source page context.
99
+ // Skip cache for stale revalidation (needs fresh data), HMR (needs
100
+ // fresh modules), and intercept contexts (source-dependent responses).
101
+ //
102
+ const canUsePrefetch = !staleRevalidation && !hmr && !interceptSourceUrl;
103
+ const cacheKey = buildPrefetchKey(previousUrl, fetchUrl);
104
+ const cachedResponse = canUsePrefetch ? consumePrefetch(cacheKey) : null;
105
+ const inflightResponsePromise = canUsePrefetch
106
+ ? consumeInflightPrefetch(cacheKey)
107
+ : null;
99
108
  // Track when the stream completes
100
109
  let resolveStreamComplete: () => void;
101
110
  const streamComplete = new Promise<void>((resolve) => {
102
111
  resolveStreamComplete = resolve;
103
112
  });
104
113
 
105
- // Create a response promise that tracks stream completion
106
- const responsePromise = fetch(fetchUrl, {
107
- headers: {
108
- "X-RSC-Router-Client-Path": previousUrl,
109
- "X-Rango-State": getRangoState(),
110
- ...(tx && { "X-RSC-Router-Request-Id": tx.requestId }),
111
- ...(interceptSourceUrl && {
112
- "X-RSC-Router-Intercept-Source": interceptSourceUrl,
113
- }),
114
- ...(hmr && { "X-RSC-HMR": "1" }),
115
- },
116
- signal,
117
- }).then((response) => {
118
- // Check for version mismatch - server wants us to reload
114
+ /**
115
+ * Validate RSC control headers on any response (fresh, cached, or
116
+ * in-flight). Handles version-mismatch reloads and server redirects.
117
+ * Returns the response unchanged when no control header is present.
118
+ */
119
+ const validateRscHeaders = (
120
+ response: Response,
121
+ source: string,
122
+ ): Response | Promise<Response> => {
123
+ // Version mismatch server wants a full page reload
119
124
  const reload = extractRscHeaderUrl(response, "X-RSC-Reload");
120
125
  if (reload === "blocked") {
121
126
  resolveStreamComplete();
@@ -123,11 +128,12 @@ export function createNavigationClient(
123
128
  }
124
129
  if (reload) {
125
130
  if (tx) {
126
- browserDebugLog(tx, "version mismatch, reloading", {
131
+ browserDebugLog(tx, `version mismatch, reloading (${source})`, {
127
132
  reloadUrl: reload.url,
128
133
  });
129
134
  }
130
135
  window.location.href = reload.url;
136
+ // Block further processing — page is reloading
131
137
  return new Promise<Response>(() => {});
132
138
  }
133
139
 
@@ -142,7 +148,7 @@ export function createNavigationClient(
142
148
  }
143
149
  if (redirect) {
144
150
  if (tx) {
145
- browserDebugLog(tx, "server redirect", {
151
+ browserDebugLog(tx, `server redirect (${source})`, {
146
152
  redirectUrl: redirect.url,
147
153
  });
148
154
  }
@@ -150,19 +156,95 @@ export function createNavigationClient(
150
156
  throw new ServerRedirect(redirect.url, undefined);
151
157
  }
152
158
 
153
- return teeWithCompletion(
154
- response,
155
- () => {
156
- if (tx) browserDebugLog(tx, "stream complete");
157
- resolveStreamComplete();
159
+ return response;
160
+ };
161
+
162
+ /** Start a fresh navigation fetch (no cache / inflight hit). */
163
+ const doFreshFetch = (): Promise<Response> => {
164
+ if (tx) {
165
+ browserDebugLog(tx, "fetching", {
166
+ path: `${fetchUrl.pathname}${fetchUrl.search}`,
167
+ });
168
+ }
169
+
170
+ return fetch(fetchUrl, {
171
+ headers: {
172
+ "X-RSC-Router-Client-Path": previousUrl,
173
+ "X-Rango-State": getRangoState(),
174
+ ...(tx && { "X-RSC-Router-Request-Id": tx.requestId }),
175
+ ...(interceptSourceUrl && {
176
+ "X-RSC-Router-Intercept-Source": interceptSourceUrl,
177
+ }),
178
+ ...(hmr && { "X-RSC-HMR": "1" }),
158
179
  },
159
180
  signal,
160
- );
161
- });
181
+ }).then((response) => {
182
+ const validated = validateRscHeaders(response, "fetch");
183
+ if (validated instanceof Promise) return validated;
184
+
185
+ return teeWithCompletion(
186
+ validated,
187
+ () => {
188
+ if (tx) browserDebugLog(tx, "stream complete");
189
+ resolveStreamComplete();
190
+ },
191
+ signal,
192
+ );
193
+ });
194
+ };
195
+
196
+ let responsePromise: Promise<Response>;
197
+
198
+ if (cachedResponse) {
199
+ if (tx) {
200
+ browserDebugLog(tx, "prefetch cache hit", { key: cacheKey });
201
+ }
202
+ responsePromise = Promise.resolve(cachedResponse).then((response) => {
203
+ const validated = validateRscHeaders(response, "prefetch cache");
204
+ if (validated instanceof Promise) return validated;
205
+
206
+ return teeWithCompletion(
207
+ validated,
208
+ () => {
209
+ if (tx) browserDebugLog(tx, "stream complete (from cache)");
210
+ resolveStreamComplete();
211
+ },
212
+ signal,
213
+ );
214
+ });
215
+ } else if (inflightResponsePromise) {
216
+ if (tx) {
217
+ browserDebugLog(tx, "reusing inflight prefetch", { key: cacheKey });
218
+ }
219
+ responsePromise = inflightResponsePromise.then(async (response) => {
220
+ if (!response) {
221
+ if (tx) {
222
+ browserDebugLog(tx, "inflight prefetch unavailable, refetching");
223
+ }
224
+ return doFreshFetch();
225
+ }
226
+
227
+ const validated = validateRscHeaders(response, "inflight prefetch");
228
+ if (validated instanceof Promise) return validated;
229
+
230
+ return teeWithCompletion(
231
+ validated,
232
+ () => {
233
+ if (tx) {
234
+ browserDebugLog(tx, "stream complete (from inflight prefetch)");
235
+ }
236
+ resolveStreamComplete();
237
+ },
238
+ signal,
239
+ );
240
+ });
241
+ } else {
242
+ responsePromise = doFreshFetch();
243
+ }
162
244
 
163
245
  try {
164
- // Deserialize RSC payload
165
246
  const payload = await deps.createFromFetch<RscPayload>(responsePromise);
247
+
166
248
  if (tx) {
167
249
  browserDebugLog(tx, "response received", {
168
250
  isPartial: payload.metadata?.isPartial,
@@ -28,9 +28,15 @@ const DEFAULT_ACTION_STATE: TrackedActionState = {
28
28
  // Maximum number of history entries to cache (URLs visited)
29
29
  const HISTORY_CACHE_SIZE = 20;
30
30
 
31
- // Cache entry: [url-key, segments, stale, handleData?]
31
+ // Cache entry: [url-key, segments, stale, handleData?, routerId?]
32
32
  // stale=true means the data may be outdated and should be revalidated on access
33
- type HistoryCacheEntry = [string, ResolvedSegment[], boolean, HandleData?];
33
+ type HistoryCacheEntry = [
34
+ string,
35
+ ResolvedSegment[],
36
+ boolean,
37
+ HandleData?,
38
+ string?,
39
+ ];
34
40
 
35
41
  /**
36
42
  * Shallow clone handleData to avoid reference sharing between cache entries.
@@ -258,6 +264,11 @@ export function createNavigationStore(
258
264
  // Used to maintain intercept context during action revalidation
259
265
  let interceptSourceUrl: string | null = null;
260
266
 
267
+ // Router identity - tracks which router is currently active.
268
+ // When this changes on a partial response, the client forces a full
269
+ // tree replacement instead of reconciling with stale segments.
270
+ let currentRouterId: string | undefined;
271
+
261
272
  // Action state tracking (for useAction hook)
262
273
  // Maps action function ID to its tracked state
263
274
  const actionStates = new Map<string, TrackedActionState>();
@@ -571,10 +582,17 @@ export function createNavigationStore(
571
582
  segments,
572
583
  false,
573
584
  clonedHandleData,
585
+ currentRouterId,
574
586
  ];
575
587
  } else {
576
588
  // Add new entry at the end (not stale)
577
- historyCache.push([historyKey, segments, false, clonedHandleData]);
589
+ historyCache.push([
590
+ historyKey,
591
+ segments,
592
+ false,
593
+ clonedHandleData,
594
+ currentRouterId,
595
+ ]);
578
596
  // Remove oldest entries if over limit
579
597
  while (historyCache.length > cacheSize) {
580
598
  historyCache.shift();
@@ -586,14 +604,22 @@ export function createNavigationStore(
586
604
  * Get cached segments for a history entry
587
605
  * Returns { segments, stale, handleData } or undefined if not cached
588
606
  */
589
- getCachedSegments(
590
- historyKey: string,
591
- ):
592
- | { segments: ResolvedSegment[]; stale: boolean; handleData?: HandleData }
607
+ getCachedSegments(historyKey: string):
608
+ | {
609
+ segments: ResolvedSegment[];
610
+ stale: boolean;
611
+ handleData?: HandleData;
612
+ routerId?: string;
613
+ }
593
614
  | undefined {
594
615
  const entry = historyCache.find(([key]) => key === historyKey);
595
616
  if (!entry) return undefined;
596
- return { segments: entry[1], stale: entry[2], handleData: entry[3] };
617
+ return {
618
+ segments: entry[1],
619
+ stale: entry[2],
620
+ handleData: entry[3],
621
+ routerId: entry[4],
622
+ };
597
623
  },
598
624
 
599
625
  /**
@@ -621,6 +647,7 @@ export function createNavigationStore(
621
647
  entry[1],
622
648
  entry[2],
623
649
  clonedHandleData,
650
+ entry[4], // preserve routerId
624
651
  ];
625
652
  }
626
653
  },
@@ -687,6 +714,14 @@ export function createNavigationStore(
687
714
  interceptSourceUrl = url;
688
715
  },
689
716
 
717
+ getRouterId(): string | undefined {
718
+ return currentRouterId;
719
+ },
720
+
721
+ setRouterId(id: string): void {
722
+ currentRouterId = id;
723
+ },
724
+
690
725
  // ========================================================================
691
726
  // UI Update Notifications
692
727
  // ========================================================================
@@ -7,7 +7,6 @@ import type {
7
7
  import { generateHistoryKey } from "./navigation-store.js";
8
8
  import {
9
9
  handleNavigationStart,
10
- handleNavigationEnd,
11
10
  ensureHistoryKey,
12
11
  } from "./scroll-restoration.js";
13
12
  import type { EventController, NavigationHandle } from "./event-controller.js";
@@ -81,11 +80,12 @@ export interface BoundTransaction {
81
80
  readonly currentUrl: string;
82
81
  /** Start streaming and get a token to end it when the stream completes */
83
82
  startStreaming(): StreamingToken;
83
+ /** Commit the navigation. Returns the effective scroll option for the caller to handle. */
84
84
  commit(
85
85
  segmentIds: string[],
86
86
  segments: ResolvedSegment[],
87
87
  overrides?: BoundCommitOverrides,
88
- ): void;
88
+ ): { scroll?: boolean };
89
89
  }
90
90
 
91
91
  /**
@@ -93,7 +93,7 @@ export interface BoundTransaction {
93
93
  * Uses the event controller handle for lifecycle management
94
94
  */
95
95
  interface NavigationTransaction extends Disposable {
96
- commit(options: CommitOptions): void;
96
+ commit(options: CommitOptions): { scroll?: boolean };
97
97
  with(
98
98
  options: Omit<CommitOptions, "segmentIds" | "segments">,
99
99
  ): BoundTransaction;
@@ -120,7 +120,7 @@ export function createNavigationTransaction(
120
120
  /**
121
121
  * Commit the navigation - updates store and URL atomically
122
122
  */
123
- function commit(opts: CommitOptions): void {
123
+ function commit(opts: CommitOptions): { scroll?: boolean } {
124
124
  committed = true;
125
125
 
126
126
  const {
@@ -150,7 +150,7 @@ export function createNavigationTransaction(
150
150
  // Without this, the entry lingers and weakens state-machine invariants.
151
151
  handle.complete(parsedUrl);
152
152
  debugLog("[Browser] Cache-only commit, historyKey:", historyKey);
153
- return;
153
+ return { scroll: false };
154
154
  }
155
155
 
156
156
  // Save current scroll position before navigating
@@ -172,7 +172,7 @@ export function createNavigationTransaction(
172
172
  debugLog("[Browser] Store updated (action)");
173
173
  // Complete navigation to clear loading state
174
174
  handle.complete(parsedUrl);
175
- return;
175
+ return { scroll: false };
176
176
  }
177
177
 
178
178
  // Build history state - include user state, intercept info, and server-set state
@@ -205,14 +205,16 @@ export function createNavigationTransaction(
205
205
  // Complete the navigation in event controller (sets idle state, updates location)
206
206
  handle.complete(parsedUrl);
207
207
 
208
- // Handle scroll after navigation
209
- handleNavigationEnd({ scroll });
208
+ // NOTE: Scroll is NOT handled here. The caller (partial-update.ts) handles
209
+ // scroll AFTER onUpdate() so React has the new content before we scroll.
210
210
 
211
211
  debugLog(
212
212
  "[Browser] Navigation committed, historyKey:",
213
213
  historyKey,
214
214
  intercept ? "(intercept)" : "",
215
215
  );
216
+
217
+ return { scroll };
216
218
  }
217
219
 
218
220
  return {
@@ -263,7 +265,7 @@ export function createNavigationTransaction(
263
265
  overrides?.state !== undefined ? overrides.state : opts.state;
264
266
  // Server-set location state: only from overrides (set by partial-update)
265
267
  const serverState = overrides?.serverState;
266
- commit({
268
+ return commit({
267
269
  ...opts,
268
270
  segmentIds,
269
271
  segments,
@@ -19,6 +19,14 @@ import type { BoundTransaction } from "./navigation-transaction.js";
19
19
  import { ServerRedirect } from "../errors.js";
20
20
  import { debugLog } from "./logging.js";
21
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
+ }
22
30
 
23
31
  /**
24
32
  * Configuration for creating a partial updater
@@ -31,8 +39,8 @@ export interface PartialUpdateConfig {
31
39
  segments: ResolvedSegment[],
32
40
  options?: RenderSegmentsOptions,
33
41
  ) => Promise<ReactNode> | ReactNode;
34
- /** RSC version received from server (from initial payload metadata) */
35
- version?: string;
42
+ /** RSC version getter returns the current version (may change after HMR) */
43
+ getVersion?: () => string | undefined;
36
44
  }
37
45
 
38
46
  /**
@@ -96,7 +104,13 @@ export type PartialUpdater = (
96
104
  export function createPartialUpdater(
97
105
  config: PartialUpdateConfig,
98
106
  ): PartialUpdater {
99
- const { store, client, onUpdate, renderSegments, version } = config;
107
+ const {
108
+ store,
109
+ client,
110
+ onUpdate,
111
+ renderSegments,
112
+ getVersion = () => undefined,
113
+ } = config;
100
114
 
101
115
  /**
102
116
  * Get current page's cached segments as an array
@@ -185,7 +199,8 @@ export function createPartialUpdater(
185
199
  // (action redirect sends empty segments for a fresh render).
186
200
  staleRevalidation:
187
201
  mode.type === "stale-revalidation" || segments.length === 0,
188
- version,
202
+ version: getVersion(),
203
+ routerId: store.getRouterId?.(),
189
204
  });
190
205
  // Mark navigation as streaming (response received, now parsing RSC).
191
206
  // Called after fetchPartial so pendingUrl stays set during the network wait,
@@ -198,6 +213,21 @@ export function createPartialUpdater(
198
213
  streamingToken.end();
199
214
  });
200
215
 
216
+ // Detect app switch: if routerId changed, the navigation crossed into
217
+ // a different router (e.g., via host router path mount). Downgrade
218
+ // partial to full so the entire tree is replaced without reconciliation
219
+ // against stale segments from the previous app.
220
+ if (payload.metadata?.routerId) {
221
+ const prevRouterId = store.getRouterId?.();
222
+ if (prevRouterId && prevRouterId !== payload.metadata.routerId) {
223
+ debugLog(
224
+ `[Browser] App switch detected (${prevRouterId} → ${payload.metadata.routerId}), forcing full update`,
225
+ );
226
+ payload.metadata.isPartial = false;
227
+ }
228
+ store.setRouterId?.(payload.metadata.routerId);
229
+ }
230
+
201
231
  // Handle server-side redirect with state
202
232
  if (payload.metadata?.redirect) {
203
233
  if (signal?.aborted) {
@@ -246,7 +276,21 @@ export function createPartialUpdater(
246
276
  forceAwait: true,
247
277
  });
248
278
 
249
- tx.commit(matchedIds, existingSegments);
279
+ const { scroll: commitScroll } = tx.commit(
280
+ matchedIds,
281
+ existingSegments,
282
+ );
283
+
284
+ // tx.commit() cached the source page's handleData because
285
+ // eventController hasn't been updated yet. Overwrite with the
286
+ // correct cached handleData to prevent cache corruption on
287
+ // subsequent navigations to this same URL.
288
+ if (mode.targetCacheHandleData) {
289
+ store.updateCacheHandleData(
290
+ store.getHistoryKey(),
291
+ mode.targetCacheHandleData,
292
+ );
293
+ }
250
294
 
251
295
  // Include cachedHandleData in metadata so NavigationProvider can restore
252
296
  // breadcrumbs and other handle data from cache.
@@ -260,6 +304,7 @@ export function createPartialUpdater(
260
304
  ...metadataWithoutHandles,
261
305
  cachedHandleData: mode.targetCacheHandleData,
262
306
  },
307
+ scroll: toScrollPayload(commitScroll),
263
308
  };
264
309
 
265
310
  const cachedHasTransition = existingSegments.some(
@@ -290,11 +335,15 @@ export function createPartialUpdater(
290
335
  forceAwait: true,
291
336
  });
292
337
 
293
- tx.commit(matchedIds, existingSegments);
338
+ const { scroll: leaveScroll } = tx.commit(
339
+ matchedIds,
340
+ existingSegments,
341
+ );
294
342
 
295
343
  onUpdate({
296
344
  root: newTree,
297
345
  metadata: payload.metadata,
346
+ scroll: toScrollPayload(leaveScroll),
298
347
  });
299
348
 
300
349
  debugLog("[Browser] Navigation complete (left intercept)");
@@ -411,8 +460,10 @@ export function createPartialUpdater(
411
460
  }
412
461
  }
413
462
 
414
- // Commit navigation - transaction handles all store mutations atomically
415
- const allSegmentIds = reconciled.segments.map((s) => s.id);
463
+ // Commit navigation - use server's matched as the authoritative segment ID list.
464
+ // reconciled.segments may be missing IDs (e.g., loader segments not in diff or cache)
465
+ // but the server's matched always includes all expected segment IDs.
466
+ const allSegmentIds = matchedIds;
416
467
  const serverLocationState = payload.metadata?.locationState;
417
468
  const overrides: CommitOverrides | undefined = isInterceptResponse
418
469
  ? {
@@ -424,7 +475,11 @@ export function createPartialUpdater(
424
475
  : serverLocationState
425
476
  ? { serverState: serverLocationState }
426
477
  : undefined;
427
- tx.commit(allSegmentIds, reconciled.segments, overrides);
478
+ const { scroll: navScroll } = tx.commit(
479
+ allSegmentIds,
480
+ reconciled.segments,
481
+ overrides,
482
+ );
428
483
 
429
484
  // For stale revalidation: verify history key hasn't changed before updating UI
430
485
  if (mode.type === "stale-revalidation") {
@@ -439,8 +494,10 @@ export function createPartialUpdater(
439
494
 
440
495
  debugLog("[partial-update] updating document");
441
496
 
442
- // Emit update to trigger React render
497
+ // Emit update to trigger React render.
498
+ // Scroll info is included so NavigationProvider applies it after React commits.
443
499
  const hasTransition = reconciled.mainSegments.some((s) => s.transition);
500
+ const scrollPayload = toScrollPayload(navScroll);
444
501
 
445
502
  if (mode.type === "action" || mode.type === "stale-revalidation") {
446
503
  startTransition(() => {
@@ -450,6 +507,7 @@ export function createPartialUpdater(
450
507
  onUpdate({
451
508
  root: newTree,
452
509
  metadata: payload.metadata!,
510
+ scroll: scrollPayload,
453
511
  });
454
512
  });
455
513
  } else if (hasTransition) {
@@ -460,12 +518,14 @@ export function createPartialUpdater(
460
518
  onUpdate({
461
519
  root: newTree,
462
520
  metadata: payload.metadata!,
521
+ scroll: scrollPayload,
463
522
  });
464
523
  });
465
524
  } else {
466
525
  onUpdate({
467
526
  root: newTree,
468
527
  metadata: payload.metadata!,
528
+ scroll: scrollPayload,
469
529
  });
470
530
  }
471
531
 
@@ -492,15 +552,16 @@ export function createPartialUpdater(
492
552
  }
493
553
 
494
554
  const fullUpdateServerState = payload.metadata?.locationState;
495
- if (fullUpdateServerState) {
496
- tx.commit(segmentIds, segments, { serverState: fullUpdateServerState });
497
- } else {
498
- tx.commit(segmentIds, segments);
499
- }
555
+ const { scroll: fullScroll } = fullUpdateServerState
556
+ ? tx.commit(segmentIds, segments, {
557
+ serverState: fullUpdateServerState,
558
+ })
559
+ : tx.commit(segmentIds, segments);
500
560
 
501
561
  const fullHasTransition = segments.some(
502
562
  (s: ResolvedSegment) => s.transition,
503
563
  );
564
+ const fullScrollPayload = toScrollPayload(fullScroll);
504
565
 
505
566
  if (mode.type === "stale-revalidation") {
506
567
  await rawStreamComplete;
@@ -511,6 +572,7 @@ export function createPartialUpdater(
511
572
  onUpdate({
512
573
  root: newTree,
513
574
  metadata: payload.metadata!,
575
+ scroll: fullScrollPayload,
514
576
  });
515
577
  });
516
578
  } else if (mode.type === "action") {
@@ -521,6 +583,7 @@ export function createPartialUpdater(
521
583
  onUpdate({
522
584
  root: newTree,
523
585
  metadata: payload.metadata!,
586
+ scroll: fullScrollPayload,
524
587
  });
525
588
  });
526
589
  } else if (fullHasTransition) {
@@ -531,12 +594,14 @@ export function createPartialUpdater(
531
594
  onUpdate({
532
595
  root: newTree,
533
596
  metadata: payload.metadata!,
597
+ scroll: fullScrollPayload,
534
598
  });
535
599
  });
536
600
  } else {
537
601
  onUpdate({
538
602
  root: newTree,
539
603
  metadata: payload.metadata!,
604
+ scroll: fullScrollPayload,
540
605
  });
541
606
  }
542
607