@rangojs/router 0.0.0-experimental.259 → 0.0.0-experimental.26

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 (225) hide show
  1. package/README.md +294 -28
  2. package/dist/bin/rango.js +355 -47
  3. package/dist/vite/index.js +1658 -1239
  4. package/package.json +3 -3
  5. package/skills/cache-guide/SKILL.md +9 -5
  6. package/skills/caching/SKILL.md +4 -4
  7. package/skills/document-cache/SKILL.md +2 -2
  8. package/skills/hooks/SKILL.md +40 -29
  9. package/skills/host-router/SKILL.md +218 -0
  10. package/skills/intercept/SKILL.md +79 -0
  11. package/skills/layout/SKILL.md +62 -2
  12. package/skills/loader/SKILL.md +229 -15
  13. package/skills/middleware/SKILL.md +109 -30
  14. package/skills/parallel/SKILL.md +57 -2
  15. package/skills/prerender/SKILL.md +189 -19
  16. package/skills/rango/SKILL.md +1 -2
  17. package/skills/response-routes/SKILL.md +3 -3
  18. package/skills/route/SKILL.md +44 -3
  19. package/skills/router-setup/SKILL.md +80 -3
  20. package/skills/theme/SKILL.md +5 -4
  21. package/skills/typesafety/SKILL.md +59 -16
  22. package/skills/use-cache/SKILL.md +16 -2
  23. package/src/__internal.ts +1 -1
  24. package/src/bin/rango.ts +56 -19
  25. package/src/browser/action-coordinator.ts +97 -0
  26. package/src/browser/event-controller.ts +29 -48
  27. package/src/browser/history-state.ts +80 -0
  28. package/src/browser/intercept-utils.ts +1 -1
  29. package/src/browser/link-interceptor.ts +19 -3
  30. package/src/browser/merge-segment-loaders.ts +9 -2
  31. package/src/browser/navigation-bridge.ts +66 -443
  32. package/src/browser/navigation-client.ts +34 -62
  33. package/src/browser/navigation-store.ts +4 -33
  34. package/src/browser/navigation-transaction.ts +295 -0
  35. package/src/browser/partial-update.ts +103 -151
  36. package/src/browser/prefetch/cache.ts +67 -0
  37. package/src/browser/prefetch/fetch.ts +137 -0
  38. package/src/browser/prefetch/observer.ts +65 -0
  39. package/src/browser/prefetch/policy.ts +42 -0
  40. package/src/browser/prefetch/queue.ts +88 -0
  41. package/src/browser/rango-state.ts +112 -0
  42. package/src/browser/react/Link.tsx +154 -44
  43. package/src/browser/react/NavigationProvider.tsx +32 -0
  44. package/src/browser/react/context.ts +6 -0
  45. package/src/browser/react/filter-segment-order.ts +11 -0
  46. package/src/browser/react/index.ts +2 -6
  47. package/src/browser/react/location-state-shared.ts +29 -11
  48. package/src/browser/react/location-state.ts +6 -4
  49. package/src/browser/react/nonce-context.ts +23 -0
  50. package/src/browser/react/shallow-equal.ts +27 -0
  51. package/src/browser/react/use-action.ts +23 -45
  52. package/src/browser/react/use-client-cache.ts +5 -3
  53. package/src/browser/react/use-handle.ts +21 -64
  54. package/src/browser/react/use-navigation.ts +7 -32
  55. package/src/browser/react/use-params.ts +5 -34
  56. package/src/browser/react/use-pathname.ts +2 -3
  57. package/src/browser/react/use-router.ts +3 -6
  58. package/src/browser/react/use-search-params.ts +2 -1
  59. package/src/browser/react/use-segments.ts +75 -114
  60. package/src/browser/response-adapter.ts +73 -0
  61. package/src/browser/rsc-router.tsx +46 -22
  62. package/src/browser/scroll-restoration.ts +10 -7
  63. package/src/browser/server-action-bridge.ts +458 -405
  64. package/src/browser/types.ts +21 -35
  65. package/src/browser/validate-redirect-origin.ts +29 -0
  66. package/src/build/generate-manifest.ts +38 -13
  67. package/src/build/generate-route-types.ts +4 -0
  68. package/src/build/index.ts +1 -0
  69. package/src/build/route-trie.ts +19 -3
  70. package/src/build/route-types/codegen.ts +13 -4
  71. package/src/build/route-types/include-resolution.ts +13 -0
  72. package/src/build/route-types/per-module-writer.ts +15 -3
  73. package/src/build/route-types/router-processing.ts +170 -18
  74. package/src/build/runtime-discovery.ts +13 -1
  75. package/src/cache/background-task.ts +34 -0
  76. package/src/cache/cache-key-utils.ts +44 -0
  77. package/src/cache/cache-policy.ts +125 -0
  78. package/src/cache/cache-runtime.ts +136 -123
  79. package/src/cache/cache-scope.ts +76 -83
  80. package/src/cache/cf/cf-cache-store.ts +12 -7
  81. package/src/cache/document-cache.ts +93 -69
  82. package/src/cache/handle-capture.ts +81 -0
  83. package/src/cache/index.ts +0 -15
  84. package/src/cache/memory-segment-store.ts +43 -69
  85. package/src/cache/profile-registry.ts +43 -8
  86. package/src/cache/read-through-swr.ts +134 -0
  87. package/src/cache/segment-codec.ts +140 -117
  88. package/src/cache/taint.ts +30 -3
  89. package/src/cache/types.ts +1 -115
  90. package/src/client.rsc.tsx +0 -1
  91. package/src/client.tsx +53 -76
  92. package/src/errors.ts +6 -1
  93. package/src/handle.ts +1 -1
  94. package/src/handles/MetaTags.tsx +5 -2
  95. package/src/host/cookie-handler.ts +8 -3
  96. package/src/host/index.ts +0 -3
  97. package/src/host/router.ts +14 -1
  98. package/src/href-client.ts +3 -1
  99. package/src/index.rsc.ts +53 -10
  100. package/src/index.ts +73 -43
  101. package/src/loader.rsc.ts +12 -4
  102. package/src/loader.ts +8 -0
  103. package/src/prerender/store.ts +60 -18
  104. package/src/prerender.ts +76 -18
  105. package/src/reverse.ts +11 -7
  106. package/src/root-error-boundary.tsx +30 -26
  107. package/src/route-definition/dsl-helpers.ts +9 -6
  108. package/src/route-definition/index.ts +0 -3
  109. package/src/route-definition/redirect.ts +15 -3
  110. package/src/route-map-builder.ts +38 -2
  111. package/src/route-name.ts +53 -0
  112. package/src/route-types.ts +7 -0
  113. package/src/router/content-negotiation.ts +1 -1
  114. package/src/router/debug-manifest.ts +16 -3
  115. package/src/router/handler-context.ts +96 -17
  116. package/src/router/intercept-resolution.ts +6 -4
  117. package/src/router/lazy-includes.ts +4 -0
  118. package/src/router/loader-resolution.ts +6 -11
  119. package/src/router/logging.ts +100 -3
  120. package/src/router/manifest.ts +32 -3
  121. package/src/router/match-api.ts +62 -54
  122. package/src/router/match-context.ts +3 -0
  123. package/src/router/match-handlers.ts +185 -11
  124. package/src/router/match-middleware/background-revalidation.ts +65 -85
  125. package/src/router/match-middleware/cache-lookup.ts +78 -10
  126. package/src/router/match-middleware/cache-store.ts +2 -0
  127. package/src/router/match-pipelines.ts +8 -43
  128. package/src/router/match-result.ts +0 -9
  129. package/src/router/metrics.ts +233 -13
  130. package/src/router/middleware-types.ts +34 -39
  131. package/src/router/middleware.ts +290 -130
  132. package/src/router/pattern-matching.ts +61 -10
  133. package/src/router/prerender-match.ts +36 -6
  134. package/src/router/preview-match.ts +7 -1
  135. package/src/router/revalidation.ts +61 -2
  136. package/src/router/router-context.ts +15 -0
  137. package/src/router/router-interfaces.ts +158 -40
  138. package/src/router/router-options.ts +223 -1
  139. package/src/router/router-registry.ts +5 -2
  140. package/src/router/segment-resolution/fresh.ts +165 -242
  141. package/src/router/segment-resolution/helpers.ts +263 -0
  142. package/src/router/segment-resolution/loader-cache.ts +102 -98
  143. package/src/router/segment-resolution/revalidation.ts +394 -272
  144. package/src/router/segment-resolution/static-store.ts +2 -2
  145. package/src/router/segment-resolution.ts +1 -3
  146. package/src/router/segment-wrappers.ts +3 -0
  147. package/src/router/telemetry-otel.ts +299 -0
  148. package/src/router/telemetry.ts +300 -0
  149. package/src/router/timeout.ts +148 -0
  150. package/src/router/trie-matching.ts +20 -2
  151. package/src/router/types.ts +7 -1
  152. package/src/router.ts +203 -18
  153. package/src/rsc/handler-context.ts +13 -2
  154. package/src/rsc/handler.ts +489 -438
  155. package/src/rsc/helpers.ts +125 -5
  156. package/src/rsc/index.ts +0 -20
  157. package/src/rsc/loader-fetch.ts +84 -42
  158. package/src/rsc/manifest-init.ts +3 -2
  159. package/src/rsc/origin-guard.ts +141 -0
  160. package/src/rsc/progressive-enhancement.ts +245 -19
  161. package/src/rsc/response-route-handler.ts +347 -0
  162. package/src/rsc/rsc-rendering.ts +47 -43
  163. package/src/rsc/runtime-warnings.ts +42 -0
  164. package/src/rsc/server-action.ts +166 -66
  165. package/src/rsc/ssr-setup.ts +128 -0
  166. package/src/rsc/types.ts +20 -2
  167. package/src/search-params.ts +38 -23
  168. package/src/server/context.ts +61 -7
  169. package/src/server/cookie-store.ts +190 -0
  170. package/src/server/fetchable-loader-store.ts +11 -6
  171. package/src/server/handle-store.ts +84 -12
  172. package/src/server/loader-registry.ts +11 -46
  173. package/src/server/request-context.ts +275 -49
  174. package/src/server.ts +6 -0
  175. package/src/ssr/index.tsx +67 -28
  176. package/src/static-handler.ts +7 -0
  177. package/src/theme/ThemeProvider.tsx +6 -1
  178. package/src/theme/index.ts +4 -18
  179. package/src/theme/theme-context.ts +1 -28
  180. package/src/theme/theme-script.ts +2 -1
  181. package/src/types/cache-types.ts +6 -1
  182. package/src/types/error-types.ts +3 -0
  183. package/src/types/global-namespace.ts +22 -0
  184. package/src/types/handler-context.ts +103 -16
  185. package/src/types/index.ts +1 -1
  186. package/src/types/loader-types.ts +9 -6
  187. package/src/types/route-config.ts +17 -26
  188. package/src/types/route-entry.ts +28 -0
  189. package/src/types/segments.ts +0 -5
  190. package/src/urls/include-helper.ts +49 -8
  191. package/src/urls/index.ts +1 -0
  192. package/src/urls/path-helper-types.ts +30 -12
  193. package/src/urls/path-helper.ts +17 -2
  194. package/src/urls/pattern-types.ts +21 -1
  195. package/src/urls/response-types.ts +29 -7
  196. package/src/urls/type-extraction.ts +23 -15
  197. package/src/use-loader.tsx +27 -9
  198. package/src/vite/discovery/bundle-postprocess.ts +32 -52
  199. package/src/vite/discovery/discover-routers.ts +52 -26
  200. package/src/vite/discovery/prerender-collection.ts +58 -41
  201. package/src/vite/discovery/route-types-writer.ts +7 -7
  202. package/src/vite/discovery/state.ts +7 -7
  203. package/src/vite/discovery/virtual-module-codegen.ts +5 -2
  204. package/src/vite/index.ts +10 -51
  205. package/src/vite/plugins/client-ref-dedup.ts +115 -0
  206. package/src/vite/plugins/client-ref-hashing.ts +3 -3
  207. package/src/vite/plugins/expose-internal-ids.ts +4 -3
  208. package/src/vite/plugins/refresh-cmd.ts +65 -0
  209. package/src/vite/plugins/use-cache-transform.ts +91 -3
  210. package/src/vite/plugins/version-plugin.ts +188 -18
  211. package/src/vite/rango.ts +61 -36
  212. package/src/vite/router-discovery.ts +173 -100
  213. package/src/vite/utils/prerender-utils.ts +81 -0
  214. package/src/vite/utils/shared-utils.ts +19 -9
  215. package/skills/testing/SKILL.md +0 -226
  216. package/src/browser/lru-cache.ts +0 -61
  217. package/src/browser/react/prefetch.ts +0 -27
  218. package/src/browser/request-controller.ts +0 -164
  219. package/src/cache/memory-store.ts +0 -253
  220. package/src/href-context.ts +0 -33
  221. package/src/route-definition/route-function.ts +0 -119
  222. package/src/router.gen.ts +0 -6
  223. package/src/static-handler.gen.ts +0 -5
  224. package/src/urls.gen.ts +0 -8
  225. /package/{CLAUDE.md → AGENTS.md} +0 -0
