@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
@@ -11,6 +11,12 @@ import {
11
11
  isBrowserDebugEnabled,
12
12
  startBrowserTransaction,
13
13
  } from "./logging.js";
14
+ import { getRangoState } from "./rango-state.js";
15
+ import {
16
+ extractRscHeaderUrl,
17
+ emptyResponse,
18
+ teeWithCompletion,
19
+ } from "./response-adapter.js";
14
20
 
15
21
  /**
16
22
  * Create a navigation client for fetching RSC payloads
@@ -84,7 +90,6 @@ export function createNavigationClient(
84
90
  if (version) {
85
91
  fetchUrl.searchParams.set("_rsc_v", version);
86
92
  }
87
-
88
93
  if (tx) {
89
94
  browserDebugLog(tx, "fetching", {
90
95
  path: `${fetchUrl.pathname}${fetchUrl.search}`,
@@ -101,6 +106,7 @@ export function createNavigationClient(
101
106
  const responsePromise = fetch(fetchUrl, {
102
107
  headers: {
103
108
  "X-RSC-Router-Client-Path": previousUrl,
109
+ "X-Rango-State": getRangoState(),
104
110
  ...(tx && { "X-RSC-Router-Request-Id": tx.requestId }),
105
111
  ...(interceptSourceUrl && {
106
112
  "X-RSC-Router-Intercept-Source": interceptSourceUrl,
@@ -110,25 +116,18 @@ export function createNavigationClient(
110
116
  signal,
111
117
  }).then((response) => {
112
118
  // Check for version mismatch - server wants us to reload
113
- const reloadUrl = response.headers.get("X-RSC-Reload");
114
- if (reloadUrl) {
115
- // Validate origin to prevent open redirect via crafted headers
116
- try {
117
- const target = new URL(reloadUrl, window.location.origin);
118
- if (target.origin !== window.location.origin) {
119
- throw new Error(
120
- `X-RSC-Reload blocked: origin mismatch (${target.origin})`,
121
- );
122
- }
123
- } catch (e) {
124
- console.error("[rango]", e);
125
- return response;
126
- }
119
+ const reload = extractRscHeaderUrl(response, "X-RSC-Reload");
120
+ if (reload === "blocked") {
121
+ resolveStreamComplete();
122
+ return emptyResponse();
123
+ }
124
+ if (reload) {
127
125
  if (tx) {
128
- browserDebugLog(tx, "version mismatch, reloading", { reloadUrl });
126
+ browserDebugLog(tx, "version mismatch, reloading", {
127
+ reloadUrl: reload.url,
128
+ });
129
129
  }
130
- window.location.href = reloadUrl;
131
- // Return a never-resolving promise to prevent further processing
130
+ window.location.href = reload.url;
132
131
  return new Promise<Response>(() => {});
133
132
  }
134
133
 
@@ -136,56 +135,29 @@ export function createNavigationClient(
136
135
  // X-RSC-Redirect instead of a 3xx (which fetch would auto-follow
137
136
  // to a URL rendering full HTML). Throw ServerRedirect so the
138
137
  // navigation bridge catches it and re-navigates with _skipCache.
139
- const redirectUrl = response.headers.get("X-RSC-Redirect");
140
- if (redirectUrl) {
141
- if (tx) {
142
- browserDebugLog(tx, "server redirect", { redirectUrl });
143
- }
138
+ const redirect = extractRscHeaderUrl(response, "X-RSC-Redirect");
139
+ if (redirect === "blocked") {
144
140
  resolveStreamComplete();
145
- throw new ServerRedirect(redirectUrl, undefined);
141
+ return emptyResponse();
146
142
  }
147
-
148
- if (!response.body) {
149
- // No body means stream is already complete
143
+ if (redirect) {
144
+ if (tx) {
145
+ browserDebugLog(tx, "server redirect", {
146
+ redirectUrl: redirect.url,
147
+ });
148
+ }
150
149
  resolveStreamComplete();
151
- return response;
150
+ throw new ServerRedirect(redirect.url, undefined);
152
151
  }
153
152
 
154
- // Tee the stream: one for RSC runtime, one for tracking completion
155
- const [rscStream, trackingStream] = response.body.tee();
156
-
157
- // Consume the tracking stream to detect when it closes
158
- (async () => {
159
- const reader = trackingStream.getReader();
160
-
161
- // Cancel tracking if navigation is aborted
162
- const onAbort = reader.cancel.bind(reader);
163
- signal?.addEventListener("abort", onAbort, { once: true });
164
-
165
- try {
166
- while (true) {
167
- const { done } = await reader.read();
168
- if (done) break;
169
- }
170
- } finally {
171
- signal?.removeEventListener("abort", onAbort);
172
- reader.releaseLock();
173
- if (tx) {
174
- browserDebugLog(tx, "stream complete");
175
- }
153
+ return teeWithCompletion(
154
+ response,
155
+ () => {
156
+ if (tx) browserDebugLog(tx, "stream complete");
176
157
  resolveStreamComplete();
177
- }
178
- })().catch((error) => {
179
- console.error("[Browser] Error reading tracking stream:", error);
180
- resolveStreamComplete();
181
- });
182
-
183
- // Return response with the RSC stream
184
- return new Response(rscStream, {
185
- headers: response.headers,
186
- status: response.status,
187
- statusText: response.statusText,
188
- });
158
+ },
159
+ signal,
160
+ );
189
161
  });
190
162
 
191
163
  try {
@@ -12,6 +12,7 @@ import type {
12
12
  ActionStateListener,
13
13
  HandleData,
14
14
  } from "./types.js";
15
+ import { clearPrefetchCache } from "./prefetch/cache.js";
15
16
 
16
17
  /**
17
18
  * Default action state (idle with no payload)
@@ -320,6 +321,7 @@ export function createNavigationStore(
320
321
  */
