@rangojs/router 0.0.0-experimental.124 → 0.0.0-experimental.126

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 (235) hide show
  1. package/README.md +6 -4
  2. package/dist/bin/rango.js +3 -4
  3. package/dist/vite/index.js +315 -68
  4. package/package.json +19 -18
  5. package/skills/breadcrumbs/SKILL.md +60 -0
  6. package/skills/hooks/SKILL.md +2 -2
  7. package/skills/route/SKILL.md +6 -0
  8. package/skills/server-actions/SKILL.md +25 -1
  9. package/skills/testing/SKILL.md +17 -17
  10. package/skills/testing/cache-prerender.md +29 -3
  11. package/skills/testing/flight.md +13 -10
  12. package/skills/testing/render-handler.md +3 -0
  13. package/skills/testing/server-tree.md +1 -1
  14. package/skills/testing/setup.md +1 -1
  15. package/src/__internal.ts +0 -65
  16. package/src/browser/action-coordinator.ts +1 -1
  17. package/src/browser/action-fence.ts +10 -0
  18. package/src/browser/event-controller.ts +1 -83
  19. package/src/browser/navigation-store-handle.ts +3 -4
  20. package/src/browser/navigation-store.ts +0 -39
  21. package/src/browser/navigation-transaction.ts +0 -32
  22. package/src/browser/partial-update.ts +23 -84
  23. package/src/browser/prefetch/cache.ts +6 -45
  24. package/src/browser/prefetch/queue.ts +6 -3
  25. package/src/browser/rango-state.ts +2 -23
  26. package/src/browser/react/Link.tsx +0 -2
  27. package/src/browser/react/NavigationProvider.tsx +2 -1
  28. package/src/browser/react/ScrollRestoration.tsx +10 -6
  29. package/src/browser/react/filter-segment-order.ts +0 -2
  30. package/src/browser/react/index.ts +0 -45
  31. package/src/browser/react/location-state-shared.ts +0 -13
  32. package/src/browser/react/location-state.ts +0 -1
  33. package/src/browser/react/use-action.ts +6 -15
  34. package/src/browser/react/use-handle.ts +0 -5
  35. package/src/browser/react/use-link-status.ts +0 -4
  36. package/src/browser/react/use-navigation.ts +0 -3
  37. package/src/browser/react/use-params.ts +0 -2
  38. package/src/browser/react/use-router.ts +2 -1
  39. package/src/browser/react/use-search-params.ts +0 -5
  40. package/src/browser/react/use-segments.ts +0 -13
  41. package/src/browser/rsc-router.tsx +10 -3
  42. package/src/browser/server-action-bridge.ts +51 -3
  43. package/src/browser/types.ts +23 -5
  44. package/src/browser/validate-redirect-origin.ts +43 -16
  45. package/src/build/index.ts +8 -9
  46. package/src/build/route-trie.ts +46 -11
  47. package/src/build/route-types/param-extraction.ts +6 -3
  48. package/src/build/route-types/router-processing.ts +0 -8
  49. package/src/cache/cache-policy.ts +0 -54
  50. package/src/cache/cache-runtime.ts +48 -24
  51. package/src/cache/cache-scope.ts +0 -27
  52. package/src/cache/cache-tag.ts +0 -37
  53. package/src/cache/cf/cf-cache-store.ts +72 -45
  54. package/src/cache/cf/index.ts +0 -24
  55. package/src/cache/document-cache.ts +10 -36
  56. package/src/cache/handle-snapshot.ts +0 -40
  57. package/src/cache/index.ts +0 -27
  58. package/src/cache/memory-segment-store.ts +0 -52
  59. package/src/cache/profile-registry.ts +6 -30
  60. package/src/cache/read-through-swr.ts +41 -11
  61. package/src/cache/segment-codec.ts +0 -16
  62. package/src/cache/types.ts +0 -98
  63. package/src/client.rsc.tsx +4 -22
  64. package/src/client.tsx +19 -32
  65. package/src/context-var.ts +12 -0
  66. package/src/defer.ts +196 -0
  67. package/src/deps/ssr.ts +0 -1
  68. package/src/handle.ts +2 -12
  69. package/src/handles/MetaTags.tsx +0 -14
  70. package/src/handles/breadcrumbs.ts +16 -5
  71. package/src/handles/meta.ts +0 -39
  72. package/src/host/cookie-handler.ts +0 -36
  73. package/src/host/errors.ts +0 -24
  74. package/src/host/index.ts +6 -0
  75. package/src/host/pattern-matcher.ts +7 -50
  76. package/src/host/router.ts +1 -65
  77. package/src/host/testing.ts +0 -16
  78. package/src/host/types.ts +6 -2
  79. package/src/href-client.ts +0 -4
  80. package/src/index.rsc.ts +27 -2
  81. package/src/index.ts +7 -0
  82. package/src/internal-debug.ts +2 -4
  83. package/src/loader.rsc.ts +4 -15
  84. package/src/loader.ts +3 -9
  85. package/src/network-error-thrower.tsx +1 -6
  86. package/src/outlet-provider.tsx +1 -5
  87. package/src/prerender/param-hash.ts +10 -11
  88. package/src/prerender/store.ts +23 -30
  89. package/src/prerender.ts +34 -0
  90. package/src/redirect-origin.ts +100 -0
  91. package/src/root-error-boundary.tsx +1 -19
  92. package/src/route-content-wrapper.tsx +1 -44
  93. package/src/route-definition/dsl-helpers.ts +7 -19
  94. package/src/route-definition/helpers-types.ts +3 -3
  95. package/src/route-definition/redirect.ts +43 -9
  96. package/src/route-definition/resolve-handler-use.ts +6 -0
  97. package/src/route-map-builder.ts +0 -16
  98. package/src/router/content-negotiation.ts +0 -13
  99. package/src/router/error-handling.ts +12 -16
  100. package/src/router/find-match.ts +4 -31
  101. package/src/router/intercept-resolution.ts +10 -1
  102. package/src/router/lazy-includes.ts +1 -57
  103. package/src/router/loader-resolution.ts +25 -23
  104. package/src/router/logging.ts +0 -6
  105. package/src/router/manifest.ts +1 -25
  106. package/src/router/match-api.ts +0 -20
  107. package/src/router/match-context.ts +0 -22
  108. package/src/router/match-handlers.ts +0 -43
  109. package/src/router/match-middleware/background-revalidation.ts +0 -7
  110. package/src/router/match-middleware/cache-lookup.ts +96 -179
  111. package/src/router/match-middleware/cache-store.ts +0 -31
  112. package/src/router/match-middleware/intercept-resolution.ts +0 -22
  113. package/src/router/match-middleware/segment-resolution.ts +0 -22
  114. package/src/router/match-pipelines.ts +1 -42
  115. package/src/router/match-result.ts +1 -52
  116. package/src/router/metrics.ts +0 -34
  117. package/src/router/middleware-types.ts +0 -116
  118. package/src/router/middleware.ts +77 -60
  119. package/src/router/navigation-snapshot.ts +0 -51
  120. package/src/router/params-util.ts +23 -0
  121. package/src/router/pattern-matching.ts +5 -56
  122. package/src/router/prerender-match.ts +56 -51
  123. package/src/router/request-classification.ts +1 -38
  124. package/src/router/revalidation.ts +14 -62
  125. package/src/router/route-snapshot.ts +0 -1
  126. package/src/router/router-context.ts +0 -27
  127. package/src/router/router-interfaces.ts +10 -0
  128. package/src/router/segment-resolution/fresh.ts +25 -57
  129. package/src/router/segment-resolution/helpers.ts +34 -0
  130. package/src/router/segment-resolution/loader-cache.ts +35 -23
  131. package/src/router/segment-resolution/revalidation.ts +188 -283
  132. package/src/router/segment-resolution/streamed-handler-telemetry.ts +52 -0
  133. package/src/router/segment-resolution.ts +4 -1
  134. package/src/router/segment-wrappers.ts +0 -3
  135. package/src/router/telemetry-otel.ts +0 -20
  136. package/src/router/telemetry.ts +0 -22
  137. package/src/router/timeout.ts +0 -20
  138. package/src/router/trie-matching.ts +66 -45
  139. package/src/router/types.ts +1 -63
  140. package/src/router/url-params.ts +0 -5
  141. package/src/router.ts +8 -11
  142. package/src/rsc/handler-context.ts +1 -0
  143. package/src/rsc/handler.ts +20 -4
  144. package/src/rsc/helpers.ts +71 -3
  145. package/src/rsc/json-route-result.ts +38 -0
  146. package/src/rsc/origin-guard.ts +9 -15
  147. package/src/rsc/progressive-enhancement.ts +10 -1
  148. package/src/rsc/redirect-guard.ts +99 -0
  149. package/src/rsc/response-route-handler.ts +23 -18
  150. package/src/rsc/rsc-rendering.ts +2 -7
  151. package/src/rsc/runtime-warnings.ts +14 -0
  152. package/src/rsc/server-action.ts +34 -29
  153. package/src/rsc/types.ts +6 -3
  154. package/src/search-params.ts +0 -16
  155. package/src/segment-loader-promise.ts +14 -2
  156. package/src/segment-system.tsx +79 -88
  157. package/src/server/handle-store.ts +7 -24
  158. package/src/server/loader-registry.ts +5 -24
  159. package/src/server/request-context.ts +29 -92
  160. package/src/ssr/index.tsx +14 -14
  161. package/src/static-handler.ts +2 -27
  162. package/src/testing/cache-status.ts +44 -48
  163. package/src/testing/collect-handle.ts +1 -24
  164. package/src/testing/dispatch.ts +43 -6
  165. package/src/testing/e2e/index.ts +1 -22
  166. package/src/testing/e2e/matchers.ts +0 -16
  167. package/src/testing/flight-matchers.ts +0 -13
  168. package/src/testing/flight-normalize.ts +3 -30
  169. package/src/testing/flight.ts +46 -48
  170. package/src/testing/generated-routes.ts +1 -41
  171. package/src/testing/index.ts +1 -21
  172. package/src/testing/internal/context.ts +3 -45
  173. package/src/testing/internal/seed-vars.ts +0 -26
  174. package/src/testing/render-handler.ts +31 -61
  175. package/src/testing/render-route.tsx +75 -103
  176. package/src/testing/run-loader.ts +0 -96
  177. package/src/testing/run-middleware.ts +0 -26
  178. package/src/theme/ThemeProvider.tsx +0 -52
  179. package/src/theme/ThemeScript.tsx +0 -6
  180. package/src/theme/constants.ts +0 -12
  181. package/src/theme/index.ts +0 -7
  182. package/src/theme/theme-context.ts +1 -5
  183. package/src/theme/theme-script.ts +0 -14
  184. package/src/theme/use-theme.ts +0 -3
  185. package/src/types/boundaries.ts +0 -35
  186. package/src/types/error-types.ts +25 -89
  187. package/src/types/global-namespace.ts +4 -14
  188. package/src/types/handler-context.ts +28 -9
  189. package/src/types/index.ts +0 -10
  190. package/src/types/request-scope.ts +0 -19
  191. package/src/types/route-config.ts +6 -50
  192. package/src/types/route-entry.ts +0 -6
  193. package/src/types/segments.ts +0 -13
  194. package/src/urls/include-helper.ts +0 -4
  195. package/src/urls/index.ts +0 -6
  196. package/src/urls/path-helper-types.ts +2 -2
  197. package/src/urls/path-helper.ts +0 -54
  198. package/src/urls/urls-function.ts +0 -13
  199. package/src/use-loader.tsx +0 -186
  200. package/src/vite/discovery/bundle-postprocess.ts +2 -1
  201. package/src/vite/discovery/discover-routers.ts +28 -18
  202. package/src/vite/discovery/prerender-collection.ts +2 -4
  203. package/src/vite/discovery/state.ts +5 -0
  204. package/src/vite/discovery/virtual-module-codegen.ts +1 -11
  205. package/src/vite/plugin-types.ts +35 -9
  206. package/src/vite/plugins/cjs-to-esm.ts +0 -11
  207. package/src/vite/plugins/client-ref-dedup.ts +0 -11
  208. package/src/vite/plugins/client-ref-hashing.ts +0 -10
  209. package/src/vite/plugins/cloudflare-protocol-stub.ts +0 -20
  210. package/src/vite/plugins/expose-action-id.ts +2 -73
  211. package/src/vite/plugins/expose-id-utils.ts +0 -55
  212. package/src/vite/plugins/expose-ids/export-analysis.ts +0 -38
  213. package/src/vite/plugins/expose-ids/handler-transform.ts +0 -15
  214. package/src/vite/plugins/expose-ids/loader-transform.ts +0 -15
  215. package/src/vite/plugins/expose-ids/router-transform.ts +0 -13
  216. package/src/vite/plugins/expose-internal-ids.ts +10 -0
  217. package/src/vite/plugins/performance-tracks.ts +0 -3
  218. package/src/vite/plugins/refresh-cmd.ts +1 -1
  219. package/src/vite/plugins/use-cache-transform.ts +21 -46
  220. package/src/vite/plugins/version-injector.ts +0 -20
  221. package/src/vite/plugins/version-plugin.ts +1 -49
  222. package/src/vite/plugins/virtual-entries.ts +0 -15
  223. package/src/vite/rango.ts +2 -108
  224. package/src/vite/router-discovery.ts +9 -1
  225. package/src/vite/utils/ast-handler-extract.ts +0 -16
  226. package/src/vite/utils/bundle-analysis.ts +6 -13
  227. package/src/vite/utils/client-chunks.ts +0 -6
  228. package/src/vite/utils/forward-user-plugins.ts +0 -22
  229. package/src/vite/utils/manifest-utils.ts +0 -4
  230. package/src/vite/utils/package-resolution.ts +1 -73
  231. package/src/vite/utils/prerender-utils.ts +0 -35
  232. package/src/vite/utils/shared-utils.ts +3 -35
  233. package/src/browser/shallow.ts +0 -40
  234. package/src/handles/index.ts +0 -7
  235. package/src/router/middleware-cookies.ts +0 -55
