@rangojs/router 0.0.0-experimental.28 → 0.0.0-experimental.289231ba

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 (159) hide show
  1. package/AGENTS.md +4 -0
  2. package/README.md +78 -19
  3. package/dist/bin/rango.js +138 -50
  4. package/dist/vite/index.js +853 -435
  5. package/package.json +17 -16
  6. package/skills/breadcrumbs/SKILL.md +250 -0
  7. package/skills/cache-guide/SKILL.md +32 -0
  8. package/skills/caching/SKILL.md +45 -4
  9. package/skills/handler-use/SKILL.md +362 -0
  10. package/skills/hooks/SKILL.md +22 -4
  11. package/skills/intercept/SKILL.md +20 -0
  12. package/skills/layout/SKILL.md +22 -0
  13. package/skills/links/SKILL.md +3 -1
  14. package/skills/loader/SKILL.md +71 -21
  15. package/skills/middleware/SKILL.md +34 -3
  16. package/skills/migrate-nextjs/SKILL.md +560 -0
  17. package/skills/migrate-react-router/SKILL.md +764 -0
  18. package/skills/parallel/SKILL.md +185 -0
  19. package/skills/prerender/SKILL.md +110 -68
  20. package/skills/rango/SKILL.md +24 -22
  21. package/skills/route/SKILL.md +56 -2
  22. package/skills/router-setup/SKILL.md +92 -2
  23. package/skills/typesafety/SKILL.md +33 -21
  24. package/src/__internal.ts +92 -0
  25. package/src/browser/app-version.ts +14 -0
  26. package/src/browser/event-controller.ts +5 -0
  27. package/src/browser/link-interceptor.ts +4 -0
  28. package/src/browser/navigation-bridge.ts +125 -16
  29. package/src/browser/navigation-client.ts +154 -44
  30. package/src/browser/navigation-store.ts +43 -8
  31. package/src/browser/navigation-transaction.ts +11 -9
  32. package/src/browser/partial-update.ts +94 -17
  33. package/src/browser/prefetch/cache.ts +176 -27
  34. package/src/browser/prefetch/fetch.ts +110 -41
  35. package/src/browser/prefetch/policy.ts +6 -0
  36. package/src/browser/prefetch/queue.ts +92 -20
  37. package/src/browser/prefetch/resource-ready.ts +77 -0
  38. package/src/browser/react/Link.tsx +88 -9
  39. package/src/browser/react/NavigationProvider.tsx +40 -4
  40. package/src/browser/react/context.ts +7 -2
  41. package/src/browser/react/use-handle.ts +9 -58
  42. package/src/browser/react/use-navigation.ts +22 -2
  43. package/src/browser/react/use-router.ts +21 -8
  44. package/src/browser/rsc-router.tsx +143 -60
  45. package/src/browser/scroll-restoration.ts +41 -42
  46. package/src/browser/segment-reconciler.ts +36 -9
  47. package/src/browser/server-action-bridge.ts +8 -6
  48. package/src/browser/types.ts +60 -5
  49. package/src/build/generate-manifest.ts +6 -6
  50. package/src/build/generate-route-types.ts +3 -0
  51. package/src/build/route-trie.ts +50 -24
  52. package/src/build/route-types/include-resolution.ts +8 -1
  53. package/src/build/route-types/router-processing.ts +223 -74
  54. package/src/build/route-types/scan-filter.ts +8 -1
  55. package/src/cache/cache-runtime.ts +15 -11
  56. package/src/cache/cache-scope.ts +48 -7
  57. package/src/cache/cf/cf-cache-store.ts +453 -11
  58. package/src/cache/cf/index.ts +5 -1
  59. package/src/cache/document-cache.ts +17 -7
  60. package/src/cache/index.ts +1 -0
  61. package/src/cache/taint.ts +55 -0
  62. package/src/client.rsc.tsx +2 -0
  63. package/src/client.tsx +85 -230
  64. package/src/context-var.ts +72 -2
  65. package/src/debug.ts +2 -2
  66. package/src/handle.ts +40 -0
  67. package/src/handles/breadcrumbs.ts +66 -0
  68. package/src/handles/index.ts +1 -0
  69. package/src/index.rsc.ts +6 -36
  70. package/src/index.ts +50 -43
  71. package/src/prerender/store.ts +5 -4
  72. package/src/prerender.ts +138 -77
  73. package/src/reverse.ts +25 -1
  74. package/src/route-definition/dsl-helpers.ts +224 -37
  75. package/src/route-definition/helpers-types.ts +67 -19
  76. package/src/route-definition/index.ts +3 -0
  77. package/src/route-definition/redirect.ts +11 -3
  78. package/src/route-definition/resolve-handler-use.ts +149 -0
  79. package/src/route-map-builder.ts +7 -1
  80. package/src/route-types.ts +18 -0
  81. package/src/router/content-negotiation.ts +100 -1
  82. package/src/router/find-match.ts +4 -2
  83. package/src/router/handler-context.ts +111 -25
  84. package/src/router/intercept-resolution.ts +11 -4
  85. package/src/router/lazy-includes.ts +9 -6
  86. package/src/router/loader-resolution.ts +156 -21
  87. package/src/router/logging.ts +5 -2
  88. package/src/router/manifest.ts +31 -16
  89. package/src/router/match-api.ts +125 -190
  90. package/src/router/match-middleware/background-revalidation.ts +30 -2
  91. package/src/router/match-middleware/cache-lookup.ts +94 -17
  92. package/src/router/match-middleware/cache-store.ts +53 -10
  93. package/src/router/match-middleware/intercept-resolution.ts +9 -7
  94. package/src/router/match-middleware/segment-resolution.ts +61 -5
  95. package/src/router/match-result.ts +104 -10
  96. package/src/router/metrics.ts +6 -1
  97. package/src/router/middleware-types.ts +40 -12
  98. package/src/router/middleware.ts +43 -79
  99. package/src/router/navigation-snapshot.ts +182 -0
  100. package/src/router/prerender-match.ts +114 -10
  101. package/src/router/preview-match.ts +30 -102
  102. package/src/router/request-classification.ts +310 -0
  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 +44 -5
  106. package/src/router/router-options.ts +49 -18
  107. package/src/router/segment-resolution/fresh.ts +198 -20
  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 +438 -300
  111. package/src/router/segment-wrappers.ts +2 -0
  112. package/src/router/types.ts +1 -0
  113. package/src/router.ts +73 -13
  114. package/src/rsc/handler.ts +472 -372
  115. package/src/rsc/loader-fetch.ts +23 -3
  116. package/src/rsc/manifest-init.ts +5 -1
  117. package/src/rsc/progressive-enhancement.ts +14 -2
  118. package/src/rsc/rsc-rendering.ts +13 -1
  119. package/src/rsc/server-action.ts +8 -0
  120. package/src/rsc/ssr-setup.ts +2 -2
  121. package/src/rsc/types.ts +11 -1
  122. package/src/segment-content-promise.ts +67 -0
  123. package/src/segment-loader-promise.ts +122 -0
  124. package/src/segment-system.tsx +109 -23
  125. package/src/server/context.ts +166 -17
  126. package/src/server/handle-store.ts +19 -0
  127. package/src/server/loader-registry.ts +9 -8
  128. package/src/server/request-context.ts +204 -28
  129. package/src/ssr/index.tsx +4 -0
  130. package/src/static-handler.ts +18 -6
  131. package/src/types/cache-types.ts +4 -4
  132. package/src/types/handler-context.ts +149 -49
  133. package/src/types/loader-types.ts +36 -9
  134. package/src/types/route-entry.ts +19 -1
  135. package/src/types/segments.ts +2 -0
  136. package/src/urls/include-helper.ts +24 -14
  137. package/src/urls/path-helper-types.ts +39 -6
  138. package/src/urls/path-helper.ts +48 -13
  139. package/src/urls/pattern-types.ts +12 -0
  140. package/src/urls/response-types.ts +16 -6
  141. package/src/use-loader.tsx +77 -5
  142. package/src/vite/discovery/bundle-postprocess.ts +30 -33
  143. package/src/vite/discovery/discover-routers.ts +5 -1
  144. package/src/vite/discovery/prerender-collection.ts +128 -74
  145. package/src/vite/discovery/state.ts +13 -6
  146. package/src/vite/index.ts +4 -0
  147. package/src/vite/plugin-types.ts +51 -79
  148. package/src/vite/plugins/expose-action-id.ts +1 -3
  149. package/src/vite/plugins/expose-id-utils.ts +12 -0
  150. package/src/vite/plugins/expose-ids/handler-transform.ts +30 -0
  151. package/src/vite/plugins/expose-internal-ids.ts +257 -40
  152. package/src/vite/plugins/performance-tracks.ts +88 -0
  153. package/src/vite/plugins/refresh-cmd.ts +88 -26
  154. package/src/vite/plugins/version-plugin.ts +13 -1
  155. package/src/vite/rango.ts +163 -211
  156. package/src/vite/router-discovery.ts +178 -45
  157. package/src/vite/utils/banner.ts +3 -3
  158. package/src/vite/utils/prerender-utils.ts +37 -5
  159. package/src/vite/utils/shared-utils.ts +3 -2
