@rangojs/router 0.26.5 → 0.27.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 (59) hide show
  1. package/dist/bin/rango.js +2 -2
  2. package/dist/types/browser/action-coordinator.d.ts +0 -4
  3. package/dist/types/browser/action-fence.d.ts +11 -3
  4. package/dist/types/browser/event-controller.d.ts +18 -0
  5. package/dist/types/browser/history-state.d.ts +4 -0
  6. package/dist/types/browser/intercept-utils.d.ts +23 -0
  7. package/dist/types/browser/rsc-router.d.ts +1 -1
  8. package/dist/types/prefetch-markers.d.ts +12 -5
  9. package/dist/types/router/match-context.d.ts +8 -0
  10. package/dist/types/router/match-middleware/cache-lookup.d.ts +1 -1
  11. package/dist/types/router/match-middleware/cache-store.d.ts +2 -2
  12. package/dist/types/router/revalidation.d.ts +6 -1
  13. package/dist/types/router/router-context.d.ts +2 -17
  14. package/dist/types/rsc/shell-capture-constants.d.ts +1 -1
  15. package/dist/types/testing/serve-shell-request.d.ts +7 -0
  16. package/dist/types/types/handler-context.d.ts +9 -6
  17. package/dist/types/vite/plugins/server-ref-hashing.d.ts +8 -1
  18. package/dist/vite/index.js +10 -6
  19. package/package.json +2 -2
  20. package/skills/client-urls/SKILL.md +2 -1
  21. package/skills/document-cache/SKILL.md +5 -0
  22. package/skills/intercept/SKILL.md +36 -6
  23. package/skills/layout/SKILL.md +11 -7
  24. package/skills/loader/SKILL.md +30 -13
  25. package/skills/middleware/SKILL.md +1 -1
  26. package/skills/migrate-nextjs/SKILL.md +5 -4
  27. package/skills/parallel/SKILL.md +5 -4
  28. package/skills/ppr/SKILL.md +1 -0
  29. package/skills/rango/SKILL.md +18 -10
  30. package/skills/server-actions/SKILL.md +43 -5
  31. package/skills/testing/predicates.md +1 -1
  32. package/skills/testing/serve-shell-request.md +7 -7
  33. package/skills/ux-architecture/SKILL.md +3 -2
  34. package/src/browser/action-coordinator.ts +2 -13
  35. package/src/browser/action-fence.ts +11 -3
  36. package/src/browser/event-controller.ts +37 -0
  37. package/src/browser/history-state.ts +9 -1
  38. package/src/browser/intercept-utils.ts +51 -0
  39. package/src/browser/navigation-bridge.ts +20 -17
  40. package/src/browser/navigation-client.ts +5 -10
  41. package/src/browser/navigation-transaction.ts +3 -10
  42. package/src/browser/partial-update.ts +8 -0
  43. package/src/browser/rsc-router.tsx +16 -2
  44. package/src/browser/server-action-bridge.ts +223 -78
  45. package/src/cache/document-cache.ts +12 -6
  46. package/src/prefetch-markers.ts +13 -5
  47. package/src/router/match-context.ts +13 -0
  48. package/src/router/match-middleware/cache-lookup.ts +21 -10
  49. package/src/router/match-middleware/cache-store.ts +10 -4
  50. package/src/router/match-result.ts +1 -20
  51. package/src/router/navigation-snapshot.ts +2 -1
  52. package/src/router/revalidation.ts +33 -7
  53. package/src/router/router-context.ts +4 -22
  54. package/src/rsc/rsc-rendering.ts +12 -4
  55. package/src/rsc/shell-capture-constants.ts +2 -0
  56. package/src/ssr/ssr-root.tsx +1 -0
  57. package/src/testing/serve-shell-request.ts +20 -2
  58. package/src/types/handler-context.ts +9 -6
  59. package/src/vite/plugins/server-ref-hashing.ts +16 -5