321
322
  function clearCacheInternal(): void {
322
323
  historyCache.length = 0;
324
+ clearPrefetchCache();
323
325
  }
324
326
 
325
327
  /**
@@ -329,13 +331,13 @@ export function createNavigationStore(
329
331
  for (let i = 0; i < historyCache.length; i++) {
330
332
  historyCache[i][2] = true;
331
333
  }
334
+ clearPrefetchCache();
332
335
  }
333
336
 
334
337
  /**
335
338
  * Clear the history cache and broadcast to other tabs
336
339
  */
337
340
  function clearCacheAndBroadcast(): void {
338
- console.log("[Browser] Clearing cache and broadcasting to other tabs");
339
341
  clearCacheInternal();
340
342
  broadcastInvalidation();
341
343
  }
@@ -344,9 +346,6 @@ export function createNavigationStore(
344
346
  * Mark cache as stale and broadcast to other tabs
345
347
  */
346
348
  function markStaleAndBroadcast(): void {
347
- console.log(
348
- "[Browser] Marking cache as stale and broadcasting to other tabs",
349
- );
350
349
  markCacheAsStaleInternal();
351
350
  broadcastInvalidation();
352
351
  }
@@ -369,14 +368,6 @@ export function createNavigationStore(
369
368
  path: currentPath,
370
369
  segmentIds: currentSegmentIds,
371
370
  });
372
- console.log(
373
- "[Browser] Broadcast sent for path:",
374
- currentPath,
375
- "segments:",
376
- currentSegmentIds.join(", "),
377
- );
378
- } else {
379
- console.warn("[Browser] No BroadcastChannel available");
380
371
  }
381
372
  }
382
373
 
@@ -401,34 +392,21 @@ export function createNavigationStore(
401
392
  return;
402
393
  }
403
394
 
404
- console.log(
405
- "[Browser] Cache marked stale by another tab, shared segments:",
406
- mutatedSegmentIds
407
- .filter((id) => currentSegmentIds.includes(id))
408
- .join(", "),
409
- );
410
395
  markCacheAsStaleInternal();
411
396
 
412
397
  // Auto-refresh if enabled and callback is registered