@@ -15,9 +15,10 @@ import type { RenderSegmentsOptions } from "../segment-system.js";
15
15
  import { reconcileSegments } from "./segment-reconciler.js";
16
16
  import type { ReconcileActor } from "./segment-reconciler.js";
17
17
  import { hasActiveIntercept as hasActiveInterceptSlots } from "./intercept-utils.js";
18
- import type { BoundTransaction } from "./navigation-bridge.js";
18
+ import type { BoundTransaction } from "./navigation-transaction.js";
19
19
  import { ServerRedirect } from "../errors.js";
20
20
  import { debugLog } from "./logging.js";
21
+ import { validateRedirectOrigin } from "./validate-redirect-origin.js";
21
22
 
22
23
  /**
23
24
  * Configuration for creating a partial updater
@@ -51,9 +52,25 @@ export interface CommitOverrides {
51
52
  }
52
53
 
53
54
  /**
54
- * Commit context passed to partial updater for URL updates
55
- * Transaction encapsulates all store mutations for atomic commit
55
+ * Discriminated update mode for partial updates.
56
56
  */
57
+ export type UpdateMode =
58
+ | {
59
+ type: "navigate";
60
+ /** Cached segments for the target URL. When provided, these are used to build
61
+ * the segment map instead of the current page's segments. This ensures consistency
62
+ * when we send cached segment IDs to the server - if the server returns empty diff,
63
+ * we use the same segments we told the server we have. */
64
+ targetCacheSegments?: ResolvedSegment[];
65
+ /** Cached handle data for the target URL. When server returns empty diff and we're
66
+ * rendering from cache, this is passed to the UI to restore breadcrumbs etc. */
67
+ targetCacheHandleData?: Record<string, Record<string, unknown[]>>;
68
+ /** Source URL for intercept restore (popstate cache miss) */
69
+ interceptSourceUrl?: string;
70
+ }
71
+ | { type: "leave-intercept" }
72
+ | { type: "stale-revalidation"; interceptSourceUrl?: string }
73
+ | { type: "action"; interceptSourceUrl?: string };
57
74
 
