@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
@@ -7,7 +7,11 @@ import type {
7
7
  UpdateSubscriber,
8
8
  } from "./types.js";
9
9
  import { addTransitionType, createPartialUpdater } from "./partial-update.js";
10
- import { enterActionFence, exitActionFence } from "./action-fence.js";
10
+ import {
11
+ enterActionFence,
12
+ exitActionFence,
13
+ isActionFenceActive,
14
+ } from "./action-fence.js";
11
15
  import { KEEP_CACHE_HEADER } from "./cookie-name.js";
12
16
  import { createNavigationTransaction } from "./navigation-transaction.js";
13
17
  import {
@@ -16,7 +20,12 @@ import {
16
20
  } from "./segment-reconciler.js";
17
21
  import { startTransition } from "react";
18
22
  import { liveHandleDataForCache } from "./navigation-store.js";
19
- import type { EventController, ActionHandle } from "./event-controller.js";
23
+ import type {
24
+ EventController,
25
+ ActionHandle,
26
+ NavigationEntry,
27
+ NavigationHandle,
28
+ } from "./event-controller.js";
20
29
  import {
21
30
  toNetworkError,
22
31
  emitNetworkError,
@@ -43,6 +52,7 @@ import { stampLocationState } from "./react/location-state-shared.js";
43
52
  import { classifyActionOutcome } from "./action-coordinator.js";
44
53
  import { revalidationErrorOf } from "../decode-loader-results.js";
45
54
  import { getAppVersion } from "./app-version.js";
55
+ import { writePartialParams } from "./wire-protocol.js";
46
56
  import {
47
57
  decideCommitGatedOff,
48
58
  findActionFormData,
@@ -190,30 +200,153 @@ export function createServerActionBridge(
190
200
  action,
191
201
  });
192
202
 
193
- /**
194
- * Refetch current route via a navigation transaction.
195
- * Encapsulates the repeated pattern of creating a navTx + fetchPartialUpdate
196
- * used by navigated-away, hmr-missing, and consolidation-needed scenarios.
197
- * Resolves with the error of a loader that threw on the refetch.
198
- */
199
- async function refetchRoute(opts?: {
203
+ type RefetchOptions = {
200
204
  segments?: string[];
201
205
  interceptSourceUrl?: string | null;
202
206
  actionId?: string;
203
207
  /** transition({ when }) `action` fields for the refetch commit. */
204
208
  action?: TransitionWhenActionInput;
205
- }): Promise<Error | undefined> {
206
- const src = opts?.interceptSourceUrl ?? null;
209
+ };
210
+
211
+ /** The follow-up's transition({ when }) fields: the last owing action's. */
212
+ type FollowUp = Pick<RefetchOptions, "actionId" | "action">;
213
+
214
+ /**
215
+ * A navigation the user started (a click, Back/Forward, refresh(), an
216
+ * action's redirect) that a refetch may supersede: not committed yet, or
217
+ * committed with its transaction (a `prefetch: false` fill) or its stream
218
+ * still open (NavigationEntry.settled). A refetch never starts while one
219
+ * is open: its startNavigation would abort it, and with it the fill. A
220
+ * background revalidation (skipLoadingState: the follow-up, a repair, a
221
+ * Back/Forward entry's SWR) is not one.
222
+ */
223
+ const unsettledUserNavigation = (): NavigationEntry | undefined => {
224
+ const pending = eventController.getUnsettledNavigation();
225
+ return pending && !pending.options?.skipLoadingState ? pending : undefined;
226
+ };
227
+
228
+ /**
229
+ * A navigation the user started that may commit data read before an
230
+ * action's write: it has not committed yet. One that committed and only
231
+ * streams does not count: the action's own response re-rendered its page.
232
+ */
233
+ const pendingUserNavigation = (): boolean => {
234
+ const pending = eventController.getCurrentNavigation();
235
+ return !!pending && !pending.options?.skipLoadingState;
236
+ };
237
+
238
+ /**
239
+ * The follow-up the page on screen is owed (finalizeAction sets it, a
240
+ * redirect clears it as it starts, followUp() takes it). Closure state of
241
+ * this bridge, as are the three below: no hook reads it, and the store
242
+ * stays the one source of truth for what hooks render (AGENTS.md Hard
243
+ * rule 8).
244
+ */
245
+ let owed: FollowUp | undefined;
246
+ /** An action of the batch wrote (R1 applied); reset when the fence drops. */
247
+ let batchWrote = false;
248
+ /**
249
+ * Follow-ups started so far. Only the newest one runs again when it ends
250
+ * left: a newer one started after it, so its request read every write so
251
+ * far. A left follow-up's response still arrives (its request is not tied
252
+ * to its signal), and re-owing from it would start one that leaves the
253
+ * newer one, whose late answer would do the same, without end.
254
+ */
255
+ let followUpsStarted = 0;
256
+ /** The newest follow-up, until it ends: leaveFollowUp() aborts it. */
257
+ let followUpRunning: { handle: NavigationHandle; due: FollowUp } | undefined;
258
+
259
+ /**
260
+ * The follow-up revalidation: the rules are R1-R7 in
261
+ * docs/internal/execution-model.md. Once the batch's last action exited,
262
+ * the page on screen that missed a write is refetched once, whole (no
263
+ * segments held, so every loader re-runs), in the action lane (storeOnly,
264
+ * skipLoadingState: useNavigation() stays idle).
265
+ *
266
+ * - Never over an intercept: a request holding no segments drops the page
267
+ * under it (match-result.ts buildMatchResult). Checked when it would run,
268
+ * against the page then on screen; the debt is dropped there.
269
+ * - Never supersedes a navigation the user started (dc-t114): it waits for
270
+ * NavigationEntry.settled, then looks again. A navigation that failed
271
+ * keeps its error on screen, and the debt is dropped.
272
+ * - Runs again when it is left before its commit (a navigation, or an action
273
+ * that started: leaveFollowUp), for the page then on screen.
274
+ */
275
+ function followUp(): void {
276
+ if (!owed || isActionFenceActive()) return;
277
+ const pending = unsettledUserNavigation();
278
+ if (pending) {
279
+ void pending.settled.then((committed) => {
280
+ if (!committed && !pending.abort.signal.aborted) owed = undefined;
281
+ followUp();
282
+ });
283
+ return;
284
+ }
285
+ const due = owed;
286
+ owed = undefined;
287
+ if (store.getInterceptSourceUrl()) return;
288
+ const generation = ++followUpsStarted;
289
+ refetchRoute(due, {
290
+ started: (handle) => (followUpRunning = { handle, due }),
291
+ ended: (left) => {
292
+ if (generation !== followUpsStarted) return;
293
+ followUpRunning = undefined;
294
+ if (!left) return;
295
+ owed ??= due;
296
+ followUp();
297
+ },
298
+ }).catch((error) => {
299
+ if (isBackgroundSuppressible(error)) return;
300
+ console.error("[Browser] Background revalidation failed:", error);
301
+ });
302
+ }
303
+
304
+ /**
305
+ * R6: an action that starts while the follow-up has not committed leaves
306
+ * it, and the batch's end owes it again. Its request may predate that
307
+ * action's write, and its commit could land after the action's own,
308
+ * moving data on screen backwards (dc-t110). Through the controller, so
309
+ * the navigation it started stops being current.
310
+ */
311
+ function leaveFollowUp(): void {
312
+ const running = followUpRunning;
313
+ const current = eventController.getCurrentNavigation();
314
+ if (!running || current?.abort !== running.handle.abort) return;
315
+ owed ??= running.due;
316
+ eventController.abortNavigation(true);
317
+ }
318
+
319
+ /**
320
+ * Refetch the page on screen through a navigation transaction in the
321
+ * action lane: the follow-up, and the hmr-missing and consolidation-needed
322
+ * repairs. Resolves with whether it started and the error of a loader
323
+ * that threw on it. Never starts while a navigation the user started may
324
+ * be superseded (unsettledUserNavigation): its startNavigation would
325
+ * abort it (dc-t114). A repair that cannot start is owed at the batch's
326
+ * end instead (finalizeAction). `run` (the follow-up's) is handed the
327
+ * refetch's navigation, and told once whether it ended left before its
328
+ * commit.
329
+ */
330
+ async function refetchRoute(
331
+ opts: RefetchOptions = {},
332
+ run?: {
333
+ started(handle: NavigationHandle): void;
334
+ ended(left: boolean): void;
335
+ },
336
+ ): Promise<{ started: boolean; error?: Error }> {
337
+ if (unsettledUserNavigation()) return { started: false };
338
+ const src = opts.interceptSourceUrl ?? null;
207
339
  const navTx = createNavigationTransaction(
208
340
  store,
209
341
  eventController,
210
342
  window.location.href,
211
343
  { replace: true, skipLoadingState: true },
212
344
  );
345
+ run?.started(navTx.handle);
213
346
  try {
214
- return await fetchPartialUpdate(
347
+ const error = await fetchPartialUpdate(
215
348
  window.location.href,
216
- opts?.segments ?? [],
349
+ opts.segments ?? [],
217
350
  false,
218
351
  navTx.handle.signal,
219
352
  navTx.with({
@@ -224,12 +357,14 @@ export function createServerActionBridge(
224
357
  {
225
358
  type: "action" as const,
226
359
  ...(src ? { interceptSourceUrl: src } : {}),
227
- ...(opts?.actionId !== undefined ? { actionId: opts.actionId } : {}),
228
- ...(opts?.action ? { action: opts.action } : {}),
360
+ ...(opts.actionId !== undefined ? { actionId: opts.actionId } : {}),
361
+ ...(opts.action ? { action: opts.action } : {}),
229
362
  },
230
363
  );
364
+ return { started: true, error };
231
365
  } finally {
232
366
  navTx[Symbol.dispose]();
367
+ run?.ended(navTx.handle.signal.aborted && !navTx.handle.completed);
233
368
  }
234
369
  }
235
370
 
@@ -244,7 +379,18 @@ export function createServerActionBridge(
244
379
  if (tx) browserDebugLog(tx, msg, details);
245
380
  };
246
381
 
382
+ leaveFollowUp();
247
383
  const locationKey = window.history.state?.key;
384
+ // Capture URL pathname at action start to detect navigation during action
385
+ // Must use window.location (not store.path) because intercepts change URL
386
+ // without changing store.path (e.g., /kanban -> /kanban/card/1)
387
+ const actionStartPathname = window.location.pathname;
388
+ // The page the action was sent from is no longer on screen: a navigation
389
+ // committed since it started (the classifyActionOutcome rule). Its
390
+ // response then commits nothing, and its exit owes the follow-up.
391
+ const navigatedSince = (): boolean =>
392
+ window.location.pathname !== actionStartPathname ||
393
+ window.history.state?.key !== locationKey;
248
394
  log("action start", { id, argsCount: args.length });
249
395
 
250
396
  // Start action in event controller - handles lifecycle tracking. The
@@ -272,6 +418,23 @@ export function createServerActionBridge(
272
418
  // its own latch. Latched so the finally AND the early SPA-redirect returns
273
419
  // (whose Flight stream never settles) can both call it safely.
274
420
  let actionFinalized = false;
421
+ // Set where the action navigates to its redirect's target.
422
+ let redirected = false;
423
+ // Set where its repair refetch could not start (refetchRoute).
424
+ let repairOwed = false;
425
+ // R5: the redirect fetches its page after every write so far, so the
426
+ // batch's debt is cleared as it starts, not at the action's exit: a
427
+ // sibling that lands during its navigation owes for the page it commits.
428
+ const startRedirect = (): void => {
429
+ redirected = true;
430
+ owed = undefined;
431
+ };
432
+ // transition({ when }) `action` for this action's commit, whichever lane
433
+ // applies it (its own, a repair refetch, or the follow-up).
434
+ let whenAction: TransitionWhenActionInput = {
435
+ id,
436
+ formData: findActionFormData(args),
437
+ };
275
438
  // skipInvalidation: the version-mismatch reload terminal released nothing
276
439
  // server-side, so it releases the fence without invalidating.
277
440
  const finalizeAction = (skipInvalidation = false): void => {
@@ -284,9 +447,32 @@ export function createServerActionBridge(
284
447
  // marking the cache stale + broadcasting cross-tab would be spurious.
285
448
  if (responseReceived && !keepCache && !skipInvalidation) {
286
449
  store.markCacheAsStaleAndBroadcast();
450
+ batchWrote = true;
451
+ // The write may be missing from the page on screen: that page is
452
+ // not the one the response re-rendered, or a navigation the user
453
+ // started has not committed and its request may predate the write.
454
+ // A navigation that committed and only streams owes nothing: this
455
+ // response re-rendered its page. A later land never clears this:
456
+ // its request was read after its own write, not necessarily after
457
+ // this one. A redirect owes nothing: its navigation cleared the
458
+ // debt (startRedirect).
459
+ if (!redirected && (navigatedSince() || pendingUserNavigation())) {
460
+ owed = { actionId: id, action: whenAction };
461
+ }
462
+ }
463
+ // A repair that could not start is owed at the batch's end when an
464
+ // action of the batch wrote. The repair runs whatever this action's
465
+ // keepClientCache(), so a keep action that lands last discharges a
466
+ // directive-free sibling's consolidation; a lone keep action owes
467
+ // nothing.
468
+ if (repairOwed && batchWrote) {
469
+ owed = { actionId: id, action: whenAction };
287
470
  }
288
471
  } finally {
289
472
  exitActionFence();
473
+ if (!isActionFenceActive()) batchWrote = false;
474
+ // The batch's last exit runs it (followUp returns while the fence is up).
475
+ followUp();
290
476
  }
291
477
  };
292
478
  try {
@@ -302,33 +488,16 @@ export function createServerActionBridge(
302
488
  // Create temporary references for serialization
303
489
  const temporaryReferences = deps.createTemporaryReferenceSet();
304
490
 
305
- // Capture URL pathname at action start to detect navigation during action
306
- // Must use window.location (not store.path) because intercepts change URL
307
- // without changing store.path (e.g., /kanban -> /kanban/card/1)
308
- const actionStartPathname = window.location.pathname;
309
-
310
- // Build action request URL with current segments
311
- const url = new URL(window.location.href);
312
- url.searchParams.set("_rsc_action", id);
313
- url.searchParams.set(
314
- "_rsc_segments",
315
- segmentState.currentSegmentIds.join(","),
316
- );
317
- // Add version param for version mismatch detection
318
- const version = getAppVersion();
319
- if (version) {
320
- url.searchParams.set("_rsc_v", version);
321
- }
322
- // Add router ID for app switch detection
323
- const rid = store.getRouterId?.();
324
- if (rid) {
325
- url.searchParams.set("_rsc_rid", rid);
326
- }
327
- // The page's rewrite target: the server diffs the re-render against it.
328
- const rewriteTarget = store.getHistoryEntryMemory()?.rewriteTarget;
329
- if (rewriteTarget) {
330
- url.searchParams.set("_rsc_rt", rewriteTarget);
331
- }
491
+ // Build action request URL with current segments; the server diffs the
492
+ // re-render against the page's rewrite target.
493
+ const url = writePartialParams(new URL(window.location.href), {
494
+ action: id,
495
+ segmentIds: segmentState.currentSegmentIds,
496
+ emptySegments: "send",
497
+ version: getAppVersion(),
498
+ routerId: store.getRouterId?.(),
499
+ rewriteTarget: store.getHistoryEntryMemory()?.rewriteTarget,
500
+ });
332
501
 
333
502
  // Encode arguments
334
503
  const encodedBody = await deps.encodeReply(args, { temporaryReferences });
@@ -411,6 +580,7 @@ export function createServerActionBridge(
411
580
  if (redirect && redirect !== "blocked" && !handle.signal.aborted) {
412
581
  log("action simple redirect", { url: redirect.url });
413
582
  handle.complete(undefined);
583
+ startRedirect();
414
584
  // This path returns a never-settling promise, so the finally never
415
585
  // runs: invalidate + release the fence here (the mutation committed
416
586
  // and we're navigating away). Latched, so the finally is a no-op.
@@ -529,7 +699,10 @@ export function createServerActionBridge(
529
699
  : "blocked action redirect payload",
530
700
  { url: redirect.url },
531
701
  );
532
- } else if (redirect.external) {
702
+ return returnValue?.data;
703
+ }
704
+ startRedirect();
705
+ if (redirect.external) {
533
706
  log("external action redirect", { url: target });
534
707
  window.location.assign(target);
535
708
  } else {
@@ -556,41 +729,54 @@ export function createServerActionBridge(
556
729
  }
557
730
 
558
731
  // Handle error responses with error boundary UI
559
- if (isError && isPartial && segments && diff) {
732
+ if (isError && isPartial && segments) {
560
733
  log("processing error boundary response");
561
734
 
562
- // Fail current handle BEFORE aborting all actions so the event controller
563
- // records the error state (abortAllActions clears inflight entries)
735
+ // transition({ when }) sees action.error on the boundary commit and on
736
+ // a dropped one's follow-up.
737
+ whenAction = {
738
+ ...whenAction,
739
+ error: returnValue && !returnValue.ok ? returnValue.data : undefined,
740
+ };
741
+
742
+ // No consolidation: the boundary replaces the page, or the page left.
743
+ handle.clearConsolidation();
744
+
745
+ const currentKey = store.getHistoryKey();
746
+ const pageLeft = () =>
747
+ navigatedSince() || store.getHistoryKey() !== currentKey;
748
+ // The action's page left the screen (during the flight or the error
749
+ // render): drop the boundary; the call rejects and the exit owes the
750
+ // follow-up.
751
+ const dropBoundary = () => {
752
+ log("user navigated during error render, skipping");
753
+ if (returnValue && !returnValue.ok) throw returnValue.data;
754
+ handle.complete(undefined);
755
+ return undefined;
756
+ };
757
+ if (pageLeft()) return dropBoundary();
758
+
759
+ // fail() settles once the response's stream ends; the abort below
760
+ // spares this action, so its entry keeps the error for useAction().
564
761
  if (returnValue && !returnValue.ok) {
565
762
  handle.fail(returnValue.data);
566
763
  }
567
764
 
568
- // Abort all other pending action requests - error takes precedence
569
- // This prevents other actions from completing and overwriting the error UI
570
- eventController.abortAllActions();
571
-
572
- // Clear concurrent action tracking - no consolidation needed when showing error
573
- handle.clearConsolidation();
765
+ // Abort the other actions in flight: the error takes precedence, so
766
+ // none completes over the boundary.
767
+ eventController.abortAllActions(handle.id);
574
768
 
575
769
  // Get current page's cached segments
576
- const currentKey = store.getHistoryKey();
577
770
  const cached = store.getCachedSegments(currentKey);
578
771
  const cachedSegments = cached?.segments || [];
579
772
 
580
773
  // Reconcile error segments with cached tree
581
774
  const errorResult = reconcileErrorSegments(cachedSegments, segments);
582
775
 
583
- // transition({ when }) decides the error-boundary commit too, with
584
- // `action.error` set.
585
776
  const errorGatedOff = decideActionGatedOff(
586
777
  errorResult.segments,
587
778
  metadata,
588
- {
589
- id,
590
- formData: findActionFormData(args),
591
- error:
592
- returnValue && !returnValue.ok ? returnValue.data : undefined,
593
- },
779
+ whenAction,
594
780
  );
595
781
 
596
782
  // Render the full tree with error segment merged with parent layouts
@@ -603,36 +789,18 @@ export function createServerActionBridge(
603
789
  : undefined,
604
790
  });
605
791
 
606
- // Re-check route stability after async renderSegments — user may have
607
- // navigated away while the error tree was being prepared.
608
- if (window.location.pathname !== actionStartPathname) {
609
- log("user navigated during error render, skipping");
610
- if (returnValue && !returnValue.ok) {
611
- throw returnValue.data;
612
- }
613
- handle.complete(undefined);
614
- return undefined;
615
- }
616
- const currentKeyNow = store.getHistoryKey();
617
- if (currentKeyNow !== currentKey) {
618
- log("history key changed during error render, skipping cache update");
619
- if (returnValue && !returnValue.ok) {
620
- throw returnValue.data;
621
- }
622
- handle.complete(undefined);
623
- return undefined;
624
- }
625
-
626
- // Update segment tracking to exclude error segment IDs
627
- const errorSegmentIds = new Set(diff);
628
- const segmentIdsAfterError = segmentState.currentSegmentIds.filter(
629
- (id) => !errorSegmentIds.has(id),
630
- );
792
+ if (pageLeft()) return dropBoundary();
631
793
 
632
794
  // Cache (and bump the nav instance) BEFORE the UI update so a deferred
633
795
  // handle pushed by the error-boundary render still applies — see the
634
- // "normal" case below for why caching after onUpdate dropped it.
635
- store.setSegmentIds(segmentIdsAfterError);
796
+ // "normal" case below for why caching after onUpdate dropped it. The
797
+ // page stops claiming the error segments only: the loaders the
798
+ // response re-ran above the boundary stay held.
799
+ store.setSegmentIds(
800
+ segmentState.currentSegmentIds.filter(
801
+ (id) => !segments.some((s) => s.id === id && s.type === "error"),
802
+ ),
803
+ );
636
804
  const currentHandleData = liveHandleDataForCache(
637
805
  eventController,
638
806
  store,
@@ -709,6 +877,7 @@ export function createServerActionBridge(
709
877
  let revalidationError: Error | undefined;
710
878
 
711
879
  if (returnValue && !returnValue.ok) {
880
+ whenAction = { ...whenAction, error: returnValue.data };
712
881
  handle.fail(returnValue.data);
713
882
  throw returnValue.data;
714
883
  }
@@ -725,7 +894,6 @@ export function createServerActionBridge(
725
894
  currentLocationKey: window.history.state?.key,
726
895
  reconciledSegmentCount: fullSegments.length,
727
896
  matchedCount: matched.length,
728
- currentInterceptSource: store.getInterceptSourceUrl(),
729
897
  });
730
898
 
731
899
  // Apply server-set location state exhaustively here, as a successful-
@@ -745,51 +913,17 @@ export function createServerActionBridge(
745
913
  eventController,
746
914
  );
747
915
  }
748
- // transition({ when }) `action` for this action's commit, whichever
749
- // lane applies it (normal, or a refetch below).
750
- const whenAction: TransitionWhenActionInput = {
751
- id,
752
- formData: findActionFormData(args),
753
- result: returnData,
754
- };
916
+ whenAction = { ...whenAction, result: returnData };
755
917
 
756
918
  switch (scenario.type) {
757
919
  case "navigated-away": {
758
920
  log("user navigated away during action", {
759
921
  from: actionStartPathname,
760
922
  to: window.location.pathname,
761
- historyKeyChanged: scenario.historyKeyChanged,
762
923
  });
763
924
  // Clear concurrent action tracking - don't consolidate for old route's segments
764
925
  handle.clearConsolidation();
765
-
766
- if (scenario.historyKeyChanged) {
767
- // Invalidation is deferred to finalizeAction(); here we only trigger
768
- // the revalidation refetch of the new route (suppressed on keep).
769
- if (!scenario.onInterceptRoute && !keepCache) {
770
- refetchRoute({ actionId: id, action: whenAction }).catch(
771
- (error) => {
772
- if (isBackgroundSuppressible(error)) return;
773
- console.error(
774
- "[Browser] Background revalidation failed:",
775
- error,
776
- );
777
- },
778
- );
779
- }
780
- break;
781
- }
782
-
783
- // Same history key but different pathname - safe to refetch current
784
- // route. Invalidation is deferred to finalizeAction(); here we only
785
- // trigger the revalidation refetch (suppressed on keep).
786
- if (!keepCache) {
787
- revalidationError = await refetchRoute({
788
- interceptSourceUrl: store.getInterceptSourceUrl(),
789
- actionId: id,
790
- action: whenAction,
791
- });
792
- }
926
+ // finalizeAction() owes the page on screen the follow-up.
793
927
  break;
794
928
  }
795
929
 
@@ -801,11 +935,13 @@ export function createServerActionBridge(
801
935
  // resolving last must discharge a directive-free sibling's repair.
802
936
  // See the keep row in docs/design/rango-state-cookie.md (the all-keep
803
937
  // edge, and the benign re-mark-stale-after-refetch end-state delta).
804
- revalidationError = await refetchRoute({
938
+ const repair = await refetchRoute({
805
939
  interceptSourceUrl,
806
940
  actionId: id,
807
941
  action: whenAction,
808
942
  });
943
+ revalidationError = repair.error;
944
+ repairOwed = !repair.started;
809
945
  break;
810
946
  }
811
947
 
@@ -824,12 +960,14 @@ export function createServerActionBridge(
824
960
  handle.clearConsolidation();
825
961
 
826
962
  // Ungated on keepCache, same as hmr-missing above (see the keep row).
827
- revalidationError = await refetchRoute({
963
+ const repair = await refetchRoute({
828
964
  segments: segmentsToSend,
829
965
  interceptSourceUrl,
830
966
  actionId: id,
831
967
  action: whenAction,
832
968
  });
969
+ revalidationError = repair.error;
970
+ repairOwed = !repair.started;
833
971
  break;
834
972
  }
835
973
 
@@ -649,7 +649,8 @@ export interface NavigationStore {
649
649
  handlesPending?: boolean;
650
650
  }
651
651
  | undefined;
652
- hasHistoryCache(historyKey: string): boolean;
652
+ /** Whether an intercept entry is cached for the plain `historyKey`, from any source page. */
653
+ hasInterceptCache(historyKey: string): boolean;
653
654
  /**
654
655
  * Update only the handleData (and optionally the stale / handlesPending flags)
655
656
  * of an existing cache entry. When a flag is omitted the entry's current value
@@ -0,0 +1,41 @@
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
+ version?: string;
17
+ routerId?: string;
18
+ rewriteTarget?: string;
19
+ fill?: boolean;
20
+ }
21
+
22
+ /**
23
+ * Writes the partial-request parameters onto `url` in the one order every
24
+ * sender uses. Navigation and prefetch cache keys are built from the result
25
+ * (prefetch/cache.ts), so a navigation adopts a prefetch only while both
26
+ * write identically: browser/__tests__/wire-urls.test.ts pins the strings.
27
+ */
28
+ export function writePartialParams(url: URL, init: PartialRequestInit): URL {
29
+ const params = url.searchParams;
30
+ if (init.action !== undefined) params.set(WIRE.action, init.action);
31
+ else params.set(WIRE.partial, "true");
32
+ if (init.segmentIds.length > 0 || init.emptySegments === "send") {
33
+ params.set(WIRE.segments, init.segmentIds.join(","));
34
+ }
35
+ if (init.stale) params.set(WIRE.stale, "true");
36
+ if (init.version) params.set(WIRE.version, init.version);
37
+ if (init.routerId) params.set(WIRE.routerId, init.routerId);
38
+ if (init.rewriteTarget) params.set(WIRE.rewriteTarget, init.rewriteTarget);
39
+ if (init.fill) params.set(WIRE.fill, "1");
40
+ return url;
41
+ }
@@ -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.