@rangojs/router 0.28.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/dist/bin/rango.js +1 -1
  2. package/dist/types/browser/action-fence.d.ts +2 -1
  3. package/dist/types/browser/event-controller.d.ts +6 -1
  4. package/dist/types/browser/partial-update.d.ts +11 -0
  5. package/dist/types/browser/react/use-action.d.ts +7 -2
  6. package/dist/types/browser/react/use-handle.d.ts +19 -16
  7. package/dist/types/browser/react/use-link-status.d.ts +5 -2
  8. package/dist/types/browser/segment-reconciler.d.ts +26 -0
  9. package/dist/types/browser/types.d.ts +22 -4
  10. package/dist/types/browser/wire-protocol.d.ts +1 -0
  11. package/dist/types/cache/tag-invalidation.d.ts +0 -10
  12. package/dist/types/router/error-handling.d.ts +7 -1
  13. package/dist/types/router/match-context.d.ts +2 -0
  14. package/dist/types/router/match-middleware/background-revalidation.d.ts +3 -2
  15. package/dist/types/router/navigation-snapshot.d.ts +7 -0
  16. package/dist/types/router/segment-resolution/helpers.d.ts +19 -3
  17. package/dist/types/router/segment-resolution/resolution-pass.d.ts +2 -0
  18. package/dist/types/router/segment-resolution/streamed-handler-boundary.d.ts +2 -0
  19. package/dist/types/router/types.d.ts +1 -1
  20. package/dist/types/server/request-context.d.ts +30 -1
  21. package/dist/types/testing/render-route.d.ts +43 -8
  22. package/dist/types/testing/serve-shell-request.d.ts +8 -1
  23. package/dist/types/types/error-types.d.ts +27 -8
  24. package/dist/types/wire-params.d.ts +5 -0
  25. package/dist/vite/index.js +15 -19
  26. package/package.json +1 -1
  27. package/skills/client-urls/SKILL.md +4 -2
  28. package/skills/hooks/SKILL.md +1 -1
  29. package/skills/hooks/data.md +8 -1
  30. package/skills/hooks/handle-and-actions.md +33 -2
  31. package/skills/hooks/navigation.md +121 -15
  32. package/skills/hooks/state.md +6 -4
  33. package/skills/hooks/urls.md +16 -5
  34. package/skills/intercept/SKILL.md +6 -0
  35. package/skills/observability/SKILL.md +30 -0
  36. package/skills/server-actions/SKILL.md +8 -1
  37. package/skills/testing/client-components.md +25 -18
  38. package/skills/testing/serve-shell-request.md +7 -7
  39. package/skills/view-transitions/SKILL.md +1 -1
  40. package/src/browser/action-fence.ts +2 -1
  41. package/src/browser/event-controller.ts +32 -10
  42. package/src/browser/navigation-bridge.ts +45 -13
  43. package/src/browser/navigation-client.ts +2 -0
  44. package/src/browser/network-error-handler.ts +4 -0
  45. package/src/browser/partial-update.ts +33 -6
  46. package/src/browser/react/use-action.ts +24 -48
  47. package/src/browser/react/use-handle.ts +22 -18
  48. package/src/browser/react/use-link-status.ts +48 -60
  49. package/src/browser/react/use-navigation.ts +92 -41
  50. package/src/browser/segment-reconciler.ts +43 -0
  51. package/src/browser/server-action-bridge.ts +35 -8
  52. package/src/browser/types.ts +23 -4
  53. package/src/browser/wire-protocol.ts +2 -0
  54. package/src/cache/cache-runtime.ts +28 -31
  55. package/src/cache/document-cache.ts +34 -37
  56. package/src/cache/read-through-swr.ts +20 -18
  57. package/src/cache/tag-invalidation.ts +3 -18
  58. package/src/router/error-handling.ts +18 -6
  59. package/src/router/intercept-resolution.ts +9 -4
  60. package/src/router/match-api.ts +1 -0
  61. package/src/router/match-context.ts +3 -0
  62. package/src/router/match-middleware/background-revalidation.ts +5 -4
  63. package/src/router/match-middleware/cache-lookup.ts +5 -3
  64. package/src/router/navigation-snapshot.ts +10 -0
  65. package/src/router/segment-resolution/helpers.ts +49 -13
  66. package/src/router/segment-resolution/loader-cache.ts +4 -3
  67. package/src/router/segment-resolution/resolution-pass.ts +2 -0
  68. package/src/router/segment-resolution/revalidation.ts +57 -28
  69. package/src/router/segment-resolution/streamed-handler-boundary.ts +3 -0
  70. package/src/router/types.ts +1 -1
  71. package/src/router.ts +7 -4
  72. package/src/rsc/request-stages.ts +4 -2
  73. package/src/rsc/shell-capture.ts +63 -58
  74. package/src/server/request-context.ts +49 -0
  75. package/src/testing/dispatch.ts +3 -0
  76. package/src/testing/render-route.tsx +199 -13
  77. package/src/testing/serve-shell-request.ts +19 -10
  78. package/src/types/error-types.ts +27 -8
  79. package/src/wire-params.ts +5 -0