413
398
  if (crossTabAutoRefresh && crossTabRefreshCallback) {
414
399
  // If idle, refresh immediately. If loading, wait for idle then refresh.
415
400
  if (navState.state === "idle") {
416
- console.log("[Browser] Cross-tab refresh triggered (idle)");
417
401
  crossTabRefreshCallback();
418
402
  } else if (!pendingCrossTabRefresh) {
419
403
  // Only queue one refresh, ignore subsequent events while loading
420
404
  pendingCrossTabRefresh = true;
421
- console.log(
422
- "[Browser] Navigation in progress, deferring cross-tab refresh",
423
- );
424
405
  // Subscribe to state changes, refresh when idle
425
406
  const listener: StateListener = () => {
426
407
  if (navState.state === "idle") {
427
408
  stateListeners.delete(listener);
428
409
  pendingCrossTabRefresh = false;
429
- console.log(
430
- "[Browser] Cross-tab refresh triggered (deferred)",
431
- );
432
410
  crossTabRefreshCallback?.();
433
411
  }
434
412
  };
@@ -652,14 +630,7 @@ export function createNavigationStore(
652
630
  * Called after server actions to indicate data may be outdated
653
631
  */
654
632
  markCacheAsStale(): void {
655
- for (let i = 0; i < historyCache.length; i++) {
656
- historyCache[i][2] = true;
657
- }
658
- console.log(
659
- "[Browser] Marked",
660
- historyCache.length,
661
- "cache entries as stale",
662
- );
633
+ markCacheAsStaleInternal();
663
634
  },
664
635
 
665
636
  /**
@@ -0,0 +1,295 @@
1
+ import type {
2
+ NavigateOptions,
3
+ NavigationStore,
4
+ ResolvedSegment,
5
+ StreamingToken,
6
+ } from "./types.js";
7
+ import { generateHistoryKey } from "./navigation-store.js";
8
+ import {
9
+ handleNavigationStart,
10
+ handleNavigationEnd,
11
+ ensureHistoryKey,
12
+ } from "./scroll-restoration.js";
13
+ import type { EventController, NavigationHandle } from "./event-controller.js";
14
+ import { debugLog } from "./logging.js";
15
+ import { buildHistoryState } from "./history-state.js";
16
+
17
+ // Re-export for consumers that import from navigation-transaction
18
+ export { resolveNavigationState } from "./history-state.js";
19
+
20
+ /** Check if a history state object contains location state keys. */
21
+ function hasLocationState(state: unknown): boolean {
22
+ if (!state || typeof state !== "object") return false;
23
+ return (
24
+ "state" in state ||
25
+ Object.keys(state).some((k) => k.startsWith("__rsc_ls_"))
26
+ );
27
+ }
28
+
29
+ // Polyfill Symbol.dispose for Safari and older browsers
30
+ if (typeof Symbol.dispose === "undefined") {
31
+ (Symbol as any).dispose = Symbol("Symbol.dispose");
32
+ }
33
+
34
+ /**
35
+ * Options for committing a navigation transaction
36
+ */
37
+ interface CommitOptions {
38
+ url: string;
39
+ segmentIds: string[];
40
+ segments: ResolvedSegment[];
41
+ replace?: boolean;
42
+ scroll?: boolean;
43
+ /** User-provided state to store in history.state */
44
+ state?: unknown;
45
+ /** If true, only update store without changing URL/history (for server actions) */
46
+ storeOnly?: boolean;
47
+ /** If true, this is an intercept route - store in history.state for popstate handling */
48
+ intercept?: boolean;
49
+ /** Source URL where the intercept was triggered from (stored in history.state) */
50
+ interceptSourceUrl?: string;
51
+ /** If true, only update cache without touching store or history (for background stale revalidation) */
52
+ cacheOnly?: boolean;
53
+ /** Server-set location state to merge into history.pushState */
54
+ serverState?: Record<string, unknown>;
55
+ }
56
+
57
+ /**
58
+ * Options that can override the pre-configured commit settings
59
+ */
60
+ interface BoundCommitOverrides {
61
+ /** Override scroll behavior (e.g., disable for intercepts) */
62
+ scroll?: boolean;
63
+ /** Override replace behavior (e.g., force replace for intercepts) */
64
+ replace?: boolean;
65
+ /** Override user-provided state */
66
+ state?: unknown;
67
+ /** Mark this as an intercept route */
68
+ intercept?: boolean;
69
+ /** Source URL where intercept was triggered from */
70
+ interceptSourceUrl?: string;
71
+ /** If true, only update cache (for stale revalidation) */
72
+ cacheOnly?: boolean;
73
+ /** Server-set location state to merge into history.pushState */
74
+ serverState?: Record<string, unknown>;
75
+ }
76
+
77
+ /**
78
+ * Bound transaction with pre-configured commit options (without segmentIds/segments)
79
+ */
80
+ export interface BoundTransaction {
81
+ readonly currentUrl: string;
82
+ /** Start streaming and get a token to end it when the stream completes */
83
+ startStreaming(): StreamingToken;
84
+ commit(
85
+ segmentIds: string[],
86
+ segments: ResolvedSegment[],
87
+ overrides?: BoundCommitOverrides,
88
+ ): void;
89
+ }
90
+
91
+ /**
92
+ * Navigation transaction for managing state during navigation
93
+ * Uses the event controller handle for lifecycle management
94
+ */
95
+ interface NavigationTransaction extends Disposable {
96
+ commit(options: CommitOptions): void;
97
+ with(
98
+ options: Omit<CommitOptions, "segmentIds" | "segments">,
99
+ ): BoundTransaction;
100
+ /** The navigation handle from the event controller */
101
+ handle: NavigationHandle;
102
+ }
103
+
104
+ /**
105
+ * Creates a navigation transaction that coordinates with the event controller.
106
+ * Handles loading state transitions and cleanup on completion/abort.
107
+ */
108
+ export function createNavigationTransaction(
109
+ store: NavigationStore,
110
+ eventController: EventController,
111
+ url: string,
112
+ options?: NavigateOptions & { skipLoadingState?: boolean },
113
+ ): NavigationTransaction {
114
+ let committed = false;
115
+ const currentUrl = window.location.href;
116
+
117
+ // Start navigation in event controller (this sets loading state)
118
+ const handle = eventController.startNavigation(url, options);
119
+
120
+ /**
121
+ * Commit the navigation - updates store and URL atomically
122
+ */
123
+ function commit(opts: CommitOptions): void {
124
+ committed = true;
125
+
126
+ const {
127
+ url,
128
+ segmentIds,
129
+ segments,
130
+ replace,
131
+ scroll,
132
+ storeOnly,
133
+ intercept,
134
+ interceptSourceUrl,
135
+ cacheOnly,
136
+ serverState,
137
+ } = opts;
138
+
139
+ const parsedUrl = new URL(url, window.location.origin);
140
+
141
+ // Generate history key from URL (with intercept suffix for separate caching)
142
+ const historyKey = generateHistoryKey(url, { intercept });
143
+
144
+ // For cache-only commits (stale revalidation), only update cache and return
145
+ // Don't touch store state or history - user may have navigated elsewhere
146
+ if (cacheOnly) {
147
+ const currentHandleData = eventController.getHandleState().data;
148
+ store.cacheSegmentsForHistory(historyKey, segments, currentHandleData);
149
+ // Complete the navigation handle so currentNavigation is cleared.
150
+ // Without this, the entry lingers and weakens state-machine invariants.
151
+ handle.complete(parsedUrl);
152
+ debugLog("[Browser] Cache-only commit, historyKey:", historyKey);
153
+ return;
154
+ }
155
+
156
+ // Save current scroll position before navigating
157
+ handleNavigationStart();
158
+
159
+ // Update segment state atomically
160
+ store.setSegmentIds(segmentIds);
161
+ store.setCurrentUrl(url);
162
+ store.setPath(parsedUrl.pathname);
163
+
164
+ store.setHistoryKey(historyKey);
165
+
166
+ // Cache segments with current handleData for this history entry
167
+ const currentHandleData = eventController.getHandleState().data;
168
+ store.cacheSegmentsForHistory(historyKey, segments, currentHandleData);
169
+
170
+ // For server actions, skip URL/history updates but still complete navigation
171
+ if (storeOnly) {
172
+ debugLog("[Browser] Store updated (action)");
173
+ // Complete navigation to clear loading state
174
+ handle.complete(parsedUrl);
175
+ return;
176
+ }
177
+
178
+ // Build history state - include user state, intercept info, and server-set state
179
+ const historyState = buildHistoryState(
180
+ opts.state,
181
+ { intercept, sourceUrl: interceptSourceUrl },
182
+ serverState,
183
+ );
184
+
185
+ // Snapshot old state before pushState/replaceState overwrites it.
186
+ // Used to detect when location state is being cleared.
187
+ const oldState = window.history.state;
188
+
189
+ // Update browser URL
190
+ if (replace) {
191
+ window.history.replaceState(historyState, "", url);
192
+ } else {
193
+ window.history.pushState(historyState, "", url);
194
+ }
195
+ // Ensure new history entry has a scroll restoration key
196
+ ensureHistoryKey();
197
+
198
+ // Notify location state hooks when either old or new state carries
199
+ // location state. This covers both "set new state" and "clear old state"
200
+ // for same-page navigations where components don't remount.
201
+ if (hasLocationState(oldState) || hasLocationState(historyState)) {
202
+ window.dispatchEvent(new Event("__rsc_locationstate"));
203
+ }
204
+
205
+ // Complete the navigation in event controller (sets idle state, updates location)
206
+ handle.complete(parsedUrl);
207
+
208
+ // Handle scroll after navigation
209
+ handleNavigationEnd({ scroll });
210
+
211
+ debugLog(
212
+ "[Browser] Navigation committed, historyKey:",
213
+ historyKey,
214
+ intercept ? "(intercept)" : "",
215
+ );
216
+ }
217
+
218
+ return {
219
+ handle,
220
+ commit,
221
+
222
+ /**
223
+ * Create a bound transaction with pre-configured URL options
224
+ * segmentIds and segments provided at commit time (after they're resolved)
225
+ */
226
+ with(
227
+ opts: Omit<CommitOptions, "segmentIds" | "segments">,
228
+ ): BoundTransaction {
229
+ return {
230
+ get currentUrl() {
231
+ return currentUrl;
232
+ },
233
+ startStreaming() {
234
+ return handle.startStreaming();
235
+ },
236
+ commit: (
237
+ segmentIds: string[],
238
+ segments: ResolvedSegment[],
239
+ overrides?: BoundCommitOverrides,
240
+ ) => {
241
+ // Allow overrides to disable scroll (e.g., for intercepts)
242
+ const finalScroll =
243
+ overrides?.scroll !== undefined ? overrides.scroll : opts.scroll;
244
+ // Allow overrides to force replace (e.g., for intercepts)
245
+ const finalReplace =
246
+ overrides?.replace !== undefined ? overrides.replace : opts.replace;
247
+ // Intercept info: overrides take precedence, fallback to opts
248
+ const intercept =
249
+ overrides?.intercept !== undefined
250
+ ? overrides.intercept
251
+ : opts.intercept;
252
+ const interceptSourceUrl =
253
+ overrides?.interceptSourceUrl !== undefined
254
+ ? overrides.interceptSourceUrl
255
+ : opts.interceptSourceUrl;
256
+ // Cache-only mode: overrides take precedence, fallback to opts
257
+ const cacheOnly =
258
+ overrides?.cacheOnly !== undefined
259
+ ? overrides.cacheOnly
260
+ : opts.cacheOnly;
261
+ // User state: overrides take precedence, fallback to opts
262
+ const state =
263
+ overrides?.state !== undefined ? overrides.state : opts.state;
264
+ // Server-set location state: only from overrides (set by partial-update)
265
+ const serverState = overrides?.serverState;
266
+ commit({
267
+ ...opts,
268
+ segmentIds,
269
+ segments,
270
+ scroll: finalScroll,
271
+ replace: finalReplace,
272
+ state,
273
+ intercept,
274
+ interceptSourceUrl,
275
+ cacheOnly,
276
+ serverState,
277
+ });
278
+ },
279
+ };
280
+ },
281
+
282
+ [Symbol.dispose]() {
283
+ // Superseded: another navigation took over.
284
+ if (handle.signal.aborted) {
285
+ return;
286
+ }
287
+
288
+ // Failed (not committed): keep the target URL -- the error UI owns it.
289
+ // Just reset the event controller to idle.
290
+ if (!committed) {
291
+ handle[Symbol.dispose]();
292
+ }
293
+ },
294
+ };
295
+ }