@rangojs/router 0.26.6 → 0.28.0

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 (234) hide show
  1. package/dist/bin/rango.js +1 -1
  2. package/dist/types/__internal.d.ts +7 -1
  3. package/dist/types/browser/action-coordinator.d.ts +0 -4
  4. package/dist/types/browser/action-fence.d.ts +11 -3
  5. package/dist/types/browser/event-controller.d.ts +20 -1
  6. package/dist/types/browser/history-state.d.ts +7 -9
  7. package/dist/types/browser/intercept-utils.d.ts +23 -0
  8. package/dist/types/browser/navigation-store.d.ts +8 -6
  9. package/dist/types/browser/react/filter-segment-order.d.ts +2 -2
  10. package/dist/types/browser/rsc-router.d.ts +1 -1
  11. package/dist/types/browser/segment-reconciler.d.ts +6 -4
  12. package/dist/types/browser/types.d.ts +2 -1
  13. package/dist/types/browser/wire-protocol.d.ts +25 -0
  14. package/dist/types/cache/cache-key-utils.d.ts +11 -5
  15. package/dist/types/cache/cache-scope.d.ts +34 -22
  16. package/dist/types/cache/cf/cf-cache-constants.d.ts +2 -2
  17. package/dist/types/cache/cf/cf-cache-store.d.ts +47 -65
  18. package/dist/types/cache/cf/cf-kv-utils.d.ts +1 -1
  19. package/dist/types/cache/handle-snapshot.d.ts +9 -4
  20. package/dist/types/cache/segment-codec.d.ts +14 -0
  21. package/dist/types/cache/shell-snapshot.d.ts +1 -1
  22. package/dist/types/cache/tag-invalidation.d.ts +10 -0
  23. package/dist/types/cache/taint.d.ts +5 -3
  24. package/dist/types/cache/types.d.ts +7 -3
  25. package/dist/types/cache/vercel/vercel-cache-store.d.ts +8 -6
  26. package/dist/types/prefetch-markers.d.ts +4 -6
  27. package/dist/types/prerender/store.d.ts +31 -0
  28. package/dist/types/prerender/warm-request.d.ts +3 -3
  29. package/dist/types/prerender.d.ts +2 -2
  30. package/dist/types/route-name.d.ts +5 -0
  31. package/dist/types/router/error-handling.d.ts +4 -4
  32. package/dist/types/router/instrument.d.ts +4 -3
  33. package/dist/types/router/intercept-resolution.d.ts +25 -9
  34. package/dist/types/router/match-context.d.ts +12 -11
  35. package/dist/types/router/match-handlers.d.ts +2 -6
  36. package/dist/types/router/match-middleware/background-revalidation.d.ts +10 -20
  37. package/dist/types/router/match-middleware/cache-lookup.d.ts +35 -87
  38. package/dist/types/router/match-middleware/cache-store.d.ts +2 -2
  39. package/dist/types/router/match-middleware/segment-resolution.d.ts +8 -6
  40. package/dist/types/router/prerender-match.d.ts +21 -6
  41. package/dist/types/router/preview-match.d.ts +2 -7
  42. package/dist/types/router/request-classification.d.ts +5 -0
  43. package/dist/types/router/revalidation.d.ts +6 -1
  44. package/dist/types/router/router-context.d.ts +9 -63
  45. package/dist/types/router/router-interfaces.d.ts +8 -14
  46. package/dist/types/router/segment-resolution/loader-cache.d.ts +3 -3
  47. package/dist/types/router/segment-resolution/loader-mask.d.ts +4 -4
  48. package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +20 -18
  49. package/dist/types/router/segment-resolution/resolution-pass.d.ts +93 -0
  50. package/dist/types/router/segment-resolution/revalidation.d.ts +82 -31
  51. package/dist/types/router/segment-resolution.d.ts +1 -2
  52. package/dist/types/router/segment-wrappers.d.ts +11 -48
  53. package/dist/types/router/telemetry.d.ts +2 -0
  54. package/dist/types/router/types.d.ts +10 -0
  55. package/dist/types/rsc/full-payload.d.ts +2 -2
  56. package/dist/types/rsc/handler-context.d.ts +6 -6
  57. package/dist/types/rsc/json-route-result.d.ts +5 -9
  58. package/dist/types/rsc/request-stages.d.ts +203 -0
  59. package/dist/types/rsc/response-cache-serve.d.ts +7 -12
  60. package/dist/types/rsc/response-route-handler.d.ts +2 -2
  61. package/dist/types/rsc/routine-plan.d.ts +1 -1
  62. package/dist/types/rsc/server-action.d.ts +2 -2
  63. package/dist/types/rsc/shell-capture-constants.d.ts +2 -2
  64. package/dist/types/rsc/shell-capture.d.ts +132 -15
  65. package/dist/types/rsc/shell-serve.d.ts +37 -12
  66. package/dist/types/server/context.d.ts +9 -8
  67. package/dist/types/server/handle-store.d.ts +16 -0
  68. package/dist/types/server/render-pass.d.ts +72 -0
  69. package/dist/types/server/request-context.d.ts +24 -104
  70. package/dist/types/server/rewrite-trail.d.ts +2 -2
  71. package/dist/types/testing/dispatch.d.ts +35 -18
  72. package/dist/types/testing/serve-shell-request.d.ts +7 -0
  73. package/dist/types/types/cache-types.d.ts +6 -3
  74. package/dist/types/types/error-types.d.ts +9 -1
  75. package/dist/types/types/handler-context.d.ts +18 -7
  76. package/dist/types/types/segments.d.ts +1 -1
  77. package/dist/types/vite/discovery/build-discovery-phase.d.ts +40 -0
  78. package/dist/types/vite/discovery/build-env.d.ts +22 -0
  79. package/dist/types/vite/discovery/bundle-scan-phase.d.ts +12 -0
  80. package/dist/types/vite/discovery/dev-prerender-cache.d.ts +3 -2
  81. package/dist/types/vite/discovery/dev-server-phase.d.ts +26 -0
  82. package/dist/types/vite/discovery/discovery-errors.d.ts +1 -1
  83. package/dist/types/vite/discovery/state.d.ts +12 -0
  84. package/dist/types/vite/plugins/version-plugin.d.ts +1 -1
  85. package/dist/types/vite/plugins/virtual-entries.d.ts +1 -1
  86. package/dist/types/vite/router-discovery.d.ts +8 -47
  87. package/dist/types/vite/temp-rsc-server.d.ts +39 -0
  88. package/dist/types/vite/utils/prerender-utils.d.ts +9 -0
  89. package/dist/types/wire-params.d.ts +34 -0
  90. package/dist/vite/index.js +3809 -3710
  91. package/package.json +1 -1
  92. package/skills/cache-guide/SKILL.md +8 -5
  93. package/skills/caching/SKILL.md +37 -18
  94. package/skills/client-urls/SKILL.md +2 -1
  95. package/skills/document-cache/SKILL.md +18 -1
  96. package/skills/handler-use/SKILL.md +1 -1
  97. package/skills/intercept/SKILL.md +53 -13
  98. package/skills/layout/SKILL.md +11 -7
  99. package/skills/loader/SKILL.md +30 -13
  100. package/skills/middleware/SKILL.md +19 -1
  101. package/skills/migrate-nextjs/SKILL.md +5 -4
  102. package/skills/parallel/SKILL.md +5 -4
  103. package/skills/ppr/SKILL.md +19 -3
  104. package/skills/prerender/SKILL.md +39 -15
  105. package/skills/rango/SKILL.md +23 -11
  106. package/skills/server-actions/SKILL.md +51 -6
  107. package/skills/testing/predicates.md +1 -1
  108. package/skills/testing/response-routes.md +6 -4
  109. package/skills/testing/serve-shell-request.md +7 -7
  110. package/skills/use-cache/SKILL.md +46 -8
  111. package/skills/ux-architecture/SKILL.md +3 -2
  112. package/src/__internal.ts +7 -1
  113. package/src/browser/action-coordinator.ts +2 -13
  114. package/src/browser/action-fence.ts +11 -3
  115. package/src/browser/connection-warmup.ts +3 -1
  116. package/src/browser/event-controller.ts +42 -3
  117. package/src/browser/history-state.ts +15 -13
  118. package/src/browser/intercept-utils.ts +51 -0
  119. package/src/browser/navigation-bridge.ts +34 -30
  120. package/src/browser/navigation-client.ts +21 -33
  121. package/src/browser/navigation-store.ts +25 -27
  122. package/src/browser/navigation-transaction.ts +7 -11
  123. package/src/browser/partial-update.ts +8 -0
  124. package/src/browser/prefetch/cache.ts +2 -1
  125. package/src/browser/prefetch/fetch.ts +8 -16
  126. package/src/browser/prefetch/queue.ts +4 -4
  127. package/src/browser/react/NavigationProvider.tsx +2 -1
  128. package/src/browser/react/filter-segment-order.ts +2 -2
  129. package/src/browser/rsc-router.tsx +17 -2
  130. package/src/browser/segment-reconciler.ts +66 -53
  131. package/src/browser/server-action-bridge.ts +265 -127
  132. package/src/browser/types.ts +2 -1
  133. package/src/browser/wire-protocol.ts +41 -0
  134. package/src/cache/cache-key-utils.ts +11 -5
  135. package/src/cache/cache-runtime.ts +110 -50
  136. package/src/cache/cache-scope.ts +110 -64
  137. package/src/cache/cf/cf-cache-constants.ts +2 -2
  138. package/src/cache/cf/cf-cache-store.ts +486 -874
  139. package/src/cache/cf/cf-kv-utils.ts +1 -1
  140. package/src/cache/document-cache.ts +42 -15
  141. package/src/cache/handle-snapshot.ts +19 -7
  142. package/src/cache/segment-codec.ts +55 -0
  143. package/src/cache/shell-snapshot.ts +2 -2
  144. package/src/cache/tag-invalidation.ts +21 -2
  145. package/src/cache/taint.ts +5 -3
  146. package/src/cache/types.ts +7 -3
  147. package/src/cache/vercel/vercel-cache-store.ts +243 -310
  148. package/src/handles/pushed-by.ts +1 -1
  149. package/src/prefetch-markers.ts +4 -7
  150. package/src/prerender/store.ts +76 -0
  151. package/src/prerender/warm-request.ts +8 -4
  152. package/src/prerender.ts +2 -2
  153. package/src/route-name.ts +8 -0
  154. package/src/router/error-handling.ts +4 -4
  155. package/src/router/instrument.ts +4 -3
  156. package/src/router/intercept-resolution.ts +77 -75
  157. package/src/router/loader-resolution.ts +5 -4
  158. package/src/router/match-api.ts +4 -5
  159. package/src/router/match-context.ts +33 -12
  160. package/src/router/match-handlers.ts +8 -20
  161. package/src/router/match-middleware/background-revalidation.ts +41 -53
  162. package/src/router/match-middleware/cache-lookup.ts +680 -720
  163. package/src/router/match-middleware/cache-store.ts +14 -12
  164. package/src/router/match-middleware/intercept-resolution.ts +3 -23
  165. package/src/router/match-middleware/segment-resolution.ts +34 -61
  166. package/src/router/match-result.ts +1 -20
  167. package/src/router/middleware.ts +69 -11
  168. package/src/router/navigation-snapshot.ts +4 -3
  169. package/src/router/prerender-match.ts +100 -44
  170. package/src/router/preview-match.ts +6 -41
  171. package/src/router/request-classification.ts +13 -7
  172. package/src/router/revalidation.ts +33 -7
  173. package/src/router/route-snapshot.ts +2 -5
  174. package/src/router/router-context.ts +14 -117
  175. package/src/router/router-interfaces.ts +8 -14
  176. package/src/router/segment-resolution/loader-cache.ts +14 -9
  177. package/src/router/segment-resolution/loader-mask.ts +5 -5
  178. package/src/router/segment-resolution/prefetch-deferral.ts +30 -27
  179. package/src/router/segment-resolution/resolution-pass.ts +137 -0
  180. package/src/router/segment-resolution/revalidation.ts +897 -776
  181. package/src/router/segment-resolution.ts +4 -13
  182. package/src/router/segment-wrappers.ts +51 -269
  183. package/src/router/telemetry.ts +5 -0
  184. package/src/router/types.ts +10 -0
  185. package/src/router.ts +12 -13
  186. package/src/rsc/full-payload.ts +6 -5
  187. package/src/rsc/handler-context.ts +8 -14
  188. package/src/rsc/handler.ts +126 -507
  189. package/src/rsc/json-route-result.ts +5 -9
  190. package/src/rsc/loader-fetch.ts +6 -5
  191. package/src/rsc/progressive-enhancement.ts +5 -0
  192. package/src/rsc/request-stages.ts +757 -0
  193. package/src/rsc/response-cache-serve.ts +10 -14
  194. package/src/rsc/response-route-handler.ts +13 -5
  195. package/src/rsc/rewrite-passes.ts +2 -1
  196. package/src/rsc/routine-plan.ts +1 -1
  197. package/src/rsc/rsc-rendering.ts +172 -349
  198. package/src/rsc/server-action.ts +87 -113
  199. package/src/rsc/shell-capture-constants.ts +3 -1
  200. package/src/rsc/shell-capture.ts +212 -169
  201. package/src/rsc/shell-serve.ts +121 -18
  202. package/src/rsc/ssr-setup.ts +5 -4
  203. package/src/segment-system.tsx +3 -3
  204. package/src/server/context.ts +22 -25
  205. package/src/server/handle-store.ts +55 -0
  206. package/src/server/render-pass.ts +291 -0
  207. package/src/server/request-context.ts +26 -115
  208. package/src/server/rewrite-trail.ts +6 -4
  209. package/src/ssr/index.tsx +1 -1
  210. package/src/ssr/ssr-root.tsx +1 -0
  211. package/src/testing/dispatch.ts +223 -710
  212. package/src/testing/e2e/page-helpers.ts +2 -2
  213. package/src/testing/serve-shell-request.ts +37 -32
  214. package/src/types/cache-types.ts +6 -3
  215. package/src/types/error-types.ts +9 -1
  216. package/src/types/handler-context.ts +21 -7
  217. package/src/types/segments.ts +1 -1
  218. package/src/vite/discovery/build-discovery-phase.ts +204 -0
  219. package/src/vite/discovery/build-env.ts +148 -0
  220. package/src/vite/discovery/bundle-scan-phase.ts +128 -0
  221. package/src/vite/discovery/dev-prerender-cache.ts +8 -3
  222. package/src/vite/discovery/dev-server-phase.ts +1776 -0
  223. package/src/vite/discovery/discover-routers.ts +1 -1
  224. package/src/vite/discovery/discovery-errors.ts +1 -1
  225. package/src/vite/discovery/prerender-collection.ts +24 -39
  226. package/src/vite/discovery/state.ts +15 -0
  227. package/src/vite/plugins/version-plugin.ts +1 -1
  228. package/src/vite/plugins/virtual-entries.ts +1 -1
  229. package/src/vite/router-discovery.ts +29 -2428
  230. package/src/vite/temp-rsc-server.ts +223 -0
  231. package/src/vite/utils/prerender-utils.ts +47 -0
  232. package/src/wire-params.ts +35 -0
  233. package/dist/types/router/segment-resolution/fresh.d.ts +0 -72
  234. package/src/router/segment-resolution/fresh.ts +0 -945
