@rangojs/router 0.27.0 → 0.29.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 (243) 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-fence.d.ts +2 -1
  4. package/dist/types/browser/event-controller.d.ts +8 -2
  5. package/dist/types/browser/history-state.d.ts +3 -9
  6. package/dist/types/browser/navigation-store.d.ts +8 -6
  7. package/dist/types/browser/partial-update.d.ts +11 -0
  8. package/dist/types/browser/react/filter-segment-order.d.ts +2 -2
  9. package/dist/types/browser/react/use-action.d.ts +7 -2
  10. package/dist/types/browser/react/use-handle.d.ts +19 -16
  11. package/dist/types/browser/react/use-link-status.d.ts +5 -2
  12. package/dist/types/browser/segment-reconciler.d.ts +32 -4
  13. package/dist/types/browser/types.d.ts +24 -5
  14. package/dist/types/browser/wire-protocol.d.ts +26 -0
  15. package/dist/types/cache/cache-key-utils.d.ts +11 -5
  16. package/dist/types/cache/cache-scope.d.ts +34 -22
  17. package/dist/types/cache/cf/cf-cache-constants.d.ts +2 -2
  18. package/dist/types/cache/cf/cf-cache-store.d.ts +47 -65
  19. package/dist/types/cache/cf/cf-kv-utils.d.ts +1 -1
  20. package/dist/types/cache/handle-snapshot.d.ts +9 -4
  21. package/dist/types/cache/segment-codec.d.ts +14 -0
  22. package/dist/types/cache/shell-snapshot.d.ts +1 -1
  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 +5 -14
  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 +11 -5
  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 +6 -11
  35. package/dist/types/router/match-handlers.d.ts +2 -6
  36. package/dist/types/router/match-middleware/background-revalidation.d.ts +13 -22
  37. package/dist/types/router/match-middleware/cache-lookup.d.ts +34 -86
  38. package/dist/types/router/match-middleware/segment-resolution.d.ts +8 -6
  39. package/dist/types/router/navigation-snapshot.d.ts +7 -0
  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/router-context.d.ts +7 -46
  44. package/dist/types/router/router-interfaces.d.ts +8 -14
  45. package/dist/types/router/segment-resolution/helpers.d.ts +19 -3
  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 +95 -0
  50. package/dist/types/router/segment-resolution/revalidation.d.ts +82 -31
  51. package/dist/types/router/segment-resolution/streamed-handler-boundary.d.ts +2 -0
  52. package/dist/types/router/segment-resolution.d.ts +1 -2
  53. package/dist/types/router/segment-wrappers.d.ts +11 -48
  54. package/dist/types/router/telemetry.d.ts +2 -0
  55. package/dist/types/router/types.d.ts +11 -1
  56. package/dist/types/rsc/full-payload.d.ts +2 -2
  57. package/dist/types/rsc/handler-context.d.ts +6 -6
  58. package/dist/types/rsc/json-route-result.d.ts +5 -9
  59. package/dist/types/rsc/request-stages.d.ts +203 -0
  60. package/dist/types/rsc/response-cache-serve.d.ts +7 -12
  61. package/dist/types/rsc/response-route-handler.d.ts +2 -2
  62. package/dist/types/rsc/routine-plan.d.ts +1 -1
  63. package/dist/types/rsc/server-action.d.ts +2 -2
  64. package/dist/types/rsc/shell-capture-constants.d.ts +1 -1
  65. package/dist/types/rsc/shell-capture.d.ts +132 -15
  66. package/dist/types/rsc/shell-serve.d.ts +37 -12
  67. package/dist/types/server/context.d.ts +9 -8
  68. package/dist/types/server/handle-store.d.ts +16 -0
  69. package/dist/types/server/render-pass.d.ts +72 -0
  70. package/dist/types/server/request-context.d.ts +53 -104
  71. package/dist/types/server/rewrite-trail.d.ts +2 -2
  72. package/dist/types/testing/dispatch.d.ts +35 -18
  73. package/dist/types/testing/render-route.d.ts +43 -8
  74. package/dist/types/testing/serve-shell-request.d.ts +8 -1
  75. package/dist/types/types/cache-types.d.ts +6 -3
  76. package/dist/types/types/error-types.d.ts +30 -3
  77. package/dist/types/types/handler-context.d.ts +9 -1
  78. package/dist/types/types/segments.d.ts +1 -1
  79. package/dist/types/vite/discovery/build-discovery-phase.d.ts +40 -0
  80. package/dist/types/vite/discovery/build-env.d.ts +22 -0
  81. package/dist/types/vite/discovery/bundle-scan-phase.d.ts +12 -0
  82. package/dist/types/vite/discovery/dev-prerender-cache.d.ts +3 -2
  83. package/dist/types/vite/discovery/dev-server-phase.d.ts +26 -0
  84. package/dist/types/vite/discovery/discovery-errors.d.ts +1 -1
  85. package/dist/types/vite/discovery/state.d.ts +12 -0
  86. package/dist/types/vite/plugins/version-plugin.d.ts +1 -1
  87. package/dist/types/vite/plugins/virtual-entries.d.ts +1 -1
  88. package/dist/types/vite/router-discovery.d.ts +8 -47
  89. package/dist/types/vite/temp-rsc-server.d.ts +39 -0
  90. package/dist/types/vite/utils/prerender-utils.d.ts +9 -0
  91. package/dist/types/wire-params.d.ts +39 -0
  92. package/dist/vite/index.js +3822 -3727
  93. package/package.json +1 -1
  94. package/skills/cache-guide/SKILL.md +8 -5
  95. package/skills/caching/SKILL.md +37 -18
  96. package/skills/client-urls/SKILL.md +4 -2
  97. package/skills/document-cache/SKILL.md +13 -1
  98. package/skills/handler-use/SKILL.md +1 -1
  99. package/skills/hooks/SKILL.md +1 -1
  100. package/skills/hooks/data.md +8 -1
  101. package/skills/hooks/handle-and-actions.md +33 -2
  102. package/skills/hooks/navigation.md +121 -15
  103. package/skills/hooks/state.md +6 -4
  104. package/skills/hooks/urls.md +16 -5
  105. package/skills/intercept/SKILL.md +23 -7
  106. package/skills/middleware/SKILL.md +18 -0
  107. package/skills/observability/SKILL.md +30 -0
  108. package/skills/ppr/SKILL.md +18 -3
  109. package/skills/prerender/SKILL.md +39 -15
  110. package/skills/rango/SKILL.md +5 -1
  111. package/skills/server-actions/SKILL.md +15 -1
  112. package/skills/testing/client-components.md +25 -18
  113. package/skills/testing/response-routes.md +6 -4
  114. package/skills/testing/serve-shell-request.md +7 -7
  115. package/skills/use-cache/SKILL.md +46 -8
  116. package/skills/view-transitions/SKILL.md +1 -1
  117. package/src/__internal.ts +7 -1
  118. package/src/browser/action-fence.ts +2 -1
  119. package/src/browser/connection-warmup.ts +3 -1
  120. package/src/browser/event-controller.ts +37 -13
  121. package/src/browser/history-state.ts +6 -12
  122. package/src/browser/navigation-bridge.ts +59 -26
  123. package/src/browser/navigation-client.ts +20 -25
  124. package/src/browser/navigation-store.ts +25 -27
  125. package/src/browser/navigation-transaction.ts +4 -1
  126. package/src/browser/network-error-handler.ts +4 -0
  127. package/src/browser/partial-update.ts +33 -6
  128. package/src/browser/prefetch/cache.ts +2 -1
  129. package/src/browser/prefetch/fetch.ts +8 -16
  130. package/src/browser/prefetch/queue.ts +4 -4
  131. package/src/browser/react/NavigationProvider.tsx +2 -1
  132. package/src/browser/react/filter-segment-order.ts +2 -2
  133. package/src/browser/react/use-action.ts +24 -48
  134. package/src/browser/react/use-handle.ts +22 -18
  135. package/src/browser/react/use-link-status.ts +48 -60
  136. package/src/browser/react/use-navigation.ts +92 -41
  137. package/src/browser/rsc-router.tsx +1 -0
  138. package/src/browser/segment-reconciler.ts +109 -53
  139. package/src/browser/server-action-bridge.ts +81 -61
  140. package/src/browser/types.ts +25 -5
  141. package/src/browser/wire-protocol.ts +43 -0
  142. package/src/cache/cache-key-utils.ts +11 -5
  143. package/src/cache/cache-runtime.ts +132 -75
  144. package/src/cache/cache-scope.ts +110 -64
  145. package/src/cache/cf/cf-cache-constants.ts +2 -2
  146. package/src/cache/cf/cf-cache-store.ts +486 -874
  147. package/src/cache/cf/cf-kv-utils.ts +1 -1
  148. package/src/cache/document-cache.ts +64 -46
  149. package/src/cache/handle-snapshot.ts +19 -7
  150. package/src/cache/read-through-swr.ts +20 -18
  151. package/src/cache/segment-codec.ts +55 -0
  152. package/src/cache/shell-snapshot.ts +2 -2
  153. package/src/cache/tag-invalidation.ts +6 -2
  154. package/src/cache/taint.ts +5 -3
  155. package/src/cache/types.ts +7 -3
  156. package/src/cache/vercel/vercel-cache-store.ts +243 -310
  157. package/src/handles/pushed-by.ts +1 -1
  158. package/src/prefetch-markers.ts +5 -16
  159. package/src/prerender/store.ts +76 -0
  160. package/src/prerender/warm-request.ts +8 -4
  161. package/src/prerender.ts +2 -2
  162. package/src/route-name.ts +8 -0
  163. package/src/router/error-handling.ts +22 -10
  164. package/src/router/instrument.ts +4 -3
  165. package/src/router/intercept-resolution.ts +82 -75
  166. package/src/router/loader-resolution.ts +5 -4
  167. package/src/router/match-api.ts +5 -5
  168. package/src/router/match-context.ts +23 -12
  169. package/src/router/match-handlers.ts +8 -20
  170. package/src/router/match-middleware/background-revalidation.ts +46 -57
  171. package/src/router/match-middleware/cache-lookup.ts +677 -726
  172. package/src/router/match-middleware/cache-store.ts +4 -8
  173. package/src/router/match-middleware/intercept-resolution.ts +3 -23
  174. package/src/router/match-middleware/segment-resolution.ts +34 -61
  175. package/src/router/middleware.ts +69 -11
  176. package/src/router/navigation-snapshot.ts +14 -4
  177. package/src/router/prerender-match.ts +100 -44
  178. package/src/router/preview-match.ts +6 -41
  179. package/src/router/request-classification.ts +13 -7
  180. package/src/router/route-snapshot.ts +2 -5
  181. package/src/router/router-context.ts +10 -95
  182. package/src/router/router-interfaces.ts +8 -14
  183. package/src/router/segment-resolution/helpers.ts +49 -13
  184. package/src/router/segment-resolution/loader-cache.ts +15 -9
  185. package/src/router/segment-resolution/loader-mask.ts +5 -5
  186. package/src/router/segment-resolution/prefetch-deferral.ts +30 -27
  187. package/src/router/segment-resolution/resolution-pass.ts +139 -0
  188. package/src/router/segment-resolution/revalidation.ts +939 -789
  189. package/src/router/segment-resolution/streamed-handler-boundary.ts +3 -0
  190. package/src/router/segment-resolution.ts +4 -13
  191. package/src/router/segment-wrappers.ts +51 -269
  192. package/src/router/telemetry.ts +5 -0
  193. package/src/router/types.ts +11 -1
  194. package/src/router.ts +19 -17
  195. package/src/rsc/full-payload.ts +6 -5
  196. package/src/rsc/handler-context.ts +8 -14
  197. package/src/rsc/handler.ts +126 -507
  198. package/src/rsc/json-route-result.ts +5 -9
  199. package/src/rsc/loader-fetch.ts +6 -5
  200. package/src/rsc/progressive-enhancement.ts +5 -0
  201. package/src/rsc/request-stages.ts +759 -0
  202. package/src/rsc/response-cache-serve.ts +10 -14
  203. package/src/rsc/response-route-handler.ts +13 -5
  204. package/src/rsc/rewrite-passes.ts +2 -1
  205. package/src/rsc/routine-plan.ts +1 -1
  206. package/src/rsc/rsc-rendering.ts +164 -349
  207. package/src/rsc/server-action.ts +87 -113
  208. package/src/rsc/shell-capture-constants.ts +1 -1
  209. package/src/rsc/shell-capture.ts +275 -227
  210. package/src/rsc/shell-serve.ts +121 -18
  211. package/src/rsc/ssr-setup.ts +5 -4
  212. package/src/segment-system.tsx +3 -3
  213. package/src/server/context.ts +22 -25
  214. package/src/server/handle-store.ts +55 -0
  215. package/src/server/render-pass.ts +291 -0
  216. package/src/server/request-context.ts +75 -115
  217. package/src/server/rewrite-trail.ts +6 -4
  218. package/src/ssr/index.tsx +1 -1
  219. package/src/testing/dispatch.ts +226 -710
  220. package/src/testing/e2e/page-helpers.ts +2 -2
  221. package/src/testing/render-route.tsx +199 -13
  222. package/src/testing/serve-shell-request.ts +36 -40
  223. package/src/types/cache-types.ts +6 -3
  224. package/src/types/error-types.ts +30 -3
  225. package/src/types/handler-context.ts +12 -1
  226. package/src/types/segments.ts +1 -1
  227. package/src/vite/discovery/build-discovery-phase.ts +204 -0
  228. package/src/vite/discovery/build-env.ts +148 -0
  229. package/src/vite/discovery/bundle-scan-phase.ts +128 -0
  230. package/src/vite/discovery/dev-prerender-cache.ts +8 -3
  231. package/src/vite/discovery/dev-server-phase.ts +1776 -0
  232. package/src/vite/discovery/discover-routers.ts +1 -1
  233. package/src/vite/discovery/discovery-errors.ts +1 -1
  234. package/src/vite/discovery/prerender-collection.ts +24 -39
  235. package/src/vite/discovery/state.ts +15 -0
  236. package/src/vite/plugins/version-plugin.ts +1 -1
  237. package/src/vite/plugins/virtual-entries.ts +1 -1
  238. package/src/vite/router-discovery.ts +29 -2428
  239. package/src/vite/temp-rsc-server.ts +223 -0
  240. package/src/vite/utils/prerender-utils.ts +47 -0
  241. package/src/wire-params.ts +40 -0
  242. package/dist/types/router/segment-resolution/fresh.d.ts +0 -72
  243. package/src/router/segment-resolution/fresh.ts +0 -945