58
75
  /**
59
76
  * Type for the fetchPartialUpdate function
@@ -63,24 +80,9 @@ export type PartialUpdater = (
63
80
  segmentIds: string[] | undefined,
64
81
  isRetry: boolean,
65
82
  signal: AbortSignal | undefined,
66
- type: BoundTransaction,
67
- options?: {
68
- isAction?: boolean;
69
- staleRevalidation?: boolean;
70
- interceptSourceUrl?: string;
71
- /** Cached segments for the target URL. When provided, these are used to build
72
- * the segment map instead of the current page's segments. This ensures consistency
73
- * when we send cached segment IDs to the server - if the server returns empty diff,
74
- * we use the same segments we told the server we have. */
75
- targetCacheSegments?: ResolvedSegment[];
76
- /** Cached handle data for the target URL. When server returns empty diff and we're
77
- * rendering from cache, this is passed to the UI to restore breadcrumbs etc. */
78
- targetCacheHandleData?: Record<string, Record<string, unknown[]>>;
79
- /** When true, we're leaving an intercept state - don't use current segment IDs
80
- * as fallback and force a fresh render from server */
81
- leavingIntercept?: boolean;
82
- },
83
- ) => Promise<Promise<void>>;
83
+ tx: BoundTransaction,
84
+ mode?: UpdateMode,
85
+ ) => Promise<void>;
84
86
 
