@rangojs/router 0.26.6 → 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 (57) hide show
  1. package/dist/bin/rango.js +1 -1
  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/vite/index.js +1 -1
  18. package/package.json +1 -1
  19. package/skills/client-urls/SKILL.md +2 -1
  20. package/skills/document-cache/SKILL.md +5 -0
  21. package/skills/intercept/SKILL.md +36 -6
  22. package/skills/layout/SKILL.md +11 -7
  23. package/skills/loader/SKILL.md +30 -13
  24. package/skills/middleware/SKILL.md +1 -1
  25. package/skills/migrate-nextjs/SKILL.md +5 -4
  26. package/skills/parallel/SKILL.md +5 -4
  27. package/skills/ppr/SKILL.md +1 -0
  28. package/skills/rango/SKILL.md +18 -10
  29. package/skills/server-actions/SKILL.md +43 -5
  30. package/skills/testing/predicates.md +1 -1
  31. package/skills/testing/serve-shell-request.md +7 -7
  32. package/skills/ux-architecture/SKILL.md +3 -2
  33. package/src/browser/action-coordinator.ts +2 -13
  34. package/src/browser/action-fence.ts +11 -3
  35. package/src/browser/event-controller.ts +37 -0
  36. package/src/browser/history-state.ts +9 -1
  37. package/src/browser/intercept-utils.ts +51 -0
  38. package/src/browser/navigation-bridge.ts +20 -17
  39. package/src/browser/navigation-client.ts +5 -10
  40. package/src/browser/navigation-transaction.ts +3 -10
  41. package/src/browser/partial-update.ts +8 -0
  42. package/src/browser/rsc-router.tsx +16 -2
  43. package/src/browser/server-action-bridge.ts +223 -78
  44. package/src/cache/document-cache.ts +12 -6
  45. package/src/prefetch-markers.ts +13 -5
  46. package/src/router/match-context.ts +13 -0
  47. package/src/router/match-middleware/cache-lookup.ts +21 -10
  48. package/src/router/match-middleware/cache-store.ts +10 -4
  49. package/src/router/match-result.ts +1 -20
  50. package/src/router/navigation-snapshot.ts +2 -1
  51. package/src/router/revalidation.ts +33 -7
  52. package/src/router/router-context.ts +4 -22
  53. package/src/rsc/rsc-rendering.ts +12 -4
  54. package/src/rsc/shell-capture-constants.ts +2 -0
  55. package/src/ssr/ssr-root.tsx +1 -0
  56. package/src/testing/serve-shell-request.ts +20 -2
  57. package/src/types/handler-context.ts +9 -6
@@ -93,7 +93,11 @@
93
93
  */
94
94
  import type { InternalHandlerContext, ResolvedSegment } from "../../types.js";
95
95
  import { isPprEntry, type EntryData } from "../../server/context.js";
96
- import type { MatchContext, MatchPipelineState } from "../match-context.js";
96
+ import {
97
+ rendersLive,
98
+ type MatchContext,
99
+ type MatchPipelineState,
100
+ } from "../match-context.js";
97
101
  import { getRouterContext, type RouterContext } from "../router-context.js";
98
102
  import { observeEvent } from "../instrument.js";
99
103
  import { pushRevalidationTraceEntry, isTraceActive } from "../logging.js";