@@ -52,6 +52,18 @@ export interface NavigationEntry {
52
52
  phase: NavigationPhase;
53
53
  startedAt: number;
54
54
  options?: NavigateOptionsInternal & { skipLoadingState?: boolean };
55
+ /**
56
+ * Resolves once the navigation has ended: its transaction ended and every
57
+ * stream it opened closed. True when it committed, false when it ended
58
+ * without a commit (a failure) or was left (aborted, which settles it at
59
+ * once). A `prefetch: false` fill holds the transaction open
60
+ * (partial-update.ts awaits fillHoles before it resolves) and its stream
61
+ * until the fill's answer streamed, so a waiter never cuts a fill or a
62
+ * stream short. Only the action bridge's follow-up awaits it
63
+ * (server-action-bridge.ts followUp); no hook reads it, and settling it
64
+ * notifies nothing.
65
+ */
66
+ settled: Promise<boolean>;
55
67
  }
56
68
 
57
69
  /**
@@ -246,7 +258,8 @@ export interface EventController {
246
258
  /** Originating history entry key; scopes location-state arbitration. */
247
259
  cohort?: string,
248
260
  ): ActionHandle;
249
- abortAllActions(): void;
261
+ /** Abort every action in flight except `except` (an ActionHandle id). */
262
+ abortAllActions(except?: string): void;
250
263
 