85
87
  /**
86
88
  * Create a partial updater for fetching and applying RSC partial updates
@@ -90,18 +92,6 @@ export type PartialUpdater = (
90
92
  *
91
93
  * @param config - Partial update configuration
92
94
  * @returns fetchPartialUpdate function
93
- *
94
- * @example
95
- * ```typescript
96
- * const fetchPartialUpdate = createPartialUpdater({
97
- * store,
98
- * client,
99
- * onUpdate: (update) => store.emit(update),
100
- * renderSegments,
101
- * });
102
- *
103
- * await fetchPartialUpdate('/new-page');
104
- * ```
105
95
  */
106
96
  export function createPartialUpdater(
107
97
  config: PartialUpdateConfig,
@@ -119,7 +109,6 @@ export function createPartialUpdater(
119
109
 
120
110
  /**
121
111
  * Fetch partial update and trigger UI update
122
- * Returns a promise that resolves when the RSC stream is fully consumed
123
112
  *
124
113
  * @param tx - Transaction for committing segment state (required)
125
114
  * @param signal - AbortSignal to check if navigation is stale (not for aborting fetch)
@@ -130,39 +119,26 @@ export function createPartialUpdater(
130
119
  isRetry: boolean,
131
120
  signal: AbortSignal | undefined,
132
121
  tx: BoundTransaction,
133
- options?: {
134
- isAction?: boolean;
135
- staleRevalidation?: boolean;
136
- interceptSourceUrl?: string;
137
- targetCacheSegments?: ResolvedSegment[];
138
- targetCacheHandleData?: Record<string, Record<string, unknown[]>>;
139
- leavingIntercept?: boolean;
140
- },
141
- ): Promise<Promise<void>> {
142
- const {
143
- isAction = false,
144
- staleRevalidation = false,
145
- interceptSourceUrl,
146
- targetCacheSegments,
147
- targetCacheHandleData,
148
- leavingIntercept = false,
149
- } = options || {};
122
+ mode: UpdateMode = { type: "navigate" },
123
+ ): Promise<void> {
150
124
  const segmentState = store.getSegmentState();
151
125
  const url = targetUrl || window.location.href;
152
126
 
153
127
  // Capture history key at start for stale revalidation consistency check
154
128
  const historyKeyAtStart = store.getHistoryKey();
155
129
 
156
- // When leaving intercept, don't send current segment IDs - we need fresh non-intercept segments
157
- // Filter out intercept-related segments (parallel slots like @modal) from current segments
130
+ // Derive interceptSourceUrl from modes that carry it
131
+ const interceptSourceUrl =
132
+ mode.type === "stale-revalidation" ||
133
+ mode.type === "action" ||
134
+ mode.type === "navigate"
135
+ ? mode.interceptSourceUrl
136
+ : undefined;
137
+
138
+ // When leaving intercept, filter out intercept-specific segments
158
139
  let segments: string[];
159
- if (leavingIntercept) {
160
- // When leaving intercept, only send segments that aren't intercept-specific
161
- // The server will return the non-intercept version of the route
140
+ if (mode.type === "leave-intercept") {
162
141
  const currentSegments = segmentIds ?? segmentState.currentSegmentIds;
163
- // Use cached segment metadata (namespace) to identify intercept segments.
164
- // Only intercept segments have namespace starting with "intercept:" —
165
- // regular parallel segments like @sidebar are preserved.
166
142
  const currentCached = getCurrentCachedSegments();
167
143
  const interceptIds = new Set(
168
144
  currentCached
@@ -178,7 +154,6 @@ export function createPartialUpdater(
178
154
  }
179
155
 
180
156
  // For intercept revalidation, use the intercept source URL as previousUrl
181
- // This tells the server the route should be treated as an intercept
182
157
  const previousUrl =
183
158
  interceptSourceUrl || tx.currentUrl || segmentState.currentUrl;
184
159
 
@@ -191,36 +166,31 @@ export function createPartialUpdater(
191
166
  }
192
167
 
193
168
  // Get cached segments for merging with server diff.
194
- // When targetCacheSegments is provided (navigating to a cached route), use those
195
- // to ensure consistency - we use the same segments we told the server we have.
169
+ // When navigating with targetCacheSegments, use those for consistency.
196
170
  // Otherwise fall back to current page's segments (for same-route revalidation).
171
+ const targetCache =
172
+ mode.type === "navigate" ? mode.targetCacheSegments : undefined;
197
173
  const cachedSegs =
198
- targetCacheSegments && targetCacheSegments.length > 0
199
- ? targetCacheSegments
174
+ targetCache && targetCache.length > 0
175
+ ? targetCache
200
176
  : getCurrentCachedSegments();
201
- // Mark navigation as streaming (response received, now parsing RSC)
202
- // The token is ended when the stream completes
203
- const streamingToken = tx.startStreaming();
177
+
204
178
  // Fetch partial payload (no abort signal - RSC doesn't support it well)
205
- // Wrapped in try/catch to ensure streamingToken.end() is called if fetch throws,
206
- // preventing isStreaming from being permanently stuck as true.
207
179
  let fetchResult: Awaited<ReturnType<NavigationClient["fetchPartial"]>>;
208
- try {
209
- fetchResult = await client.fetchPartial({
210
- targetUrl: url,
211
- segmentIds: segments,
212
- previousUrl,
213
- // Mark stale when explicitly requested OR when no segments are sent
214
- // (action redirect sends empty segments for a fresh render).
215
- // Only the fetch URL param is affected here — behavioral side effects
216
- // (forceAwait, history key check) are controlled by the staleRevalidation variable.
217
- staleRevalidation: staleRevalidation || segments.length === 0,
218
- version,
219
- });
220
- } catch (err) {
221
- streamingToken.end();
222
- throw err;
223
- }
180
+ fetchResult = await client.fetchPartial({
181
+ targetUrl: url,
182
+ segmentIds: segments,
183
+ previousUrl,
184
+ // Mark stale when explicitly requested OR when no segments are sent
185
+ // (action redirect sends empty segments for a fresh render).
186
+ staleRevalidation:
187
+ mode.type === "stale-revalidation" || segments.length === 0,
188
+ version,
189
+ });
190
+ // Mark navigation as streaming (response received, now parsing RSC).
191
+ // Called after fetchPartial so pendingUrl stays set during the network wait,
192
+ // allowing useLinkStatus to show per-link pending indicators.
193
+ const streamingToken = tx.startStreaming();
224
194
  const { payload, streamComplete: rawStreamComplete } = fetchResult;
225
195
  debugLog("payload.metadata", payload.metadata);
226
196
 
@@ -228,17 +198,20 @@ export function createPartialUpdater(
228
198
  streamingToken.end();
229
199
  });
230
200
 
231
- // Handle server-side redirect with state: the server returned a 200 with
232
- // a redirect payload instead of a 3xx so that location state is preserved.
233
- // Throw ServerRedirect to let navigate() catch it and re-navigate with state.
234
- // Check signal.aborted first — a newer navigation may have started, and we
235
- // must not redirect from a stale response.
201
+ // Handle server-side redirect with state
236
202
  if (payload.metadata?.redirect) {
237
203
  if (signal?.aborted) {
238
- console.log(`[Browser] Ignoring stale redirect (aborted)`);
239
- return streamComplete;
204
+ debugLog("[Browser] Ignoring stale redirect (aborted)");
205
+ return;
206
+ }
207
+ const redirectUrl = validateRedirectOrigin(
208
+ payload.metadata.redirect.url,
209
+ window.location.origin,
210
+ );
211
+ if (!redirectUrl) {
212
+ debugLog("[Browser] Ignoring blocked redirect payload");
213
+ return;
240
214
  }
241
- const { url: redirectUrl } = payload.metadata.redirect;
242
215
  const serverState = payload.metadata.locationState;
243
216
  throw new ServerRedirect(redirectUrl, serverState);
244
217
  }
@@ -249,15 +222,13 @@ export function createPartialUpdater(
249
222
  // Check if this navigation is stale (a newer one started)
250
223
  if (signal?.aborted) {
251
224
  debugLog("[Browser] Ignoring stale navigation (aborted)");
252
- return streamComplete;
225
+ return;
253
226
  }
254
227
 
255
228
  debugLog(`[Browser] Partial update - matched: ${matched?.join(", ")}`);
256
229
  debugLog(`[Browser] Diff: ${diff?.join(", ")}`);
257
230
 
258
231
  // If diff is empty, nothing changed on server side.
259
- // However, if we're navigating with targetCacheSegments (to a different route),
260
- // we still need to render those segments since the UI is showing the old route.
261
232
  if (!diff || diff.length === 0) {
262
233
  const matchedIds = matched || [];
263
234
  const cacheMap = new Map(cachedSegs.map((s) => [s.id, s]));
@@ -266,8 +237,7 @@ export function createPartialUpdater(
266
237
  .filter(Boolean) as ResolvedSegment[];
267
238
 
268
239
  // When navigating with cached segments to a different route, render them.
269
- // targetCacheSegments being provided means we're navigating to a cached route.
270
- if (targetCacheSegments && targetCacheSegments.length > 0) {
240
+ if (mode.type === "navigate" && targetCache && targetCache.length > 0) {
271
241
  debugLog(
272
242
  "[Browser] No diff but navigating with cached segments - rendering target route",
273
243
  );
@@ -280,16 +250,15 @@ export function createPartialUpdater(
280
250
 
281
251
  // Include cachedHandleData in metadata so NavigationProvider can restore
282
252
  // breadcrumbs and other handle data from cache.
283
- // IMPORTANT: Remove `handles` from metadata to prevent NavigationProvider from
253
+ // Remove `handles` from metadata to prevent NavigationProvider from
284
254
  // processing an empty handles stream, which would clear the cached breadcrumbs.
285
- // When rendering from cache with empty diff, we want to use cachedHandleData instead.
286
255
  const { handles: _unusedHandles, ...metadataWithoutHandles } =
287
256
  payload.metadata!;
288
257
  const cachedUpdate = {
289
258
  root: newTree,
290
259
  metadata: {
291
260
  ...metadataWithoutHandles,
292
- cachedHandleData: targetCacheHandleData,
261
+ cachedHandleData: mode.targetCacheHandleData,
293
262
  },
294
263
  };
295
264
 
@@ -308,13 +277,11 @@ export function createPartialUpdater(
308
277
  }
309
278
 
310
279
  debugLog("[Browser] Navigation complete (rendered from cache)");
311
- return streamComplete;
280
+ return;
312
281
  }
313
282
 
314
283
  // When leaving intercept, force re-render even with empty diff
315
- // The matched segments are the non-intercept segments, which we need to render
316
- // to remove the modal from the UI
317
- if (leavingIntercept) {
284
+ if (mode.type === "leave-intercept") {
318
285
  debugLog(
319
286
  "[Browser] Leaving intercept - forcing re-render to remove modal",
320
287
  );
@@ -331,7 +298,7 @@ export function createPartialUpdater(
331
298
  });
332
299
 
333
300
  debugLog("[Browser] Navigation complete (left intercept)");
334
- return streamComplete;
301
+ return;
335
302
  }
336
303
 
337
304
  // Same route revalidation with no changes - skip UI update
@@ -340,13 +307,15 @@ export function createPartialUpdater(
340
307
  );
341
308
  tx.commit(matchedIds, existingSegments);
342
309
  debugLog("[Browser] Navigation complete (no re-render)");
343
- return streamComplete;
310
+ return;
344
311
  }
345
312
 
346
313
  // Reconcile server segments with cached segments (single source of truth)
347
314
  const matchedIds = matched || [];
348
315
  const actor: ReconcileActor =
349
- staleRevalidation || isAction ? "stale-revalidation" : "navigation";
316
+ mode.type === "stale-revalidation" || mode.type === "action"
317
+ ? "stale-revalidation"
318
+ : "navigation";
350
319
 
351
320
  const reconciled = reconcileSegments({
352
321
  actor,
@@ -376,31 +345,28 @@ export function createPartialUpdater(
376
345
  debugLog(
377
346
  "[Browser] Ignoring stale navigation (aborted during HMR retry)",
378
347
  );
379
- return streamComplete;
348
+ return;
380
349
  }
381
- if (isAction) {
382
- return streamComplete;
350
+ if (mode.type === "action") {
351
+ return;
383
352
  }
384
353
  console.warn(
385
354
  `[Browser] HMR detected: Missing ${missingCount} segments. Refetching all...`,
386
355
  );
387
356
 
388
357
  // Refetch with empty segments = server sends everything
389
- return fetchPartialUpdate(url, [], true, signal, tx, { isAction });
358
+ return fetchPartialUpdate(url, [], true, signal, tx, mode);
390
359
  }
391
360
 
392
361
  if (signal?.aborted) {
393
362
  debugLog("[Browser] Ignoring stale navigation (aborted before render)");
394
- return streamComplete;
363
+ return;
395
364
  }
396
365
 
397
366
  // Rebuild tree on client (await for loader data resolution)
398
- // Race against abort signal to allow cancellation during loader awaiting
399
- // Pass intercept segments separately for explicit handling
400
- // For stale revalidation, use forceAwait to ensure no loading fallbacks
401
367
  const renderOptions = {
402
- isAction,
403
- forceAwait: staleRevalidation,
368
+ isAction: mode.type === "action",
369
+ forceAwait: mode.type === "stale-revalidation",
404
370
  interceptSegments:
405
371
  reconciled.interceptSegments.length > 0
406
372
  ? reconciled.interceptSegments
@@ -423,36 +389,36 @@ export function createPartialUpdater(
423
389
  // Final abort check before committing - another navigation may have started
424
390
  if (signal?.aborted) {
425
391
  debugLog("[Browser] Ignoring stale navigation (aborted before commit)");
426
- return streamComplete;
392
+ return;
427
393
  }
428
394
 
429
395
  // Check if this is an intercept response (any slot is active)
430
- // If so, disable scroll to keep the current scroll position
431
396
  const isInterceptResponse = hasActiveInterceptSlots(
432
397
  payload.metadata?.slots,
433
398
  );
434
399
 
435
- // Track intercept context for action revalidation (only on navigation, not actions or stale revalidation)
436
- if (!isAction && !staleRevalidation) {
400
+ // Track intercept context (only on navigation, not actions or stale revalidation)
401
+ // Use the authoritative source from mode/history state when restoring an
402
+ // intercept via popstate cache miss; fall back to the current URL for fresh
403
+ // intercept navigations.
404
+ const effectiveInterceptSource =
405
+ interceptSourceUrl || segmentState.currentUrl;
406
+ if (mode.type !== "action" && mode.type !== "stale-revalidation") {
437
407
  if (isInterceptResponse) {
438
- // Save the source URL for action revalidation to maintain intercept context
439
- store.setInterceptSourceUrl(segmentState.currentUrl);
408
+ store.setInterceptSourceUrl(effectiveInterceptSource);
440
409
  } else {
441
- // Clear intercept context when navigating to a non-intercept route
442
410
  store.setInterceptSourceUrl(null);
443
411
  }
444
412
  }
445
413
 
446
414
  // Commit navigation - transaction handles all store mutations atomically
447
- // For intercept responses: disable scroll, mark as intercept, include source URL
448
- // Use reconciled.segments (which includes inserted diff segments) instead of matchedIds
449
415
  const allSegmentIds = reconciled.segments.map((s) => s.id);
450
416
  const serverLocationState = payload.metadata?.locationState;
451
417
  const overrides: CommitOverrides | undefined = isInterceptResponse
452
418
  ? {
453
419
  scroll: false,
454
420
  intercept: true,
455
- interceptSourceUrl: segmentState.currentUrl,
421
+ interceptSourceUrl: effectiveInterceptSource,
456
422
  ...(serverLocationState && { serverState: serverLocationState }),
457
423
  }
458
424
  : serverLocationState
@@ -461,26 +427,22 @@ export function createPartialUpdater(
461
427
  tx.commit(allSegmentIds, reconciled.segments, overrides);
462
428
 
463
429
  // For stale revalidation: verify history key hasn't changed before updating UI
464
- // If user navigated away, skip UI update to avoid corrupting current view
465
- if (staleRevalidation) {
430
+ if (mode.type === "stale-revalidation") {
466
431
  const historyKeyNow = store.getHistoryKey();
467
432
  if (historyKeyNow !== historyKeyAtStart) {
468
433
  debugLog(
469
434
  `[Browser] Stale revalidation: history key changed (${historyKeyAtStart} -> ${historyKeyNow}), skipping UI update`,
470
435
  );
471
- return streamComplete;
436
+ return;
472
437
  }
473
438
  }
474
439
 
475
440
  debugLog("[partial-update] updating document");
476
441
 
477
442
  // Emit update to trigger React render
478
- // For stale revalidation: wait for stream to complete (loaders resolved), then update
479
- // For actions: wrap in startTransition to avoid UI flickering
480
- // For transitions: wrap in startTransition + addTransitionType for ViewTransition
481
443
  const hasTransition = reconciled.mainSegments.some((s) => s.transition);
482
444
 
483
- if (isAction || staleRevalidation) {
445
+ if (mode.type === "action" || mode.type === "stale-revalidation") {
484
446
  startTransition(() => {
485
447
  if (hasTransition && addTransitionType) {
486
448
  addTransitionType("action");
@@ -508,33 +470,27 @@ export function createPartialUpdater(
508
470
  }
509
471
 
510
472
  debugLog("[Browser] Navigation complete");
511
- return streamComplete;
473
+ return;
512
474
  } else {
513
475
  // Full update (fallback)
514
- // Reconstruct the tree client-side from segments via renderSegments
515
- // to ensure consistent component references with action revalidation.
516
476
  console.warn(`[Browser] Full update (fallback)`);
517
477
 
518
478
  const segments = payload.metadata?.segments || [];
519
479
 
520
- // Check if this navigation is stale (a newer one started)
521
480
  if (signal?.aborted) {
522
481
  debugLog("[Browser] Ignoring stale navigation (aborted)");
523
- return streamComplete;
482
+ return;
524
483
  }
525
484
 
526
485
  const segmentIds = segments.map((s: ResolvedSegment) => s.id);
527
486
 
528
- // Render on client for consistent component references
529
487
  const newTree = await renderSegments(segments);
530
488
 
531
- // Final abort check before committing - another navigation may have started
532
489
  if (signal?.aborted) {
533
490
  debugLog("[Browser] Ignoring stale navigation (aborted before commit)");
534
- return streamComplete;
491
+ return;
535
492
  }
536
493
 
537
- // Commit navigation - transaction handles all store mutations atomically
538
494
  const fullUpdateServerState = payload.metadata?.locationState;
539
495
  if (fullUpdateServerState) {
540
496
  tx.commit(segmentIds, segments, { serverState: fullUpdateServerState });
@@ -542,15 +498,11 @@ export function createPartialUpdater(
542
498
  tx.commit(segmentIds, segments);
543
499
  }
544
500
 
545
- // Emit update to trigger React render
546
- // For stale revalidation: wait for stream to complete, then update
547
- // For actions: wrap in startTransition to avoid UI flickering
548
- // For transitions: wrap in startTransition + addTransitionType
549
501
  const fullHasTransition = segments.some(
550
502
  (s: ResolvedSegment) => s.transition,
551
503
  );
552
504
 
553
- if (staleRevalidation) {
505
+ if (mode.type === "stale-revalidation") {
554
506
  await rawStreamComplete;
555
507
  startTransition(() => {
556
508
  if (fullHasTransition && addTransitionType) {
@@ -561,7 +513,7 @@ export function createPartialUpdater(
561
513
  metadata: payload.metadata!,
562
514
  });
563
515
  });
564
- } else if (isAction) {
516
+ } else if (mode.type === "action") {
565
517
  startTransition(async () => {
566
518
  if (fullHasTransition && addTransitionType) {
567
519
  addTransitionType("action");
@@ -588,7 +540,7 @@ export function createPartialUpdater(
588
540
  });
589
541
  }
590
542
 
591
- return streamComplete;
543
+ return;
592
544
  }
593
545
  }
594
546
 
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Prefetch Tracking
3
+ *
4
+ * Tracks in-flight and completed prefetches for deduplication.
5
+ * The actual response caching is handled by the browser's HTTP cache
6
+ * via Vary: X-Rango-State.
7
+ */
8
+
9
+ import { cancelAllPrefetches } from "./queue.js";
10
+ import { invalidateRangoState } from "../rango-state.js";
11
+
12
+ const inflight = new Set<string>();
13
+ const prefetched = new Set<string>();
14
+
15
+ // Generation counter incremented on each clearPrefetchCache(). Fetches that
16
+ // started before a clear carry a stale generation and must not re-add their
17
+ // key to the prefetched set (the browser HTTP cache entry is already invalid
18
+ // due to Rango-State rotation).
19
+ let generation = 0;
20
+
21
+ /**
22
+ * Check if a prefetch is already in-flight or completed for the given key.
23
+ */
24
+ export function hasPrefetch(key: string): boolean {
25
+ return prefetched.has(key) || inflight.has(key);
26
+ }
27
+
28
+ /**
29
+ * Capture the current generation. The returned value is passed to
30
+ * markPrefetched so it can detect stale completions.
31
+ */
32
+ export function currentGeneration(): number {
33
+ return generation;
34
+ }
35
+
36
+ /**
37
+ * Mark a key as successfully prefetched (response is in browser HTTP cache).
38
+ * Skips if the generation has changed since the fetch started (cache was
39
+ * invalidated mid-flight, so the response uses a stale X-Rango-State).
40
+ */
41
+ export function markPrefetched(key: string, fetchGeneration: number): void {
42
+ if (fetchGeneration === generation) {
43
+ prefetched.add(key);
44
+ }
45
+ }
46
+
47
+ export function markPrefetchInflight(key: string): void {
48
+ inflight.add(key);
49
+ }
50
+
51
+ export function clearPrefetchInflight(key: string): void {
52
+ inflight.delete(key);
53
+ }
54
+
55
+ /**
56
+ * Invalidate prefetch state. Called when server actions mutate data.
57
+ * Updates the localStorage state key so next fetch has a different
58
+ * X-Rango-State value, causing Vary mismatch in browser HTTP cache.
59
+ * Also cancels any in-flight or queued speculative prefetches.
60
+ */
61
+ export function clearPrefetchCache(): void {
62
+ generation++;
63
+ inflight.clear();
64
+ prefetched.clear();
65
+ cancelAllPrefetches();
66
+ invalidateRangoState();
67
+ }