@@ -38,7 +38,7 @@ import {
38
38
  type DecodedPrefetch,
39
39
  } from "./prefetch/cache.js";
40
40
  import { cancelAllPrefetches } from "./prefetch/loader.js";
41
- import { FILL_PARAM } from "../prefetch-markers.js";
41
+ import { FILL_PARAM, STALE_PARAM } from "../prefetch-markers.js";
42
42
 
43
43
  /**
44
44
  * Create a navigation client for fetching RSC payloads
@@ -108,7 +108,7 @@ export function createNavigationClient(
108
108
  fetchUrl.searchParams.set("_rsc_partial", "true");
109
109
  fetchUrl.searchParams.set("_rsc_segments", segmentIds.join(","));
110
110
  if (staleRevalidation) {
111
- fetchUrl.searchParams.set("_rsc_stale", "true");
111
+ fetchUrl.searchParams.set(STALE_PARAM, "true");
112
112
  }
113
113
  if (version) {
114
114
  fetchUrl.searchParams.set("_rsc_v", version);
@@ -144,15 +144,10 @@ export function createNavigationClient(
144
144
  // server-action invalidation) auto-invalidates both scopes.
145
145
  // Skip cache for stale revalidation (needs fresh data), HMR (needs
146
146
  // fresh modules), and intercept contexts (source-dependent responses).
147
- // Suspend prefetch consumption while an action is in flight: a queued
148
- // prefetch holds pre-mutation data and must not be served until the
149
- // action's response decides whether anything changed.
147
+ // A prefetch served during an action's flight holds pre-write data:
148
+ // the action bridge's follow-up refetches the page it commits.
150
149
  const canUsePrefetch =
151
- !staleRevalidation &&
152
- !hmr &&
153
- !fill &&
154
- !interceptSourceUrl &&
155
- !isActionFenceActive();
150
+ !staleRevalidation && !hmr && !fill && !interceptSourceUrl;
156
151
  const rangoState = getRangoState();
157
152
  const wildcardKey = buildPrefetchKey(rangoState, fetchUrl);
158
153
  const cacheKey = buildSourceKey(rangoState, previousUrl, fetchUrl);
@@ -156,7 +156,6 @@ export function createNavigationTransaction(
156
156
  url: string,
157
157
  options?: NavigateOptionsInternal & { skipLoadingState?: boolean },
158
158
  ): NavigationTransaction {
159
- let committed = false;
160
159
  const currentUrl = window.location.href;
161
160
 
162
161
  const handle = eventController.startNavigation(url, options);
@@ -165,8 +164,6 @@ export function createNavigationTransaction(
165
164
  * Commit the navigation - updates store and URL atomically
166
165
  */
167
166
  function commit(opts: CommitOptions): CommitResult {
168
- committed = true;
169
-
170
167
  const {
171
168
  url,
172
169
  segmentIds,
@@ -304,14 +301,10 @@ export function createNavigationTransaction(
304
301
  };
305
302
  },
306
303
 
304
+ // Reaches the handle committed or not: its dispose settles the entry
305
+ // (NavigationEntry.settled), and resets no state once it completed.
307
306
  [Symbol.dispose]() {
308
- if (handle.signal.aborted) {
309
- return;
310
- }
311
-
312
- if (!committed) {
313
- handle[Symbol.dispose]();
314
- }
307
+ if (!handle.signal.aborted) handle[Symbol.dispose]();
315
308
  },
316
309
  };
317
310
  }
