@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
@@ -46,6 +46,60 @@ export interface ReconcileResult {
46
46
  interceptSegments: ResolvedSegment[];
47
47
  }
48
48
 
49
+ /**
50
+ * A segment the server sent over its cached copy, keeping the cached
51
+ * structural properties so the React tree keeps its shape. Changing them
52
+ * between renders alters the element nesting (with/without
53
+ * RouteContentWrapper, MountContextProvider, etc.), which remounts components
54
+ * and destroys useActionState.
55
+ */
56
+ function mergeCached(
57
+ fromServer: ResolvedSegment,
58
+ fromCache: ResolvedSegment,
59
+ actor: ReconcileActor,
60
+ ): ResolvedSegment {
61
+ let merged = fromServer;
62
+
63
+ // When server returns component: null for a layout segment, it means
64
+ // "this segment doesn't need re-rendering" - preserve the cached component
65
+ // to maintain the outlet chain and prevent React tree changes
66
+ if (
67
+ fromServer.component === null &&
68
+ fromServer.type === "layout" &&
69
+ fromCache.component != null
70
+ ) {
71
+ merged = { ...merged, component: fromCache.component };
72
+ }
73
+
74
+ // Loading preservation is actor-aware:
75
+ // - action/stale-revalidation: always preserve cached value to prevent
76
+ // tree remount (even when cached is undefined, to avoid adding a
77
+ // Suspense boundary that wasn't there before)
78
+ // - navigation: only when cached is defined (building a new tree)
79
+ if (
80
+ fromServer.loading !== fromCache.loading &&
81
+ (actor !== "navigation" || fromCache.loading !== undefined)
82
+ ) {
83
+ merged = { ...merged, loading: fromCache.loading };
84
+ }
85
+
86
+ // mountPath: SSR segments may lack mountPath while revalidated segments
87
+ // include it. The conditional MountContextProvider wrapper changes tree depth.
88
+ if (fromServer.mountPath !== fromCache.mountPath) {
89
+ merged = { ...merged, mountPath: fromCache.mountPath };
90
+ }
91
+
92
+ // Dev-mode assertion: warn if the merged result still differs from cache
93
+ // in tree-structural properties. This catches bugs where the merge code
94
+ // above fails to preserve a value it should have.
95
+ assertSegmentStructure(
96
+ fromCache,
97
+ merged,
98
+ actor === "action" ? "action-bridge" : "partial-update",
99
+ );
100
+ return merged;
101
+ }
102
+
49
103
  /**
50
104
  * Single source of truth for merging server segments with cached segments.
51
105
  *
@@ -70,7 +124,6 @@ export interface ReconcileResult {
70
124
  */
