@rangojs/router 0.0.0-experimental.18 → 0.0.0-experimental.19

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 (177) hide show
  1. package/README.md +46 -8
  2. package/dist/bin/rango.js +105 -18
  3. package/dist/vite/index.js +227 -93
  4. package/package.json +15 -14
  5. package/skills/hooks/SKILL.md +1 -1
  6. package/skills/intercept/SKILL.md +79 -0
  7. package/skills/layout/SKILL.md +62 -2
  8. package/skills/loader/SKILL.md +94 -1
  9. package/skills/middleware/SKILL.md +81 -0
  10. package/skills/parallel/SKILL.md +57 -2
  11. package/skills/prerender/SKILL.md +187 -17
  12. package/skills/route/SKILL.md +42 -1
  13. package/skills/router-setup/SKILL.md +77 -0
  14. package/src/__internal.ts +1 -1
  15. package/src/bin/rango.ts +38 -19
  16. package/src/browser/action-coordinator.ts +97 -0
  17. package/src/browser/event-controller.ts +25 -27
  18. package/src/browser/history-state.ts +80 -0
  19. package/src/browser/intercept-utils.ts +1 -1
  20. package/src/browser/link-interceptor.ts +0 -3
  21. package/src/browser/merge-segment-loaders.ts +9 -2
  22. package/src/browser/navigation-bridge.ts +46 -13
  23. package/src/browser/navigation-client.ts +32 -61
  24. package/src/browser/navigation-store.ts +1 -31
  25. package/src/browser/navigation-transaction.ts +46 -207
  26. package/src/browser/partial-update.ts +102 -150
  27. package/src/browser/{prefetch-cache.ts → prefetch/cache.ts} +23 -4
  28. package/src/browser/{prefetch-fetch.ts → prefetch/fetch.ts} +36 -8
  29. package/src/browser/prefetch/policy.ts +42 -0
  30. package/src/browser/{prefetch-queue.ts → prefetch/queue.ts} +10 -3
  31. package/src/browser/react/Link.tsx +28 -23
  32. package/src/browser/react/NavigationProvider.tsx +9 -1
  33. package/src/browser/react/index.ts +2 -6
  34. package/src/browser/react/location-state-shared.ts +1 -1
  35. package/src/browser/react/location-state.ts +2 -0
  36. package/src/browser/react/nonce-context.ts +23 -0
  37. package/src/browser/react/use-action.ts +9 -1
  38. package/src/browser/react/use-handle.ts +3 -25
  39. package/src/browser/react/use-params.ts +2 -4
  40. package/src/browser/react/use-pathname.ts +2 -3
  41. package/src/browser/react/use-router.ts +1 -1
  42. package/src/browser/react/use-search-params.ts +2 -1
  43. package/src/browser/react/use-segments.ts +7 -60
  44. package/src/browser/response-adapter.ts +73 -0
  45. package/src/browser/rsc-router.tsx +29 -23
  46. package/src/browser/scroll-restoration.ts +10 -7
  47. package/src/browser/server-action-bridge.ts +115 -96
  48. package/src/browser/types.ts +1 -31
  49. package/src/browser/validate-redirect-origin.ts +29 -0
  50. package/src/build/generate-manifest.ts +5 -0
  51. package/src/build/generate-route-types.ts +2 -0
  52. package/src/build/route-types/codegen.ts +13 -4
  53. package/src/build/route-types/include-resolution.ts +13 -0
  54. package/src/build/route-types/per-module-writer.ts +15 -3
  55. package/src/build/route-types/router-processing.ts +45 -3
  56. package/src/build/runtime-discovery.ts +13 -1
  57. package/src/cache/background-task.ts +34 -0
  58. package/src/cache/cache-key-utils.ts +44 -0
  59. package/src/cache/cache-policy.ts +125 -0
  60. package/src/cache/cache-runtime.ts +132 -96
  61. package/src/cache/cache-scope.ts +71 -73
  62. package/src/cache/cf/cf-cache-store.ts +9 -4
  63. package/src/cache/document-cache.ts +72 -47
  64. package/src/cache/handle-capture.ts +81 -0
  65. package/src/cache/memory-segment-store.ts +18 -7
  66. package/src/cache/profile-registry.ts +43 -8
  67. package/src/cache/read-through-swr.ts +134 -0
  68. package/src/cache/segment-codec.ts +101 -112
  69. package/src/cache/taint.ts +26 -0
  70. package/src/client.tsx +53 -30
  71. package/src/errors.ts +6 -1
  72. package/src/handle.ts +1 -1
  73. package/src/handles/MetaTags.tsx +5 -2
  74. package/src/host/cookie-handler.ts +8 -3
  75. package/src/host/router.ts +14 -1
  76. package/src/href-client.ts +3 -1
  77. package/src/index.rsc.ts +33 -1
  78. package/src/index.ts +27 -0
  79. package/src/loader.rsc.ts +12 -4
  80. package/src/loader.ts +8 -0
  81. package/src/prerender/store.ts +4 -3
  82. package/src/prerender.ts +76 -18
  83. package/src/reverse.ts +11 -7
  84. package/src/root-error-boundary.tsx +30 -26
  85. package/src/route-definition/dsl-helpers.ts +9 -6
  86. package/src/route-definition/redirect.ts +15 -3
  87. package/src/route-map-builder.ts +38 -2
  88. package/src/route-name.ts +53 -0
  89. package/src/route-types.ts +7 -0
  90. package/src/router/content-negotiation.ts +1 -1
  91. package/src/router/debug-manifest.ts +16 -3
  92. package/src/router/handler-context.ts +94 -15
  93. package/src/router/intercept-resolution.ts +6 -4
  94. package/src/router/lazy-includes.ts +4 -0
  95. package/src/router/loader-resolution.ts +1 -0
  96. package/src/router/logging.ts +100 -3
  97. package/src/router/manifest.ts +32 -3
  98. package/src/router/match-api.ts +61 -7
  99. package/src/router/match-context.ts +3 -0
  100. package/src/router/match-handlers.ts +185 -11
  101. package/src/router/match-middleware/background-revalidation.ts +65 -85
  102. package/src/router/match-middleware/cache-lookup.ts +69 -4
  103. package/src/router/match-middleware/cache-store.ts +2 -0
  104. package/src/router/match-pipelines.ts +8 -43
  105. package/src/router/middleware-types.ts +7 -0
  106. package/src/router/middleware.ts +93 -8
  107. package/src/router/pattern-matching.ts +41 -5
  108. package/src/router/prerender-match.ts +34 -6
  109. package/src/router/preview-match.ts +7 -1
  110. package/src/router/revalidation.ts +61 -2
  111. package/src/router/router-context.ts +15 -0
  112. package/src/router/router-interfaces.ts +34 -0
  113. package/src/router/router-options.ts +200 -0
  114. package/src/router/segment-resolution/fresh.ts +123 -30
  115. package/src/router/segment-resolution/helpers.ts +19 -0
  116. package/src/router/segment-resolution/loader-cache.ts +37 -146
  117. package/src/router/segment-resolution/revalidation.ts +358 -94
  118. package/src/router/segment-wrappers.ts +3 -0
  119. package/src/router/telemetry-otel.ts +299 -0
  120. package/src/router/telemetry.ts +300 -0
  121. package/src/router/timeout.ts +148 -0
  122. package/src/router/types.ts +7 -1
  123. package/src/router.ts +155 -11
  124. package/src/rsc/handler-context.ts +11 -0
  125. package/src/rsc/handler.ts +380 -88
  126. package/src/rsc/helpers.ts +25 -16
  127. package/src/rsc/loader-fetch.ts +84 -42
  128. package/src/rsc/origin-guard.ts +141 -0
  129. package/src/rsc/progressive-enhancement.ts +232 -19
  130. package/src/rsc/response-route-handler.ts +37 -26
  131. package/src/rsc/rsc-rendering.ts +12 -5
  132. package/src/rsc/runtime-warnings.ts +42 -0
  133. package/src/rsc/server-action.ts +134 -58
  134. package/src/rsc/types.ts +8 -0
  135. package/src/search-params.ts +22 -10
  136. package/src/server/context.ts +53 -5
  137. package/src/server/fetchable-loader-store.ts +11 -6
  138. package/src/server/handle-store.ts +66 -9
  139. package/src/server/loader-registry.ts +11 -46
  140. package/src/server/request-context.ts +90 -9
  141. package/src/ssr/index.tsx +63 -27
  142. package/src/static-handler.ts +7 -0
  143. package/src/theme/ThemeProvider.tsx +6 -1
  144. package/src/theme/index.ts +1 -6
  145. package/src/theme/theme-context.ts +1 -28
  146. package/src/theme/theme-script.ts +2 -1
  147. package/src/types/cache-types.ts +5 -0
  148. package/src/types/error-types.ts +3 -0
  149. package/src/types/global-namespace.ts +9 -0
  150. package/src/types/handler-context.ts +35 -13
  151. package/src/types/loader-types.ts +7 -0
  152. package/src/types/route-entry.ts +28 -0
  153. package/src/urls/include-helper.ts +49 -8
  154. package/src/urls/index.ts +1 -0
  155. package/src/urls/path-helper-types.ts +30 -12
  156. package/src/urls/path-helper.ts +17 -2
  157. package/src/urls/pattern-types.ts +21 -1
  158. package/src/urls/response-types.ts +27 -2
  159. package/src/urls/type-extraction.ts +23 -15
  160. package/src/use-loader.tsx +12 -4
  161. package/src/vite/discovery/bundle-postprocess.ts +12 -7
  162. package/src/vite/discovery/discover-routers.ts +30 -18
  163. package/src/vite/discovery/prerender-collection.ts +24 -27
  164. package/src/vite/discovery/route-types-writer.ts +7 -7
  165. package/src/vite/discovery/virtual-module-codegen.ts +5 -2
  166. package/src/vite/plugins/client-ref-hashing.ts +3 -3
  167. package/src/vite/plugins/use-cache-transform.ts +91 -3
  168. package/src/vite/rango.ts +3 -3
  169. package/src/vite/router-discovery.ts +99 -36
  170. package/src/vite/utils/prerender-utils.ts +21 -0
  171. package/src/vite/utils/shared-utils.ts +3 -1
  172. package/src/browser/request-controller.ts +0 -164
  173. package/src/href-context.ts +0 -33
  174. package/src/router.gen.ts +0 -6
  175. package/src/static-handler.gen.ts +0 -5
  176. package/src/urls.gen.ts +0 -8
  177. /package/src/browser/{prefetch-observer.ts → prefetch/observer.ts} +0 -0