@@ -24,6 +24,7 @@ import {
24
24
  import {
25
25
  hasActiveIntercept as hasActiveInterceptSlots,
26
26
  isInterceptSegment,
27
+ wholeInterceptMatched,
27
28
  } from "./intercept-utils.js";
28
29
  import type { BoundTransaction } from "./navigation-transaction.js";
29
30
  import { ServerRedirect } from "../errors.js";
@@ -741,6 +742,13 @@ export function createPartialUpdater(
741
742
  if (redirected(payload.metadata)) return;
742
743
 
743
744
  if (payload.metadata?.isPartial) {
745
+ // Held nothing, answered as an intercept: the page it lands over stays
746
+ // under the slot, in `matched` for every reader below (the commit's
747
+ // handle cleanup included).
748
+ const kept =
749
+ segments.length === 0 &&
750
+ wholeInterceptMatched(cachedSegs, payload.metadata);
751
+ if (kept) payload.metadata.matched = kept;
744
752
  const { segments: newSegments, matched, diff } = payload.metadata;
745
753
  let serverSegments = newSegments || [];
746
754
  let sent = diff || [];
@@ -42,6 +42,7 @@ import { setAppVersion } from "./app-version.js";
42
42
  import {
43
43
  isInterceptSegment,
44
44
  splitInterceptSegments,
45
+ wholeInterceptMatched,
45
46
  } from "./intercept-utils.js";
46
47
  import { createAppShellRef } from "./app-shell.js";
47
48
  import { bootLog, IS_BROWSER_DEBUG } from "./logging.js";
@@ -75,7 +76,7 @@ export interface InitBrowserAppOptions {
75
76
  storeOptions?: {
76
77
  /**
77
78
  * Maximum number of history entries to cache
78
- * @default 10
79
+ * @default 20
79
80
  */
80
81
  cacheSize?: number;
81
82
  };
@@ -531,6 +532,9 @@ export async function initBrowserApp(
531
532
  const streamingToken = handle.startStreaming();
532
533
 
533
534
  const interceptSourceUrl = store.getInterceptSourceUrl();
535
+ // Read before a version change below clears the history cache.
536
+ const onScreen =
537
+ store.getCachedSegments(store.getHistoryKey())?.segments ?? [];
534
538
 
535
539
  try {
536
540
  const { payload, streamComplete } = await client.fetchPartial({
@@ -582,7 +586,17 @@ export async function initBrowserApp(
582
586
  // a paramless document GET would run the SSR path and surface the
583
587
  // not-found page during that same transient.
584
588
  if (payload.metadata?.isPartial) {
585
- const segments = payload.metadata.segments || [];
589
+ // Held nothing: an intercept answer keeps the page under the slot
590
+ // from the entry on screen; the layouts and the slot are fresh.
591
+ let segments = payload.metadata.segments || [];
592
+ const kept = wholeInterceptMatched(onScreen, payload.metadata);
593
+ if (kept) {
594
+ payload.metadata.matched = kept;
595
+ const byId = new Map(
596
+ [...onScreen, ...segments].map((s) => [s.id, s]),
597
+ );
598
+ segments = kept.flatMap((id) => byId.get(id) ?? []);
599
+ }
586
600
  const matched = payload.metadata.matched || [];
587
601
 
588
602
  // Derive intercept state from the returned payload, not the
@@ -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,
@@ -190,30 +199,153 @@ export function createServerActionBridge(
190
199
  action,
191
200
  });
192
201
 
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?: {
202
+ type RefetchOptions = {
200
203
  segments?: string[];
201
204
  interceptSourceUrl?: string | null;
202
205
  actionId?: string;
203
206
  /** transition({ when }) `action` fields for the refetch commit. */
204
207
  action?: TransitionWhenActionInput;
205
- }): Promise<Error | undefined> {
206
- const src = opts?.interceptSourceUrl ?? null;
208
+ };
209
+
210
+ /** The follow-up's transition({ when }) fields: the last owing action's. */
211
+ type FollowUp = Pick<RefetchOptions, "actionId" | "action">;
212
+
213
+ /**
214
+ * A navigation the user started (a click, Back/Forward, refresh(), an
215
+ * action's redirect) that a refetch may supersede: not committed yet, or
216
+ * committed with its transaction (a `prefetch: false` fill) or its stream
217
+ * still open (NavigationEntry.settled). A refetch never starts while one
218
+ * is open: its startNavigation would abort it, and with it the fill. A
219
+ * background revalidation (skipLoadingState: the follow-up, a repair, a
220
+ * Back/Forward entry's SWR) is not one.
221
+ */
222
+ const unsettledUserNavigation = (): NavigationEntry | undefined => {
223
+ const pending = eventController.getUnsettledNavigation();
224
+ return pending && !pending.options?.skipLoadingState ? pending : undefined;
225
+ };
226
+
227
+ /**
228
+ * A navigation the user started that may commit data read before an
229
+ * action's write: it has not committed yet. One that committed and only
230
+ * streams does not count: the action's own response re-rendered its page.
231
+ */
232
+ const pendingUserNavigation = (): boolean => {
233
+ const pending = eventController.getCurrentNavigation();
234
+ return !!pending && !pending.options?.skipLoadingState;
235
+ };
236
+
237
+ /**
238
+ * The follow-up the page on screen is owed (finalizeAction sets it, a
239
+ * redirect clears it as it starts, followUp() takes it). Closure state of
240
+ * this bridge, as are the three below: no hook reads it, and the store
241
+ * stays the one source of truth for what hooks render (AGENTS.md Hard
242
+ * rule 8).
243
+ */
244
+ let owed: FollowUp | undefined;
245
+ /** An action of the batch wrote (R1 applied); reset when the fence drops. */
246
+ let batchWrote = false;
247
+ /**
248
+ * Follow-ups started so far. Only the newest one runs again when it ends
249
+ * left: a newer one started after it, so its request read every write so
250
+ * far. A left follow-up's response still arrives (its request is not tied
251
+ * to its signal), and re-owing from it would start one that leaves the
252
+ * newer one, whose late answer would do the same, without end.
253
+ */
254
+ let followUpsStarted = 0;
255
+ /** The newest follow-up, until it ends: leaveFollowUp() aborts it. */
256
+ let followUpRunning: { handle: NavigationHandle; due: FollowUp } | undefined;
257
+
258
+ /**
259
+ * The follow-up revalidation: the rules are R1-R7 in
260
+ * docs/internal/execution-model.md. Once the batch's last action exited,
261
+ * 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).
264
+ *
265
+ * - Never over an intercept: a request holding no segments drops the page
266
+ * under it (match-result.ts buildMatchResult). Checked when it would run,
267
+ * against the page then on screen; the debt is dropped there.
268
+ * - Never supersedes a navigation the user started (dc-t114): it waits for
269
+ * NavigationEntry.settled, then looks again. A navigation that failed
270
+ * keeps its error on screen, and the debt is dropped.
271
+ * - Runs again when it is left before its commit (a navigation, or an action
272
+ * that started: leaveFollowUp), for the page then on screen.
273
+ */
274
+ function followUp(): void {
275
+ if (!owed || isActionFenceActive()) return;
276
+ const pending = unsettledUserNavigation();
277
+ if (pending) {
278
+ void pending.settled.then((committed) => {
279
+ if (!committed && !pending.abort.signal.aborted) owed = undefined;
280
+ followUp();
281
+ });
282
+ return;
283
+ }
284
+ const due = owed;
285
+ owed = undefined;
286
+ if (store.getInterceptSourceUrl()) return;
287
+ const generation = ++followUpsStarted;
288
+ refetchRoute(due, {
289
+ started: (handle) => (followUpRunning = { handle, due }),
290
+ ended: (left) => {
291
+ if (generation !== followUpsStarted) return;
292
+ followUpRunning = undefined;
293
+ if (!left) return;
294
+ owed ??= due;
295
+ followUp();
296
+ },
297
+ }).catch((error) => {
298
+ if (isBackgroundSuppressible(error)) return;
299
+ console.error("[Browser] Background revalidation failed:", error);
300
+ });
301
+ }
302
+
303
+ /**
304
+ * R6: an action that starts while the follow-up has not committed leaves
305
+ * it, and the batch's end owes it again. Its request may predate that
306
+ * action's write, and its commit could land after the action's own,
307
+ * moving data on screen backwards (dc-t110). Through the controller, so
308
+ * the navigation it started stops being current.
309
+ */
310
+ function leaveFollowUp(): void {
311
+ const running = followUpRunning;
312
+ const current = eventController.getCurrentNavigation();
313
+ if (!running || current?.abort !== running.handle.abort) return;
314
+ owed ??= running.due;
315
+ eventController.abortNavigation(true);
316
+ }
317
+
318
+ /**
319
+ * Refetch the page on screen through a navigation transaction in the
320
+ * action lane: the follow-up, and the hmr-missing and consolidation-needed
321
+ * repairs. Resolves with whether it started and the error of a loader
322
+ * that threw on it. Never starts while a navigation the user started may
323
+ * be superseded (unsettledUserNavigation): its startNavigation would
324
+ * abort it (dc-t114). A repair that cannot start is owed at the batch's
325
+ * end instead (finalizeAction). `run` (the follow-up's) is handed the
326
+ * refetch's navigation, and told once whether it ended left before its
327
+ * commit.
328
+ */
329
+ async function refetchRoute(
330
+ opts: RefetchOptions = {},
331
+ run?: {
332
+ started(handle: NavigationHandle): void;
333
+ ended(left: boolean): void;
334
+ },
335
+ ): Promise<{ started: boolean; error?: Error }> {
336
+ if (unsettledUserNavigation()) return { started: false };
337
+ const src = opts.interceptSourceUrl ?? null;
207
338
  const navTx = createNavigationTransaction(
208
339
  store,
209
340
  eventController,
210
341
  window.location.href,
211
342
  { replace: true, skipLoadingState: true },
212
343
  );
344
+ run?.started(navTx.handle);
213
345
  try {
214
- return await fetchPartialUpdate(
346
+ const error = await fetchPartialUpdate(
215
347
  window.location.href,
216
- opts?.segments ?? [],
348
+ opts.segments ?? [],
217
349
  false,
218
350
  navTx.handle.signal,
219
351
  navTx.with({
@@ -224,12 +356,14 @@ export function createServerActionBridge(
224
356
  {
225
357
  type: "action" as const,
226
358
  ...(src ? { interceptSourceUrl: src } : {}),
227
- ...(opts?.actionId !== undefined ? { actionId: opts.actionId } : {}),
228
- ...(opts?.action ? { action: opts.action } : {}),
359
+ ...(opts.actionId !== undefined ? { actionId: opts.actionId } : {}),
360
+ ...(opts.action ? { action: opts.action } : {}),
229
361
  },
230
362
  );
363
+ return { started: true, error };
231
364
  } finally {
232
365
  navTx[Symbol.dispose]();
366
+ run?.ended(navTx.handle.signal.aborted && !navTx.handle.completed);
233
367
  }
234
368
  }
235
369
 
@@ -244,7 +378,18 @@ export function createServerActionBridge(
244
378
  if (tx) browserDebugLog(tx, msg, details);
245
379
  };
246
380
 
381
+ leaveFollowUp();
247
382
  const locationKey = window.history.state?.key;
383
+ // Capture URL pathname at action start to detect navigation during action
384
+ // Must use window.location (not store.path) because intercepts change URL
385
+ // without changing store.path (e.g., /kanban -> /kanban/card/1)
386
+ const actionStartPathname = window.location.pathname;
387
+ // The page the action was sent from is no longer on screen: a navigation
388
+ // committed since it started (the classifyActionOutcome rule). Its
389
+ // response then commits nothing, and its exit owes the follow-up.
390
+ const navigatedSince = (): boolean =>
391
+ window.location.pathname !== actionStartPathname ||
392
+ window.history.state?.key !== locationKey;
248
393
  log("action start", { id, argsCount: args.length });
249
394
 
250
395
  // Start action in event controller - handles lifecycle tracking. The
@@ -272,6 +417,23 @@ export function createServerActionBridge(
272
417
  // its own latch. Latched so the finally AND the early SPA-redirect returns
273
418
  // (whose Flight stream never settles) can both call it safely.
274
419
  let actionFinalized = false;
420
+ // Set where the action navigates to its redirect's target.
421
+ let redirected = false;
422
+ // Set where its repair refetch could not start (refetchRoute).
423
+ let repairOwed = false;
424
+ // R5: the redirect fetches its page after every write so far, so the
425
+ // batch's debt is cleared as it starts, not at the action's exit: a
426
+ // sibling that lands during its navigation owes for the page it commits.
427
+ const startRedirect = (): void => {
428
+ redirected = true;
429
+ owed = undefined;
430
+ };
431
+ // transition({ when }) `action` for this action's commit, whichever lane
432
+ // applies it (its own, a repair refetch, or the follow-up).
433
+ let whenAction: TransitionWhenActionInput = {
434
+ id,
435
+ formData: findActionFormData(args),
436
+ };
275
437
  // skipInvalidation: the version-mismatch reload terminal released nothing
276
438
  // server-side, so it releases the fence without invalidating.
277
439
  const finalizeAction = (skipInvalidation = false): void => {
@@ -284,9 +446,32 @@ export function createServerActionBridge(
284
446
  // marking the cache stale + broadcasting cross-tab would be spurious.
285
447
  if (responseReceived && !keepCache && !skipInvalidation) {
286
448
  store.markCacheAsStaleAndBroadcast();
449
+ batchWrote = true;
450
+ // The write may be missing from the page on screen: that page is
451
+ // not the one the response re-rendered, or a navigation the user
452
+ // started has not committed and its request may predate the write.
453
+ // A navigation that committed and only streams owes nothing: this
454
+ // response re-rendered its page. A later land never clears this:
455
+ // its request was read after its own write, not necessarily after
456
+ // this one. A redirect owes nothing: its navigation cleared the
457
+ // debt (startRedirect).
458
+ if (!redirected && (navigatedSince() || pendingUserNavigation())) {
459
+ owed = { actionId: id, action: whenAction };
460
+ }
461
+ }
462
+ // A repair that could not start is owed at the batch's end when an
463
+ // action of the batch wrote. The repair runs whatever this action's
464
+ // keepClientCache(), so a keep action that lands last discharges a
465
+ // directive-free sibling's consolidation; a lone keep action owes
466
+ // nothing.
467
+ if (repairOwed && batchWrote) {
468
+ owed = { actionId: id, action: whenAction };
287
469
  }
288
470
  } finally {
289
471
  exitActionFence();
472
+ if (!isActionFenceActive()) batchWrote = false;
473
+ // The batch's last exit runs it (followUp returns while the fence is up).
474
+ followUp();
290
475
  }
291
476
  };
292
477
  try {
@@ -302,11 +487,6 @@ export function createServerActionBridge(
302
487
  // Create temporary references for serialization
303
488
  const temporaryReferences = deps.createTemporaryReferenceSet();
304
489
 
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
490
  // Build action request URL with current segments
311
491
  const url = new URL(window.location.href);
312
492
  url.searchParams.set("_rsc_action", id);
@@ -411,6 +591,7 @@ export function createServerActionBridge(
411
591
  if (redirect && redirect !== "blocked" && !handle.signal.aborted) {
412
592
  log("action simple redirect", { url: redirect.url });
413
593
  handle.complete(undefined);
594
+ startRedirect();
414
595
  // This path returns a never-settling promise, so the finally never
415
596
  // runs: invalidate + release the fence here (the mutation committed
416
597
  // and we're navigating away). Latched, so the finally is a no-op.
@@ -529,7 +710,10 @@ export function createServerActionBridge(
529
710
  : "blocked action redirect payload",
530
711
  { url: redirect.url },
531
712
  );
532
- } else if (redirect.external) {
713
+ return returnValue?.data;
714
+ }
715
+ startRedirect();
716
+ if (redirect.external) {
533
717
  log("external action redirect", { url: target });
534
718
  window.location.assign(target);
535
719
  } else {
@@ -582,15 +766,14 @@ export function createServerActionBridge(
582
766
 
583
767
  // transition({ when }) decides the error-boundary commit too, with
584
768
  // `action.error` set.
769
+ whenAction = {
770
+ ...whenAction,
771
+ error: returnValue && !returnValue.ok ? returnValue.data : undefined,
772
+ };
585
773
  const errorGatedOff = decideActionGatedOff(
586
774
  errorResult.segments,
587
775
  metadata,
588
- {
589
- id,
590
- formData: findActionFormData(args),
591
- error:
592
- returnValue && !returnValue.ok ? returnValue.data : undefined,
593
- },
776
+ whenAction,
594
777
  );
595
778
 
596
779
  // Render the full tree with error segment merged with parent layouts
@@ -603,9 +786,10 @@ export function createServerActionBridge(
603
786
  : undefined,
604
787
  });
605
788
 
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) {
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) {
609
793
  log("user navigated during error render, skipping");
610
794
  if (returnValue && !returnValue.ok) {
611
795
  throw returnValue.data;
@@ -613,15 +797,6 @@ export function createServerActionBridge(
613
797
  handle.complete(undefined);
614
798
  return undefined;
615
799
  }
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
800
 
626
801
  // Update segment tracking to exclude error segment IDs
627
802
  const errorSegmentIds = new Set(diff);
@@ -709,6 +884,7 @@ export function createServerActionBridge(
709
884
  let revalidationError: Error | undefined;
710
885
 
711
886
  if (returnValue && !returnValue.ok) {
887
+ whenAction = { ...whenAction, error: returnValue.data };
712
888
  handle.fail(returnValue.data);
713
889
  throw returnValue.data;
714
890
  }
@@ -725,7 +901,6 @@ export function createServerActionBridge(
725
901
  currentLocationKey: window.history.state?.key,
726
902
  reconciledSegmentCount: fullSegments.length,
727
903
  matchedCount: matched.length,
728
- currentInterceptSource: store.getInterceptSourceUrl(),
729
904
  });
730
905
 
731
906
  // Apply server-set location state exhaustively here, as a successful-
@@ -745,51 +920,17 @@ export function createServerActionBridge(
745
920
  eventController,
746
921
  );
747
922
  }
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
- };
923
+ whenAction = { ...whenAction, result: returnData };
755
924
 
756
925
  switch (scenario.type) {
757
926
  case "navigated-away": {
758
927
  log("user navigated away during action", {
759
928
  from: actionStartPathname,
760
929
  to: window.location.pathname,
761
- historyKeyChanged: scenario.historyKeyChanged,
762
930
  });
763
931
  // Clear concurrent action tracking - don't consolidate for old route's segments
764
932
  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
- }
933
+ // finalizeAction() owes the page on screen the follow-up.
793
934
  break;
794
935
  }
795
936
 
@@ -801,11 +942,13 @@ export function createServerActionBridge(
801
942
  // resolving last must discharge a directive-free sibling's repair.
802
943
  // See the keep row in docs/design/rango-state-cookie.md (the all-keep
803
944
  // edge, and the benign re-mark-stale-after-refetch end-state delta).
804
- revalidationError = await refetchRoute({
945
+ const repair = await refetchRoute({
805
946
  interceptSourceUrl,
806
947
  actionId: id,
807
948
  action: whenAction,
808
949
  });
950
+ revalidationError = repair.error;
951
+ repairOwed = !repair.started;
809
952
  break;
810
953
  }
811
954
 
@@ -824,12 +967,14 @@ export function createServerActionBridge(
824
967
  handle.clearConsolidation();
825
968
 
826
969
  // Ungated on keepCache, same as hmr-missing above (see the keep row).
827
- revalidationError = await refetchRoute({
970
+ const repair = await refetchRoute({
828
971
  segments: segmentsToSend,
829
972
  interceptSourceUrl,
830
973
  actionId: id,
831
974
  action: whenAction,
832
975
  });
976
+ revalidationError = repair.error;
977
+ repairOwed = !repair.started;
833
978
  break;
834
979
  }
835
980
 
@@ -14,7 +14,7 @@
14
14
  */
15
15
 
16
16
  import { requestHeaders } from "../server/request-headers.js";
17
- import { FILL_PARAM } from "../prefetch-markers.js";
17
+ import { FILL_PARAM, STALE_PARAM } from "../prefetch-markers.js";
18
18
  import type { MiddlewareFn, MiddlewareContext } from "../router/middleware.js";
19
19
  import {
20
20
  documentCacheStoresRender,
@@ -238,11 +238,17 @@ export function createDocumentCacheMiddleware<TEnv = any>(
238
238
  return next();
239
239
  }
240
240
 
241
- // Skip fill requests (prefetch: false): a fill is the second half of one
242
- // adoption, rendered against what that client holds. Never served from a
243
- // stored body and never stored. Asked of the raw URL like the two skips
244
- // above: the request context is not read yet.
245
- if (rawUrl.searchParams.has(FILL_PARAM)) {
241
+ // Skip fill and stale partial requests: never served from a stored body,
242
+ // never stored. A fill renders against what one client holds. A stale
243
+ // request (the follow-up revalidation after an action, a Back/Forward
244
+ // entry's revalidation, or one holding no segments) may follow a write
245
+ // the key cannot see. Partial only: _rsc params leave the key, so a
246
+ // document GET carrying them must not force an origin render.
247
+ if (
248
+ rawUrl.searchParams.has("_rsc_partial") &&
249
+ (rawUrl.searchParams.has(FILL_PARAM) ||
250
+ rawUrl.searchParams.get(STALE_PARAM) === "true")
251
+ ) {
246
252
  return next();
247
253
  }
248
254
 
@@ -1,9 +1,11 @@
1
1
  /**
2
- * The wire markers of prefetches and of `prefetch: false`
3
- * (docs/design/prefetch-false.md): shared by the browser and the server
4
- * (router/segment-resolution/prefetch-deferral.ts requestKind,
5
- * rsc/prefetch-render-error.ts). Dependency free, like segment-fragments.ts:
6
- * the browser must not import the server modules that use them.
2
+ * The wire markers of prefetches, of `prefetch: false`
3
+ * (docs/design/prefetch-false.md) and of stale requests: shared by the browser
4
+ * and the server (router/segment-resolution/prefetch-deferral.ts requestKind,
5
+ * rsc/prefetch-render-error.ts, rsc/rsc-rendering.ts,
6
+ * cache/document-cache.ts, router/navigation-snapshot.ts). Dependency free,
7
+ * like segment-fragments.ts: the browser must not import the server modules
8
+ * that use them.
7
9
  */
8
10
 
9
11
  /** Request header of a `<Link>` prefetch (browser/prefetch/fetch.ts). */
@@ -12,6 +14,12 @@ export const PREFETCH_HEADER = "X-Rango-Prefetch";
12
14
  /** Query param of a fill request (browser/navigation-client.ts). */
13
15
  export const FILL_PARAM = "_rsc_fill";
14
16
 
17
+ /**
18
+ * Query param of a stale request (browser/navigation-client.ts): `revalidate()`
19
+ * sees `stale: true` (router/navigation-snapshot.ts).
20
+ */
21
+ export const STALE_PARAM = "_rsc_stale";
22
+
15
23
  /**
16
24
  * The last row of a prefetch whose render reported an error
17
25
  * (rsc/prefetch-render-error.ts): a Flight hint with a code React does not