@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
@@ -11,12 +11,7 @@ import { createEventController } from "./event-controller.js";
11
11
  import { createNavigationClient } from "./navigation-client.js";
12
12
  import { createServerActionBridge } from "./server-action-bridge.js";
13
13
  import { createNavigationBridge } from "./navigation-bridge.js";
14
- import {
15
- NavigationProvider,
16
- initHandleDataSync,
17
- initSegmentsSync,
18
- } from "./react/index.js";
19
- import { initThemeConfigSync } from "../theme/theme-context.js";
14
+ import { NavigationProvider } from "./react/index.js";
20
15
  import type {
21
16
  RscPayload,
22
17
  RscBrowserDependencies,
@@ -27,6 +22,10 @@ import type {
27
22
  import type { EventController } from "./event-controller.js";
28
23
  import type { ResolvedThemeConfig, Theme } from "../theme/types.js";
29
24
  import { initRangoState } from "./rango-state.js";
25
+ import {
26
+ isInterceptSegment,
27
+ splitInterceptSegments,
28
+ } from "./intercept-utils.js";
30
29
 
31
30
  // Vite HMR types are provided by vite/client
32
31
 
@@ -169,16 +168,6 @@ export async function initBrowserApp(
169
168
  initialLocation: new URL(window.location.href),
170
169
  });
171
170
 
172
- // Initialize segments state BEFORE hydration to avoid mismatch
173
- initSegmentsSync(
174
- initialPayload.metadata?.matched,
175
- initialPayload.metadata?.pathname,
176
- initialPayload.metadata?.params,
177
- );
178
-
179
- // Initialize theme config for MetaTags (must match SSR state)
180
- initThemeConfigSync(effectiveThemeConfig);
181
-
182
171
  // Initialize event controller with segment order (even without handles)
183
172
  eventController.setHandleData({}, initialPayload.metadata?.matched);
184
173
 
@@ -194,12 +183,11 @@ export async function initBrowserApp(
194
183
  for await (const handleData of handlesGenerator) {
195
184
  lastHandleData = handleData;
196
185
  }
197
- // Initialize both event controller AND module-level SSR state for hydration compatibility
186
+ // Initialize event controller with initial handle state before hydration.
198
187
  eventController.setHandleData(
199
188
  lastHandleData,
200
189
  initialPayload.metadata?.matched,
201
190
  );
202
- initHandleDataSync(lastHandleData, initialPayload.metadata?.matched);
203
191
 
204
192
  // Update the initial cache entry with the processed handleData
205
193
  // The cache entry was created by createNavigationStore but without handleData
@@ -272,16 +260,19 @@ export async function initBrowserApp(
272
260
  import.meta.hot.on("rsc:update", async () => {
273
261
  console.log("[RSCRouter] HMR: Server update, refetching RSC");
274
262
 
275
- const handle = eventController.startNavigation(window.location.href, {
263
+ using handle = eventController.startNavigation(window.location.href, {
276
264
  replace: true,
277
265
  });
278
266
  const streamingToken = handle.startStreaming();
279
267
 
268
+ const interceptSourceUrl = store.getInterceptSourceUrl();
269
+
280
270
  try {
281
271
  const { payload, streamComplete } = await client.fetchPartial({
282
272
  targetUrl: window.location.href,
283
273
  segmentIds: [],
284
274
  previousUrl: store.getSegmentState().currentUrl,
275
+ interceptSourceUrl: interceptSourceUrl || undefined,
285
276
  hmr: true,
286
277
  });
287
278
 
@@ -289,10 +280,22 @@ export async function initBrowserApp(
289
280
  const segments = payload.metadata.segments || [];
290
281
  const matched = payload.metadata.matched || [];
291
282
 
283
+ // Derive intercept state from the returned payload, not the
284
+ // pre-fetch store snapshot. If the HMR edit removed intercept
285
+ // behavior, the response won't contain intercept segments.
286
+ const responseIsIntercept = segments.some(isInterceptSegment);
287
+
288
+ // Sync store intercept state with what the server returned
289
+ if (!responseIsIntercept && interceptSourceUrl) {
290
+ store.setInterceptSourceUrl(null);
291
+ }
292
+
292
293
  store.setSegmentIds(matched);
293
294
  store.setCurrentUrl(window.location.href);
294
295
 
295
- const historyKey = generateHistoryKey(window.location.href);
296
+ const historyKey = generateHistoryKey(window.location.href, {
297
+ intercept: responseIsIntercept,
298
+ });
296
299
  store.setHistoryKey(historyKey);
297
300
  const currentHandleData = eventController.getHandleState().data;
298
301
  store.cacheSegmentsForHistory(
@@ -301,18 +304,21 @@ export async function initBrowserApp(
301
304
  currentHandleData,
302
305
  );
303
306
 
307
+ const { main, intercept } = splitInterceptSegments(segments);
304
308
  store.emitUpdate({
305
- root: renderSegments(segments),
309
+ root: renderSegments(main, {
310
+ interceptSegments: intercept.length > 0 ? intercept : undefined,
311
+ }),
306
312
  metadata: payload.metadata,
307
313
  });
308
314
  }
309
315
 
310
316
  await streamComplete;
317
+ handle.complete(new URL(window.location.href));
318
+ console.log("[RSCRouter] HMR: RSC stream complete");
311
319
  } finally {
312
320
  streamingToken.end();
313
321
  }
314
- handle.complete(new URL(window.location.href));
315
- console.log("[RSCRouter] HMR: RSC stream complete");
316
322
  });
317
323
  }
318
324
 
@@ -8,6 +8,8 @@
8
8
  * - Supports hash link scrolling
9
9
  */
10
10
 
11
+ import { debugLog } from "./logging.js";
12
+
11
13
  const SCROLL_STORAGE_KEY = "rsc-router-scroll-positions";
12
14
 
13
15
  /**
@@ -139,12 +141,13 @@ export function initScrollRestoration(options?: {
139
141
 
140
142
  window.addEventListener("pagehide", handlePageHide);
141
143
 
142
- console.log(
144
+ debugLog(
143
145
  "[Scroll] Initialized, loaded positions:",
144
146
  Object.keys(savedScrollPositions).length,
145
147
  );
146
148
 
147
149
  return () => {
150
+ cancelScrollRestorationPolling();
148
151
  window.removeEventListener("pagehide", handlePageHide);
149
152
  window.history.scrollRestoration = "auto";
150
153
  initialized = false;
@@ -267,13 +270,13 @@ export function restoreScrollPosition(options?: {
267
270
 
268
271
  if (canScrollToPosition) {
269
272
  window.scrollTo(0, savedY);
270
- console.log("[Scroll] Restored position:", savedY, "for key:", key);
273
+ debugLog("[Scroll] Restored position:", savedY, "for key:", key);
271
274
  return true;
272
275
  }
273
276
 
274
277
  // Scroll as far as we can for now
275
278
  window.scrollTo(0, maxScrollY);
276
- console.log("[Scroll] Partial restore to:", maxScrollY, "target:", savedY);
279
+ debugLog("[Scroll] Partial restore to:", maxScrollY, "target:", savedY);
277
280
 
278
281
  // Poll while streaming until we can scroll to target position
279
282
  if (options?.retryIfStreaming && options?.isStreaming?.()) {
@@ -282,14 +285,14 @@ export function restoreScrollPosition(options?: {
282
285
  pendingPollInterval = setInterval(() => {
283
286
  // Stop if we've exceeded the timeout
284
287
  if (Date.now() - startTime > SCROLL_POLL_TIMEOUT_MS) {
285
- console.log("[Scroll] Polling timeout, giving up");
288
+ debugLog("[Scroll] Polling timeout, giving up");
286
289
  cancelScrollRestorationPolling();
287
290
  return;
288
291
  }
289
292
 
290
293
  // Stop if streaming ended
291
294
  if (!options.isStreaming?.()) {
292
- console.log("[Scroll] Streaming ended, stopping poll");
295
+ debugLog("[Scroll] Streaming ended, stopping poll");
293
296
  cancelScrollRestorationPolling();
294
297
  return;
295
298
  }
@@ -299,7 +302,7 @@ export function restoreScrollPosition(options?: {
299
302
  document.documentElement.scrollHeight - window.innerHeight;
300
303
  if (savedY <= currentMaxScrollY) {
301
304
  window.scrollTo(0, savedY);
302
- console.log("[Scroll] Poll restored position:", savedY);
305
+ debugLog("[Scroll] Poll restored position:", savedY);
303
306
  cancelScrollRestorationPolling();
304
307
  }
305
308
  }, SCROLL_POLL_INTERVAL_MS);
@@ -322,7 +325,7 @@ export function scrollToHash(): boolean {
322
325
  const element = document.getElementById(id);
323
326
  if (element) {
324
327
  element.scrollIntoView();
325
- console.log("[Scroll] Scrolled to hash element:", id);
328
+ debugLog("[Scroll] Scrolled to hash element:", id);
326
329
  return true;
327
330
  }
328
331
  } catch (e) {
@@ -9,7 +9,6 @@ import {
9
9
  reconcileSegments,
10
10
  reconcileErrorSegments,
11
11
  } from "./segment-reconciler.js";
12
- import { classifyActionResponse } from "./action-response-classifier.js";
13
12
  import { startTransition } from "react";
14
13
  import type { EventController } from "./event-controller.js";
15
14
  import {
@@ -22,6 +21,14 @@ import {
22
21
  isBrowserDebugEnabled,
23
22
  startBrowserTransaction,
24
23
  } from "./logging.js";
24
+ import { validateRedirectOrigin } from "./validate-redirect-origin.js";
25
+ import {
26
+ extractRscHeaderUrl,
27
+ emptyResponse,
28
+ teeWithCompletion,
29
+ } from "./response-adapter.js";
30
+ import { mergeLocationState } from "./history-state.js";
31
+ import { classifyActionOutcome } from "./action-coordinator.js";
25
32
 
26
33
  // Polyfill Symbol.dispose/asyncDispose for Safari and older browsers
27
34
  if (typeof Symbol.dispose === "undefined") {
@@ -109,7 +116,7 @@ export function createServerActionBridge(
109
116
  ...(src ? { intercept: true, interceptSourceUrl: src } : {}),
110
117
  }),
111
118
  {
112
- isAction: true,
119
+ type: "action" as const,
113
120
  ...(src ? { interceptSourceUrl: src } : {}),
114
121
  },
115
122
  );
@@ -180,6 +187,14 @@ export function createServerActionBridge(
180
187
  // Track streaming token - will be set when response arrives
181
188
  let streamingToken: { end(): void } | null = null;
182
189
 
190
+ // Use a dedicated abort controller for the fetch so we can cancel network
191
+ // I/O without disrupting the Flight stream once the response has arrived.
192
+ // Aborting a response mid-stream causes React's Flight decoder to throw
193
+ // asynchronous unhandled errors (BodyStreamBuffer was aborted).
194
+ const fetchAbort = new AbortController();
195
+ const onHandleAbort = () => fetchAbort.abort();
196
+ handle.signal.addEventListener("abort", onHandleAbort, { once: true });
197
+
183
198
  // Send action request with stream tracking
184
199
  const responsePromise = fetch(url, {
185
200
  method: "POST",
@@ -193,25 +208,21 @@ export function createServerActionBridge(
193
208
  }),
194
209
  },
195
210
  body: encodedBody,
211
+ signal: fetchAbort.signal,
196
212
  }).then(async (response) => {
213
+ // Response arrived — disconnect fetch abort from handle abort so
214
+ // abortAllActions() doesn't disrupt the in-progress Flight stream.
215
+ handle.signal.removeEventListener("abort", onHandleAbort);
216
+
197
217
  // Check for version mismatch - server wants us to reload
198
- const reloadUrl = response.headers.get("X-RSC-Reload");
199
- if (reloadUrl) {
200
- // Validate origin to prevent open redirect via crafted headers
201
- try {
202
- const target = new URL(reloadUrl, window.location.origin);
203
- if (target.origin !== window.location.origin) {
204
- throw new Error(
205
- `X-RSC-Reload blocked: origin mismatch (${target.origin})`,
206
- );
207
- }
208
- } catch (e) {
209
- console.error("[rango]", e);
210
- return response;
211
- }
212
- log("version mismatch on action, reloading", { reloadUrl });
213
- window.location.href = reloadUrl;
214
- // Return a never-resolving promise to prevent further processing
218
+ const reload = extractRscHeaderUrl(response, "X-RSC-Reload");
219
+ if (reload === "blocked") {
220
+ resolveStreamComplete();
221
+ return emptyResponse();
222
+ }
223
+ if (reload) {
224
+ log("version mismatch on action, reloading", { reloadUrl: reload.url });
225
+ window.location.href = reload.url;
215
226
  return new Promise<Response>(() => {});
216
227
  }
217
228
 
@@ -219,64 +230,34 @@ export function createServerActionBridge(
219
230
  // Short-circuits before createFromFetch — no Flight deserialization needed.
220
231
  // Check handle.signal.aborted to avoid redirecting from a stale action
221
232
  // when the user has already navigated away.
222
- const simpleRedirectUrl = response.headers.get("X-RSC-Redirect");
223
- if (simpleRedirectUrl && !handle.signal.aborted) {
224
- if (tx) {
225
- browserDebugLog(tx, "action simple redirect", {
226
- url: simpleRedirectUrl,
227
- });
228
- }
233
+ const redirect = extractRscHeaderUrl(response, "X-RSC-Redirect");
234
+ if (redirect && redirect !== "blocked" && !handle.signal.aborted) {
235
+ log("action simple redirect", { url: redirect.url });
229
236
  handle.complete(undefined);
230
237
  if (onNavigate) {
231
- await onNavigate(simpleRedirectUrl, {
238
+ await onNavigate(redirect.url, {
232
239
  replace: true,
233
240
  _skipCache: true,
234
241
  });
235
242
  } else {
236
- window.location.href = simpleRedirectUrl;
243
+ window.location.href = redirect.url;
237
244
  }
238
245
  return new Promise<Response>(() => {});
239
246
  }
247
+ if (redirect === "blocked") {
248
+ resolveStreamComplete();
249
+ return emptyResponse();
250
+ }
240
251
 
241
252
  // Start streaming immediately when response arrives
242
253
  if (!handle.signal.aborted) {
243
254
  streamingToken = handle.startStreaming();
244
255
  }
245
256
 
246
- if (!response.body) {
247
- // No body means stream is already complete
257
+ return teeWithCompletion(response, () => {
258
+ log("stream complete");
248
259
  streamingToken?.end();
249
260
  resolveStreamComplete();
250
- return response;
251
- }
252
-
253
- // Tee the stream: one for RSC runtime, one for tracking completion
254
- const [rscStream, trackingStream] = response.body.tee();
255
-
256
- // Consume the tracking stream to detect when it closes
257
- (async () => {
258
- const reader = trackingStream.getReader();
259
- try {
260
- while (true) {
261
- const { done } = await reader.read();
262
- if (done) break;
263
- }
264
- } finally {
265
- reader.releaseLock();
266
- log("stream complete");
267
- streamingToken?.end();
268
- resolveStreamComplete();
269
- }
270
- })().catch((error) => {
271
- console.error("[STREAMING] Error reading tracking stream:", error);
272
- streamingToken?.end();
273
- });
274
-
275
- // Return response with the RSC stream
276
- return new Response(rscStream, {
277
- headers: response.headers,
278
- status: response.status,
279
- statusText: response.statusText,
280
261
  });
281
262
  });
282
263
 
@@ -294,6 +275,15 @@ export function createServerActionBridge(
294
275
  // resolveStreamComplete is assigned in the Promise constructor so it's safe to call
295
276
  resolveStreamComplete!();
296
277
 
278
+ // Silently swallow abort errors — the action was intentionally cancelled
279
+ // (e.g., user navigated away or abortAllActions was called).
280
+ // Return undefined instead of throwing to avoid surfacing as a page error.
281
+ // Check both DOMException AbortError and stream-level abort messages
282
+ // (BodyStreamBuffer was aborted) that propagate from the aborted fetch.
283
+ if (handle.signal.aborted) {
284
+ return undefined;
285
+ }
286
+
297
287
  // Convert network-level errors to NetworkError for proper handling
298
288
  const networkError = toNetworkError(error, {
299
289
  url: url.toString(),
@@ -314,6 +304,14 @@ export function createServerActionBridge(
314
304
  diffCount: payload.metadata?.diff?.length ?? 0,
315
305
  });
316
306
 
307
+ // Guard: if the action was aborted while streaming (e.g., user navigated
308
+ // away or abortAllActions fired), bail out before any reconcile/render/cache
309
+ // writes to avoid overwriting the current UI with stale action results.
310
+ if (handle.signal.aborted) {
311
+ log("action aborted after response, skipping reconciliation");
312
+ return undefined;
313
+ }
314
+
317
315
  // Process response
318
316
  const { metadata, returnValue } = payload;
319
317
 
@@ -322,9 +320,17 @@ export function createServerActionBridge(
322
320
  // Check handle.signal.aborted to avoid redirecting from a stale action
323
321
  // when the user has already navigated away.
324
322
  if (metadata?.redirect && !handle.signal.aborted) {
325
- const { url: redirectUrl } = metadata.redirect;
323
+ const redirectUrl = validateRedirectOrigin(
324
+ metadata.redirect.url,
325
+ window.location.origin,
326
+ );
327
+ if (!redirectUrl) {
328
+ log("blocked action redirect payload", { url: metadata.redirect.url });
329
+ handle.complete(returnValue?.data);
330
+ return returnValue?.data;
331
+ }
326
332
  const redirectState = metadata.locationState;
327
- console.log(`[Browser] Action redirect to ${redirectUrl}`);
333
+ log("action redirect", { url: redirectUrl });
328
334
  handle.complete(returnValue?.data);
329
335
  if (onNavigate) {
330
336
  await onNavigate(redirectUrl, {
@@ -338,6 +344,15 @@ export function createServerActionBridge(
338
344
  return returnValue?.data;
339
345
  }
340
346
 
347
+ // Bail out if the action was aborted after deserialization (e.g. user
348
+ // navigated away or abortAllActions was called while the Flight stream
349
+ // was being consumed). Without this check the code below would mutate
350
+ // the store / UI for a stale action.
351
+ if (handle.signal.aborted) {
352
+ log("action aborted after deserialization, skipping mutations");
353
+ return returnValue?.data;
354
+ }
355
+
341
356
  const { matched, diff, segments, isPartial, isError } = metadata || {};
342
357
 
343
358
  // Log action result
@@ -349,6 +364,12 @@ export function createServerActionBridge(
349
364
  if (isError && isPartial && segments && diff) {
350
365
  log("processing error boundary response");
351
366
 
367
+ // Fail current handle BEFORE aborting all actions so the event controller
368
+ // records the error state (abortAllActions clears inflight entries)
369
+ if (returnValue && !returnValue.ok) {
370
+ handle.fail(returnValue.data);
371
+ }
372
+
352
373
  // Abort all other pending action requests - error takes precedence
353
374
  // This prevents other actions from completing and overwriting the error UI
354
375
  eventController.abortAllActions();
@@ -373,6 +394,26 @@ export function createServerActionBridge(
373
394
  : undefined,
374
395
  });
375
396
 
397
+ // Re-check route stability after async renderSegments — user may have
398
+ // navigated away while the error tree was being prepared.
399
+ if (window.location.pathname !== actionStartPathname) {
400
+ log("user navigated during error render, skipping");
401
+ if (returnValue && !returnValue.ok) {
402
+ throw returnValue.data;
403
+ }
404
+ handle.complete(undefined);
405
+ return undefined;
406
+ }
407
+ const currentKeyNow = store.getHistoryKey();
408
+ if (currentKeyNow !== currentKey) {
409
+ log("history key changed during error render, skipping cache update");
410
+ if (returnValue && !returnValue.ok) {
411
+ throw returnValue.data;
412
+ }
413
+ handle.complete(undefined);
414
+ return undefined;
415
+ }
416
+
376
417
  // Update UI with error boundary
377
418
  startTransition(() => {
378
419
  onUpdate({ root: errorTree, metadata: metadata! });
@@ -395,7 +436,6 @@ export function createServerActionBridge(
395
436
 
396
437
  // Throw the error so the action promise rejects
397
438
  if (returnValue && !returnValue.ok) {
398
- handle.fail(returnValue.data);
399
439
  throw returnValue.data;
400
440
  }
401
441
 
@@ -405,9 +445,13 @@ export function createServerActionBridge(
405
445
  }
406
446
 
407
447
  if (!isPartial) {
408
- // Full update not supported for actions
448
+ // Protocol invariant: action revalidation responses MUST be partial.
449
+ // The server always sends isPartial: true for successful revalidation
450
+ // and isPartial: true + isError: true for error boundary responses.
451
+ // A non-partial payload here indicates a server-side bug.
409
452
  throw new Error(
410
- `[Browser] Full update after action is not supported yet`,
453
+ `[Browser] Action response missing isPartial the server must ` +
454
+ `always send partial payloads for action revalidation.`,
411
455
  );
412
456
  }
413
457
 
@@ -449,20 +493,17 @@ export function createServerActionBridge(
449
493
  }
450
494
 
451
495
  // Classify the post-reconciliation scenario
452
- const consolidationSegments = handle.getConsolidationSegments();
453
- const otherFetchingActions = [
454
- ...eventController.getInflightActions().values(),
455
- ].filter((a) => a.phase === "fetching" && a.id !== handle.id);
456
-
457
- const scenario = classifyActionResponse({
496
+ const scenario = classifyActionOutcome({
497
+ handleId: handle.id,
498
+ inflightActions: eventController.getInflightActions(),
499
+ hadAnyConcurrentActions: eventController.hadAnyConcurrentActions(),
500
+ revalidatedSegments: handle.getRevalidatedSegments(),
458
501
  actionStartPathname,
459
502
  currentPathname: window.location.pathname,
460
503
  actionStartLocationKey: locationKey,
461
504
  currentLocationKey: window.history.state?.key,
462
505
  reconciledSegmentCount: fullSegments.length,
463
506
  matchedCount: matched.length,
464
- consolidationSegments: consolidationSegments || null,
465
- otherFetchingActionCount: otherFetchingActions.length,
466
507
  currentInterceptSource: store.getInterceptSourceUrl(),
467
508
  });
468
509
 
@@ -572,19 +613,7 @@ export function createServerActionBridge(
572
613
  // Apply server-set location state to history.state (non-redirect flow)
573
614
  const actionLocationState = metadata?.locationState;
574
615
  if (actionLocationState) {
575
- const merged = {
576
- ...window.history.state,
577
- ...actionLocationState,
578
- };
579
- window.history.replaceState(merged, "", window.location.href);
580
- // Notify useLocationState hooks so they re-read from history.state
581
- if (
582
- Object.keys(actionLocationState).some((k) =>
583
- k.startsWith("__rsc_ls_"),
584
- )
585
- ) {
586
- window.dispatchEvent(new Event("__rsc_locationstate"));
587
- }
616
+ mergeLocationState(actionLocationState);
588
617
  }
589
618
 
590
619
  // Update store state
@@ -616,16 +645,6 @@ export function createServerActionBridge(
616
645
  deps.setServerCallback(handleServerAction);
617
646
  isRegistered = true;
618
647
  },
619
-
620
- /**
621
- * Unregister the server action callback
622
- */
623
- unregister(): void {
624
- if (!isRegistered) {
625
- return;
626
- }
627
- isRegistered = false;
628
- },
629
648
  };
630
649
  }
631
650
 
@@ -121,7 +121,7 @@ export interface NavigationState {
121
121
  /** Whether RSC data is currently streaming (initial load or navigation) */
122
122
  isStreaming: boolean;
123
123
 
124
- /** Current location (updated optimistically) */
124
+ /** Current location */
125
125
  location: NavigationLocation;
126
126
 
127
127
  /** URL being navigated to (null when idle) */
@@ -402,35 +402,6 @@ export interface NavigationStore {
402
402
  ): () => void;
403
403
  }
404
404
 
405
- // ============================================================================
406
- // Request Controller Types
407
- // ============================================================================
408
-
409
- /**
410
- * Disposable abort controller with automatic cleanup
411
- */
412
- export interface DisposableAbortController extends Disposable {
413
- controller: AbortController;
414
- }
415
-
416
- /**
417
- * Request controller for managing concurrent requests
418
- *
419
- * Separates navigation requests (aborted on new navigation) from
420
- * action requests (complete independently of navigation).
421
- */
422
- export interface RequestController {
423
- create(): AbortController;
424
- createDisposable(): DisposableAbortController;
425
- /** Create a disposable controller for actions (not aborted by navigation) */
426
- createActionDisposable(): DisposableAbortController;
427
- /** Abort all navigation requests (not actions) */
428
- abortAll(): void;
429
- /** Abort all action requests (used for error handling) */
430
- abortAllActions(): void;
431
- remove(controller: AbortController): void;
432
- }
433
-
434
405
  // ============================================================================
435
406
  // Navigation Client Types
436
407
  // ============================================================================
@@ -488,7 +459,6 @@ export interface LinkInterceptorOptions {
488
459
  */
489
460
  export interface ServerActionBridge {
490
461
  register(): void;
491
- unregister(): void;
492
462
  }
493
463
 
494
464
  /**
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Validate that a client-consumed redirect URL (from headers or Flight payload)
3
+ * targets the same origin as the current page. Prevents open-redirect attacks
4
+ * via crafted responses.
5
+ *
6
+ * @returns The canonical (normalized) URL string on success, or null if blocked.
7
+ */
8
+ export function validateRedirectOrigin(
9
+ url: string,
10
+ currentOrigin: string,
11
+ ): string | null {
12
+ try {
13
+ const target = new URL(url, currentOrigin);
14
+ if (target.origin !== currentOrigin) {
15
+ console.error(
16
+ `[rango] Redirect blocked: origin mismatch (${target.origin})`,
17
+ );
18
+ return null;
19
+ }
20
+ // Return pathname+search+hash for relative inputs, full href for absolute.
21
+ // This normalizes protocol-relative and other ambiguous forms.
22
+ return target.href.startsWith(currentOrigin)
23
+ ? target.href
24
+ : target.pathname + target.search + target.hash;
25
+ } catch {
26
+ console.error(`[rango] Redirect blocked: invalid URL "${url}"`);
27
+ return null;
28
+ }
29
+ }
@@ -200,6 +200,11 @@ function buildPrefixTreeNode(
200
200
  }
201
201
  }
202
202
 
203
+ // Remove from visited so sibling branches can reuse the same patterns
204
+ // without false circular-include detection. Only ancestors in the current
205
+ // recursion path should trigger the cycle guard.
206
+ visited.delete(patterns);
207
+
203
208
  return {
204
209
  staticPrefix: extractStaticPrefix(urlPrefix),
205
210
  fullPrefix: urlPrefix,
@@ -27,6 +27,8 @@ export {
27
27
  extractUrlsVariableFromRouter,
28
28
  buildCombinedRouteMapForRouterFile,
29
29
  detectUnresolvableIncludes,
30
+ detectUnresolvableIncludesForUrlsFile,
30
31
  findRouterFiles,
31
32
  writeCombinedRouteTypes,
32
33
  } from "./route-types/router-processing.js";
34
+ export { findUrlsVariableNames } from "./route-types/per-module-writer.js";