@@ -237,10 +237,6 @@ export interface EventController {
237
237
  hadAnyConcurrentActions(): boolean;
238
238
  }
239
239
 
240
- // ============================================================================
241
- // Default States
242
- // ============================================================================
243
-
244
240
  const DEFAULT_ACTION_STATE: TrackedActionState = {
245
241
  state: "idle",
246
242
  actionId: null,
@@ -261,16 +257,12 @@ function matchesActionId(
261
257
  entryActionId: string,
262
258
  ): boolean {
263
259
  if (subscriptionId.includes("#")) {
264
- // Full ID: exact match
265
260
  return subscriptionId === entryActionId;
266
261
  }
267
- // Action name only: suffix match (matches "anything#actionName")
268
262
  return entryActionId.endsWith(`#${subscriptionId}`);
269
263
  }
270
264
 
271
- // Coalesce rapid notifications into one microtask-deferred fan-out; the
272
- // setTimeout(0) batching prevents render storms. Each notifier owns its timer
273
- // so listener kinds coalesce independently.
265
+ // Batch rapid notifications into one microtask to prevent render storms
274
266
  function makeDebouncedNotifier(listeners: Set<() => void>): () => void {
275
267
  let timeout: ReturnType<typeof setTimeout> | null = null;
276
268
  return () => {
@@ -282,13 +274,6 @@ function makeDebouncedNotifier(listeners: Set<() => void>): () => void {
282
274
  };
283
275
  }
284
276
 
285
- // ============================================================================
286
- // Implementation
287
- // ============================================================================
288
-
289
- /**
290
- * Configuration for creating an event controller
291
- */
292
277
  export interface EventControllerConfig {
293
278
  initialLocation?: NavigationLocation;
294
279
  }
@@ -306,51 +291,34 @@ export interface EventControllerConfig {
306
291
  export function createEventController(
307
292
  config?: EventControllerConfig,
308
293
  ): EventController {
309
- // ========================================================================
310
- // Source of Truth
311
- // ========================================================================
312
-
313
- // Current navigation in progress (null = idle)
314
294
  let currentNavigation: NavigationEntry | null = null;
315
295
 
316
- // All in-flight actions (keyed by unique instance ID)
317
296
  const inflightActions = new Map<string, ActionEntry>();
318
297
 
319
- // Committed location (updated when navigation completes)
320
298
  let location: NavigationLocation =
321
299
  config?.initialLocation ??
322
300
  (typeof window !== "undefined"
323
301
  ? new URL(window.location.href)
324
302
  : new URL("/", "http://localhost"));
325
303
 
326
- // Track if any concurrent actions occurred (for consolidation)
327
304
  let hadAnyConcurrentActions = false;
328
305
 
329
- // Track segments revalidated by concurrent actions
330
306
  const concurrentRevalidatedSegments = new Set<string>();
331
307
 
332
- // Active streaming count (independent of navigation/action lifecycle)
333
308
  let activeStreamCount = 0;
334
309
 
335
- // Handle data from RSC payload
336
310
  let handleData: HandleData = {};
337
311
  let handleSegmentOrder: string[] = [];
338
312
  let routeSegmentIds: string[] = [];
339
313
 
340
- // Merged route params from current match
341
314
  let routeParams: Record<string, string> = {};
342
315
 
343
- // ========================================================================
344
- // Listeners
345
- // ========================================================================
346
-
347
316
  const stateListeners = new Set<StateListener>();
348
317
  const actionListeners = new Map<string, Set<ActionStateListener>>();
349
318
  const handleListeners = new Set<HandleListener>();
350
319
 
351
320
  const notify = makeDebouncedNotifier(stateListeners);
352
321
 
353
- // Debounce per-action notifications
354
322
  const actionNotifyTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
355
323
 
356
324
  function notifyAction(actionId: string) {
@@ -362,8 +330,6 @@ export function createEventController(
362
330
  actionId,
363
331
  setTimeout(() => {
364
332
  actionNotifyTimeouts.delete(actionId);
365
- // Notify all listeners whose subscription ID matches this action
366
- // This includes exact matches and suffix matches (e.g., "addToCart" matches "hash#addToCart")
367
333
  for (const [subscriptionId, listeners] of actionListeners) {
368
334
  if (matchesActionId(subscriptionId, actionId)) {
369
335
  const state = getActionState(subscriptionId);
@@ -376,12 +342,7 @@ export function createEventController(
376
342
 
377
343
  const notifyHandles = makeDebouncedNotifier(handleListeners);
378
344
 
379
- // ========================================================================
380
- // Derived State
381
- // ========================================================================
382
-
383
345
  function getState(): DerivedNavigationState {
384
- // Build inflight actions list (for compatibility with existing API)
385
346
  const inflightActionsList: InflightAction[] = [...inflightActions.values()]
386
347
  .filter((a) => a.phase !== "settling")
387
348
  .map((a) => ({
@@ -391,15 +352,12 @@ export function createEventController(
391
352
  startedAt: a.startedAt,
392
353
  }));
393
354
 
394
- // State: loading if navigation OR actions are in progress
395
- // Background revalidations (skipLoadingState) don't affect visible state
396
355
  const hasActiveActions = inflightActionsList.length > 0;
397
356
  const isVisibleNavigation =
398
357
  currentNavigation !== null &&
399
358
  !currentNavigation.options?.skipLoadingState;
400
359
  const state = isVisibleNavigation || hasActiveActions ? "loading" : "idle";
401
360
 
402
- // Streaming: true if any active streams (navigation or action) or loading
403
361
  const isStreaming = activeStreamCount > 0 || state === "loading";
404
362
 
405
363
  return {
@@ -421,8 +379,6 @@ export function createEventController(
421
379
  }
422
380
 
423
381
  function getActionState(actionId: string): TrackedActionState {
424
- // Prefer the most-recent non-settling entry; fall back to most-recent
425
- // settling so a just-settled action's result/error stays readable.
426
382
  const entry = [...inflightActions.values()]
427
383
  .filter((a) => matchesActionId(actionId, a.actionId))
428
384
  .reduce<ActionEntry | undefined>((best, a) => {
@@ -437,7 +393,6 @@ export function createEventController(
437
393
  return { ...DEFAULT_ACTION_STATE };
438
394
  }
439
395
 
440
- // Derive state from phase
441
396
  let state: ActionLifecycleState;
442
397
  switch (entry.phase) {
443
398
  case "fetching":
@@ -881,40 +836,3 @@ export function createEventController(
881
836
  hadAnyConcurrentActions: () => hadAnyConcurrentActions,
882
837
  };
883
838
  }
884
-
885
- // ============================================================================
886
- // Singleton
887
- // ============================================================================
888
-
889
- let controllerInstance: EventController | null = null;
890
-
891
- /**
892
- * Initialize the global event controller
893
- */
894
- export function initEventController(
895
- config?: EventControllerConfig,
896
- ): EventController {
897
- if (!controllerInstance) {
898
- controllerInstance = createEventController(config);
899
- }
900
- return controllerInstance;
901
- }
902
-
903
- /**
904
- * Get the global event controller
905
- */
906
- export function getEventController(): EventController {
907
- if (!controllerInstance) {
908
- throw new Error(
909
- "Event controller not initialized. Call initEventController first.",
910
- );
911
- }
912
- return controllerInstance;
913
- }
914
-
915
- /**
916
- * Reset the controller instance (for testing)
917
- */
918
- export function resetEventController(): void {
919
- controllerInstance = null;
920
- }
@@ -1,10 +1,9 @@
1
1
  /**
2
2
  * A module-level handle to the active navigation store.
3
3
  *
4
- * The real boot path (`rsc-router.tsx`) calls `createNavigationStore()`
5
- * directly, so the `getNavigationStore()` singleton in `navigation-store.ts`
6
- * is never populated in a running app (it throws; only unit tests use it).
7
- * This handle is the live reference for code that needs the store but does not
4
+ * The boot path (`rsc-router.tsx`) calls `createNavigationStore()` directly;
5
+ * there is no global store singleton. This handle is the live reference for
6
+ * code that needs the store but does not
8
7
  * receive it by argument: the jar-divergence observer (below) and the client
9
8
  * seat of `invalidateClientCache()` (added later).
10
9
  *
@@ -813,42 +813,3 @@ export function createNavigationStore(
813
813
  },
814
814
  };
815
815
  }
816
-
817
- // Singleton store instance
818
- let storeInstance: NavigationStore | null = null;
819
-
820
- /**
821
- * Initialize the global navigation store
822
- *
823
- * Should be called once during app initialization.
824
- * Subsequent calls return the existing instance.
825
- */
826
- export function initNavigationStore(
827
- config?: NavigationStoreConfig,
828
- ): NavigationStore {
829
- if (!storeInstance) {
830
- storeInstance = createNavigationStore(config);
831
- }
832
- return storeInstance;
833
- }
834
-
835
- /**
836
- * Get the global navigation store
837
- *
838
- * Throws if store hasn't been initialized.
839
- */
840
- export function getNavigationStore(): NavigationStore {
841
- if (!storeInstance) {
842
- throw new Error(
843
- "Navigation store not initialized. Call initNavigationStore first.",
844
- );
845
- }
846
- return storeInstance;
847
- }
848
-
849
- /**
850
- * Reset the store instance (for testing)
851
- */
852
- export function resetNavigationStore(): void {
853
- storeInstance = null;
854
- }
@@ -13,7 +13,6 @@ import type { EventController, NavigationHandle } from "./event-controller.js";
13
13
  import { debugLog } from "./logging.js";
14
14
  import { buildHistoryState, pushHistoryWithIdx } from "./history-state.js";
15
15
 
16
- // Re-export for consumers that import from navigation-transaction
17
16
  export { resolveNavigationState } from "./history-state.js";
18
17
 
19
18
  /** Check if a history state object contains location state keys. */
@@ -25,7 +24,6 @@ function hasLocationState(state: unknown): boolean {
25
24
  );
26
25
  }
27
26
 
28
- // Polyfill Symbol.dispose for Safari and older browsers
29
27
  if (typeof Symbol.dispose === "undefined") {
30
28
  (Symbol as any).dispose = Symbol("Symbol.dispose");
31
29
  }
@@ -114,7 +112,6 @@ export function createNavigationTransaction(
114
112
  let committed = false;
115
113
  const currentUrl = window.location.href;
116
114
 
117
- // Start navigation in event controller (this sets loading state)
118
115
  const handle = eventController.startNavigation(url, options);
119
116
 
120
117
  /**
@@ -138,72 +135,50 @@ export function createNavigationTransaction(
138
135
 
139
136
  const parsedUrl = new URL(url, window.location.origin);
140
137
 
141
- // Generate history key from URL (with intercept suffix for separate caching)
142
138
  const historyKey = generateHistoryKey(url, { intercept });
143
139
 
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
140
  if (cacheOnly) {
147
141
  const currentHandleData = eventController.getHandleState().data;
148
142
  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
143
  handle.complete(parsedUrl);
152
144
  debugLog("[Browser] Cache-only commit, historyKey:", historyKey);
153
145
  return { scroll: false };
154
146
  }
155
147
 
156
- // Save current scroll position before navigating
157
148
  handleNavigationStart();
158
149
 
159
- // Update segment state atomically
160
150
  store.setSegmentIds(segmentIds);
161
151
  store.setCurrentUrl(url);
162
152
  store.setPath(parsedUrl.pathname);
163
153
 
164
154
  store.setHistoryKey(historyKey);
165
155
 
166
- // Cache segments with current handleData for this history entry
167
156
  const currentHandleData = eventController.getHandleState().data;
168
157
  store.cacheSegmentsForHistory(historyKey, segments, currentHandleData);
169
158
 
170
- // For server actions, skip URL/history updates but still complete navigation
171
159
  if (storeOnly) {
172
160
  debugLog("[Browser] Store updated (action)");
173
- // Complete navigation to clear loading state
174
161
  handle.complete(parsedUrl);
175
162
  return { scroll: false };
176
163
  }
177
164
 
178
- // Build history state - include user state, intercept info, and server-set state
179
165
  const historyState = buildHistoryState(
180
166
  opts.state,
181
167
  { intercept, sourceUrl: interceptSourceUrl },
182
168
  serverState,
183
169
  );
184
170
 
185
- // Snapshot old state before pushState/replaceState overwrites it.
186
- // Used to detect when location state is being cleared.
187
171
  const oldState = window.history.state;
188
172
 
189
- // Update browser URL (stamps history.state.idx for back() first-entry detection)
190
173
  pushHistoryWithIdx(historyState, url, replace ?? false);
191
- // Ensure new history entry has a scroll restoration key
192
174
  ensureHistoryKey();
193
175
 
194
- // Notify location state hooks when either old or new state carries
195
- // location state. This covers both "set new state" and "clear old state"
196
- // for same-page navigations where components don't remount.
197
176
  if (hasLocationState(oldState) || hasLocationState(historyState)) {
198
177
  window.dispatchEvent(new Event("__rsc_locationstate"));
199
178
  }
200
179
 
201
- // Complete the navigation in event controller (sets idle state, updates location)
202
180
  handle.complete(parsedUrl);
203
181
 
204
- // NOTE: Scroll is NOT handled here. The caller (partial-update.ts) handles
205
- // scroll AFTER onUpdate() so React has the new content before we scroll.
206
-
207
182
  debugLog(
208
183
  "[Browser] Navigation committed, historyKey:",
209
184
  historyKey,
@@ -217,10 +192,6 @@ export function createNavigationTransaction(
217
192
  handle,
218
193
  commit,
219
194
 
220
- /**
221
- * Create a bound transaction with pre-configured URL options
222
- * segmentIds and segments provided at commit time (after they're resolved)
223
- */
224
195
  with(
225
196
  opts: Omit<CommitOptions, "segmentIds" | "segments">,
226
197
  ): BoundTransaction {
@@ -264,13 +235,10 @@ export function createNavigationTransaction(
264
235
  },
265
236
 
266
237
  [Symbol.dispose]() {
267
- // Superseded: another navigation took over.
268
238
  if (handle.signal.aborted) {
269
239
  return;
270
240
  }
271
241
 
272
- // Failed (not committed): keep the target URL -- the error UI owns it.
273
- // Just reset the event controller to idle.
274
242
  if (!committed) {
275
243
  handle[Symbol.dispose]();
276
244
  }
@@ -21,23 +21,18 @@ import {
21
21
  import type { BoundTransaction } from "./navigation-transaction.js";
22
22
  import { ServerRedirect } from "../errors.js";
23
23
  import { debugLog } from "./logging.js";
24
- import { validateRedirectOrigin } from "./validate-redirect-origin.js";
24
+ import {
25
+ validateRedirectOrigin,
26
+ validateExternalRedirect,
27
+ } from "./validate-redirect-origin.js";
25
28
  import type { NavigationUpdate } from "./types.js";
26
29
 
27
- /** Build a scroll payload from the commit's scroll option */
28
30
  function toScrollPayload(
29
31
  scroll: boolean | undefined,
30
32
  ): NonNullable<NavigationUpdate["scroll"]> {
31
33
  return { enabled: scroll !== false ? scroll : false };
32
34
  }
33
35
 
34
- /**
35
- * Whether to wrap an update in startViewTransition.
36
- *
37
- * Intercept-driven updates only mutate the parallel slot — the main outlet
38
- * shows the same content — so transitions on the underlying main segments
39
- * shouldn't fire (otherwise their elements get hoisted above the modal).
40
- */
41
36
  function shouldStartViewTransition(segments: ResolvedSegment[]): boolean {
42
37
  let hasIntercept = false;
43
38
  let hasTransition = false;
@@ -112,15 +107,6 @@ export type PartialUpdater = (
112
107
  mode?: UpdateMode,
113
108
  ) => Promise<void>;
114
109
 
115
- /**
116
- * Create a partial updater for fetching and applying RSC partial updates
117
- *
118
- * This function is shared between navigation-bridge and server-action-bridge
119
- * to handle partial RSC updates with HMR resilience.
120
- *
121
- * @param config - Partial update configuration
122
- * @returns fetchPartialUpdate function
123
- */
124
110
  export function createPartialUpdater(
125
111
  config: PartialUpdateConfig,
126
112
  ): PartialUpdater {
@@ -132,21 +118,12 @@ export function createPartialUpdater(
132
118
  getVersion = () => undefined,
133
119
  } = config;
134
120
 
135
- /**
136
- * Get current page's cached segments as an array
137
- */
138
121
  function getCurrentCachedSegments(): ResolvedSegment[] {
139
122
  const currentKey = store.getHistoryKey();
140
123
  const cached = store.getCachedSegments(currentKey);
141
124
  return cached?.segments || [];
142
125
  }
143
126
 
144
- /**
145
- * Fetch partial update and trigger UI update
146
- *
147
- * @param tx - Transaction for committing segment state (required)
148
- * @param signal - AbortSignal to check if navigation is stale (not for aborting fetch)
149
- */
150
127
  async function fetchPartialUpdate(
151
128
  targetUrl: string,
152
129
  segmentIds: string[] | undefined,
@@ -158,20 +135,16 @@ export function createPartialUpdater(
158
135
  const segmentState = store.getSegmentState();
159
136
  const url = targetUrl || window.location.href;
160
137
 
161
- // Capture history key at start for stale revalidation consistency check
162
138
  const historyKeyAtStart = store.getHistoryKey();
163
139
 
164
140
  const interceptSourceUrl = mode.interceptSourceUrl;
165
141
 
166
- // When leaving intercept, filter out intercept-specific segments
167
142
  let segments: string[];
168
143
  if (mode.type === "leave-intercept") {
169
144
  const currentSegments = segmentIds ?? segmentState.currentSegmentIds;
170
145
  const currentCached = getCurrentCachedSegments();
171
146
  const interceptIds = new Set(
172
- currentCached
173
- .filter((s) => s.namespace?.startsWith("intercept:"))
174
- .map((s) => s.id),
147
+ currentCached.filter(isInterceptSegment).map((s) => s.id),
175
148
  );
176
149
  segments = currentSegments.filter((id) => !interceptIds.has(id));
177
150
  debugLog(
@@ -181,12 +154,6 @@ export function createPartialUpdater(
181
154
  segments = segmentIds ?? segmentState.currentSegmentIds;
182
155
  }
183
156
 
184
- // For intercept revalidation, use the intercept source URL as previousUrl.
185
- // For leave-intercept, tx.currentUrl captures window.location.href at tx
186
- // creation, which on popstate is already the destination URL and would
187
- // tell the server "from == to". segmentState.currentUrl still points at
188
- // the URL the cached segments render (the intercept URL), which is the
189
- // correct "from" for the server's diff computation.
190
157
  const previousUrl =
191
158
  mode.type === "leave-intercept"
192
159
  ? segmentState.currentUrl || tx.currentUrl
@@ -200,9 +167,6 @@ export function createPartialUpdater(
200
167
  debugLog(`[Browser] Intercept context from: ${interceptSourceUrl}`);
201
168
  }
202
169
 
203
- // Get cached segments for merging with server diff.
204
- // When navigating with targetCacheSegments, use those for consistency.
205
- // Otherwise fall back to current page's segments (for same-route revalidation).
206
170
  const targetCache =
207
171
  mode.type === "navigate" && mode.targetCacheSegments?.length
208
172
  ? mode.targetCacheSegments
@@ -213,22 +177,16 @@ export function createPartialUpdater(
213
177
  `[Browser] cachedSegs source: ${cachedSegsSource} (${cachedSegs.length} segments: ${cachedSegs.map((s) => s.id).join(", ")})`,
214
178
  );
215
179
 
216
- // Fetch partial payload (no abort signal - RSC doesn't support it well)
217
180
  let fetchResult: Awaited<ReturnType<NavigationClient["fetchPartial"]>>;
218
181
  fetchResult = await client.fetchPartial({
219
182
  targetUrl: url,
220
183
  segmentIds: segments,
221
184
  previousUrl,
222
- // Mark stale when explicitly requested OR when no segments are sent
223
- // (action redirect sends empty segments for a fresh render).
224
185
  staleRevalidation:
225
186
  mode.type === "stale-revalidation" || segments.length === 0,
226
187
  version: getVersion(),
227
188
  routerId: store.getRouterId?.(),
228
189
  });
229
- // Mark navigation as streaming (response received, now parsing RSC).
230
- // Called after fetchPartial so pendingUrl stays set during the network wait,
231
- // allowing useLinkStatus to show per-link pending indicators.
232
190
  const streamingToken = tx.startStreaming();
233
191
  const { payload, streamComplete: rawStreamComplete } = fetchResult;
234
192
  debugLog("payload.metadata", payload.metadata);
@@ -237,13 +195,6 @@ export function createPartialUpdater(
237
195
  streamingToken.end();
238
196
  });
239
197
 
240
- // Integrity guard (defense in depth). The server redirects on a cross-app
241
- // routerId mismatch (X-RSC-Reload), so a partial payload's routerId must
242
- // match this client's. If it doesn't — a stale/edge cache keyed without the
243
- // routerId, a proxy mixing app responses, or a server classification bug —
244
- // do NOT splice a foreign app's segments and client references into this
245
- // document. Force a full reload so the server re-establishes the
246
- // authoritative document for this URL.
247
198
  const currentRouterId = store.getRouterId?.();
248
199
  if (
249
200
  payload.metadata?.routerId &&
@@ -258,12 +209,29 @@ export function createPartialUpdater(
258
209
  return;
259
210
  }
260
211
 
261
- // Handle server-side redirect with state
262
212
  if (payload.metadata?.redirect) {
263
213
  if (signal?.aborted) {
264
214
  debugLog("[Browser] Ignoring stale redirect (aborted)");
265
215
  return;
266
216
  }
217
+ // Explicit off-host redirect (redirect(url, { external: true })):
218
+ // hard-navigate, but still scheme-validate (http/https only). external
219
+ // waives the same-origin check the app opted out of, NOT scheme safety, so
220
+ // a forged payload carrying a javascript:/data: URL cannot script via
221
+ // location.assign.
222
+ if (payload.metadata.redirect.external) {
223
+ const externalUrl = validateExternalRedirect(
224
+ payload.metadata.redirect.url,
225
+ window.location.origin,
226
+ );
227
+ if (!externalUrl) {
228
+ debugLog("[Browser] Ignoring blocked external redirect payload");
229
+ return;
230
+ }
231
+ debugLog("[Browser] External redirect (hard navigation)");
232
+ window.location.assign(externalUrl);
233
+ return;
234
+ }
267
235
  const redirectUrl = validateRedirectOrigin(
268
236
  payload.metadata.redirect.url,
269
237
  window.location.origin,
@@ -288,7 +256,6 @@ export function createPartialUpdater(
288
256
  debugLog(`[Browser] Partial update - matched: ${matched?.join(", ")}`);
289
257
  debugLog(`[Browser] Diff: ${diff?.join(", ")}`);
290
258
 
291
- // If diff is empty, nothing changed on server side.
292
259
  if (!diff || diff.length === 0) {
293
260
  const matchedIds = matched || [];
294
261
  const cacheMap = new Map(cachedSegs.map((s) => [s.id, s]));
@@ -296,7 +263,6 @@ export function createPartialUpdater(
296
263
  .map((id: string) => cacheMap.get(id))
297
264
  .filter(Boolean) as ResolvedSegment[];
298
265
 
299
- // When navigating with cached segments to a different route, render them.
300
266
  if (mode.type === "navigate" && targetCache) {
301
267
  debugLog(
302
268
  "[Browser] No diff but navigating with cached segments - rendering target route",
@@ -311,10 +277,6 @@ export function createPartialUpdater(
311
277
  existingSegments,
312
278
  );
313
279
 
314
- // tx.commit() cached the source page's handleData because
315
- // eventController hasn't been updated yet. Overwrite with the
316
- // correct cached handleData to prevent cache corruption on
317
- // subsequent navigations to this same URL.
318
280
  if (mode.targetCacheHandleData) {
319
281
  store.updateCacheHandleData(
320
282
  store.getHistoryKey(),
@@ -322,10 +284,6 @@ export function createPartialUpdater(
322
284
  );
323
285
  }
324
286
 
325
- // Include cachedHandleData in metadata so NavigationProvider can restore
326
- // breadcrumbs and other handle data from cache.
327
- // Remove `handles` from metadata to prevent NavigationProvider from
328
- // processing an empty handles stream, which would clear the cached breadcrumbs.
329
287
  const { handles: _unusedHandles, ...metadataWithoutHandles } =
330
288
  payload.metadata!;
331
289
  const cachedUpdate = {
@@ -352,7 +310,6 @@ export function createPartialUpdater(
352
310
  return;
353
311
  }
354
312
 
355
- // When leaving intercept, force re-render even with empty diff
356
313
  if (mode.type === "leave-intercept") {
357
314
  debugLog(
358
315
  "[Browser] Leaving intercept - forcing re-render to remove modal",
@@ -377,7 +334,6 @@ export function createPartialUpdater(
377
334
  return;
378
335
  }
379
336
 
380
- // Same route revalidation with no changes - skip UI update
381
337
  debugLog(
382
338
  "[Browser] No changes - all revalidations returned false, keeping existing UI",
383
339
  );
@@ -386,7 +342,6 @@ export function createPartialUpdater(
386
342
  return;
387
343
  }
388
344
 
389
- // Reconcile server segments with cached segments (single source of truth)
390
345
  const matchedIds = matched || [];
391
346
  const actor: ReconcileActor =
392
347
  mode.type === "stale-revalidation" || mode.type === "action"
@@ -402,7 +357,6 @@ export function createPartialUpdater(
402
357
  insertMissingDiff: true,
403
358
  });
404
359
 
405
- // HMR RESILIENCE: Check if we're missing any matched segments
406
360
  const reconciledIdSet = new Set(reconciled.segments.map((s) => s.id));
407
361
  const missingIds = matchedIds.filter(
408
362
  (id: string) => !reconciledIdSet.has(id),
@@ -430,7 +384,6 @@ export function createPartialUpdater(
430
384
  `[Browser] HMR detected: Missing ${missingCount} segments. Refetching all...`,
431
385
  );
432
386
 
433
- // Refetch with empty segments = server sends everything
434
387
  return fetchPartialUpdate(url, [], true, signal, tx, mode);
435
388
  }
436
389
 
@@ -439,7 +392,6 @@ export function createPartialUpdater(
439
392
  return;
440
393
  }
441
394
 
442
- // Rebuild tree on client (await for loader data resolution)
443
395
  const renderOptions = {
444
396
  isAction: mode.type === "action",
445
397
  forceAwait: mode.type === "stale-revalidation",
@@ -462,21 +414,15 @@ export function createPartialUpdater(
462
414
  ])
463
415
  : renderSegments(reconciled.mainSegments, renderOptions));
464
416
 
465
- // Final abort check before committing - another navigation may have started
466
417
  if (signal?.aborted) {
467
418
  debugLog("[Browser] Ignoring stale navigation (aborted before commit)");
468
419
  return;
469
420
  }
470
421
 
471
- // Check if this is an intercept response (any slot is active)
472
422
  const isInterceptResponse = hasActiveInterceptSlots(
473
423
  payload.metadata?.slots,
474
424
  );
475
425
 
476
- // Track intercept context (only on navigation, not actions or stale revalidation)
477
- // Use the authoritative source from mode/history state when restoring an
478
- // intercept via popstate cache miss; fall back to the current URL for fresh
479
- // intercept navigations.
480
426
  const effectiveInterceptSource =
481
427
  interceptSourceUrl || segmentState.currentUrl;
482
428
  if (mode.type !== "action" && mode.type !== "stale-revalidation") {
@@ -487,9 +433,6 @@ export function createPartialUpdater(
487
433
  }
488
434
  }
489
435
 
490
- // Commit navigation - use server's matched as the authoritative segment ID list.
491
- // reconciled.segments may be missing IDs (e.g., loader segments not in diff or cache)
492
- // but the server's matched always includes all expected segment IDs.
493
436
  const allSegmentIds = matchedIds;
494
437
  const serverLocationState = payload.metadata?.locationState;
495
438
  const overrides: CommitOverrides | undefined = isInterceptResponse
@@ -508,7 +451,6 @@ export function createPartialUpdater(
508
451
  overrides,
509
452
  );
510
453
 
511
- // For stale revalidation: verify history key hasn't changed before updating UI
512
454
  if (mode.type === "stale-revalidation") {
513
455
  const historyKeyNow = store.getHistoryKey();
514
456
  if (historyKeyNow !== historyKeyAtStart) {
@@ -521,8 +463,6 @@ export function createPartialUpdater(
521
463
 
522
464
  debugLog("[partial-update] updating document");
523
465
 
524
- // Emit update to trigger React render.
525
- // Scroll info is included so NavigationProvider applies it after React commits.
526
466
  const hasTransition = shouldStartViewTransition(reconciled.segments);
527
467
  const scrollPayload = toScrollPayload(navScroll);
528
468
 
@@ -559,7 +499,6 @@ export function createPartialUpdater(
559
499
  debugLog("[Browser] Navigation complete");
560
500
  return;
561
501
  } else {
562
- // Full update (fallback)
563
502
  console.warn(`[Browser] Full update (fallback)`);
564
503
 
565
504
  const segments = payload.metadata?.segments || [];