@@ -4,12 +4,19 @@ import type {
4
4
  NavigateOptionsInternal,
5
5
  ResolvedSegment,
6
6
  } from "./types.js";
7
+ import { setAppVersion } from "./app-version.js";
7
8
  import * as React from "react";
8
9
  import { startTransition } from "react";
9
10
  import {
10
11
  createNavigationTransaction,
11
12
  resolveNavigationState,
12
13
  } from "./navigation-transaction.js";
14
+ import { buildHistoryState } from "./history-state.js";
15
+ import {
16
+ handleNavigationStart,
17
+ handleNavigationEnd,
18
+ ensureHistoryKey,
19
+ } from "./scroll-restoration.js";
13
20
 
14
21
  // addTransitionType is only available in React experimental
15
22
  const addTransitionType: ((type: string) => void) | undefined =
@@ -18,7 +25,6 @@ const addTransitionType: ((type: string) => void) | undefined =
18
25
  import { setupLinkInterception } from "./link-interceptor.js";
19
26
  import { createPartialUpdater } from "./partial-update.js";
20
27
  import { generateHistoryKey } from "./navigation-store.js";
21
- import { handleNavigationEnd } from "./scroll-restoration.js";
22
28
  import type { EventController } from "./event-controller.js";
23
29
  import { isInterceptOnlyCache } from "./intercept-utils.js";