251
264
  // State access
252
265
  getState(): DerivedNavigationState;
@@ -351,6 +364,12 @@ export interface EventController {
351
364
 
352
365
  // Direct state access for advanced use
353
366
  getCurrentNavigation(): NavigationEntry | null;
367
+ /**
368
+ * The newest navigation until it settles (NavigationEntry.settled): before
369
+ * its commit, and after it while its transaction or a stream is still
370
+ * open. Null once it settled or was left.
371
+ */
372
+ getUnsettledNavigation(): NavigationEntry | null;
354
373
  getInflightActions(): Map<string, ActionEntry>;
355
374
  /** Whether any concurrent actions have occurred (shared across all handles) */
356
375
  hadAnyConcurrentActions(): boolean;
@@ -513,6 +532,9 @@ export function createEventController(
513
532
  // links a request to its signal only until then): the navigation stops
514
533
  // reading it, and it streams to its end.
515
534
  let streamingNavigation: AbortController | null = null;
535
+ // The newest navigation until it settles (getUnsettledNavigation). Not
536
+ // derived state: no hook reads it, and moving it notifies nothing.
537
+ let unsettledNavigation: NavigationEntry | null = null;
516
538
 
517
539
  const inflightActions = new Map<string, ActionEntry>();
518
540
 
@@ -743,19 +765,31 @@ export function createEventController(
743
765
  }
744
766
 
745
767
  const abort = new AbortController();
768
+ let resolveSettled!: (committed: boolean) => void;
746
769
  const entry: NavigationEntry = {
747
770
  url,
748
771
  abort,
749
772
  phase: "fetching",
750
773
  startedAt: Date.now(),
751
774
  options,
775
+ settled: new Promise((resolve) => (resolveSettled = resolve)),
776
+ };
777
+ const settle = (committed: boolean): void => {
778
+ if (unsettledNavigation === entry) unsettledNavigation = null;
779
+ resolveSettled(committed);
752
780
  };
781
+ abort.signal.addEventListener("abort", () => settle(false));
753
782
 
754
783
  currentNavigation = entry;
784
+ unsettledNavigation = entry;
755
785
  notify();
756
786
 
757
787
  let completed = false;
788
+ let disposed = false;
758
789
  let openStreams = 0;
790
+ const settleIfEnded = (): void => {
791
+ if (disposed && openStreams === 0) settle(completed);
792
+ };
759
793
 
760
794
  return {
761
795
  abort,
@@ -780,6 +814,7 @@ export function createEventController(
780
814
  if (--openStreams === 0 && streamingNavigation === abort) {
781
815
  streamingNavigation = null;
782
816
  }
817
+ settleIfEnded();
783
818
  notify();
784
819
  },
785
820
  };
@@ -797,6 +832,8 @@ export function createEventController(
797
832
 
798
833
  // Disposable: cleanup if not completed (e.g., error thrown)
799
834
  [Symbol.dispose]() {
835
+ disposed = true;
836
+ settleIfEnded();
800
837
  // If aborted by another navigation, don't touch state
801
838
  if (abort.signal.aborted) return;
802
839
 
@@ -1072,13 +1109,14 @@ export function createEventController(
1072
1109
  };
1073
1110
  }
1074
1111
 
1075
- function abortAllActions() {
1112
+ /** Abort every action in flight except `except` (an ActionHandle id). */
1113
+ function abortAllActions(except?: string) {
1076
1114
  for (const [id, entry] of inflightActions) {
1077
1115
  // Preserve settling entries — they have already been handled by
1078
1116
  // fail()/complete() and will self-cleanup via the settlement timeout.
1079
1117
  // Clearing them here would prevent debounced notifications from
1080
1118
  // delivering the error/result state to subscribers.
1081
- if (entry.phase === "settling") continue;
1119
+ if (entry.phase === "settling" || id === except) continue;
1082
1120
  entry.abort.abort();
1083
1121
  inflightActions.delete(id);
1084
1122
  }
@@ -1287,6 +1325,7 @@ export function createEventController(
1287
1325
 
1288
1326
  // Direct access
1289
1327
  getCurrentNavigation: () => currentNavigation,
1328
+ getUnsettledNavigation: () => unsettledNavigation,
1290
1329
  getInflightActions: () => inflightActions,
1291
1330
  hadAnyConcurrentActions: () => hadAnyConcurrentActions,
1292
1331
  };
@@ -9,6 +9,7 @@ import {
9
9
  resolveLocationStateEntries,
10
10
  stampLocationState,
11
11
  } from "./react/location-state-shared.js";
12
+ import { WIRE } from "../wire-params.js";
12
13
 
13
14
  /**
14
15
  * Check if state is from typed LocationStateEntry[] (has __rsc_ls_ keys)
@@ -137,16 +138,9 @@ export function pushHistoryWithIdx(
137
138
  }
138
139
 
139
140
  /**
140
- * The forced-MISS reload marker (`SHELL_MISS_PARAM` in rsc/shell-serve.ts,
141
- * which the browser bundle must not import): a degraded PPR shell HIT
142
- * reloads the page with it, and the server renders a marked request like a
143
- * cache miss.
144
- */
145
- export const SHELL_MISS_MARKER: string = "_rsc_shell";
146
-
147
- /**
148
- * Drop the forced-MISS marker from the address bar at boot, keeping the
149
- * history entry's state (initBrowserApp calls it before any location read).
141
+ * Drop the forced-MISS marker (`WIRE.shellMiss`, rsc/shell-serve.ts) from the
142
+ * address bar at boot, keeping the history entry's state (initBrowserApp
143
+ * calls it before any location read).
150
144
  * Left in place, a refresh or a shared link would stay off the shell and the
151
145
  * app's search params would carry it. The server strips the same marker from
152
146
  * the request before rendering (shell-serve.ts withoutShellMissMarker), so
@@ -154,8 +148,8 @@ export const SHELL_MISS_MARKER: string = "_rsc_shell";
154
148
  */
155
149
  export function stripShellMissMarker(): void {
156
150
  const url = new URL(window.location.href);
157
- if (!url.searchParams.has(SHELL_MISS_MARKER)) return;
158
- url.searchParams.delete(SHELL_MISS_MARKER);
151
+ if (!url.searchParams.has(WIRE.shellMiss)) return;
152
+ url.searchParams.delete(WIRE.shellMiss);
159
153
  window.history.replaceState(window.history.state, "", url.href);
160
154
  }
161
155
 
@@ -169,13 +163,21 @@ export function stripShellMissMarker(): void {
169
163
  *
170
164
  * A slot is recognised by its key alone (the `~r` suffix): at start-up the
171
165
  * module that defines it may not be loaded.
166
+ *
167
+ * The same pass drops the entry's intercept marks (`intercept`,
168
+ * `sourceUrl`): a document request never renders an intercept, so the entry
169
+ * is a full page for later Back/Forward.
172
170
  */
173
171
  export function clearLocationStateOnDocumentLoad(): void {
174
172
  const state: unknown = window.history.state;
175
173
  if (state === null || typeof state !== "object") return;
176
174
  let next: Record<string, unknown> | undefined;
177
175
  for (const key in state) {
178
- if (isLocationStateKey(key) && key.endsWith(CLEAR_ON_RELOAD_KEY_SUFFIX)) {
176
+ if (
177
+ (isLocationStateKey(key) && key.endsWith(CLEAR_ON_RELOAD_KEY_SUFFIX)) ||
178
+ key === "intercept" ||
179
+ key === "sourceUrl"
180
+ ) {
179
181
  next ??= { ...state };
180
182
  delete next[key];
181
183
  }
@@ -1,5 +1,7 @@
1
1
  import type { ResolvedSegment } from "./types.js";
2
2
  import type { SlotState } from "../types.js";
3
+ import { isPlaceholder } from "../types/segments.js";
4
+ import { loaderParentId } from "./react/filter-segment-order.js";
3
5
 
4
6
  /**
5
7
  * Check if a segment is an intercept segment.
@@ -51,3 +53,52 @@ export function hasActiveIntercept(slots?: Record<string, SlotState>): boolean {
51
53
  export function isInterceptOnlyCache(segments: ResolvedSegment[]): boolean {
52
54
  return segments.some(isInterceptSegment);
53
55
  }
56
+
57
+ /**
58
+ * An intercept's `matched`: the client's ids without any of the slot's (its
59
+ * id and every loader segment on it, another intercept's into the same slot
60
+ * included), then the slot's (interceptSegmentIds). One order for the
61
+ * prefetch, its fill, an action and a revisit: a fill whose `matched` differs
62
+ * from its tree's is rendered as a new tree (browser/partial-update.ts
63
+ * fillHoles). Shared by router/match-result.ts and wholeInterceptMatched.
64
+ */
65
+ export function interceptMatched(
66
+ clientIds: readonly string[],
67
+ slotIds: readonly string[],
68
+ ): string[] {
69
+ // loaderParentId leaves a non-loader id as it is: the slot's own id goes too.
70
+ return [
71
+ ...clientIds.filter((id) => loaderParentId(id) !== slotIds[0]),
72
+ ...slotIds,
73
+ ];
74
+ }
75
+
76
+ /**
77
+ * The `matched` the browser keeps for an intercept answer to a request that
78
+ * held no segments (a followed redirect, a Back/Forward revalidation with
79
+ * handles pending, the dev HMR refetch), or undefined to keep the answer's.
80
+ * The server never renders the page under an intercept and lists it only
81
+ * from the ids a request held, so such an answer has an empty outlet under
82
+ * the slot. The page it lands over (`held`) stays, as the server lists it for
83
+ * a request that holds it.
84
+ */
85
+ export function wholeInterceptMatched(
86
+ held: readonly ResolvedSegment[],
87
+ answer: {
88
+ matched?: string[];
89
+ segments?: ResolvedSegment[];
90
+ slots?: Record<string, SlotState>;
91
+ },
92
+ ): string[] | undefined {
93
+ if (!hasActiveIntercept(answer.slots)) return undefined;
94
+ const heldIds = held.filter((s) => !isPlaceholder(s)).map((s) => s.id);
95
+ if (heldIds.length === 0) return undefined;
96
+ const slot = answer.segments?.find(
97
+ (s) => isInterceptSegment(s) && s.type !== "loader",
98
+ );
99
+ if (!slot) return undefined;
100
+ const slotLoaderIds = (answer.matched ?? []).filter(
101
+ (id) => id !== slot.id && loaderParentId(id) === slot.id,
102
+ );
103
+ return interceptMatched(heldIds, [slot.id, ...slotLoaderIds]);
104
+ }
@@ -273,15 +273,18 @@ export function createNavigationBridge(
273
273
  targetUrl.pathname === new URL(window.location.href).pathname
274
274
  ) {
275
275
  // Preserve intercept context from the current history entry so that
276
- // popstate uses the correct cache key (:intercept suffix) and restores
277
- // the right full-page vs modal semantics.
276
+ // popstate uses the same cache key and restores the right full-page
277
+ // vs intercept semantics.
278
278
  const currentHistoryState = window.history.state;
279
279
  const isIntercept = currentHistoryState?.intercept === true;
280
280
  const interceptSourceUrl = isIntercept
281
281
  ? currentHistoryState?.sourceUrl
282
282
  : undefined;
283
283
 
284
- const historyKey = generateHistoryKey(url, { intercept: isIntercept });
284
+ const historyKey = generateHistoryKey(url, {
285
+ intercept: isIntercept,
286
+ interceptSource: interceptSourceUrl,
287
+ });
285
288
 
286
289
  // Copy current segments to the new history key so back/forward restores instantly.
287
290
  // The array itself, not a clone: getPageHistoryKeys reads a shared
@@ -405,12 +408,6 @@ export function createNavigationBridge(
405
408
  });
406
409
  }
407
410
 
408
- // Also check if there's an intercept cache entry for this URL
409
- // If so, this URL CAN be intercepted, and we shouldn't use the non-intercept cache
410
- // because the navigation might result in an intercept (depending on source URL)
411
- const interceptHistoryKey = generateHistoryKey(url, { intercept: true });
412
- const hasInterceptCache = store.hasHistoryCache(interceptHistoryKey);
413
-
414
411
  // Skip cached SWR for:
415
412
  // 1. intercept caches - interception depends on source page context
416
413
  // 2. routes that CAN be intercepted - we don't know if this navigation will intercept
@@ -420,10 +417,12 @@ export function createNavigationBridge(
420
417
  const hasUsableCache =
421
418
  isRestorable(cachedSegments) &&
422
419
  !isInterceptOnlyCache(cachedSegments) &&
423
- !hasInterceptCache &&
424
420
  !isLeavingIntercept &&
425
421
  !cached?.stale &&
426
- !options?._skipCache;
422
+ !options?._skipCache &&
423
+ // A cached intercept entry for this URL (from any source page) means
424
+ // the navigation might intercept, depending on the source URL.
425
+ !store.hasInterceptCache(historyKey);
427
426
 
428
427
  // Forward navigations always await fetchPartialUpdate before rendering,
429
428
  // so useNavigation should always report "loading". skipLoadingState is
@@ -643,16 +642,17 @@ export function createNavigationBridge(
643
642
  const isIntercept = historyState?.intercept === true;
644
643
  const interceptSourceUrl = historyState?.sourceUrl;
645
644
 
646
- // Check if intercept context is changing (same URL, different intercept state)
647
- // If so, abort in-flight actions - their results would be for wrong context
645
+ // Actions in flight keep running across an intercept change, as across
646
+ // any navigation (server-action-bridge.ts followUp).
648
647
  const currentInterceptSource = store.getInterceptSourceUrl();
649
- const newInterceptSource = interceptSourceUrl ?? null;
650
- if (currentInterceptSource !== newInterceptSource) {
651
- debugLog(
652
- `[Browser] Intercept context changing (${currentInterceptSource} -> ${newInterceptSource}), aborting in-flight actions`,
653
- );
654
- eventController.abortAllActions();
655
- }
648
+ // An intercept is fetched only from its source page (the page under
649
+ // an open intercept counts as on screen). From any other page the
650
+ // entry loads as a document, which renders its URL's full page.
651
+ const loadsDocument =
652
+ isIntercept &&
653
+ generateHistoryKey(
654
+ currentInterceptSource ?? store.getSegmentState().currentUrl,
655
+ ) !== generateHistoryKey(interceptSourceUrl);
656
656
 
657
657
  // Popstate that exits an intercept to a non-intercept destination. The
658
658
  // fallback fetch path below needs `leave-intercept` mode so it filters
@@ -663,8 +663,10 @@ export function createNavigationBridge(
663
663
  const isLeavingIntercept =
664
664
  !isIntercept && currentInterceptSource !== null;
665
665
 
666
- // Compute history key from URL (with intercept suffix if applicable)
667
- const historyKey = generateHistoryKey(url, { intercept: isIntercept });
666
+ const historyKey = generateHistoryKey(url, {
667
+ intercept: isIntercept,
668
+ interceptSource: interceptSourceUrl,
669
+ });
668
670
 
669
671
  debugLog(
670
672
  "[Browser] Popstate -",
@@ -673,13 +675,6 @@ export function createNavigationBridge(
673
675
  historyKey,
674
676
  );
675
677
 
676
- // If this is an intercept, restore the intercept context
677
- if (isIntercept && interceptSourceUrl) {
678
- store.setInterceptSourceUrl(interceptSourceUrl);
679
- } else {
680
- store.setInterceptSourceUrl(null);
681
- }
682
-
683
678
  // Helper to check if streaming is in progress
684
679
  const isStreaming = () => eventController.getState().isStreaming;
685
680
 
@@ -701,6 +696,7 @@ export function createNavigationBridge(
701
696
  const isStale = (cached?.stale ?? false) || isActionFenceActive();
702
697
 
703
698
  if (isRestorable(cachedSegments)) {
699
+ store.setInterceptSourceUrl(interceptSourceUrl ?? null);
704
700
  // transition({ when }) decides the restore (kind "pop") against the
705
701
  // entry being left, so before the store moves to the restored entry.
706
702
  // The decision the entry was committed with is not reused: it was
@@ -872,7 +868,15 @@ export function createNavigationBridge(
872
868
  debugLog("[Browser] History cache miss for key:", historyKey);
873
869
  }
874
870
 
875
- // Fetch if not cached
871
+ // Fetch if not cached. The document load starts at the top: pagehide
872
+ // saves the scroll on screen under this entry, and the browser
873
+ // restores it after the reload. Instant, so `scroll-behavior: smooth`
874
+ // cannot leave it part-way when pagehide saves it.
875
+ if (loadsDocument) {
876
+ window.scrollTo({ top: 0, behavior: "instant" });
877
+ return window.location.reload();
878
+ }
879
+ store.setInterceptSourceUrl(interceptSourceUrl ?? null);
876
880
  const tx = createNavigationTransaction(store, eventController, url, {
877
881
  replace: true,
878
882
  });
@@ -38,7 +38,7 @@ import {
38
38
  type DecodedPrefetch,
39
39
  } from "./prefetch/cache.js";
40
40
  import { cancelAllPrefetches } from "./prefetch/loader.js";
41
- import { FILL_PARAM } from "../prefetch-markers.js";
41
+ import { writePartialParams } from "./wire-protocol.js";
42
42
 
43
43
  /**
44
44
  * Create a navigation client for fetching RSC payloads
@@ -103,30 +103,23 @@ export function createNavigationClient(
103
103
  }
104
104
 
105
105
  // Build fetch URL with partial rendering params (used for both
106
- // cache key lookup and actual fetch if cache misses)
107
- const fetchUrl = new URL(targetUrl, window.location.origin);
108
- fetchUrl.searchParams.set("_rsc_partial", "true");
109
- fetchUrl.searchParams.set("_rsc_segments", segmentIds.join(","));
110
- if (staleRevalidation) {
111
- fetchUrl.searchParams.set("_rsc_stale", "true");
112
- }
113
- if (version) {
114
- fetchUrl.searchParams.set("_rsc_v", version);
115
- }
116
- if (routerId) {
117
- fetchUrl.searchParams.set("_rsc_rid", routerId);
118
- }
119
- // The page left's rewrite target (#1144), in both prefetch keys like
120
- // the params above: buildPrefetchUrl sets it in the same place.
121
- if (rewriteTarget) {
122
- fetchUrl.searchParams.set("_rsc_rt", rewriteTarget);
123
- }
124
- // A param, not a header: the document cache decides from the raw URL
125
- // (cache/document-cache.ts), and it keeps the fill out of every
126
- // prefetch key below.
127
- if (fill) {
128
- fetchUrl.searchParams.set(FILL_PARAM, "1");
129
- }
106
+ // cache key lookup and actual fetch if cache misses). The page left's
107
+ // rewrite target (#1144) is in both prefetch keys, like the params
108
+ // around it. The fill is a param, not a header: the document cache
109
+ // decides from the raw URL (cache/document-cache.ts), and it keeps the
110
+ // fill out of every prefetch key below.
111
+ const fetchUrl = writePartialParams(
112
+ new URL(targetUrl, window.location.origin),
113
+ {
114
+ segmentIds,
115
+ emptySegments: "send",
116
+ stale: staleRevalidation,
117
+ version,
118
+ routerId,
119
+ rewriteTarget,
120
+ fill,
121
+ },
122
+ );
130
123
 
131
124
  // If the lazy prefetch runtime has not loaded yet, prevent a deferred
132
125
  // speculative request from starting after this navigation misses the
@@ -144,15 +137,10 @@ export function createNavigationClient(
144
137
  // server-action invalidation) auto-invalidates both scopes.
145
138
  // Skip cache for stale revalidation (needs fresh data), HMR (needs
146
139
  // fresh modules), and intercept contexts (source-dependent responses).
147
- // Suspend prefetch consumption while an action is in flight: a queued
148
- // prefetch holds pre-mutation data and must not be served until the
149
- // action's response decides whether anything changed.
140
+ // A prefetch served during an action's flight holds pre-write data:
141
+ // the action bridge's follow-up refetches the page it commits.
150
142
  const canUsePrefetch =
151
- !staleRevalidation &&
152
- !hmr &&
153
- !fill &&
154
- !interceptSourceUrl &&
155
- !isActionFenceActive();
143
+ !staleRevalidation && !hmr && !fill && !interceptSourceUrl;
156
144
  const rangoState = getRangoState();
157
145
  const wildcardKey = buildPrefetchKey(rangoState, fetchUrl);
158
146
  const cacheKey = buildSourceKey(rangoState, previousUrl, fetchUrl);
@@ -34,7 +34,7 @@ const HISTORY_ENTRY_MEMORY_SIZE = 200;
34
34
  // stale=true means the data may be outdated and should be revalidated on access.
35
35
  // navInstance is the monotonic nav-instance token (see navInstance below): it
36
36
  // identifies the per-commit visit that owns this entry. generateHistoryKey is
37
- // URL-only, so A->B->A reuses the same key; the token lets a late async
37
+ // the URL (plus the source page for an intercept), so A->B->A reuses the key; the token lets a late async
38
38
  // resolution tell its own visit's entry apart from a newer same-URL visit's, so
39
39
  // a stale nav can never clobber a fresher one.
40
40
  // handlesPending=true means the entry's handle data is INCOMPLETE (a deferred
@@ -124,36 +124,35 @@ function getCacheInvalidationChannel(): BroadcastChannel | null {
124
124
  * Options for generating a history key
125
125
  */
126
126
  export interface HistoryKeyOptions {
127
- /** If true, append :intercept suffix to differentiate intercept entries */
127
+ /** Key an intercept entry (apart from the plain entry for the URL). */
128
128
  intercept?: boolean;
129
+ /** The page the intercept was opened over (history.state.sourceUrl). */
130
+ interceptSource?: string;
131
+ }
132
+
133
+ function urlKey(url: string): string {
134
+ const parsed = new URL(url, "http://localhost");
135
+ return parsed.pathname + parsed.search;
129
136
  }
130
137
 
131
138
  /**
132
- * Generate a cache key from a URL.
133
- * Uses pathname + search (query params) directly as the key.
134
- * Hash fragments (#) are excluded since they don't affect server data.
139
+ * Generate a cache key from a URL: pathname + search. Hash fragments (#) are
140
+ * excluded since they don't affect server data.
135
141
  *
136
- * For intercept routes, append `:intercept` suffix to cache them separately
137
- * from non-intercept versions of the same URL.
142
+ * An intercept entry is keyed `<url>:intercept:<source>`: its cached tree
143
+ * includes the page under the intercept. hasInterceptCache relies on the
144
+ * `:intercept:` separator.
138
145
  */
139
146
  export function generateHistoryKey(
140
147
  url?: string,
141
148
  options?: HistoryKeyOptions,
142
149
  ): string {
143
- if (!url) {
144
- url = typeof window !== "undefined" ? window.location.href : "/";
145
- }
146
-
147
- // Parse URL and use only pathname + search (exclude hash fragment)
148
- const parsed = new URL(url, "http://localhost");
149
- let key = parsed.pathname + parsed.search;
150
-
151
- // Append intercept suffix for separate caching
152
- if (options?.intercept) {
153
- key += ":intercept";
154
- }
155
-
156
- return key;
150
+ const key = urlKey(
151
+ url || (typeof window !== "undefined" ? window.location.href : "/"),
152
+ );
153
+ if (!options?.intercept) return key;
154
+ const source = options.interceptSource;
155
+ return `${key}:intercept:${source ? urlKey(source) : ""}`;
157
156
  }
158
157
 
159
158
  /**
@@ -258,7 +257,8 @@ export function createNavigationStore(
258
257
 
259
258
  // Monotonic nav-instance token. Bumped each time a cache entry is created or
260
259
  // replaced in cacheSegmentsForHistory (i.e. once per commit). Because
261
- // generateHistoryKey is URL-only, two visits to the same URL share a key; this
260
+ // generateHistoryKey is the URL (plus the source page for an intercept), two
261
+ // visits to the same URL share a key; this
262
262
  // token gives each visit a distinct identity so a late async handle resolution
263
263
  // can tell whether it still owns the live page / the target cache entry, and
264
264
  // never overwrite a newer same-URL visit's state.
@@ -580,11 +580,9 @@ export function createNavigationStore(
580
580
  };
581
581
  },
582
582
 
583
- /**
584
- * Check if segments are cached for a history entry
585
- */
586
- hasHistoryCache(historyKey: string): boolean {
587
- return historyCache.some(([key]) => key === historyKey);
583
+ hasInterceptCache(historyKey: string): boolean {
584
+ const prefix = `${historyKey}:intercept:`;
585
+ return historyCache.some(([key]) => key.startsWith(prefix));
588
586
  },
589
587
 
590
588
  /**
@@ -156,7 +156,6 @@ export function createNavigationTransaction(
156
156
  url: string,
157
157
  options?: NavigateOptionsInternal & { skipLoadingState?: boolean },
158
158
  ): NavigationTransaction {
159
- let committed = false;
160
159
  const currentUrl = window.location.href;
161
160
 
162
161
  const handle = eventController.startNavigation(url, options);
@@ -165,8 +164,6 @@ export function createNavigationTransaction(
165
164
  * Commit the navigation - updates store and URL atomically
166
165
  */
167
166
  function commit(opts: CommitOptions): CommitResult {
168
- committed = true;
169
-
170
167
  const {
171
168
  url,
172
169
  segmentIds,
@@ -183,7 +180,10 @@ export function createNavigationTransaction(
183
180
 
184
181
  const parsedUrl = new URL(url, window.location.origin);
185
182
 
186
- const historyKey = generateHistoryKey(url, { intercept });
183
+ const historyKey = generateHistoryKey(url, {
184
+ intercept,
185
+ interceptSource: interceptSourceUrl,
186
+ });
187
187
 
188
188
  if (cacheOnly) {
189
189
  const currentHandleData = eventController.getHandleState().data;
@@ -304,14 +304,10 @@ export function createNavigationTransaction(
304
304
  };
305
305
  },
306
306
 
307
+ // Reaches the handle committed or not: its dispose settles the entry
308
+ // (NavigationEntry.settled), and resets no state once it completed.
307
309
  [Symbol.dispose]() {
308
- if (handle.signal.aborted) {
309
- return;
310
- }
311
-
312
- if (!committed) {
313
- handle[Symbol.dispose]();
314
- }
310
+ if (!handle.signal.aborted) handle[Symbol.dispose]();
315
311
  },
316
312
  };
317
313
  }
@@ -24,6 +24,7 @@ import {
24
24
  import {
25
25
  hasActiveIntercept as hasActiveInterceptSlots,
26
26
  isInterceptSegment,
27
+ wholeInterceptMatched,
27
28
  } from "./intercept-utils.js";
28
29
  import type { BoundTransaction } from "./navigation-transaction.js";
29
30
  import { ServerRedirect } from "../errors.js";
@@ -741,6 +742,13 @@ export function createPartialUpdater(
741
742
  if (redirected(payload.metadata)) return;
742
743
 
743
744
  if (payload.metadata?.isPartial) {
745
+ // Held nothing, answered as an intercept: the page it lands over stays
746
+ // under the slot, in `matched` for every reader below (the commit's
747
+ // handle cleanup included).
748
+ const kept =
749
+ segments.length === 0 &&
750
+ wholeInterceptMatched(cachedSegs, payload.metadata);
751
+ if (kept) payload.metadata.matched = kept;
744
752
  const { segments: newSegments, matched, diff } = payload.metadata;
745
753
  let serverSegments = newSegments || [];
746
754
  let sent = diff || [];
@@ -43,6 +43,7 @@ import { abortAllPrefetches } from "./loader.js";
43
43
  import { notifyPrefetchCacheInvalidated } from "./invalidation.js";
44
44
  import { invalidateRangoState } from "../rango-state.js";
45
45
  import type { RscPayload } from "../types.js";
46
+ import { WIRE } from "../../wire-params.js";
46
47
 
47
48
  /**
48
49
  * A prefetch that has been fetched AND eagerly decoded. Storing the decoded
@@ -190,7 +191,7 @@ let generation = 0;
190
191
  */
191
192
  export function buildPrefetchKey(prefix: string, targetUrl: URL): string {
192
193
  const normalizedTarget = new URL(targetUrl);
193
- normalizedTarget.searchParams.delete("_rsc_segments");
194
+ normalizedTarget.searchParams.delete(WIRE.segments);
194
195
  return prefix + "\0" + normalizedTarget.pathname + normalizedTarget.search;
195
196
  }
196
197