71
125
  export function reconcileSegments(input: ReconcileInput): ReconcileResult {
72
126
  const { actor, matched, diff, insertMissingDiff } = input;
73
- const context = actor === "action" ? "action-bridge" : "partial-update";
74
127
 
75
128
  // Build lookup maps from arrays
76
129
  const serverSegments = new Map<string, ResolvedSegment>();
@@ -111,59 +164,13 @@ export function reconcileSegments(input: ReconcileInput): ReconcileResult {
111
164
 
112
165
  if (fromServer) {
113
166
  const inDiff = diffSet.has(segId);
114
- // Preserve cached structural properties to maintain consistent React tree.
115
- // Changing these between renders alters the element nesting
116
- // (with/without RouteContentWrapper, MountContextProvider, etc.),
117
- // causing React to remount components and destroy useActionState.
118
167
  if (fromCache) {
119
- let merged = fromServer;
120
-
121
- // When server returns component: null for a layout segment, it means
122
- // "this segment doesn't need re-rendering" - preserve the cached component
123
- // to maintain the outlet chain and prevent React tree changes
124
- if (
125
- fromServer.component === null &&
126
- fromServer.type === "layout" &&
127
- fromCache.component != null
128
- ) {
129
- merged = { ...merged, component: fromCache.component };
130
- }
131
-
132
- // Loading preservation is actor-aware:
133
- // - action/stale-revalidation: always preserve cached value to prevent
134
- // tree remount (even when cached is undefined, to avoid adding a
135
- // Suspense boundary that wasn't there before)
136
- // - navigation: only when cached is defined (building a new tree)
137
- if (actor !== "navigation") {
138
- if (fromServer.loading !== fromCache.loading) {
139
- merged = { ...merged, loading: fromCache.loading };
140
- }
141
- } else {
142
- if (
143
- fromCache.loading !== undefined &&
144
- fromServer.loading !== fromCache.loading
145
- ) {
146
- merged = { ...merged, loading: fromCache.loading };
147
- }
148
- }
149
-
150
- // mountPath: SSR segments may lack mountPath while revalidated segments
151
- // include it. The conditional MountContextProvider wrapper changes tree depth.
152
- if (fromServer.mountPath !== fromCache.mountPath) {
153
- merged = { ...merged, mountPath: fromCache.mountPath };
154
- }
155
-
156
- // Dev-mode assertion: warn if the merged result still differs from cache
157
- // in tree-structural properties. This catches bugs where the merge code
158
- // above fails to preserve a value it should have.
159
- assertSegmentStructure(fromCache, merged, context);
160
-
161
168
  if (IS_BROWSER_DEBUG) {
162
169
  debugLog(
163
170
  `[reconcile] ${segId}: SERVER+CACHE merge (${inDiff ? "in diff" : "not in diff"}, type=${fromServer.type}, component=${fromServer.component === null ? "null→cached" : "server"})`,
164
171
  );
165
172
  }
166
- return merged;
173
+ return mergeCached(fromServer, fromCache, actor);
167
174
  }
168
175
  if (IS_BROWSER_DEBUG) {
169
176
  debugLog(
@@ -221,11 +228,13 @@ export function reconcileSegments(input: ReconcileInput): ReconcileResult {
221
228
  }
222
229
 
223
230
  /**
224
- * Reconcile error segments with cached segments.
231
+ * Reconcile an error-boundary response with cached segments.
225
232
  *
226
- * For error responses, the server returns the error boundary segment.
227
- * This function overlays error segments onto the full cached tree,
228
- * preserving sibling layouts that aren't in the error parent chain.
233
+ * The server returns the error boundary segment and, above it, what the
234
+ * action's revalidation re-ran (rsc/server-action.ts). This function overlays
235
+ * them onto the full cached tree, preserving sibling layouts that aren't in
236
+ * the error parent chain. The error segment replaces its cached copy; every
237
+ * other segment merges as an action's does (mergeCached).
229
238
  */
230
239
  export function reconcileErrorSegments(
231
240
  cachedSegments: ResolvedSegment[],
@@ -236,7 +245,11 @@ export function reconcileErrorSegments(
236
245
 
237
246
  const segments = cachedSegments.map((cached) => {
238
247
  const fromServer = errorMap.get(cached.id);
239
- return fromServer || cached;
248
+ return !fromServer
249
+ ? cached
250
+ : fromServer.type === "error"
251
+ ? fromServer
252
+ : mergeCached(fromServer, cached, "action");
240
253
  });
241
254
 
242
255
  const { main, intercept } = splitInterceptSegments(segments);
@@ -247,3 +260,46 @@ export function reconcileErrorSegments(
247
260
  interceptSegments: intercept,
248
261
  };
249
262
  }
263
+
264
+ /**
265
+ * The ids a page holds once an action's error boundary `boundary` shows: the
266
+ * ones above it. Nothing at or below the boundary ran after the action's
267
+ * write (its response and the follow-up cut there), so the next request must
268
+ * not hold it. ShortCodes nest, so those ids start with the boundary's: the
269
+ * boundary, its loaders (`${id}D…`), its slots and every entry under it. A
270
+ * sibling with a longer index (`…R10` beside `…R1`) is kept.
271
+ */
272
+ export function idsAboveBoundary(
273
+ ids: string[],
274
+ boundary: string | undefined,
275
+ ): string[] {
276
+ return boundary
277
+ ? ids.filter(
278
+ (id) =>
279
+ !id.startsWith(boundary) || /\d/.test(id.charAt(boundary.length)),
280
+ )
281
+ : ids;
282
+ }
283
+
284
+ /**
285
+ * The error segment of every action error boundary committed, by identity
286
+ * (server-action-bridge.ts adds and deletes). An entry shows one while its
287
+ * cached list still holds that object: a later commit on the entry replaces
288
+ * the list, and a refetch that holds the boundary keeps it. Weak, so a
289
+ * replaced boundary is not kept alive. Two routers never share a segment
290
+ * object. No hook reads it.
291
+ */
292
+ export const actionBoundaries: WeakSet<ResolvedSegment> = new WeakSet();
293
+
294
+ /**
295
+ * The index of the deepest action error boundary in `segments`, -1 for none.
296
+ * It is that action's outcome, which no GET reproduces: a background refetch
297
+ * of the page (the follow-up, Back/Forward's stale revalidation, the
298
+ * cross-tab refresh) holds it and renders what is above it. At 0 nothing is
299
+ * above it.
300
+ */
301
+ export function actionBoundaryAt(segments: readonly ResolvedSegment[]): number {
302
+ let at = segments.length;
303
+ while (at-- && !actionBoundaries.has(segments[at]));
304
+ return at;
305
+ }
@@ -15,6 +15,9 @@ import {
15
15
  import { KEEP_CACHE_HEADER } from "./cookie-name.js";
16
16
  import { createNavigationTransaction } from "./navigation-transaction.js";
17
17
  import {
18
+ actionBoundaries,
19
+ actionBoundaryAt,
20
+ idsAboveBoundary,
18
21
  reconcileSegments,
19
22
  reconcileErrorSegments,
20
23
  } from "./segment-reconciler.js";
@@ -52,6 +55,7 @@ import { stampLocationState } from "./react/location-state-shared.js";
52
55
  import { classifyActionOutcome } from "./action-coordinator.js";
53
56
  import { revalidationErrorOf } from "../decode-loader-results.js";
54
57
  import { getAppVersion } from "./app-version.js";
58
+ import { writePartialParams } from "./wire-protocol.js";
55
59
  import {
56
60
  decideCommitGatedOff,
57
61
  findActionFormData,
@@ -205,6 +209,8 @@ export function createServerActionBridge(
205
209
  actionId?: string;
206
210
  /** transition({ when }) `action` fields for the refetch commit. */
207
211
  action?: TransitionWhenActionInput;
212
+ /** The follow-up's held error boundary (UpdateMode `action`). */
213
+ boundary?: string;
208
214
  };
209
215
 
210
216
  /** The follow-up's transition({ when }) fields: the last owing action's. */
@@ -254,13 +260,13 @@ export function createServerActionBridge(
254
260
  let followUpsStarted = 0;
255
261
  /** The newest follow-up, until it ends: leaveFollowUp() aborts it. */
256
262
  let followUpRunning: { handle: NavigationHandle; due: FollowUp } | undefined;
257
-
258
263
  /**
259
264
  * The follow-up revalidation: the rules are R1-R7 in
260
265
  * docs/internal/execution-model.md. Once the batch's last action exited,
261
266
  * the page on screen that missed a write is refetched once, whole (no
262
- * segments held, so every loader re-runs), in the action lane (storeOnly,
263
- * skipLoadingState: useNavigation() stays idle).
267
+ * segments held, so every loader re-runs; above an action's boundary, see
268
+ * below), in the action lane (storeOnly, skipLoadingState:
269
+ * useNavigation() stays idle).
264
270
  *
265
271
  * - Never over an intercept: a request holding no segments drops the page
266
272
  * under it (match-result.ts buildMatchResult). Checked when it would run,
@@ -270,6 +276,10 @@ export function createServerActionBridge(
270
276
  * keeps its error on screen, and the debt is dropped.
271
277
  * - Runs again when it is left before its commit (a navigation, or an action
272
278
  * that started: leaveFollowUp), for the page then on screen.
279
+ * - Holds the action error boundary the page shows (actionBoundaryAt): it
280
+ * is that action's outcome, which no GET reproduces, so the server
281
+ * renders every segment above it and nothing at or below it. With
282
+ * nothing above it (the page's first segment), the debt is dropped.
273
283
  */
274
284
  function followUp(): void {
275
285
  if (!owed || isActionFenceActive()) return;
@@ -284,8 +294,16 @@ export function createServerActionBridge(
284
294
  const due = owed;
285
295
  owed = undefined;
286
296
  if (store.getInterceptSourceUrl()) return;
297
+ // The deepest action boundary the page shows: none (-1) refetches the
298
+ // page whole, the first segment (0) leaves nothing above it.
299
+ const shown =
300
+ store.getCachedSegments(store.getHistoryKey())?.segments ?? [];
301
+ const at = actionBoundaryAt(shown);
302
+ if (at === 0) return;
303
+ // Not on `due`: a run owed again (owed ??= due) looks again.
304
+ const held = { ...due, boundary: shown[at]?.id };
287
305
  const generation = ++followUpsStarted;
288
- refetchRoute(due, {
306
+ refetchRoute(held, {
289
307
  started: (handle) => (followUpRunning = { handle, due }),
290
308
  ended: (left) => {
291
309
  if (generation !== followUpsStarted) return;
@@ -358,6 +376,7 @@ export function createServerActionBridge(
358
376
  ...(src ? { interceptSourceUrl: src } : {}),
359
377
  ...(opts.actionId !== undefined ? { actionId: opts.actionId } : {}),
360
378
  ...(opts.action ? { action: opts.action } : {}),
379
+ boundary: opts.boundary,
361
380
  },
362
381
  );
363
382
  return { started: true, error };
@@ -487,28 +506,16 @@ export function createServerActionBridge(
487
506
  // Create temporary references for serialization
488
507
  const temporaryReferences = deps.createTemporaryReferenceSet();
489
508
 
490
- // Build action request URL with current segments
491
- const url = new URL(window.location.href);
492
- url.searchParams.set("_rsc_action", id);
493
- url.searchParams.set(
494
- "_rsc_segments",
495
- segmentState.currentSegmentIds.join(","),
496
- );
497
- // Add version param for version mismatch detection
498
- const version = getAppVersion();
499
- if (version) {
500
- url.searchParams.set("_rsc_v", version);
501
- }
502
- // Add router ID for app switch detection
503
- const rid = store.getRouterId?.();
504
- if (rid) {
505
- url.searchParams.set("_rsc_rid", rid);
506
- }
507
- // The page's rewrite target: the server diffs the re-render against it.
508
- const rewriteTarget = store.getHistoryEntryMemory()?.rewriteTarget;
509
- if (rewriteTarget) {
510
- url.searchParams.set("_rsc_rt", rewriteTarget);
511
- }
509
+ // Build action request URL with current segments; the server diffs the
510
+ // re-render against the page's rewrite target.
511
+ const url = writePartialParams(new URL(window.location.href), {
512
+ action: id,
513
+ segmentIds: segmentState.currentSegmentIds,
514
+ emptySegments: "send",
515
+ version: getAppVersion(),
516
+ routerId: store.getRouterId?.(),
517
+ rewriteTarget: store.getHistoryEntryMemory()?.rewriteTarget,
518
+ });
512
519
 
513
520
  // Encode arguments
514
521
  const encodedBody = await deps.encodeReply(args, { temporaryReferences });
@@ -740,36 +747,50 @@ export function createServerActionBridge(
740
747
  }
741
748
 
742
749
  // Handle error responses with error boundary UI
743
- if (isError && isPartial && segments && diff) {
750
+ if (isError && isPartial && segments) {
744
751
  log("processing error boundary response");
745
752
 
746
- // Fail current handle BEFORE aborting all actions so the event controller
747
- // records the error state (abortAllActions clears inflight entries)
753
+ // transition({ when }) sees action.error on the boundary commit and on
754
+ // a dropped one's follow-up.
755
+ whenAction = {
756
+ ...whenAction,
757
+ error: returnValue && !returnValue.ok ? returnValue.data : undefined,
758
+ };
759
+
760
+ // No consolidation: the boundary replaces the page, or the page left.
761
+ handle.clearConsolidation();
762
+
763
+ const currentKey = store.getHistoryKey();
764
+ const pageLeft = () =>
765
+ navigatedSince() || store.getHistoryKey() !== currentKey;
766
+ // The action's page left the screen (during the flight or the error
767
+ // render): drop the boundary; the call rejects and the exit owes the
768
+ // follow-up.
769
+ const dropBoundary = () => {
770
+ log("user navigated during error render, skipping");
771
+ if (returnValue && !returnValue.ok) throw returnValue.data;
772
+ handle.complete(undefined);
773
+ return undefined;
774
+ };
775
+ if (pageLeft()) return dropBoundary();
776
+
777
+ // fail() settles once the response's stream ends; the abort below
778
+ // spares this action, so its entry keeps the error for useAction().
748
779
  if (returnValue && !returnValue.ok) {
749
780
  handle.fail(returnValue.data);
750
781
  }
751
782
 
752
- // Abort all other pending action requests - error takes precedence
753
- // This prevents other actions from completing and overwriting the error UI
754
- eventController.abortAllActions();
755
-
756
- // Clear concurrent action tracking - no consolidation needed when showing error
757
- handle.clearConsolidation();
783
+ // Abort the other actions in flight: the error takes precedence, so
784
+ // none completes over the boundary.
785
+ eventController.abortAllActions(handle.id);
758
786
 
759
787
  // Get current page's cached segments
760
- const currentKey = store.getHistoryKey();
761
788
  const cached = store.getCachedSegments(currentKey);
762
789
  const cachedSegments = cached?.segments || [];
763
790
 
764
791
  // Reconcile error segments with cached tree
765
792
  const errorResult = reconcileErrorSegments(cachedSegments, segments);
766
793
 
767
- // transition({ when }) decides the error-boundary commit too, with
768
- // `action.error` set.
769
- whenAction = {
770
- ...whenAction,
771
- error: returnValue && !returnValue.ok ? returnValue.data : undefined,
772
- };
773
794
  const errorGatedOff = decideActionGatedOff(
774
795
  errorResult.segments,
775
796
  metadata,
@@ -786,28 +807,22 @@ export function createServerActionBridge(
786
807
  : undefined,
787
808
  });
788
809
 
789
- // The page the action was sent from left the screen, during the
790
- // flight or while the error tree rendered: the boundary is not shown,
791
- // and the action's exit owes the page on screen the follow-up.
792
- if (navigatedSince() || store.getHistoryKey() !== currentKey) {
793
- log("user navigated during error render, skipping");
794
- if (returnValue && !returnValue.ok) {
795
- throw returnValue.data;
796
- }
797
- handle.complete(undefined);
798
- return undefined;
799
- }
800
-
801
- // Update segment tracking to exclude error segment IDs
802
- const errorSegmentIds = new Set(diff);
803
- const segmentIdsAfterError = segmentState.currentSegmentIds.filter(
804
- (id) => !errorSegmentIds.has(id),
805
- );
810
+ if (pageLeft()) return dropBoundary();
806
811
 
807
812
  // Cache (and bump the nav instance) BEFORE the UI update so a deferred
808
813
  // handle pushed by the error-boundary render still applies — see the
809
- // "normal" case below for why caching after onUpdate dropped it.
810
- store.setSegmentIds(segmentIdsAfterError);
814
+ // "normal" case below for why caching after onUpdate dropped it. The
815
+ // action's boundary is the last error segment: the server's cut stops
816
+ // there (segment-resolution/revalidation.ts resolveAllSegments). The
817
+ // page holds what is above it, the loaders the response re-ran
818
+ // included, and no error segment.
819
+ const errors = segments.filter((s) => s.type === "error");
820
+ const boundary = errors[errors.length - 1];
821
+ store.setSegmentIds(
822
+ idsAboveBoundary(segmentState.currentSegmentIds, boundary?.id).filter(
823
+ (id) => !errors.some((s) => s.id === id),
824
+ ),
825
+ );
811
826
  const currentHandleData = liveHandleDataForCache(
812
827
  eventController,
813
828
  store,
@@ -819,6 +834,7 @@ export function createServerActionBridge(
819
834
  currentHandleData,
820
835
  );
821
836
  store.rememberDisplayedEntry();
837
+ if (boundary) actionBoundaries.add(boundary);
822
838
 
823
839
  // Update UI with error boundary.
824
840
  if (process.env.NODE_ENV !== "production") auditTreeCause("action");
@@ -919,6 +935,10 @@ export function createServerActionBridge(
919
935
  metadata?.locationState,
920
936
  eventController,
921
937
  );
938
+ // A success on the page supersedes the action boundary it shows
939
+ // (R4): a repair that stands in for this commit, direct or owed to
940
+ // the batch's end, holds none.
941
+ for (const s of cachedSegments) actionBoundaries.delete(s);
922
942
  }
923
943
  whenAction = { ...whenAction, result: returnData };
924
944
 
@@ -201,13 +201,27 @@ export interface InflightAction {
201
201
  * @internal This type is an implementation detail. Use PublicNavigationState instead.
202
202
  */
203
203
  export interface NavigationState {
204
- /** Navigation lifecycle state (idle or loading during navigation) */
204
+ /**
205
+ * Lifecycle state: `"loading"` while a navigation is in flight and while a
206
+ * server action runs, `"idle"` otherwise
207
+ */
205
208
  state: "idle" | "loading";
206
209
 
207
- /** Whether RSC data is currently streaming (initial load or navigation) */
210
+ /**
211
+ * Whether RSC data is currently streaming: a navigation's response, or a
212
+ * server action's response (initial load, navigation or action)
213
+ */
208
214
  isStreaming: boolean;
209
215
 
210
- /** Current location */
216
+ /**
217
+ * True while a navigation is in flight (fetching or streaming, before its
218
+ * commit); false during a server action, whose `state` is still `"loading"`.
219
+ * Drive a navigation progress bar from this and an action spinner from
220
+ * `state`.
221
+ */
222
+ isNavigating: boolean;
223
+
224
+ /** The page on screen; moves when the next page shows (`pendingUrl` is the page being fetched) */
211
225
  location: NavigationLocation;
212
226
 
213
227
  /** URL being navigated to (null when idle) */
@@ -505,6 +519,8 @@ export interface NavigateOptionsInternal extends Omit<
505
519
  _skipCache?: boolean;
506
520
  /** Server redirects already followed by this navigation chain (0 for a user navigation) */
507
521
  _redirectHops?: number;
522
+ /** The URL a redirected navigation chain started from: a link to it stays pending (useLinkStatus) */
523
+ _redirectFrom?: string;
508
524
  }
509
525
 
510
526
  /**
@@ -649,7 +665,8 @@ export interface NavigationStore {
649
665
  handlesPending?: boolean;
650
666
  }
651
667
  | undefined;
652
- hasHistoryCache(historyKey: string): boolean;
668
+ /** Whether an intercept entry is cached for the plain `historyKey`, from any source page. */
669
+ hasInterceptCache(historyKey: string): boolean;
653
670
  /**
654
671
  * Update only the handleData (and optionally the stale / handlesPending flags)
655
672
  * of an existing cache entry. When a flag is omitted the entry's current value
@@ -717,6 +734,8 @@ export interface FetchPartialOptions {
717
734
  signal?: AbortSignal;
718
735
  /** If true, this is a stale cache revalidation request - server should force revalidators */
719
736
  staleRevalidation?: boolean;
737
+ /** Sent as `_rsc_boundary` (wire-params.ts WIRE.boundary). */
738
+ boundary?: string;
720
739
  interceptSourceUrl?: string;
721
740
  /** RSC version for cache invalidation detection */
722
741
  version?: string;
@@ -802,7 +821,8 @@ export interface ServerActionBridgeConfig {
802
821
  */
803
822
  export interface NavigationBridge {
804
823
  navigate(url: string, options?: NavigateOptionsInternal): Promise<void>;
805
- refresh(): Promise<void>;
824
+ /** `crossTab`: another tab's write; an action's error boundary stays. */
825
+ refresh(crossTab?: boolean): Promise<void>;
806
826
  /** `event`: the popstate event; a traversal the browser animated itself (hasUAVisualTransition) is not animated again. */
807
827
  handlePopstate(event?: PopStateEvent): Promise<void>;
808
828
  registerLinkInterception(): () => void;
@@ -0,0 +1,43 @@
1
+ import { WIRE } from "../wire-params.js";
2
+
3
+ /** The parameters of a partial request: a navigation, a prefetch or a JS server action. */
4
+ export interface PartialRequestInit {
5
+ /** A JS server action's id: written as `_rsc_action` in place of `_rsc_partial`. */
6
+ action?: string;
7
+ /** The mounted segment ids the server diffs against. */
8
+ segmentIds: readonly string[];
9
+ /**
10
+ * An empty `segmentIds`: "send" writes `_rsc_segments=` (navigation,
11
+ * action), "omit" leaves it out (prefetch). The prefetch cache keys carry
12
+ * the difference.
13
+ */
14
+ emptySegments: "send" | "omit";
15
+ stale?: boolean;
16
+ boundary?: string;
17
+ version?: string;
18
+ routerId?: string;
19
+ rewriteTarget?: string;
20
+ fill?: boolean;
21
+ }
22
+
23
+ /**
24
+ * Writes the partial-request parameters onto `url` in the one order every
25
+ * sender uses. Navigation and prefetch cache keys are built from the result
26
+ * (prefetch/cache.ts), so a navigation adopts a prefetch only while both
27
+ * write identically: browser/__tests__/wire-urls.test.ts pins the strings.
28
+ */
29
+ export function writePartialParams(url: URL, init: PartialRequestInit): URL {
30
+ const params = url.searchParams;
31
+ if (init.action !== undefined) params.set(WIRE.action, init.action);
32
+ else params.set(WIRE.partial, "true");
33
+ if (init.segmentIds.length > 0 || init.emptySegments === "send") {
34
+ params.set(WIRE.segments, init.segmentIds.join(","));
35
+ }
36
+ if (init.stale) params.set(WIRE.stale, "true");
37
+ if (init.boundary) params.set(WIRE.boundary, init.boundary);
38
+ if (init.version) params.set(WIRE.version, init.version);
39
+ if (init.routerId) params.set(WIRE.routerId, init.routerId);
40
+ if (init.rewriteTarget) params.set(WIRE.rewriteTarget, init.rewriteTarget);
41
+ if (init.fill) params.set(WIRE.fill, "1");
42
+ return url;
43
+ }
@@ -187,8 +187,10 @@ export const KEY_PART_PREFIX = "key:";
187
187
  * (CacheScope.resolveKeyFrom, CacheScope.resolvePartition): the `key()`
188
188
  * results of the scope's cache() chain, outermost first, then its default
189
189
  * parts: the keyGenerator results of enclosing scopes on another store
190
- * (#974) and, for a record key under a scope without its own `key()`, its
191
- * default key (or its store's keyGenerator result, #970).
190
+ * (#974) and, for a route record key, the scope's own part: without a
191
+ * `key()` of its own, its default key (or its store's keyGenerator result,
192
+ * #970); with one, the default key's request kind (`doc`, `partial` or
193
+ * `intercept:<intercept>`, routeRecordKind in cache-scope.ts).
192
194
  *
193
195
  * Key scheme invariant (#975). A `key()` result is often request input,
194
196
  * and a record key has no route or scope discriminator (the store reads
@@ -214,9 +216,13 @@ export const KEY_PART_PREFIX = "key:";
214
216
  * A namespaced `key()` result starts with `key:` and holds no `|`. A
215
217
  * composed key holds a `|` and either starts with `key:` or holds no `:`.
216
218
  * Among composed keys, splitting on `|` recovers each part, a part's kind
217
- * (a `key:` prefix, or no `:` at all) and its value. Pinned by the collision
218
- * probe in src/cache/__tests__/cache-scope-chain.test.ts. A keyGenerator
219
- * result is the store's own namespace, as before.
219
+ * (a `key:` prefix, or no `:` at all) and its value. A request kind part is
220
+ * a default key's prefix without the URL after it, so it never equals a
221
+ * default key part; a keyGenerator part can equal it (`doc`) only when the
222
+ * keyGenerator returns a constant, which already gives every URL one record.
223
+ * Pinned by the collision probe in
224
+ * src/cache/__tests__/cache-scope-chain.test.ts. A keyGenerator result is
225
+ * the store's own namespace, as before.
220
226
  *
221
227
  * Shared with the testing helper `shellCacheKey`, so a test builds the
222
228
  * production partition.