@@ -280,9 +284,9 @@ async function* yieldFromStore<TEnv>(
280
284
  /**
281
285
  * The entry may be newer than the client's copy: an on-demand overlay entry
282
286
  * that a refresh (possibly inside this very action) replaced. On an action
283
- * the route's own segments are then re-sent, as a live action re-render
284
- * re-sends them (revalidation.ts "action:route-segment"); the immutable
285
- * bundled manifest keeps the client's copy.
287
+ * or a stale request the route's own segments are then re-sent, as a live
288
+ * action re-render re-sends them (revalidation.ts "action:route-segment");
289
+ * the immutable bundled manifest keeps the client's copy.
286
290
  */
287
291
  replaceable?: boolean,
288
292
  ): AsyncGenerator<ResolvedSegment> {
@@ -348,7 +352,9 @@ async function* yieldFromStore<TEnv>(
348
352
  // different content, so we must NOT nullify.
349
353
  const paramsChanged =
350
354
  !ctx.isFullMatch && !paramsEqual(ctx.matched.params, ctx.prevParams);
351
- const resendRouteSegments = replaceable === true && ctx.isAction;
355
+ // The action defaults' route segment (revalidation.ts), not a cache skip.
356
+ const resendRouteSegments =
357
+ replaceable === true && (ctx.isAction || ctx.stale);
352
358
  for (const segment of segments) {
353
359
  if (interceptFillSkips(ctx, segment)) continue;
354
360
  if (!ctx.isFullMatch && ctx.clientSegmentSet.has(segment.id)) {
@@ -679,8 +685,9 @@ export function withCacheLookup<TEnv>(
679
685
  // its result is applied client-side via useActionState). A plain on-demand
680
686
  // route is the same case, served from the overlay first (an overlay entry
681
687
  // is newer than the build one, and may be the only one). Passthrough
682
- // routes keep a liveHandler, so they still re-render fresh on actions.
683
- if (!ctx.isAction || !isPassthroughPrerenderRoute) {
688
+ // routes keep a liveHandler, so a live render (rendersLive) re-renders
689
+ // them fresh.
690
+ if (!rendersLive(ctx) || !isPassthroughPrerenderRoute) {
684
691
  await ensurePrerenderDeps();
685
692
  if (prerenderStoreInstance || prerenderOverlay) {
686
693
  const served = yield* tryPrerenderLookup(
@@ -698,7 +705,11 @@ export function withCacheLookup<TEnv>(
698
705
  }
699
706
  }
700
707
 
701
- if (!ctx.isAction && !ctx.matched.pr && globalThis.__PRERENDER_DEV_URL) {
708
+ if (
709
+ !rendersLive(ctx) &&
710
+ !ctx.matched.pr &&
711
+ globalThis.__PRERENDER_DEV_URL
712
+ ) {
702
713
  const hasStatic = ctx.entries.some(
703
714
  (e) =>
704
715
  (e.type === "layout" ||
@@ -737,7 +748,7 @@ export function withCacheLookup<TEnv>(
737
748
  // pushes of segments the response does not carry. withCacheStore skips
738
749
  // the write for the same request.
739
750
  if (
740
- ctx.isAction ||
751
+ rendersLive(ctx) ||
741
752
  !ctx.cacheScope?.enabled ||
742
753
  !recordUse(ctx.handlerContext).read
743
754
  ) {
@@ -1004,7 +1015,7 @@ export function withCacheLookup<TEnv>(
1004
1015
  routeKey: ctx.routeKey,
1005
1016
  context: ctx.handlerContext,
1006
1017
  actionContext: ctx.actionContext,
1007
- stale: cacheResult.shouldRevalidate || ctx.stale || undefined,
1018
+ cacheStale: cacheResult.shouldRevalidate,
1008
1019
  traceSource: "cache-hit",
1009
1020
  });
1010
1021
 
@@ -19,7 +19,7 @@
19
19
  * +---------------------+
20
20
  * | Should skip cache? |
21
21
  * | - !cacheScope |──yes──> return
22
- * | - isAction |
22
+ * | - rendersLive |
23
23
  * | - cacheHit |
24
24
  * | - method !== GET |
25
25
  * +---------------------+
@@ -105,7 +105,11 @@ import {
105
105
  getRequestContext,
106
106
  type RequestContext,
107
107
  } from "../../server/request-context.js";
108
- import type { MatchContext, MatchPipelineState } from "../match-context.js";
108
+ import {
109
+ rendersLive,
110
+ type MatchContext,
111
+ type MatchPipelineState,
112
+ } from "../match-context.js";
109
113
  import { createShellImplicitDocScope } from "../../cache/cache-scope.js";
110
114
  import { recordSegmentTags } from "../../cache/cache-tag.js";
111
115
  import { executionStart } from "../../cache/tag-invalidation.js";
@@ -155,7 +159,7 @@ export function withCacheStore<TEnv>(
155
159
  // and could only write an incomplete one.
156
160
  if (
157
161
  !ctx.cacheScope?.enabled ||
158
- ctx.isAction ||
162
+ rendersLive(ctx) ||
159
163
  state.cacheHit ||
160
164
  ctx.request.method !== "GET" ||
161
165
  !recordUse(ctx.handlerContext).write
@@ -355,7 +359,9 @@ function recordShellCaptureDocRecord<TEnv>(
355
359
  if (!marker?.store) return;
356
360
  const scope = ctx.cacheScope;
357
361
  if (!scope || scope.isShellImplicitDocScope) return;
358
- if (ctx.isAction || ctx.isIntercept || ctx.request.method !== "GET") return;
362
+ if (rendersLive(ctx) || ctx.isIntercept || ctx.request.method !== "GET") {
363
+ return;
364
+ }
359
365
  if (state.cacheSource === "prerender") return;
360
366
  if (!scope.allowsCache("write")) return;
361
367
 
@@ -109,7 +109,7 @@
109
109
  */
110
110
  import type { MatchResult, ResolvedSegment } from "../types.js";
111
111
  import { isPlaceholder } from "../types/segments.js";
112
- import { loaderParentId } from "../browser/react/filter-segment-order.js";
112
+ import { interceptMatched } from "../browser/intercept-utils.js";
113
113
  import type { MatchContext, MatchPipelineState } from "./match-context.js";
114
114
  import { debugLog } from "./logging.js";
115
115
  import { appendMetric } from "./metrics.js";
@@ -196,25 +196,6 @@ function deduplicateLoaderSegments(
196
196
  return { segments: result, removedIds };
197
197
  }
198
198
 
199
- /**
200
- * An intercept's `matched`: the client's ids without any of the slot's (its
201
- * id and every loader segment on it, another intercept's into the same slot
202
- * included), then the slot's (interceptSegmentIds). One order for the
203
- * prefetch, its fill, an action and a revisit: a fill whose `matched` differs
204
- * from its tree's is rendered as a new tree (browser/partial-update.ts
205
- * fillHoles).
206
- */
207
- function interceptMatched(
208
- clientIds: readonly string[],
209
- slotIds: readonly string[],
210
- ): string[] {
211
- // loaderParentId leaves a non-loader id as it is: the slot's own id goes too.
212
- return [
213
- ...clientIds.filter((id) => loaderParentId(id) !== slotIds[0]),
214
- ...slotIds,
215
- ];
216
- }
217
-
218
199
  export function buildMatchResult<TEnv>(
219
200
  allSegments: ResolvedSegment[],
220
201
  ctx: MatchContext<TEnv>,
@@ -1,5 +1,6 @@
1
1
  import { isLoaderSegmentId } from "../browser/react/filter-segment-order.js";
2
2
  import { requestHeaders } from "../server/request-headers.js";
3
+ import { STALE_PARAM } from "../prefetch-markers.js";
3
4
  import type { RouteMatchResult } from "./pattern-matching.js";
4
5
 
5
6
  export interface NavigationSnapshot {
@@ -134,7 +135,7 @@ export async function resolveNavigation(
134
135
  ): Promise<NavigationSnapshot | null> {
135
136
  const clientSegmentIds =
136
137
  url.searchParams.get("_rsc_segments")?.split(",").filter(Boolean) || [];
137
- const stale = url.searchParams.get("_rsc_stale") === "true";
138
+ const stale = url.searchParams.get(STALE_PARAM) === "true";
138
139
  const previousUrl = getNavigationContextHeader(request, url.origin);
139
140
  const interceptSourceUrl = getInterceptSourceHeader(request);
140
141
  const isHmr = !!requestHeaders(request).get("X-RSC-HMR");
@@ -24,6 +24,22 @@ import { makeIsAction } from "./is-action.js";
24
24
  const REVALIDATE_ALWAYS: ShouldRevalidateFn<any, any> = () => true;
25
25
  const REVALIDATE_NEVER: ShouldRevalidateFn<any, any> = () => false;
26
26
 
27
+ /** Trace reasons of the action defaults, per lane: an action, a stale request. */
28
+ const ACTION_DEFAULT_REASONS = {
29
+ action: {
30
+ route: "action:route-segment",
31
+ loader: "action:loader-segment",
32
+ routeChild: "action:belongs-to-route",
33
+ parentChain: "action:parent-chain-skip",
34
+ },
35
+ stale: {
36
+ route: "stale:route-segment",
37
+ loader: "stale:loader-segment",
38
+ routeChild: "stale:belongs-to-route",
39
+ parentChain: "stale:parent-chain-skip",
40
+ },
41
+ } as const;
42
+
27
43
  /**
28
44
  * The predicate `revalidate(true)` / `revalidate(false)` stands for: the
29
45
  * constant hard decision of `revalidate(() => true)` / `() => false`.
@@ -61,8 +77,13 @@ export interface EvaluateRevalidationOptions<TEnv> {
61
77
  context: HandlerContext<any, TEnv>;
62
78
  /** Action context if triggered by action */
63
79
  actionContext?: ActionContext;
64
- /** If true, this is a stale cache revalidation request */
80
+ /** The browser's `_rsc_stale` signal: seeds the action defaults. */
65
81
  stale?: boolean;
82
+ /**
83
+ * The route-cache entry is past its TTL (SWR). Predicates see it as `stale`;
84
+ * it never seeds the action defaults.
85
+ */
86
+ cacheStale?: boolean;
66
87
  /** Trace source hint for the revalidation trace */
67
88
  traceSource?: RevalidationTraceEntry["source"];
68
89
  /**
@@ -100,6 +121,7 @@ export async function evaluateRevalidation<TEnv>(
100
121
  context,
101
122
  actionContext,
102
123
  stale,
124
+ cacheStale,
103
125
  traceSource,
104
126
  defaultOverride,
105
127
  } = options;
@@ -147,19 +169,23 @@ export async function evaluateRevalidation<TEnv>(
147
169
  if (defaultOverride) {
148
170
  defaultShouldRevalidate = defaultOverride.value;
149
171
  defaultReason = defaultOverride.reason;
150
- } else if (request.method === "POST") {
172
+ } else if (request.method === "POST" || stale) {
173
+ // A stale request re-renders a page an action may have changed after the
174
+ // client cached it. Not an action: no actionContext, isAction() is false.
175
+ const reasons =
176
+ ACTION_DEFAULT_REASONS[request.method === "POST" ? "action" : "stale"];
151
177
  if (segment.type === "route") {
152
178
  defaultShouldRevalidate = true;
153
- defaultReason = "action:route-segment";
179
+ defaultReason = reasons.route;
154
180
  } else if (segment.type === "loader") {
155
181
  defaultShouldRevalidate = true;
156
- defaultReason = "action:loader-segment";
182
+ defaultReason = reasons.loader;
157
183
  } else if (segment.belongsToRoute) {
158
184
  defaultShouldRevalidate = true;
159
- defaultReason = "action:belongs-to-route";
185
+ defaultReason = reasons.routeChild;
160
186
  } else {
161
187
  defaultShouldRevalidate = false;
162
- defaultReason = "action:parent-chain-skip";
188
+ defaultReason = reasons.parentChain;
163
189
  }
164
190
  } else {
165
191
  if (segment.type === "route") {
@@ -251,7 +277,7 @@ export async function evaluateRevalidation<TEnv>(
251
277
  routeName: toRouteName,
252
278
  fromRouteName,
253
279
  toRouteName,
254
- stale,
280
+ stale: cacheStale ? true : stale,
255
281
  });
256
282
  } catch (error) {
257
283
  // A thrown Response is control flow (e.g. `throw redirect(...)`), not a
@@ -20,6 +20,7 @@ import type {
20
20
  import type { RouteMatchResult } from "./pattern-matching.js";
21
21
  import type { TelemetrySink } from "./telemetry.js";
22
22
  import type { ResolveSegmentOptions } from "./segment-resolution.js";
23
+ import type { EvaluateRevalidationOptions } from "./revalidation.js";
23
24
 
24
25
  /**
25
26
  * Revalidation context passed to segment resolution
@@ -166,28 +167,9 @@ export interface RouterContext<TEnv = any> {
166
167
  gen: AsyncGenerator<T | { __type: "id"; id: string }>,
167
168
  ) => Promise<{ items: T[]; matchedIds: string[] }>;
168
169
 
169
- evaluateRevalidation: (params: {
170
- segment: ResolvedSegment;
171
- prevParams: Record<string, string>;
172
- getPrevSegment: (() => Promise<ResolvedSegment | undefined>) | null;
173
- request: Request;
174
- prevUrl: URL;
175
- nextUrl: URL;
176
- revalidations: Array<{ name: string; fn: any }>;
177
- routeKey: string;
178
- context: HandlerContext<any, TEnv>;
179
- actionContext?: any;
180
- stale?: boolean;
181
- traceSource?:
182
- | "segment-resolution"
183
- | "cache-hit"
184
- | "loader"
185
- | "parallel"
186
- | "orphan-layout"
187
- | "route-handler"
188
- | "layout-handler"
189
- | "intercept-loader";
190
- }) => Promise<boolean>;
170
+ evaluateRevalidation: (
171
+ params: EvaluateRevalidationOptions<TEnv>,
172
+ ) => Promise<boolean>;
191
173
 
192
174
  getRequestContext: () =>
193
175
  | {
@@ -99,7 +99,7 @@ import {
99
99
  import { contextGet } from "../context-var.js";
100
100
  import { rewriteShellAudience, rewriteTrail } from "../server/rewrite-trail.js";
101
101
  import { visitorShellKey } from "./shell-capture-constants.js";
102
- import { PREFETCH_HEADER } from "../prefetch-markers.js";
102
+ import { PREFETCH_HEADER, STALE_PARAM } from "../prefetch-markers.js";
103
103
  import {
104
104
  isWarmReplace,
105
105
  noteWarmShellEvent,
@@ -1268,11 +1268,14 @@ function renderPreparedRscResponse<TEnv>(
1268
1268
  // carries deferred units answers one page's prefetch, and a fill is the
1269
1269
  // second half of one adoption: neither is reused by a shared cache or the
1270
1270
  // document cache (which also refuses a payload marked `_payloadDeferred`),
1271
- // whatever Cache-Control the route set. A route with no flag keeps its
1272
- // headers.
1271
+ // whatever Cache-Control the route set; so is a stale partial render, which
1272
+ // a shared cache keyed by URL must not store or serve. A route with no flag
1273
+ // keeps its headers.
1273
1274
  if (
1274
1275
  reqCtx._payloadDeferred ||
1275
- (isPartial && reqCtx._requestKind === "fill")
1276
+ (isPartial &&
1277
+ (reqCtx._requestKind === "fill" ||
1278
+ url.searchParams.get(STALE_PARAM) === "true"))
1276
1279
  ) {
1277
1280
  rscHeaders["cache-control"] = NOT_REUSED;
1278
1281
  }
@@ -1460,6 +1463,11 @@ async function matchPartialWithPprReplay<TEnv>(
1460
1463
  if (!getNavigationContextHeader(request, url.origin)) {
1461
1464
  return runMatch({ outcome: "BYPASS", reason: "no-navigation-context" });
1462
1465
  }
1466
+ // A stale partial renders live, as an action does (rendersLive,
1467
+ // router/match-context.ts): the record may predate the action's write.
1468
+ if (url.searchParams.get(STALE_PARAM) === "true") {
1469
+ return runMatch({ outcome: "BYPASS", reason: "stale" });
1470
+ }
1463
1471
  // Fragment passthrough (#700), extended to partial replay. Navigation
1464
1472
  // context proves the request can be matched; the separate capability header
1465
1473
  // proves its payload consumer can expand envelopes. Older clients and the
@@ -52,6 +52,8 @@ export const PPR_REPLAY_STATUS_HEADER: string = "x-rango-ppr-replay";
52
52
  */
53
53
  export const PPR_REPLAY_BYPASS_REASONS = [
54
54
  "method",
55
+ // A stale request (`_rsc_stale`) renders live, as an action does.
56
+ "stale",
55
57
  "dynamic",
56
58
  "nonce",
57
59
  "store-unavailable",
@@ -140,6 +140,7 @@ function createSsrEventController(opts: {
140
140
  },
141
141
  abortAllActions: () => {},
142
142
  getCurrentNavigation: () => null,
143
+ getUnsettledNavigation: () => null,
143
144
  getInflightActions: () => new Map(),
144
145
  hadAnyConcurrentActions: () => false,
145
146
  };
@@ -58,7 +58,11 @@ import {
58
58
  SHELL_CAPTURE_MAX_WAIT_MS,
59
59
  } from "../rsc/shell-capture-constants.js";
60
60
  import { SEGMENT_FRAGMENT_CAPABILITY_HEADER } from "../segment-fragments.js";
61
- import { FILL_PARAM, PREFETCH_HEADER } from "../prefetch-markers.js";
61
+ import {
62
+ FILL_PARAM,
63
+ PREFETCH_HEADER,
64
+ STALE_PARAM,
65
+ } from "../prefetch-markers.js";
62
66
  import { resolveDeferredHandleValues } from "../handles/deferred-resolution.js";
63
67
  import { withoutPushedBy } from "../handles/pushed-by.js";
64
68
  import type { HandleData } from "../server/handle-store.js";
@@ -105,6 +109,12 @@ export interface ServeShellRequestOptions<TEnv = any> {
105
109
  * that source. `rewriteTarget` sends the `metadata.rewriteTarget` the page at
106
110
  * `from` carried when middleware rewrote it (`_rsc_rt`), as the browser
107
111
  * does: the server then diffs against the route that rendered that page.
112
+ * `stale: true` sends a stale request (`_rsc_stale`), as the browser sends
113
+ * it: every `revalidate()` sees `stale: true` (see `revalidate()`'s `stale`)
114
+ * and segments take an action's revalidation defaults; it renders live, as
115
+ * an action does, so neither the route cache nor the document cache answers
116
+ * or stores it, and a `ppr` route's `replayStatus` is
117
+ * `{ outcome: "BYPASS", reason: "stale" }`.
108
118
  */
109
119
  partial?:
110
120
  | true
@@ -115,6 +125,7 @@ export interface ServeShellRequestOptions<TEnv = any> {
115
125
  prefetch?: boolean;
116
126
  fill?: boolean;
117
127
  interceptSource?: string;
128
+ stale?: boolean;
118
129
  };
119
130
  /**
120
131
  * POST this form as a submit before hydration: the no-JS request the
@@ -628,14 +639,21 @@ async function buildRequest(
628
639
  prefetch = false,
629
640
  fill = false,
630
641
  interceptSource,
642
+ stale = false,
631
643
  } = partial === true ? {} : partial;
644
+ if (stale && prefetch) {
645
+ throw new Error(
646
+ "serveShellRequest: partial.stale cannot be combined with partial.prefetch",
647
+ );
648
+ }
632
649
  const target = new URL(url);
633
650
  target.searchParams.set("_rsc_partial", "true");
634
651
  target.searchParams.set("_rsc_segments", segments.join(","));
635
652
  if (rewriteTarget) target.searchParams.set("_rsc_rt", rewriteTarget);
636
- // As the browser sends them: browser/navigation-client.ts (fill) and
653
+ // As the browser sends them: browser/navigation-client.ts (fill, stale) and
637
654
  // browser/prefetch/fetch.ts (prefetch).
638
655
  if (fill) target.searchParams.set(FILL_PARAM, "1");
656
+ if (stale) target.searchParams.set(STALE_PARAM, "true");
639
657
  if (prefetch && !headers.has(PREFETCH_HEADER)) {
640
658
  headers.set(PREFETCH_HEADER, "1");
641
659
  }
@@ -637,10 +637,10 @@ export type IsActionFn = (...actions: ActionRef[]) => boolean;
637
637
  * ```ts
638
638
  * import * as CartActions from "./actions/cart";
639
639
  *
640
- * // Idiom A — "mine, else defer": re-render on my actions, otherwise return
641
- * // undefined so the segment's default decision still applies (and downstream
642
- * // revalidators get a say).
643
- * revalidate((ctx) => ctx.isAction(CartActions) || undefined)
640
+ * // Idiom A — "mine, else defer": re-render on my actions and on Back after
641
+ * // an action, otherwise return undefined so the segment's default decision
642
+ * // still applies (and downstream revalidators get a say).
643
+ * revalidate((ctx) => ctx.isAction(CartActions) || ctx.stale || undefined)
644
644
  *
645
645
  * // Idiom B — "mine only": re-render on my actions and suppress everything
646
646
  * // else (isAction returns a raw boolean, so an unrelated action yields false).
@@ -768,8 +768,11 @@ export type ShouldRevalidateFn<TParams = GenericParams, TEnv = any> = (args: {
768
768
  // ── Staleness signal ─────────────────────────────────────────────────
769
769
 
770
770
  /**
771
- * `true` when the browser signals that data may be stale — typically because
772
- * a server action was executed in this or another tab (`_rsc_stale` header).
771
+ * `true` on a revalidation of a page the browser marked stale (the
772
+ * `_rsc_stale` query param): Back/Forward onto a history entry after a
773
+ * server action in this or another tab, or while one is in flight. A request
774
+ * that holds no segments (the follow-up after an action, `refresh()`) is
775
+ * marked stale too, but there every segment renders and no predicate runs.
773
776
  *
774
777
  * This is NOT segment cache staleness (loaders are never segment-cached).
775
778
  * Use this to decide whether loader data should be re-fetched after an