@@ -158,8 +158,8 @@ export interface ActionHandle extends Disposable {
158
158
  readonly settled: boolean;
159
159
  /** Check if any concurrent actions were started */
160
160
  hadConcurrentActions: boolean;
161
- /** Get segments to consolidate (only valid when this is the last action) */
162
- getConsolidationSegments(): string[] | null;
161
+ /** Get raw set of segments revalidated by concurrent actions */
162
+ getRevalidatedSegments(): Set<string>;
163
163
  /** Clear consolidation tracking */
164
164
  clearConsolidation(): void;
165
165
  }
@@ -210,6 +210,8 @@ export interface EventController {
210
210
  // Direct state access for advanced use
211
211
  getCurrentNavigation(): NavigationEntry | null;
212
212
  getInflightActions(): Map<string, ActionEntry>;
213
+ /** Whether any concurrent actions have occurred (shared across all handles) */
214
+ hadAnyConcurrentActions(): boolean;
213
215
  }
214
216
 
215
217
  // ============================================================================
@@ -388,8 +390,8 @@ export function createEventController(
388
390
  state,
389
391
  isStreaming,
390
392
  location,
391
- // pendingUrl only during fetching phase - once streaming starts (URL changed), not pending
392
- // Background revalidations don't expose a pending URL
393
+ // pendingUrl only during fetching phase - once streaming starts (URL changed), not pending.
394
+ // Background revalidations (skipLoadingState) don't expose a pending URL.
393
395
  pendingUrl:
394
396
  currentNavigation?.phase === "fetching" &&
395
397
  !currentNavigation.options?.skipLoadingState
@@ -482,6 +484,7 @@ export function createEventController(
482
484
 
483
485
  startStreaming(): StreamingToken {
484
486
  let ended = false;
487
+ entry.phase = "streaming";
485
488
  activeStreamCount++;
486
489
  notify();
487
490
 
@@ -669,24 +672,8 @@ export function createEventController(
669
672
  // If streaming is in progress, tryFinalize() will be called when streaming ends
670
673
  },
671
674
 
672
- getConsolidationSegments(): string[] | null {
673
- // Only consolidate if all actions have at least received their response
674
- // We don't need to wait for streaming to complete since we're refetching anyway
675
- // Count actions that are still fetching (waiting for server response)
676
- const stillFetchingCount = [...inflightActions.values()].filter(
677
- (a) => a.phase === "fetching",
678
- ).length;
679
-
680
- if (stillFetchingCount > 0) {
681
- return null; // Some actions still waiting for server response
682
- }
683
- if (!hadAnyConcurrentActions) {
684
- return null; // No concurrent actions occurred
685
- }
686
- if (concurrentRevalidatedSegments.size === 0) {
687
- return null; // No segments to consolidate
688
- }
689
- return Array.from(concurrentRevalidatedSegments);
675
+ getRevalidatedSegments(): Set<string> {
676
+ return concurrentRevalidatedSegments;
690
677
  },
691
678
 
692
679
  clearConsolidation() {
@@ -721,16 +708,26 @@ export function createEventController(
721
708
  }
722
709
 
723
710
  function abortAllActions() {
724
- for (const entry of inflightActions.values()) {
711
+ for (const [id, entry] of inflightActions) {
712
+ // Preserve settling entries — they have already been handled by
713
+ // fail()/complete() and will self-cleanup via the settlement timeout.
714
+ // Clearing them here would prevent debounced notifications from
715
+ // delivering the error/result state to subscribers.
716
+ if (entry.phase === "settling") continue;
725
717
  entry.abort.abort();
718
+ inflightActions.delete(id);
726
719
  }
727
- inflightActions.clear();
728
720
  hadAnyConcurrentActions = false;
729
721
  concurrentRevalidatedSegments.clear();
730
722
  notify();
731
- // Notify all action listeners
732
- for (const actionId of actionListeners.keys()) {
733
- notifyAction(actionId);
723
+ // Notify all action listeners directly by subscription ID.
724
+ // actionListeners keys are subscription IDs (possibly short names like
725
+ // "addToCart"), not full entry actionIds. Passing them to notifyAction
726
+ // would fail the suffix matcher — instead, notify each subscriber with
727
+ // its own state.
728
+ for (const [subscriptionId, listeners] of actionListeners) {
729
+ const state = getActionState(subscriptionId);
730
+ listeners.forEach((listener) => listener(state));
734
731
  }
735
732
  }
736
733
 
@@ -860,6 +857,7 @@ export function createEventController(
860
857
  // Direct access
861
858
  getCurrentNavigation: () => currentNavigation,
862
859
  getInflightActions: () => inflightActions,
860
+ hadAnyConcurrentActions: () => hadAnyConcurrentActions,
863
861
  };
864
862
  }
865
863
 
@@ -0,0 +1,80 @@
1
+ import {
2
+ isLocationStateEntry,
3
+ resolveLocationStateEntries,
4
+ } from "./react/location-state-shared.js";
5
+
6
+ /**
7
+ * Check if state is from typed LocationStateEntry[] (has __rsc_ls_ keys)
8
+ */
9
+ function isTypedLocationState(
10
+ state: unknown,
11
+ ): state is Record<string, unknown> {
12
+ if (state === null || typeof state !== "object") return false;
13
+ return Object.keys(state).some((key) => key.startsWith("__rsc_ls_"));
14
+ }
15
+
16
+ /**
17
+ * Resolve navigation state - handles both LocationStateEntry[] and plain formats
18
+ */
19
+ export function resolveNavigationState(state: unknown): unknown {
20
+ if (
21
+ Array.isArray(state) &&
22
+ state.length > 0 &&
23
+ isLocationStateEntry(state[0])
24
+ ) {
25
+ return resolveLocationStateEntries(state);
26
+ }
27
+ return state;
28
+ }
29
+
30
+ /**
31
+ * Build history state object from user state
32
+ * - Typed state: spread directly into history.state
33
+ * - Plain state: store in history.state.state
34
+ */
35
+ export function buildHistoryState(
36
+ userState: unknown,
37
+ routerState?: { intercept?: boolean; sourceUrl?: string },
38
+ serverState?: Record<string, unknown>,
39
+ ): Record<string, unknown> | null {
40
+ const result: Record<string, unknown> = {};
41
+
42
+ if (routerState?.intercept) {
43
+ result.intercept = true;
44
+ if (routerState.sourceUrl) {
45
+ result.sourceUrl = routerState.sourceUrl;
46
+ }
47
+ }
48
+
49
+ if (userState !== undefined) {
50
+ if (isTypedLocationState(userState)) {
51
+ Object.assign(result, userState);
52
+ } else {
53
+ result.state = userState;
54
+ }
55
+ }
56
+
57
+ if (serverState) {
58
+ Object.assign(result, serverState);
59
+ }
60
+
61
+ return Object.keys(result).length > 0 ? result : null;
62
+ }
63
+
64
+ /**
65
+ * Merge server-set location state into the current history entry.
66
+ * Replaces the current history state and dispatches notification event
67
+ * so useLocationState hooks re-read from history.state.
68
+ */
69
+ export function mergeLocationState(
70
+ locationState: Record<string, unknown>,
71
+ ): void {
72
+ const merged = {
73
+ ...window.history.state,
74
+ ...locationState,
75
+ };
76
+ window.history.replaceState(merged, "", window.location.href);
77
+ if (Object.keys(locationState).some((k) => k.startsWith("__rsc_ls_"))) {
78
+ window.dispatchEvent(new Event("__rsc_locationstate"));
79
+ }
80
+ }
@@ -44,7 +44,7 @@ export function hasActiveIntercept(slots?: Record<string, SlotState>): boolean {
44
44
 
45
45
  /**
46
46
  * Check if cached segments contain any intercept segments.
47
- * Intercept caches shouldn't be used for optimistic rendering since
47
+ * Intercept caches shouldn't be used for cached SWR rendering since
48
48
  * whether interception happens depends on the current page context.
49
49
  */
50
50
  export function isInterceptOnlyCache(segments: ResolvedSegment[]): boolean {
@@ -131,10 +131,7 @@ export function setupLinkInterception(
131
131
 
132
132
  document.addEventListener("click", handleClick);
133
133
 
134
- console.log("[Browser] Link interception enabled");
135
-
136
134
  return () => {
137
135
  document.removeEventListener("click", handleClick);
138
- console.log("[Browser] Link interception disabled");
139
136
  };
140
137
  }
@@ -91,6 +91,11 @@ export function insertMissingDiffSegments(
91
91
  ): void {
92
92
  if (!diff || diff.length === 0) return;
93
93
 
94
+ // Track how many siblings have been inserted per parent so each new
95
+ // sibling goes after the last one rather than always at parentIndex + 1
96
+ // (which would reverse the server order).
97
+ const insertedPerParent = new Map<string, number>();
98
+
94
99
  diff.forEach((diffId: string) => {
95
100
  if (!matchedIdSet.has(diffId)) {
96
101
  const fromServer = newSegmentMap.get(diffId);
@@ -104,8 +109,10 @@ export function insertMissingDiffSegments(
104
109
  (s) => s.id === parentLayoutId,
105
110
  );
106
111
  if (parentIndex !== -1) {
107
- // Insert loader segment right after its parent layout
108
- allSegments.splice(parentIndex + 1, 0, fromServer);
112
+ const alreadyInserted = insertedPerParent.get(parentLayoutId) ?? 0;
113
+ const insertAt = parentIndex + 1 + alreadyInserted;
114
+ allSegments.splice(insertAt, 0, fromServer);
115
+ insertedPerParent.set(parentLayoutId, alreadyInserted + 1);
109
116
  debugLog(
110
117
  `[Browser] Inserted diff segment ${diffId} after ${parentLayoutId}`,
111
118
  );
@@ -28,6 +28,7 @@ import {
28
28
  } from "./network-error-handler.js";
29
29
  import { debugLog } from "./logging.js";
30
30
  import { ServerRedirect } from "../errors.js";
31
+ import { validateRedirectOrigin } from "./validate-redirect-origin.js";
31
32
 
32
33
  // Polyfill Symbol.dispose for Safari and older browsers
33
34
  if (typeof Symbol.dispose === "undefined") {
@@ -81,7 +82,7 @@ export function createNavigationBridge(
81
82
  return {
82
83
  /**
83
84
  * Navigate to a URL
84
- * Uses optimistic rendering from cache when available (SWR pattern)
85
+ * Uses cached segments for SWR revalidation when available
85
86
  */
86
87
  async navigate(
87
88
  url: string,
@@ -93,10 +94,30 @@ export function createNavigationBridge(
93
94
  ? resolveNavigationState(options.state)
94
95
  : undefined;
95
96
 
97
+ // Cross-origin URLs are not handled by SPA navigation.
98
+ // Fall back to a full browser navigation for http/https only.
99
+ let targetUrl: URL;
100
+ try {
101
+ targetUrl = new URL(url, window.location.origin);
102
+ } catch {
103
+ console.warn(`[rango] navigate() ignored: malformed URL "${url}"`);
104
+ return;
105
+ }
106
+ if (targetUrl.origin !== window.location.origin) {
107
+ if (targetUrl.protocol !== "http:" && targetUrl.protocol !== "https:") {
108
+ console.error(
109
+ `[rango] navigate() blocked: unsupported scheme "${targetUrl.protocol}"`,
110
+ );
111
+ return;
112
+ }
113
+ window.location.href = targetUrl.href;
114
+ return;
115
+ }
116
+
96
117
  // Only abort pending requests when navigating to a different route
97
118
  // Same-route navigation (e.g., /todos -> /todos) should not cancel in-flight actions
98
119
  const currentPath = new URL(window.location.href).pathname;
99
- const targetPath = new URL(url, window.location.origin).pathname;
120
+ const targetPath = targetUrl.pathname;
100
121
  if (currentPath !== targetPath) {
101
122
  eventController.abortNavigation();
102
123
  }
@@ -155,7 +176,7 @@ export function createNavigationBridge(
155
176
  const interceptHistoryKey = generateHistoryKey(url, { intercept: true });
156
177
  const hasInterceptCache = store.hasHistoryCache(interceptHistoryKey);
157
178
 
158
- // Skip optimistic rendering for:
179
+ // Skip cached SWR for:
159
180
  // 1. intercept caches - interception depends on source page context
160
181
  // 2. routes that CAN be intercepted - we don't know if this navigation will intercept
161
182
  // 3. when leaving intercept - we need fresh non-intercept segments from server
@@ -191,19 +212,14 @@ export function createNavigationBridge(
191
212
  scroll: options?.scroll,
192
213
  state: resolvedState,
193
214
  }),
194
- // Pass cached segments (merged with current page's fresh segments for shared IDs)
195
- // so the segment map is consistent with what we tell the server we have.
196
- // Server decides what needs revalidation based on route matching and custom functions.
197
- // No need for staleRevalidation flag - we're sending the freshest segments we have.
198
- // Also pass cached handle data for restoring breadcrumbs when server returns empty diff.
199
- // When leaving intercept, pass the flag so fetchPartialUpdate knows to filter segments.
200
215
  hasUsableCache
201
216
  ? {
217
+ type: "navigate" as const,
202
218
  targetCacheSegments: cachedSegments,
203
219
  targetCacheHandleData: cachedHandleData,
204
220
  }
205
221
  : isLeavingIntercept
206
- ? { leavingIntercept: true }
222
+ ? { type: "leave-intercept" as const }
207
223
  : undefined,
208
224
  );
209
225
  } catch (error) {
@@ -211,7 +227,14 @@ export function createNavigationBridge(
211
227
  // `using` cleanup resets loading state. Re-navigate to the redirect
212
228
  // target carrying the server-set state into history.pushState.
213
229
  if (error instanceof ServerRedirect) {
214
- return this.navigate(error.url, {
230
+ const redirectUrl = validateRedirectOrigin(
231
+ error.url,
232
+ window.location.origin,
233
+ );
234
+ if (!redirectUrl) {
235
+ return;
236
+ }
237
+ return this.navigate(redirectUrl, {
215
238
  state: error.state,
216
239
  replace: options?.replace,
217
240
  _skipCache: true,
@@ -408,7 +431,7 @@ export function createNavigationBridge(
408
431
  interceptSourceUrl,
409
432
  cacheOnly: true,
410
433
  }),
411
- { staleRevalidation: true, interceptSourceUrl },
434
+ { type: "stale-revalidation", interceptSourceUrl },
412
435
  )
413
436
  .catch((error) => {
414
437
  if (isBackgroundSuppressible(error)) return;
@@ -444,7 +467,14 @@ export function createNavigationBridge(
444
467
  undefined,
445
468
  false,
446
469
  tx.handle.signal,
447
- tx.with({ url, replace: true, scroll: false }),
470
+ tx.with({
471
+ url,
472
+ replace: true,
473
+ scroll: false,
474
+ intercept: isIntercept,
475
+ interceptSourceUrl,
476
+ }),
477
+ isIntercept ? { type: "navigate", interceptSourceUrl } : undefined,
448
478
  );
449
479
  // Restore scroll position after fetch completes
450
480
  handleNavigationEnd({ restore: true, isStreaming });
@@ -498,6 +528,9 @@ export function createNavigationBridge(
498
528
  "[Browser] Page restored from bfcache, resetting navigation state",
499
529
  );
500
530
  eventController.abortNavigation();
531
+ // pagehide flips scrollRestoration to "auto" for bfcache compat;
532
+ // restore "manual" so the router controls scroll on SPA navigations.
533
+ window.history.scrollRestoration = "manual";
501
534
  }
502
535
  };
503
536
 
@@ -12,6 +12,11 @@ import {
12
12
  startBrowserTransaction,
13
13
  } from "./logging.js";
14
14
  import { getRangoState } from "./rango-state.js";
15
+ import {
16
+ extractRscHeaderUrl,
17
+ emptyResponse,
18
+ teeWithCompletion,
19
+ } from "./response-adapter.js";
15
20
 
16
21
  /**
17
22
  * Create a navigation client for fetching RSC payloads
@@ -111,25 +116,18 @@ export function createNavigationClient(
111
116
  signal,
112
117
  }).then((response) => {
113
118
  // Check for version mismatch - server wants us to reload
114
- const reloadUrl = response.headers.get("X-RSC-Reload");
115
- if (reloadUrl) {
116
- // Validate origin to prevent open redirect via crafted headers
117
- try {
118
- const target = new URL(reloadUrl, window.location.origin);
119
- if (target.origin !== window.location.origin) {
120
- throw new Error(
121
- `X-RSC-Reload blocked: origin mismatch (${target.origin})`,
122
- );
123
- }
124
- } catch (e) {
125
- console.error("[rango]", e);
126
- return response;
127
- }
119
+ const reload = extractRscHeaderUrl(response, "X-RSC-Reload");
120
+ if (reload === "blocked") {
121
+ resolveStreamComplete();
122
+ return emptyResponse();
123
+ }
124
+ if (reload) {
128
125
  if (tx) {
129
- browserDebugLog(tx, "version mismatch, reloading", { reloadUrl });
126
+ browserDebugLog(tx, "version mismatch, reloading", {
127
+ reloadUrl: reload.url,
128
+ });
130
129
  }
131
- window.location.href = reloadUrl;
132
- // Return a never-resolving promise to prevent further processing
130
+ window.location.href = reload.url;
133
131
  return new Promise<Response>(() => {});
134
132
  }
135
133
 
@@ -137,56 +135,29 @@ export function createNavigationClient(
137
135
  // X-RSC-Redirect instead of a 3xx (which fetch would auto-follow
138
136
  // to a URL rendering full HTML). Throw ServerRedirect so the
139
137
  // navigation bridge catches it and re-navigates with _skipCache.
140
- const redirectUrl = response.headers.get("X-RSC-Redirect");
141
- if (redirectUrl) {
142
- if (tx) {
143
- browserDebugLog(tx, "server redirect", { redirectUrl });
144
- }
138
+ const redirect = extractRscHeaderUrl(response, "X-RSC-Redirect");
139
+ if (redirect === "blocked") {
145
140
  resolveStreamComplete();
146
- throw new ServerRedirect(redirectUrl, undefined);
141
+ return emptyResponse();
147
142
  }
148
-
149
- if (!response.body) {
150
- // No body means stream is already complete
143
+ if (redirect) {
144
+ if (tx) {
145
+ browserDebugLog(tx, "server redirect", {
146
+ redirectUrl: redirect.url,
147
+ });
148
+ }
151
149
  resolveStreamComplete();
152
- return response;
150
+ throw new ServerRedirect(redirect.url, undefined);
153
151
  }
154
152
 
155
- // Tee the stream: one for RSC runtime, one for tracking completion
156
- const [rscStream, trackingStream] = response.body.tee();
157
-
158
- // Consume the tracking stream to detect when it closes
159
- (async () => {
160
- const reader = trackingStream.getReader();
161
-
162
- // Cancel tracking if navigation is aborted
163
- const onAbort = reader.cancel.bind(reader);
164
- signal?.addEventListener("abort", onAbort, { once: true });
165
-
166
- try {
167
- while (true) {
168
- const { done } = await reader.read();
169
- if (done) break;
170
- }
171
- } finally {
172
- signal?.removeEventListener("abort", onAbort);
173
- reader.releaseLock();
174
- if (tx) {
175
- browserDebugLog(tx, "stream complete");
176
- }
153
+ return teeWithCompletion(
154
+ response,
155
+ () => {
156
+ if (tx) browserDebugLog(tx, "stream complete");
177
157
  resolveStreamComplete();
178
- }
179
- })().catch((error) => {
180
- console.error("[Browser] Error reading tracking stream:", error);
181
- resolveStreamComplete();
182
- });
183
-
184
- // Return response with the RSC stream
185
- return new Response(rscStream, {
186
- headers: response.headers,
187
- status: response.status,
188
- statusText: response.statusText,
189
- });
158
+ },
159
+ signal,
160
+ );
190
161
  });
191
162
 
192
163
  try {
@@ -12,7 +12,7 @@ import type {
12
12
  ActionStateListener,
13
13
  HandleData,
14
14
  } from "./types.js";
15
- import { clearPrefetchCache } from "./prefetch-cache.js";
15
+ import { clearPrefetchCache } from "./prefetch/cache.js";
16
16
 
17
17
  /**
18
18
  * Default action state (idle with no payload)
@@ -338,7 +338,6 @@ export function createNavigationStore(
338
338
  * Clear the history cache and broadcast to other tabs
339
339
  */
340
340
  function clearCacheAndBroadcast(): void {
341
- console.log("[Browser] Clearing cache and broadcasting to other tabs");
342
341
  clearCacheInternal();
343
342
  broadcastInvalidation();
344
343
  }
@@ -347,9 +346,6 @@ export function createNavigationStore(
347
346
  * Mark cache as stale and broadcast to other tabs
348
347
  */
349
348
  function markStaleAndBroadcast(): void {
350
- console.log(
351
- "[Browser] Marking cache as stale and broadcasting to other tabs",
352
- );
353
349
  markCacheAsStaleInternal();
354
350
  broadcastInvalidation();
355
351
  }
@@ -372,14 +368,6 @@ export function createNavigationStore(
372
368
  path: currentPath,
373
369
  segmentIds: currentSegmentIds,
374
370
  });
375
- console.log(
376
- "[Browser] Broadcast sent for path:",
377
- currentPath,
378
- "segments:",
379
- currentSegmentIds.join(", "),
380
- );
381
- } else {
382
- console.warn("[Browser] No BroadcastChannel available");
383
371
  }
384
372
  }
385
373
 
@@ -404,34 +392,21 @@ export function createNavigationStore(
404
392
  return;
405
393
  }
406
394
 
407
- console.log(
408
- "[Browser] Cache marked stale by another tab, shared segments:",
409
- mutatedSegmentIds
410
- .filter((id) => currentSegmentIds.includes(id))
411
- .join(", "),
412
- );
413
395
  markCacheAsStaleInternal();
414
396
 
415
397
  // Auto-refresh if enabled and callback is registered
416
398
  if (crossTabAutoRefresh && crossTabRefreshCallback) {
417
399
  // If idle, refresh immediately. If loading, wait for idle then refresh.
418
400
  if (navState.state === "idle") {
419
- console.log("[Browser] Cross-tab refresh triggered (idle)");
420
401
  crossTabRefreshCallback();
421
402
  } else if (!pendingCrossTabRefresh) {
422
403
  // Only queue one refresh, ignore subsequent events while loading
423
404
  pendingCrossTabRefresh = true;
424
- console.log(
425
- "[Browser] Navigation in progress, deferring cross-tab refresh",
426
- );
427
405
  // Subscribe to state changes, refresh when idle
428
406
  const listener: StateListener = () => {
429
407
  if (navState.state === "idle") {
430
408
  stateListeners.delete(listener);
431
409
  pendingCrossTabRefresh = false;
432
- console.log(
433
- "[Browser] Cross-tab refresh triggered (deferred)",
434
- );
435
410
  crossTabRefreshCallback?.();
436
411
  }
437
412
  };
@@ -656,11 +631,6 @@ export function createNavigationStore(
656
631
  */
657
632
  markCacheAsStale(): void {
658
633
  markCacheAsStaleInternal();
659
- console.log(
660
- "[Browser] Marked",
661
- historyCache.length,
662
- "cache entries as stale",
663
- );
664
634
  },
665
635
 
666
636
  /**