@@ -92,6 +92,11 @@ export interface ActionEntry {
92
92
  startedAt: number;
93
93
  /** Whether action processing is complete (may still be streaming) */
94
94
  completed?: boolean;
95
+ /**
96
+ * Set when a later call started (see startAction): "id" hides this call
97
+ * from every reader, "name" from readers that match by export name.
98
+ */
99
+ supersededBy?: "id" | "name";
95
100
  }
96
101
 
97
102
  /**
@@ -104,7 +109,7 @@ export interface DerivedNavigationState {
104
109
  isStreaming: boolean;
105
110
  /** Whether a navigation is active (fetching or streaming, before commit) */
106
111
  isNavigating: boolean;
107
- /** Current committed location */
112
+ /** The location of the last page flushed to React (useNavigation keeps the page on screen) */
108
113
  location: NavigationLocation;
109
114
  /** URL being navigated to (null if idle) */
110
115
  pendingUrl: string | null;
@@ -258,7 +263,8 @@ export interface EventController {
258
263
  /** Originating history entry key; scopes location-state arbitration. */
259
264
  cohort?: string,
260
265
  ): ActionHandle;
261
- abortAllActions(): void;
266
+ /** Abort every action in flight except `except` (an ActionHandle id). */
267
+ abortAllActions(except?: string): void;
262
268
 