@@ -53,7 +53,7 @@ export function useHandle<T, A, S>(
53
53
  return selectorRef.current ? selectorRef.current(collected) : collected;
54
54
  };
55
55
 
56
- const [value, setValue] = useCommitAnchoredState(collect);
56
+ const [value, setValue] = useCommitAnchoredState(collect, true);
57
57
 
58
58
  useEffect(() => {
59
59
  if (!ctx) return;
@@ -68,25 +68,29 @@ export function useHandle<T, A, S>(
68
68
  }
69
69
 
70
70
  /**
71
- * Route state the controller's handle notifications drive (useHandle,
72
- * useSegments) commits with the page it belongs to. NavigationProvider
73
- * flushes the controller inside every payload update, so a value known then
74
- * is set in the page's lane. A page handed over urgently (signal "release")
75
- * takes a value that arrives before it commits the same way: set at default
76
- * priority, it renders in the page's lane, and the page's effects read it.
77
- * A page in a transition (signal "anchor") does not: until it commits
71
+ * Route state the controller's handle notifications drive commits with the
72
+ * page it belongs to. NavigationProvider flushes the controller inside every
73
+ * payload update, with signal "anchor" for a page in a transition and
74
+ * "release" for an urgent one, so the page's own change is set in the page's
75
+ * lane and the page's effects read it. From the anchor until the page commits
78
76
  * (releaseRouteState() from the provider's layout effect, signal "release"),
79
- * a new value waits, and is applied in the commit's task, right after the
80
- * page's own commit. In the page's lane, a value that suspends in a revealed
81
- * <Suspense> (a crumb's content) would hold the page; in a lane of its own,
82
- * it commits over the page being left and takes the page's transition types
83
- * (#1165); and a transition entangled with the page misses its commit when it
84
- * is set after React rendered the page, before its view transition's
85
- * mutation, and starts a view transition of its own. A value shallow-equal to
86
- * the last one sets nothing, so a reader renders only when its value moves.
77
+ * a later change waits and is applied in the commit's task, right after the
78
+ * page's own commit: set in a lane of its own, it would commit over the page
79
+ * being left (#1165).
80
+ *
81
+ * `waitForRelease`, set by useHandle only: the anchor's own change waits too,
82
+ * so a handle value lands right after its page's commit, never in its lane. A
83
+ * handle value can carry a promise that suspends, and in the page's lane one
84
+ * that suspends in a revealed <Suspense> would hold the page. Nothing
85
+ * useSegments reads can suspend, so it leaves it off and its path and
86
+ * location commit with their page.
87
+ *
88
+ * A value shallow-equal to the last one sets nothing, so a reader renders only
89
+ * when its value moves.
87
90
  */
88
91
  export function useCommitAnchoredState<T>(
89
92
  init: () => T,
93
+ waitForRelease?: boolean,
90
94
  ): [T, (value: T, signal?: RouteStateSignal) => void] {
91
95
  // An app that enables the React Compiler compiles the router too; its memo
92
96
  // cache gains nothing here and cost 107 B gzip in the router chunk.
@@ -102,8 +106,8 @@ export function useCommitAnchoredState<T>(
102
106
  r.last = next;
103
107
  r.held = true;
104
108
  }
105
- // The anchor's own change goes in the page's lane, with the page.
106
- if (!r.held || (r.waiting && signal !== "anchor")) return;
109
+ if (!r.held || (r.waiting && (waitForRelease || signal !== "anchor")))
110
+ return;
107
111
  r.held = false;
108
112
  setValue(() => r.last);
109
113
  });
@@ -5,12 +5,10 @@ import {
5
5
  useContext,
6
6
  useState,
7
7
  useEffect,
8
- useRef,
9
- useOptimistic,
10
- startTransition,
11
8
  type Context,
12
9
  } from "react";
13
10
  import { NavigationStoreContext } from "./context.js";
11
+ import type { EventController, NavigationEntry } from "../event-controller.js";
14
12
 
15
13
  /**
16
14
  * Context for Link component to provide its destination URL
@@ -43,24 +41,45 @@ function normalizeUrl(url: string, origin: string): string {
43
41
  }
44
42
 
45
43
  /**
46
- * Check if this link's destination matches the pending navigation URL
44
+ * The navigation in flight a link can be pending for. One that shows no
45
+ * loading state (a background revalidation, an action's refetch) is none: it
46
+ * goes to no link and ends none whose page React still holds.
47
+ */
48
+ function visibleNavigation(ec: EventController): NavigationEntry | null {
49
+ const navigation = ec.getCurrentNavigation();
50
+ return navigation && !navigation.options?.skipLoadingState
51
+ ? navigation
52
+ : null;
53
+ }
54
+
55
+ /**
56
+ * Whether `navigation` goes to `linkTo`: its URL or, after a server redirect,
57
+ * the URL its chain started from.
47
58
  */
48
59
  function isPendingFor(
49
60
  linkTo: string | null,
50
- pendingUrl: string | null,
61
+ navigation: NavigationEntry | null,
51
62
  origin: string,
52
63
  ): boolean {
53
- if (linkTo === null || pendingUrl === null) {
64
+ if (linkTo === null || !navigation) {
54
65
  return false;
55
66
  }
56
- return normalizeUrl(pendingUrl, origin) === normalizeUrl(linkTo, origin);
67
+ const to = normalizeUrl(linkTo, origin);
68
+ const from = navigation.options?._redirectFrom;
69
+ return (
70
+ normalizeUrl(navigation.url, origin) === to ||
71
+ (from !== undefined && normalizeUrl(from, origin) === to)
72
+ );
57
73
  }
58
74
 
59
75
  /**
60
76
  * Hook to track the pending state of a Link component
61
77
  *
62
- * Must be used inside a Link component. Returns `{ pending: true }`
63
- * when navigation to this link's destination is in progress.
78
+ * Must be used inside a Link component. Returns `{ pending: true }` while a
79
+ * navigation to the link's destination is in flight, until the page it
80
+ * brings commits: under transition() while React holds that page, and
81
+ * through a server redirect until the target page commits. A newer
82
+ * navigation ends it at once.
64
83
  *
65
84
  * Useful for showing inline loading indicators on individual links.
66
85
  *
@@ -84,63 +103,32 @@ export function useLinkStatus(): LinkStatus {
84
103
 
85
104
  const origin = ctx?.origin ?? "http://localhost";
86
105
 
87
- const [basePending, setBasePending] = useState<boolean>(() => {
88
- if (!ctx || linkTo === null) {
89
- return false;
90
- }
91
- const state =
92
- ctx.eventController.getHydrationSnapshot()?.state ??
93
- ctx.eventController.getState();
94
- return isPendingFor(linkTo, state.pendingUrl, origin);
95
- });
96
-
97
- const prevPending = useRef(basePending);
98
-
99
- // Tracks whether the most recent setOptimisticPending call pinned the value
100
- // to a non-idle (loading) state. Used to decide whether to emit a release
101
- // update when returning to idle, so the optimistic store doesn't stay pinned
102
- // to `true` if a parent transition (e.g. the <Link> click / view transition
103
- // commit) is still pending.
104
- const optimisticPinnedRef = useRef(false);
105
-
106
- const [pending, setOptimisticPending] = useOptimistic(basePending);
107
-
106
+ // The document renders no navigation in flight.
107
+ const [pending, setPending] = useState<boolean>(
108
+ () =>
109
+ !!ctx &&
110
+ !ctx.eventController.getHydrationSnapshot() &&
111
+ isPendingFor(linkTo, visibleNavigation(ctx.eventController), origin),
112
+ );
113
+
114
+ // The value commits in the lane of the notification that carries it, as
115
+ // useNavigation's does: at once from the click's task, and with the page
116
+ // when its commit clears the navigation (NavigationProvider flushes that
117
+ // notification inside the page's update). Set once per navigation: a
118
+ // repeated urgent set would commit ahead of a held page's settle. A
119
+ // background navigation (an action's refetch) is not a newer one, so a
120
+ // held link stays pending.
108
121
  useEffect(() => {
109
122
  if (!ctx || linkTo === null) {
110
123
  return;
111
124
  }
112
125
 
126
+ let last: NavigationEntry | null | undefined;
113
127
  const update = () => {
114
- const state = ctx.eventController.getState();
115
- const isPending = isPendingFor(linkTo, state.pendingUrl, origin);
116
-
117
- if (isPending !== prevPending.current) {
118
- prevPending.current = isPending;
119
-
120
- const shouldPin = isPending && state.state !== "idle";
121
-
122
- if (shouldPin) {
123
- // Pin the optimistic value so the spinner shows immediately even if
124
- // a parent transition (e.g. <Link> click) defers the urgent
125
- // setBasePending commit.
126
- startTransition(() => {
127
- setOptimisticPending(isPending);
128
- });
129
- optimisticPinnedRef.current = true;
130
- } else if (optimisticPinnedRef.current) {
131
- // Release a previously-pinned optimistic value. Without this,
132
- // useOptimistic keeps returning the stale `true` while any parent
133
- // transition is still pending, even after basePending flipped to
134
- // false at navigation completion — leaving the link spinner stuck.
135
- startTransition(() => {
136
- setOptimisticPending(isPending);
137
- });
138
- optimisticPinnedRef.current = false;
139
- }
140
-
141
- // Always update base state
142
- setBasePending(isPending);
143
- }
128
+ const navigation = visibleNavigation(ctx.eventController);
129
+ if (navigation === last) return;
130
+ last = navigation;
131
+ setPending(isPendingFor(linkTo, navigation, origin));
144
132
  };
145
133
 
146
134
  // Catch-up: re-read state synchronously on mount before subscribing, so a
@@ -5,13 +5,15 @@ import {
5
5
  useState,
6
6
  useEffect,
7
7
  useLayoutEffect,
8
- startTransition,
9
8
  useRef,
10
9
  } from "react";
11
10
  import { NavigationStoreContext } from "./context.js";
12
11
  import { shallowEqual } from "./shallow-equal.js";
13
12
  import type { PublicNavigationState } from "../types.js";
14
- import type { DerivedNavigationState } from "../event-controller.js";
13
+ import type {
14
+ DerivedNavigationState,
15
+ RouteStateSignal,
16
+ } from "../event-controller.js";
15
17
 
16
18
  /**
17
19
  * Convert derived state to public version (strips inflightActions)
@@ -21,6 +23,32 @@ function toPublicState(state: DerivedNavigationState): PublicNavigationState {
21
23
  return publicState;
22
24
  }
23
25
 
26
+ /** The state a value was selected from, and the value. */
27
+ interface Snapshot<T> {
28
+ state: PublicNavigationState;
29
+ value: T;
30
+ }
31
+
32
+ /**
33
+ * A notification while React holds a page: work that starts shows at once
34
+ * (loading, streaming, a newer click's URL), what settles waits for the
35
+ * page's commit (rango-d152), and location moves only with the notification
36
+ * that moves it (the page on screen until the next one shows).
37
+ */
38
+ function hold(
39
+ base: PublicNavigationState,
40
+ live: PublicNavigationState,
41
+ moved: boolean,
42
+ ): PublicNavigationState {
43
+ return {
44
+ state: live.state === "loading" ? live.state : base.state,
45
+ isStreaming: live.isStreaming || base.isStreaming,
46
+ isNavigating: live.isNavigating || base.isNavigating,
47
+ pendingUrl: live.pendingUrl ?? base.pendingUrl,
48
+ location: moved ? live.location : base.location,
49
+ };
50
+ }
51
+
24
52
  /**
25
53
  * Hook to access reactive navigation state with optional selector for performance.
26
54
  *
@@ -46,18 +74,20 @@ export function useNavigation<T>(
46
74
  throw new Error("useNavigation must be used within NavigationProvider");
47
75
  }
48
76
 
49
- const [value, setValue] = useState<T | PublicNavigationState>(() => {
50
- const publicState = toPublicState(
51
- ctx.eventController.getHydrationSnapshot()?.state ??
52
- ctx.eventController.getState(),
53
- );
54
- return selector ? selector(publicState) : publicState;
55
- });
56
- const prevState = useRef(value);
57
- // The value on screen: a set value that differs has not committed yet.
58
- const committedRef = useRef(value);
77
+ const [snapshot, setSnapshot] = useState<Snapshot<T | PublicNavigationState>>(
78
+ () => {
79
+ const state = toPublicState(
80
+ ctx.eventController.getHydrationSnapshot()?.state ??
81
+ ctx.eventController.getState(),
82
+ );
83
+ return { state, value: selector ? selector(state) : state };
84
+ },
85
+ );
86
+ // What React ends on once every set so far commits, and what it committed.
87
+ const latest = useRef(snapshot);
88
+ const committed = useRef(snapshot);
59
89
  useLayoutEffect(() => {
60
- committedRef.current = value;
90
+ committed.current = snapshot;
61
91
  });
62
92
 
63
93
  // Store selector in a ref so the subscription callback always uses the
@@ -74,34 +104,48 @@ export function useNavigation<T>(
74
104
  // the location hooks' values do: NavigationProvider flushes a navigation's
75
105
  // notification into the transition that commits its tree, so idle commits
76
106
  // with the destination's location (useSearchParams, usePathname,
77
- // useParams). An optimistic update renders in the sync lane, ahead of that
78
- // transition, and put idle on screen with the location being left (#1164).
107
+ // useParams). From that page's anchor to its release every other
108
+ // notification goes through hold(), urgently, so React rebases the same
109
+ // update onto the page when it commits. Never a transition of its own.
79
110
  useEffect(() => {
80
- let last: DerivedNavigationState | undefined;
81
- const update = () => {
82
- const currentState = ctx.eventController.getState();
83
- // Neither the state nor the location moved (the stream starting or
84
- // ending): while a value of this hook's is held, a transition, which
85
- // React entangles with it, so it never commits ahead of the location.
86
- // Otherwise urgent: a transition of its own would start a view
87
- // transition, and could commit ahead of a held page and take its
88
- // transition types.
89
- const quiet =
90
- currentState.state === last?.state &&
91
- currentState.location === last.location;
92
- last = currentState;
93
- const publicState = toPublicState(currentState);
94
- const nextSelected = selectorRef.current
95
- ? selectorRef.current(publicState)
96
- : publicState;
97
-
98
- // Check if selected value has changed
99
- if (!shallowEqual(nextSelected, prevState.current)) {
100
- const held = prevState.current !== committedRef.current;
101
- prevState.current = nextSelected;
102
- if (quiet && held) startTransition(() => setValue(nextSelected));
103
- else setValue(nextSelected);
111
+ let pageHeld = false;
112
+ let location: PublicNavigationState["location"] | undefined;
113
+ const select = (state: PublicNavigationState) =>
114
+ selectorRef.current ? selectorRef.current(state) : state;
115
+ const update = (signal?: RouteStateSignal) => {
116
+ if (signal) pageHeld = signal === "anchor";
117
+ const live = toPublicState(ctx.eventController.getState());
118
+ const moved = live.location !== location;
119
+ location = live.location;
120
+ const held = pageHeld && !signal;
121
+ const next = (
122
+ base: Snapshot<T | PublicNavigationState>,
123
+ ): Snapshot<T | PublicNavigationState> => {
124
+ const state = held ? hold(base.state, live, moved) : live;
125
+ return { state, value: select(state) };
126
+ };
127
+ const target = next(latest.current);
128
+ // A release is compared with the state React renders it on, not
129
+ // `latest`: an urgent page can end the hold before React commits the
130
+ // anchor's set. An anchor sets inside its page's transition, which
131
+ // renders every set so far, so `latest` is that state. A release that
132
+ // finds every set committed with its value sets nothing: the updater
133
+ // would render the reader again only to bail out.
134
+ if (signal === "release") {
135
+ if (
136
+ latest.current === committed.current &&
137
+ shallowEqual(target.value, latest.current.value)
138
+ )
139
+ return;
140
+ latest.current = target;
141
+ setSnapshot((prev) =>
142
+ shallowEqual(prev.value, target.value) ? prev : target,
143
+ );
144
+ return;
104
145
  }
146
+ if (shallowEqual(target.value, latest.current.value)) return;
147
+ latest.current = target;
148
+ setSnapshot(held ? next : target);
105
149
  };
106
150
 
107
151
  // Catch-up: re-read state synchronously on mount before subscribing, so a
@@ -110,8 +154,15 @@ export function useNavigation<T>(
110
154
  // useSearchParams.
111
155
  update();
112
156
 
113
- return ctx.eventController.subscribe(update);
157
+ const unsubscribe = ctx.eventController.subscribe(update);
158
+ // The anchor and release signals ride the handle channel. One without a
159
+ // signal re-reads the same state, as the state channel's own would.
160
+ const unsubscribeSignals = ctx.eventController.subscribeToHandles(update);
161
+ return () => {
162
+ unsubscribe();
163
+ unsubscribeSignals();
164
+ };
114
165
  }, []);
115
166
 
116
- return value as T | PublicNavigationState;
167
+ return snapshot.value as T | PublicNavigationState;
117
168
  }
@@ -260,3 +260,46 @@ export function reconcileErrorSegments(
260
260
  interceptSegments: intercept,
261
261
  };
262
262
  }
263
+
264
+ /**
265
+ * The ids a page holds once an action's error boundary `boundary` shows: the
266
+ * ones above it. Nothing at or below the boundary ran after the action's
267
+ * write (its response and the follow-up cut there), so the next request must
268
+ * not hold it. ShortCodes nest, so those ids start with the boundary's: the
269
+ * boundary, its loaders (`${id}D…`), its slots and every entry under it. A
270
+ * sibling with a longer index (`…R10` beside `…R1`) is kept.
271
+ */
272
+ export function idsAboveBoundary(
273
+ ids: string[],
274
+ boundary: string | undefined,
275
+ ): string[] {
276
+ return boundary
277
+ ? ids.filter(
278
+ (id) =>
279
+ !id.startsWith(boundary) || /\d/.test(id.charAt(boundary.length)),
280
+ )
281
+ : ids;
282
+ }
283
+
284
+ /**
285
+ * The error segment of every action error boundary committed, by identity
286
+ * (server-action-bridge.ts adds and deletes). An entry shows one while its
287
+ * cached list still holds that object: a later commit on the entry replaces
288
+ * the list, and a refetch that holds the boundary keeps it. Weak, so a
289
+ * replaced boundary is not kept alive. Two routers never share a segment
290
+ * object. No hook reads it.
291
+ */
292
+ export const actionBoundaries: WeakSet<ResolvedSegment> = new WeakSet();
293
+
294
+ /**
295
+ * The index of the deepest action error boundary in `segments`, -1 for none.
296
+ * It is that action's outcome, which no GET reproduces: a background refetch
297
+ * of the page (the follow-up, Back/Forward's stale revalidation, the
298
+ * cross-tab refresh) holds it and renders what is above it. At 0 nothing is
299
+ * above it.
300
+ */
301
+ export function actionBoundaryAt(segments: readonly ResolvedSegment[]): number {
302
+ let at = segments.length;
303
+ while (at-- && !actionBoundaries.has(segments[at]));
304
+ return at;
305
+ }
@@ -15,6 +15,9 @@ import {
15
15
  import { KEEP_CACHE_HEADER } from "./cookie-name.js";
16
16
  import { createNavigationTransaction } from "./navigation-transaction.js";
17
17
  import {
18
+ actionBoundaries,
19
+ actionBoundaryAt,
20
+ idsAboveBoundary,
18
21
  reconcileSegments,
19
22
  reconcileErrorSegments,
20
23
  } from "./segment-reconciler.js";
@@ -206,6 +209,8 @@ export function createServerActionBridge(
206
209
  actionId?: string;
207
210
  /** transition({ when }) `action` fields for the refetch commit. */
208
211
  action?: TransitionWhenActionInput;
212
+ /** The follow-up's held error boundary (UpdateMode `action`). */
213
+ boundary?: string;
209
214
  };
210
215
 
211
216
  /** The follow-up's transition({ when }) fields: the last owing action's. */
@@ -255,13 +260,13 @@ export function createServerActionBridge(
255
260
  let followUpsStarted = 0;
256
261
  /** The newest follow-up, until it ends: leaveFollowUp() aborts it. */
257
262
  let followUpRunning: { handle: NavigationHandle; due: FollowUp } | undefined;
258
-
259
263
  /**
260
264
  * The follow-up revalidation: the rules are R1-R7 in
261
265
  * docs/internal/execution-model.md. Once the batch's last action exited,
262
266
  * 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).
267
+ * segments held, so every loader re-runs; above an action's boundary, see
268
+ * below), in the action lane (storeOnly, skipLoadingState:
269
+ * useNavigation() stays idle).
265
270
  *
266
271
  * - Never over an intercept: a request holding no segments drops the page
267
272
  * under it (match-result.ts buildMatchResult). Checked when it would run,
@@ -271,6 +276,10 @@ export function createServerActionBridge(
271
276
  * keeps its error on screen, and the debt is dropped.
272
277
  * - Runs again when it is left before its commit (a navigation, or an action
273
278
  * that started: leaveFollowUp), for the page then on screen.
279
+ * - Holds the action error boundary the page shows (actionBoundaryAt): it
280
+ * is that action's outcome, which no GET reproduces, so the server
281
+ * renders every segment above it and nothing at or below it. With
282
+ * nothing above it (the page's first segment), the debt is dropped.
274
283
  */
275
284
  function followUp(): void {
276
285
  if (!owed || isActionFenceActive()) return;
@@ -285,8 +294,16 @@ export function createServerActionBridge(
285
294
  const due = owed;
286
295
  owed = undefined;
287
296
  if (store.getInterceptSourceUrl()) return;
297
+ // The deepest action boundary the page shows: none (-1) refetches the
298
+ // page whole, the first segment (0) leaves nothing above it.
299
+ const shown =
300
+ store.getCachedSegments(store.getHistoryKey())?.segments ?? [];
301
+ const at = actionBoundaryAt(shown);
302
+ if (at === 0) return;
303
+ // Not on `due`: a run owed again (owed ??= due) looks again.
304
+ const held = { ...due, boundary: shown[at]?.id };
288
305
  const generation = ++followUpsStarted;
289
- refetchRoute(due, {
306
+ refetchRoute(held, {
290
307
  started: (handle) => (followUpRunning = { handle, due }),
291
308
  ended: (left) => {
292
309
  if (generation !== followUpsStarted) return;
@@ -359,6 +376,7 @@ export function createServerActionBridge(
359
376
  ...(src ? { interceptSourceUrl: src } : {}),
360
377
  ...(opts.actionId !== undefined ? { actionId: opts.actionId } : {}),
361
378
  ...(opts.action ? { action: opts.action } : {}),
379
+ boundary: opts.boundary,
362
380
  },
363
381
  );
364
382
  return { started: true, error };
@@ -794,11 +812,15 @@ export function createServerActionBridge(
794
812
  // Cache (and bump the nav instance) BEFORE the UI update so a deferred
795
813
  // handle pushed by the error-boundary render still applies — see the
796
814
  // "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.
815
+ // action's boundary is the last error segment: the server's cut stops
816
+ // there (segment-resolution/revalidation.ts resolveAllSegments). The
817
+ // page holds what is above it, the loaders the response re-ran
818
+ // included, and no error segment.
819
+ const errors = segments.filter((s) => s.type === "error");
820
+ const boundary = errors[errors.length - 1];
799
821
  store.setSegmentIds(
800
- segmentState.currentSegmentIds.filter(
801
- (id) => !segments.some((s) => s.id === id && s.type === "error"),
822
+ idsAboveBoundary(segmentState.currentSegmentIds, boundary?.id).filter(
823
+ (id) => !errors.some((s) => s.id === id),
802
824
  ),
803
825
  );
804
826
  const currentHandleData = liveHandleDataForCache(
@@ -812,6 +834,7 @@ export function createServerActionBridge(
812
834
  currentHandleData,
813
835
  );
814
836
  store.rememberDisplayedEntry();
837
+ if (boundary) actionBoundaries.add(boundary);
815
838
 
816
839
  // Update UI with error boundary.
817
840
  if (process.env.NODE_ENV !== "production") auditTreeCause("action");
@@ -912,6 +935,10 @@ export function createServerActionBridge(
912
935
  metadata?.locationState,
913
936
  eventController,
914
937
  );
938
+ // A success on the page supersedes the action boundary it shows
939
+ // (R4): a repair that stands in for this commit, direct or owed to
940
+ // the batch's end, holds none.
941
+ for (const s of cachedSegments) actionBoundaries.delete(s);
915
942
  }
916
943
  whenAction = { ...whenAction, result: returnData };
917
944
 
@@ -201,13 +201,27 @@ export interface InflightAction {
201
201
  * @internal This type is an implementation detail. Use PublicNavigationState instead.
202
202
  */
203
203
  export interface NavigationState {
204
- /** Navigation lifecycle state (idle or loading during navigation) */
204
+ /**
205
+ * Lifecycle state: `"loading"` while a navigation is in flight and while a
206
+ * server action runs, `"idle"` otherwise
207
+ */
205
208
  state: "idle" | "loading";
206
209
 
207
- /** Whether RSC data is currently streaming (initial load or navigation) */
210
+ /**
211
+ * Whether RSC data is currently streaming: a navigation's response, or a
212
+ * server action's response (initial load, navigation or action)
213
+ */
208
214
  isStreaming: boolean;
209
215
 
210
- /** Current location */
216
+ /**
217
+ * True while a navigation is in flight (fetching or streaming, before its
218
+ * commit); false during a server action, whose `state` is still `"loading"`.
219
+ * Drive a navigation progress bar from this and an action spinner from
220
+ * `state`.
221
+ */
222
+ isNavigating: boolean;
223
+
224
+ /** The page on screen; moves when the next page shows (`pendingUrl` is the page being fetched) */
211
225
  location: NavigationLocation;
212
226
 
213
227
  /** URL being navigated to (null when idle) */
@@ -505,6 +519,8 @@ export interface NavigateOptionsInternal extends Omit<
505
519
  _skipCache?: boolean;
506
520
  /** Server redirects already followed by this navigation chain (0 for a user navigation) */
507
521
  _redirectHops?: number;
522
+ /** The URL a redirected navigation chain started from: a link to it stays pending (useLinkStatus) */
523
+ _redirectFrom?: string;
508
524
  }
509
525
 
510
526
  /**
@@ -718,6 +734,8 @@ export interface FetchPartialOptions {
718
734
  signal?: AbortSignal;
719
735
  /** If true, this is a stale cache revalidation request - server should force revalidators */
720
736
  staleRevalidation?: boolean;
737
+ /** Sent as `_rsc_boundary` (wire-params.ts WIRE.boundary). */
738
+ boundary?: string;
721
739
  interceptSourceUrl?: string;
722
740
  /** RSC version for cache invalidation detection */
723
741
  version?: string;
@@ -803,7 +821,8 @@ export interface ServerActionBridgeConfig {
803
821
  */
804
822
  export interface NavigationBridge {
805
823
  navigate(url: string, options?: NavigateOptionsInternal): Promise<void>;
806
- refresh(): Promise<void>;
824
+ /** `crossTab`: another tab's write; an action's error boundary stays. */
825
+ refresh(crossTab?: boolean): Promise<void>;
807
826
  /** `event`: the popstate event; a traversal the browser animated itself (hasUAVisualTransition) is not animated again. */
808
827
  handlePopstate(event?: PopStateEvent): Promise<void>;
809
828
  registerLinkInterception(): () => void;
@@ -13,6 +13,7 @@ export interface PartialRequestInit {
13
13
  */
14
14
  emptySegments: "send" | "omit";
15
15
  stale?: boolean;
16
+ boundary?: string;
16
17
  version?: string;
17
18
  routerId?: string;
18
19
  rewriteTarget?: string;
@@ -33,6 +34,7 @@ export function writePartialParams(url: URL, init: PartialRequestInit): URL {
33
34
  params.set(WIRE.segments, init.segmentIds.join(","));
34
35
  }
35
36
  if (init.stale) params.set(WIRE.stale, "true");
37
+ if (init.boundary) params.set(WIRE.boundary, init.boundary);
36
38
  if (init.version) params.set(WIRE.version, init.version);
37
39
  if (init.routerId) params.set(WIRE.routerId, init.routerId);
38
40
  if (init.rewriteTarget) params.set(WIRE.rewriteTarget, init.rewriteTarget);