24
30
  import {
@@ -35,11 +41,6 @@ if (typeof Symbol.dispose === "undefined") {
35
41
  (Symbol as any).dispose = Symbol("Symbol.dispose");
36
42
  }
37
43
 
38
- /** Get IDs of non-loader segments (layouts, routes, parallels). */
39
- function getNonLoaderSegmentIds(segments: ResolvedSegment[]): string[] {
40
- return segments.filter((s) => s.type !== "loader").map((s) => s.id);
41
- }
42
-
43
44
  export { createNavigationTransaction };
44
45
 
45
46
  /**
@@ -67,8 +68,8 @@ export interface NavigationBridgeConfigWithController extends NavigationBridgeCo
67
68
  export function createNavigationBridge(
68
69
  config: NavigationBridgeConfigWithController,
69
70
  ): NavigationBridge {
70
- const { store, client, eventController, onUpdate, renderSegments, version } =
71
- config;
71
+ const { store, client, eventController, onUpdate, renderSegments } = config;
72
+ let version = config.version;
72
73
 
73
74
  // Create shared partial updater
74
75
  const fetchPartialUpdate = createPartialUpdater({
@@ -76,7 +77,7 @@ export function createNavigationBridge(
76
77
  client,
77
78
  onUpdate,
78
79
  renderSegments,
79
- version,
80
+ getVersion: () => version,
80
81
  });
81
82
 
82
83
  return {
@@ -114,6 +115,85 @@ export function createNavigationBridge(
114
115
  return;
115
116
  }
116
117
 
118
+ // Shallow navigation: skip RSC fetch when revalidate is false
119
+ // and the pathname hasn't changed (search param / hash only change).
120
+ if (
121
+ options?.revalidate === false &&
122
+ targetUrl.pathname === new URL(window.location.href).pathname
123
+ ) {
124
+ // Preserve intercept context from the current history entry so that
125
+ // popstate uses the correct cache key (:intercept suffix) and restores
126
+ // the right full-page vs modal semantics.
127
+ const currentHistoryState = window.history.state;
128
+ const isIntercept = currentHistoryState?.intercept === true;
129
+ const interceptSourceUrl = isIntercept
130
+ ? currentHistoryState?.sourceUrl
131
+ : undefined;
132
+
133
+ const historyKey = generateHistoryKey(url, { intercept: isIntercept });
134
+
135
+ // Copy current segments to the new history key so back/forward restores instantly
136
+ const currentKey = store.getHistoryKey();
137
+ const currentCache = store.getCachedSegments(currentKey);
138
+ if (currentCache?.segments) {
139
+ const currentHandleData = eventController.getHandleState().data;
140
+ store.cacheSegmentsForHistory(
141
+ historyKey,
142
+ currentCache.segments,
143
+ currentHandleData,
144
+ );
145
+ }
146
+
147
+ // Save current scroll position before changing URL
148
+ handleNavigationStart();
149
+
150
+ // Snapshot old state before pushState/replaceState overwrites it
151
+ const oldState = window.history.state;
152
+
153
+ // Update browser URL (carry intercept context into history state)
154
+ const historyState = buildHistoryState(
155
+ resolvedState,
156
+ {
157
+ intercept: isIntercept || undefined,
158
+ sourceUrl: interceptSourceUrl,
159
+ },
160
+ {},
161
+ );
162
+ if (options.replace) {
163
+ window.history.replaceState(historyState, "", url);
164
+ } else {
165
+ window.history.pushState(historyState, "", url);
166
+ }
167
+
168
+ // Ensure new history entry has a scroll restoration key
169
+ ensureHistoryKey();
170
+
171
+ // Notify useLocationState() hooks when state changes
172
+ const hasOldState =
173
+ oldState &&
174
+ typeof oldState === "object" &&
175
+ ("state" in oldState ||
176
+ Object.keys(oldState).some((k) => k.startsWith("__rsc_ls_")));
177
+ const hasNewState =
178
+ historyState &&
179
+ ("state" in historyState ||
180
+ Object.keys(historyState).some((k) => k.startsWith("__rsc_ls_")));
181
+ if (hasOldState || hasNewState) {
182
+ window.dispatchEvent(new Event("__rsc_locationstate"));
183
+ }
184
+
185
+ // Update store history key so future navigations reference the right cache
186
+ store.setHistoryKey(historyKey);
187
+ store.setCurrentUrl(url);
188
+
189
+ // Notify hooks — location updates, state stays idle
190
+ eventController.setLocation(targetUrl);
191
+
192
+ // Handle post-navigation scroll
193
+ handleNavigationEnd({ scroll: options.scroll });
194
+ return;
195
+ }
196
+
117
197
  // Only abort pending requests when navigating to a different route
118
198
  // Same-route navigation (e.g., /todos -> /todos) should not cancel in-flight actions
119
199
  const currentPath = new URL(window.location.href).pathname;
@@ -181,18 +261,24 @@ export function createNavigationBridge(
181
261
  // 2. routes that CAN be intercepted - we don't know if this navigation will intercept
182
262
  // 3. when leaving intercept - we need fresh non-intercept segments from server
183
263
  // 4. redirect-with-state - force re-render so hooks read fresh state
264
+ // 5. stale cache - server action invalidated it, need fresh data with loading state
184
265
  const hasUsableCache =
185
266
  cachedSegments &&
186
267
  cachedSegments.length > 0 &&
187
268
  !isInterceptOnlyCache(cachedSegments) &&
188
269
  !hasInterceptCache &&
189
270
  !isLeavingIntercept &&
271
+ !cached?.stale &&
190
272
  !options?._skipCache;
191
273
 
274
+ // Forward navigations always await fetchPartialUpdate before rendering,
275
+ // so useNavigation should always report "loading". skipLoadingState is
276
+ // only used for popstate background revalidation (line ~526) where
277
+ // cached content renders instantly without a network wait.
192
278
  const tx = createNavigationTransaction(store, eventController, url, {
193
279
  ...options,
194
280
  state: resolvedState,
195
- skipLoadingState: hasUsableCache,
281
+ skipLoadingState: false,
196
282
  });
197
283
 
198
284
  // REVALIDATE: Fetch fresh data from server
@@ -200,7 +286,7 @@ export function createNavigationBridge(
200
286
  await fetchPartialUpdate(
201
287
  url,
202
288
  hasUsableCache
203
- ? getNonLoaderSegmentIds(cachedSegments!)
289
+ ? cachedSegments!.map((s) => s.id)
204
290
  : options?._skipCache
205
291
  ? [] // Action redirect: send no segments so server renders everything fresh
206
292
  : undefined,
@@ -332,6 +418,15 @@ export function createNavigationBridge(
332
418
  eventController.abortAllActions();
333
419
  }
334
420
 
421
+ // Popstate that exits an intercept to a non-intercept destination. The
422
+ // fallback fetch path below needs `leave-intercept` mode so it filters
423
+ // the cached @modal segment from the request and forces a re-render —
424
+ // otherwise a cache-miss popstate whose server response has an empty
425
+ // diff hits the "no changes" branch in partial-update and the modal
426
+ // stays on screen.
427
+ const isLeavingIntercept =
428
+ !isIntercept && currentInterceptSource !== null;
429
+
335
430
  // Compute history key from URL (with intercept suffix if applicable)
336
431
  const historyKey = generateHistoryKey(url, { intercept: isIntercept });
337
432
 
@@ -368,6 +463,12 @@ export function createNavigationBridge(
368
463
  store.setCurrentUrl(url);
369
464
  store.setPath(new URL(url).pathname);
370
465
 
466
+ // Restore router identity from cache so subsequent navigations
467
+ // don't falsely detect an app switch.
468
+ if (cached?.routerId) {
469
+ store.setRouterId?.(cached.routerId);
470
+ }
471
+
371
472
  // Render from cache - force await to skip loading fallbacks
372
473
  try {
373
474
  const root = await renderSegments(cachedSegments, {
@@ -393,6 +494,7 @@ export function createNavigationBridge(
393
494
  cachedHandleData,
394
495
  params: cachedParams,
395
496
  },
497
+ scroll: { restore: true, isStreaming },
396
498
  };
397
499
  const hasTransition = cachedSegments.some((s) => s.transition);
398
500
  if (hasTransition) {
@@ -406,14 +508,11 @@ export function createNavigationBridge(
406
508
  onUpdate(popstateUpdate);
407
509
  }
408
510
 
409
- // Restore scroll position for back/forward navigation
410
- handleNavigationEnd({ restore: true, isStreaming });
411
-
412
511
  // SWR: If stale, trigger background revalidation
413
512
  if (isStale) {
414
513
  debugLog("[Browser] Cache is stale, background revalidating...");
415
514
  // Background revalidation - don't await, just fire and forget
416
- const segmentIds = getNonLoaderSegmentIds(cachedSegments);
515
+ const segmentIds = cachedSegments.map((s) => s.id);
417
516
 
418
517
  const tx = createNavigationTransaction(
419
518
  store,
@@ -478,7 +577,11 @@ export function createNavigationBridge(
478
577
  intercept: isIntercept,
479
578
  interceptSourceUrl,
480
579
  }),
481
- isIntercept ? { type: "navigate", interceptSourceUrl } : undefined,
580
+ isIntercept
581
+ ? { type: "navigate", interceptSourceUrl }
582
+ : isLeavingIntercept
583
+ ? { type: "leave-intercept" }
584
+ : undefined,
482
585
  );
483
586
  // Restore scroll position after fetch completes
484
587
  handleNavigationEnd({ restore: true, isStreaming });
@@ -555,6 +658,12 @@ export function createNavigationBridge(
555
658
  window.removeEventListener("pageshow", handlePageShow);
556
659
  };
557
660
  },
661
+
662
+ updateVersion(newVersion: string): void {
663
+ version = newVersion;
664
+ setAppVersion(newVersion);
665
+ store.clearHistoryCache();
666
+ },
558
667
  };
559
668
  }
560
669
 
@@ -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,60 @@ 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);
94
+ }
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
+ // Wildcard key matches prefetch entries stored with a custom prefetchKey
105
+ // (Link's prefetchKey prop stores under "*" instead of the source URL).
106
+ const wildcardKey = "*\0" + fetchUrl.pathname + fetchUrl.search;
107
+
108
+ let cachedResponse: Response | null = null;
109
+ let hitKey: string | null = null;
110
+ if (canUsePrefetch) {
111
+ cachedResponse = consumePrefetch(cacheKey);
112
+ if (cachedResponse) {
113
+ hitKey = cacheKey;
114
+ } else {
115
+ cachedResponse = consumePrefetch(wildcardKey);
116
+ if (cachedResponse) hitKey = wildcardKey;
117
+ }
97
118
  }
98
119
 
120
+ let inflightResponsePromise: Promise<Response | null> | null = null;
121
+ if (canUsePrefetch && !cachedResponse) {
122
+ inflightResponsePromise = consumeInflightPrefetch(cacheKey);
123
+ if (inflightResponsePromise) {
124
+ hitKey = cacheKey;
125
+ } else {
126
+ inflightResponsePromise = consumeInflightPrefetch(wildcardKey);
127
+ if (inflightResponsePromise) hitKey = wildcardKey;
128
+ }
129
+ }
99
130
  // Track when the stream completes
100
131
  let resolveStreamComplete: () => void;
101
132
  const streamComplete = new Promise<void>((resolve) => {
102
133
  resolveStreamComplete = resolve;
103
134
  });
104
135
 
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
136
+ /**
137
+ * Validate RSC control headers on any response (fresh, cached, or
138
+ * in-flight). Handles version-mismatch reloads and server redirects.
139
+ * Returns the response unchanged when no control header is present.
140
+ */
141
+ const validateRscHeaders = (
142
+ response: Response,
143
+ source: string,
144
+ ): Response | Promise<Response> => {
145
+ // Version mismatch server wants a full page reload
119
146
  const reload = extractRscHeaderUrl(response, "X-RSC-Reload");
120
147
  if (reload === "blocked") {
121
148
  resolveStreamComplete();
@@ -123,11 +150,12 @@ export function createNavigationClient(
123
150
  }
124
151
  if (reload) {
125
152
  if (tx) {
126
- browserDebugLog(tx, "version mismatch, reloading", {
153
+ browserDebugLog(tx, `version mismatch, reloading (${source})`, {
127
154
  reloadUrl: reload.url,
128
155
  });
129
156
  }
130
157
  window.location.href = reload.url;
158
+ // Block further processing — page is reloading
131
159
  return new Promise<Response>(() => {});
132
160
  }
133
161
 
@@ -142,7 +170,7 @@ export function createNavigationClient(
142
170
  }
143
171
  if (redirect) {
144
172
  if (tx) {
145
- browserDebugLog(tx, "server redirect", {
173
+ browserDebugLog(tx, `server redirect (${source})`, {
146
174
  redirectUrl: redirect.url,
147
175
  });
148
176
  }
@@ -150,19 +178,101 @@ export function createNavigationClient(
150
178
  throw new ServerRedirect(redirect.url, undefined);
151
179
  }
152
180
 
153
- return teeWithCompletion(
154
- response,
155
- () => {
156
- if (tx) browserDebugLog(tx, "stream complete");
157
- resolveStreamComplete();
181
+ return response;
182
+ };
183
+
184
+ /** Start a fresh navigation fetch (no cache / inflight hit). */
185
+ const doFreshFetch = (): Promise<Response> => {
186
+ if (tx) {
187
+ browserDebugLog(tx, "fetching", {
188
+ path: `${fetchUrl.pathname}${fetchUrl.search}`,
189
+ });
190
+ }
191
+
192
+ return fetch(fetchUrl, {
193
+ headers: {
194
+ "X-RSC-Router-Client-Path": previousUrl,
195
+ "X-Rango-State": getRangoState(),
196
+ ...(tx && { "X-RSC-Router-Request-Id": tx.requestId }),
197
+ ...(interceptSourceUrl && {
198
+ "X-RSC-Router-Intercept-Source": interceptSourceUrl,
199
+ }),
200
+ ...(hmr && { "X-RSC-HMR": "1" }),
158
201
  },
159
202
  signal,
160
- );
161
- });
203
+ }).then((response) => {
204
+ const validated = validateRscHeaders(response, "fetch");
205
+ if (validated instanceof Promise) return validated;
206
+
207
+ return teeWithCompletion(
208
+ validated,
209
+ () => {
210
+ if (tx) browserDebugLog(tx, "stream complete");
211
+ resolveStreamComplete();
212
+ },
213
+ signal,
214
+ );
215
+ });
216
+ };
217
+
218
+ let responsePromise: Promise<Response>;
219
+
220
+ if (cachedResponse) {
221
+ if (tx) {
222
+ browserDebugLog(tx, "prefetch cache hit", {
223
+ key: hitKey,
224
+ wildcard: hitKey === wildcardKey,
225
+ });
226
+ }
227
+ responsePromise = Promise.resolve(cachedResponse).then((response) => {
228
+ const validated = validateRscHeaders(response, "prefetch cache");
229
+ if (validated instanceof Promise) return validated;
230
+
231
+ return teeWithCompletion(
232
+ validated,
233
+ () => {
234
+ if (tx) browserDebugLog(tx, "stream complete (from cache)");
235
+ resolveStreamComplete();
236
+ },
237
+ signal,
238
+ );
239
+ });
240
+ } else if (inflightResponsePromise) {
241
+ if (tx) {
242
+ browserDebugLog(tx, "reusing inflight prefetch", {
243
+ key: hitKey,
244
+ wildcard: hitKey === wildcardKey,
245
+ });
246
+ }
247
+ responsePromise = inflightResponsePromise.then(async (response) => {
248
+ if (!response) {
249
+ if (tx) {
250
+ browserDebugLog(tx, "inflight prefetch unavailable, refetching");
251
+ }
252
+ return doFreshFetch();
253
+ }
254
+
255
+ const validated = validateRscHeaders(response, "inflight prefetch");
256
+ if (validated instanceof Promise) return validated;
257
+
258
+ return teeWithCompletion(
259
+ validated,
260
+ () => {
261
+ if (tx) {
262
+ browserDebugLog(tx, "stream complete (from inflight prefetch)");
263
+ }
264
+ resolveStreamComplete();
265
+ },
266
+ signal,
267
+ );
268
+ });
269
+ } else {
270
+ responsePromise = doFreshFetch();
271
+ }
162
272
 
163
273
  try {
164
- // Deserialize RSC payload
165
274
  const payload = await deps.createFromFetch<RscPayload>(responsePromise);
275
+
166
276
  if (tx) {
167
277
  browserDebugLog(tx, "response received", {
168
278
  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
  // ========================================================================