263
269
  // State access
264
270
  getState(): DerivedNavigationState;
@@ -711,15 +717,18 @@ export function createEventController(
711
717
  // idle snapshot (the same reference use-action falls back to).
712
718
  if (inflightActions.size === 0) return DEFAULT_ACTION_STATE;
713
719
 
714
- const entry = [...inflightActions.values()]
715
- .filter((a) => matchesActionId(actionId, a.actionId))
716
- .reduce<ActionEntry | undefined>((best, a) => {
717
- if (!best) return a;
718
- const aActive = a.phase !== "settling";
719
- const bActive = best.phase !== "settling";
720
- if (aActive !== bActive) return aActive ? a : best;
721
- return a.startedAt > best.startedAt ? a : best;
722
- }, undefined);
720
+ // The latest call this reader matches, or none once a later call left the
721
+ // map: an earlier call still running is never reported again.
722
+ const byName = !actionId.includes("#");
723
+ let entry: ActionEntry | undefined;
724
+ for (const a of inflightActions.values()) {
725
+ if (
726
+ matchesActionId(actionId, a.actionId) &&
727
+ (byName ? !a.supersededBy : a.supersededBy !== "id")
728
+ ) {
729
+ entry = a;
730
+ }
731
+ }
723
732
 
724
733
  if (!entry) {
725
734
  return { ...DEFAULT_ACTION_STATE };
@@ -912,6 +921,20 @@ export function createEventController(
912
921
  hadAnyConcurrentActions = true;
913
922
  }
914
923
 
924
+ // useAction reports the latest call. Every earlier call that a reader of
925
+ // this one also matches is hidden from that reader for good, so it never
926
+ // shows again after this call settles and leaves the map: a call of the
927
+ // same action from every reader, a call of another action with the same
928
+ // export name from readers that match by name (matchesActionId).
929
+ const hash = actionId.lastIndexOf("#");
930
+ const name = hash === -1 ? null : actionId.slice(hash + 1);
931
+ for (const a of inflightActions.values()) {
932
+ if (a.actionId === actionId) a.supersededBy = "id";
933
+ else if (name !== null && matchesActionId(name, a.actionId)) {
934
+ a.supersededBy ??= "name";
935
+ }
936
+ }
937
+
915
938
  const entry: ActionEntry = {
916
939
  id,
917
940
  actionId,
@@ -1108,13 +1131,14 @@ export function createEventController(
1108
1131
  };
1109
1132
  }
1110
1133
 
1111
- function abortAllActions() {
1134
+ /** Abort every action in flight except `except` (an ActionHandle id). */
1135
+ function abortAllActions(except?: string) {
1112
1136
  for (const [id, entry] of inflightActions) {
1113
1137
  // Preserve settling entries — they have already been handled by
1114
1138
  // fail()/complete() and will self-cleanup via the settlement timeout.
1115
1139
  // Clearing them here would prevent debounced notifications from
1116
1140
  // delivering the error/result state to subscribers.
1117
- if (entry.phase === "settling") continue;
1141
+ if (entry.phase === "settling" || id === except) continue;
1118
1142
  entry.abort.abort();
1119
1143
  inflightActions.delete(id);
1120
1144
  }
@@ -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
 
@@ -45,6 +45,7 @@ import {
45
45
  } from "./navigation-store.js";
46
46
  import type { EventController } from "./event-controller.js";
47
47
  import { isInterceptOnlyCache } from "./intercept-utils.js";
48
+ import { actionBoundaryAt, idsAboveBoundary } from "./segment-reconciler.js";
48
49
  import {
49
50
  toNetworkError,
50
51
  emitNetworkError,
@@ -206,7 +207,10 @@ export function createNavigationBridge(
206
207
  error: ServerRedirect,
207
208
  url: string,
208
209
  hops: number,
209
- options: { replace?: boolean; transition?: boolean },
210
+ options: Pick<
211
+ NavigateOptionsInternal,
212
+ "replace" | "transition" | "_redirectFrom"
213
+ >,
210
214
  ): Promise<void> {
211
215
  const redirectUrl = validateRedirectOrigin(
212
216
  error.url,
@@ -228,6 +232,7 @@ export function createNavigationBridge(
228
232
  ...options,
229
233
  _skipCache: true,
230
234
  _redirectHops: hops + 1,
235
+ _redirectFrom: options._redirectFrom ?? url,
231
236
  });
232
237
  }
233
238
 
@@ -273,15 +278,18 @@ export function createNavigationBridge(
273
278
  targetUrl.pathname === new URL(window.location.href).pathname
274
279
  ) {
275
280
  // 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.
281
+ // popstate uses the same cache key and restores the right full-page
282
+ // vs intercept semantics.
278
283
  const currentHistoryState = window.history.state;
279
284
  const isIntercept = currentHistoryState?.intercept === true;
280
285
  const interceptSourceUrl = isIntercept
281
286
  ? currentHistoryState?.sourceUrl
282
287
  : undefined;
283
288
 
284
- const historyKey = generateHistoryKey(url, { intercept: isIntercept });
289
+ const historyKey = generateHistoryKey(url, {
290
+ intercept: isIntercept,
291
+ interceptSource: interceptSourceUrl,
292
+ });
285
293
 
286
294
  // Copy current segments to the new history key so back/forward restores instantly.
287
295
  // The array itself, not a clone: getPageHistoryKeys reads a shared
@@ -405,25 +413,24 @@ export function createNavigationBridge(
405
413
  });
406
414
  }
407
415
 
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
416
  // Skip cached SWR for:
415
417
  // 1. intercept caches - interception depends on source page context
416
418
  // 2. routes that CAN be intercepted - we don't know if this navigation will intercept
417
419
  // 3. when leaving intercept - we need fresh non-intercept segments from server
418
420
  // 4. redirect-with-state - force re-render so hooks read fresh state
419
421
  // 5. stale cache - server action invalidated it, need fresh data with loading state
422
+ // 6. an error boundary in the list - a link renders the page again; its
423
+ // ids would tell the server the client holds what the error replaced
420
424
  const hasUsableCache =
421
425
  isRestorable(cachedSegments) &&
426
+ !cachedSegments.some((s) => s.type === "error") &&
422
427
  !isInterceptOnlyCache(cachedSegments) &&
423
- !hasInterceptCache &&
424
428
  !isLeavingIntercept &&
425
429
  !cached?.stale &&
426
- !options?._skipCache;
430
+ !options?._skipCache &&
431
+ // A cached intercept entry for this URL (from any source page) means
432
+ // the navigation might intercept, depending on the source URL.
433
+ !store.hasInterceptCache(historyKey);
427
434
 
428
435
  // Forward navigations always await fetchPartialUpdate before rendering,
429
436
  // so useNavigation should always report "loading". skipLoadingState is
@@ -539,7 +546,11 @@ export function createNavigationBridge(
539
546
  error,
540
547
  url,
541
548
  options?._redirectHops ?? 0,
542
- { replace: options?.replace, transition: options?.transition },
549
+ {
550
+ replace: options?.replace,
551
+ transition: options?.transition,
552
+ _redirectFrom: options?._redirectFrom,
553
+ },
543
554
  );
544
555
  }
545
556
 
@@ -570,9 +581,19 @@ export function createNavigationBridge(
570
581
  },
571
582
 
572
583
  /**
573
- * Refresh current route
584
+ * Refresh current route. A cross-tab refresh holds the action error
585
+ * boundary the page shows (actionBoundaryAt), as the follow-up does: it
586
+ * is this tab's own outcome, not a read another tab's write changed. A
587
+ * refresh the app asks for renders it again.
574
588
  */
575
- async refresh(): Promise<void> {
589
+ async refresh(crossTab?: boolean): Promise<void> {
590
+ const shown =
591
+ (crossTab &&
592
+ !store.getInterceptSourceUrl() &&
593
+ store.getCachedSegments(store.getHistoryKey())?.segments) ||
594
+ [];
595
+ const at = actionBoundaryAt(shown);
596
+ if (at === 0) return;
576
597
  eventController.abortNavigation();
577
598
 
578
599
  const tx = createNavigationTransaction(
@@ -590,7 +611,7 @@ export function createNavigationBridge(
590
611
  false,
591
612
  tx.handle.signal,
592
613
  tx.with({ url: window.location.href, replace: true, scroll: false }),
593
- { type: "navigate", refresh: true },
614
+ { type: "navigate", refresh: true, boundary: shown[at]?.id },
594
615
  );
595
616
  } catch (error) {
596
617
  // Aborted or superseded: bail without rendering a boundary (see navigate()).
@@ -664,8 +685,10 @@ export function createNavigationBridge(
664
685
  const isLeavingIntercept =
665
686
  !isIntercept && currentInterceptSource !== null;
666
687
 
667
- // Compute history key from URL (with intercept suffix if applicable)
668
- const historyKey = generateHistoryKey(url, { intercept: isIntercept });
688
+ const historyKey = generateHistoryKey(url, {
689
+ intercept: isIntercept,
690
+ interceptSource: interceptSourceUrl,
691
+ });
669
692
 
670
693
  debugLog(
671
694
  "[Browser] Popstate -",
@@ -716,9 +739,20 @@ export function createNavigationBridge(
716
739
  }),
717
740
  });
718
741
 
742
+ // An action error boundary the entry shows (actionBoundaryAt) is
743
+ // held: the page holds the ids above it, so a link renders it again,
744
+ // and the revalidation below renders above it. Never under an
745
+ // intercept, as the follow-up (server-action-bridge.ts).
746
+ const boundaryAt = isIntercept ? -1 : actionBoundaryAt(cachedSegments);
747
+ const boundary = cachedSegments[boundaryAt]?.id;
748
+ const heldIds = idsAboveBoundary(
749
+ cachedSegments.map((s) => s.id),
750
+ boundary,
751
+ );
752
+
719
753
  // Update store to point to this history entry
720
754
  store.setHistoryKey(historyKey);
721
- store.setSegmentIds(cachedSegments.map((s) => s.id));
755
+ store.setSegmentIds(heldIds);
722
756
  store.setCurrentUrl(url);
723
757
  store.setPath(new URL(url).pathname);
724
758
 
@@ -797,8 +831,9 @@ export function createNavigationBridge(
797
831
  onUpdate({ ...popstateUpdate, inTransition: true });
798
832
  });
799
833
  }
800
- // SWR: If stale, trigger background revalidation
801
- if (isStale) {
834
+ // SWR: If stale, trigger background revalidation. Nothing is
835
+ // above a boundary at the page's first segment.
836
+ if (isStale && boundaryAt !== 0) {
802
837
  debugLog("[Browser] Cache is stale, background revalidating...");
803
838
  // Background revalidation - don't await, just fire and forget.
804
839
  // When the entry's handles are incomplete (a deferred Meta was still
@@ -810,9 +845,7 @@ export function createNavigationBridge(
810
845
  // would stay the pre-resolution carry. handlesPending is set only for
811
846
  // the deferred-Meta-aborted case, so action/cross-tab SWR keeps the
812
847
  // cheap diff path.
813
- const segmentIds = cached?.handlesPending
814
- ? []
815
- : cachedSegments.map((s) => s.id);
848
+ const segmentIds = cached?.handlesPending ? [] : heldIds;
816
849
 
817
850
  const tx = createNavigationTransaction(
818
851
  store,
@@ -834,7 +867,7 @@ export function createNavigationBridge(
834
867
  interceptSourceUrl,
835
868
  cacheOnly: true,
836
869
  }),
837
- { type: "stale-revalidation", interceptSourceUrl },
870
+ { type: "stale-revalidation", interceptSourceUrl, boundary },
838
871
  )
839
872
  .catch((error) => {
840
873
  if (isBackgroundSuppressible(error)) return;
@@ -989,7 +1022,7 @@ export function createNavigationBridge(
989
1022
 
990
1023
  // Register cross-tab refresh callback with the store
991
1024
  store.setCrossTabRefreshCallback(() => {
992
- this.refresh();
1025
+ this.refresh(true);
993
1026
  });
994
1027
 
995
1028
  window.addEventListener("popstate", handlePopstate);
@@ -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, STALE_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
@@ -82,6 +82,7 @@ export function createNavigationClient(
82
82
  rewriteTarget,
83
83
  signal,
84
84
  staleRevalidation,
85
+ boundary,
85
86
  interceptSourceUrl,
86
87
  version,
87
88
  routerId,
@@ -103,30 +104,24 @@ export function createNavigationClient(
103
104
  }
104
105
 
105
106
  // 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(STALE_PARAM, "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
- }
107
+ // cache key lookup and actual fetch if cache misses). The page left's
108
+ // rewrite target (#1144) is in both prefetch keys, like the params
109
+ // around it. The fill is a param, not a header: the document cache
110
+ // decides from the raw URL (cache/document-cache.ts), and it keeps the
111
+ // fill out of every prefetch key below.
112
+ const fetchUrl = writePartialParams(
113
+ new URL(targetUrl, window.location.origin),
114
+ {
115
+ segmentIds,
116
+ emptySegments: "send",
117
+ stale: staleRevalidation,
118
+ boundary,
119
+ version,
120
+ routerId,
121
+ rewriteTarget,
122
+ fill,
123
+ },
124
+ );
130
125
 
131
126
  // If the lazy prefetch runtime has not loaded yet, prevent a deferred
132
127
  // speculative request from starting after this navigation misses the
@@ -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
  /**
@@ -180,7 +180,10 @@ export function createNavigationTransaction(
180
180
 
181
181
  const parsedUrl = new URL(url, window.location.origin);
182
182
 
183
- const historyKey = generateHistoryKey(url, { intercept });
183
+ const historyKey = generateHistoryKey(url, {
184
+ intercept,
185
+ interceptSource: interceptSourceUrl,
186
+ });
184
187
 
185
188
  if (cacheOnly) {
186
189
  const currentHandleData = eventController.getHandleState().data;
@@ -28,6 +28,9 @@ export function toNetworkError(
28
28
  * Render an error into the segment tree via the onUpdate subscriber so the
29
29
  * nearest error boundary catches it. Wrapped in startTransition; RenderErrorThrower
30
30
  * throws during render (async rejections do not reach boundaries on their own).
31
+ * Flagged inTransition like every tree handed over in a transition, so route
32
+ * state that arrives after it (the failed navigation's idle) waits for its
33
+ * commit (NavigationProvider's "anchor").
31
34
  */
32
35
  function emitErrorToBoundary(
33
36
  onUpdate: UpdateSubscriber,
@@ -43,6 +46,7 @@ function emitErrorToBoundary(
43
46
  segments: [],
44
47
  isError: true,
45
48
  },
49
+ inTransition: true,
46
50
  });
47
51
  });
48
52
  }
@@ -14,7 +14,7 @@ export const addTransitionType: ((type: string) => void) | undefined =
14
14
  "addTransitionType" in React ? (React as any).addTransitionType : undefined;
15
15
 
16
16
  import type { RenderSegmentsOptions } from "../segment-system.js";
17
- import { reconcileSegments } from "./segment-reconciler.js";
17
+ import { idsAboveBoundary, reconcileSegments } from "./segment-reconciler.js";
18
18
  import type { ReconcileActor } from "./segment-reconciler.js";
19
19
  import { Defer } from "./defer.js";
20
20
  import {
@@ -236,15 +236,28 @@ export type UpdateMode =
236
236
  * transition({ when }) sees kind "revalidate", not "replace".
237
237
  */
238
238
  refresh?: boolean;
239
+ /** The cross-tab refresh's held boundary (see `action`). */
240
+ boundary?: string;
241
+ }
242
+ | { type: "leave-intercept"; interceptSourceUrl?: string; boundary?: never }
243
+ | {
244
+ type: "stale-revalidation";
245
+ interceptSourceUrl?: string;
246
+ /** Back/Forward's held boundary (see `action`). */
247
+ boundary?: string;
239
248
  }
240
- | { type: "leave-intercept"; interceptSourceUrl?: string }
241
- | { type: "stale-revalidation"; interceptSourceUrl?: string }
242
249
  | {
243
250
  type: "action";
244
251
  interceptSourceUrl?: string;
245
252
  actionId?: string;
246
253
  /** The action's FormData and result, for transition({ when }) `action`. */
247
254
  action?: TransitionWhenActionInput;
255
+ /**
256
+ * An action's error boundary the page shows (actionBoundaryAt): the
257
+ * server renders above it, its cached segment stays, and the page
258
+ * holds only the ids above it.
259
+ */
260
+ boundary?: string;
248
261
  };
249
262
 
250
263
  /**
@@ -569,6 +582,7 @@ export function createPartialUpdater(
569
582
  const historyKeyAtStart = store.getHistoryKey();
570
583
 
571
584
  const interceptSourceUrl = mode.interceptSourceUrl;
585
+ const boundary = mode.boundary;
572
586
 
573
587
  let segments: string[];
574
588
  if (mode.type === "leave-intercept") {
@@ -690,6 +704,7 @@ export function createPartialUpdater(
690
704
  : store.getHistoryEntryMemory()?.rewriteTarget,
691
705
  staleRevalidation:
692
706
  mode.type === "stale-revalidation" || segments.length === 0,
707
+ boundary,
693
708
  version: getVersion(),
694
709
  routerId: store.getRouterId?.(),
695
710
  });
@@ -874,14 +889,14 @@ export function createPartialUpdater(
874
889
  // in place of each missing value, and this navigation fills them from
875
890
  // one fill request once it has committed the tree.
876
891
  let defers: Holes["defers"] | undefined;
877
- let heldIds = matchedIds;
892
+ let heldIds = idsAboveBoundary(matchedIds, boundary);
878
893
  const placeholders = serverSegments.filter(isPlaceholder);
879
894
  if (placeholders.length > 0) {
880
895
  defers = armHoles(placeholders);
881
896
  // A placeholder is not held: every later request from this page
882
897
  // leaves its id out, so the server renders it.
883
898
  const deferredIds = new Set(placeholders.map((s) => s.id));
884
- heldIds = matchedIds.filter((id) => !deferredIds.has(id));
899
+ heldIds = heldIds.filter((id) => !deferredIds.has(id));
885
900
  }
886
901
 
887
902
  const reconcile = () =>
@@ -895,7 +910,10 @@ export function createPartialUpdater(
895
910
  });
896
911
  let reconciled = reconcile();
897
912
 
898
- const missing = missingIds(matchedIds, reconciled.segments);
913
+ // Not the ids at or below a held boundary: its loaders may never have
914
+ // reached the client (the route failed on its GET), and nothing renders
915
+ // them under the error.
916
+ const missing = missingIds(heldIds, reconciled.segments);
899
917
 
900
918
  if (missing.length > 0) {
901
919
  const missingCount = missing.length;
@@ -1119,6 +1137,15 @@ export function createPartialUpdater(
1119
1137
  }),
1120
1138
  ...(serverLocationState && { serverState: serverLocationState }),
1121
1139
  };
1140
+ // A refetch that held an action's boundary commits only while the page
1141
+ // on screen still shows it: a later commit there (an action's success)
1142
+ // replaced it, and this response would put the error back.
1143
+ if (
1144
+ boundary &&
1145
+ !entryOnScreen()?.includes(cachedSegs.find((s) => s.id === boundary)!)
1146
+ ) {
1147
+ return;
1148
+ }
1122
1149
  const cachedHandleData = readCachedHandleData?.();
1123
1150
  const { scroll: navScroll } = tx.commit(
1124
1151
  heldIds,
@@ -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