@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.
- package/dist/bin/rango.js +1 -1
- package/dist/types/browser/action-fence.d.ts +2 -1
- package/dist/types/browser/event-controller.d.ts +6 -1
- package/dist/types/browser/partial-update.d.ts +11 -0
- package/dist/types/browser/react/use-action.d.ts +7 -2
- package/dist/types/browser/react/use-handle.d.ts +19 -16
- package/dist/types/browser/react/use-link-status.d.ts +5 -2
- package/dist/types/browser/segment-reconciler.d.ts +26 -0
- package/dist/types/browser/types.d.ts +22 -4
- package/dist/types/browser/wire-protocol.d.ts +1 -0
- package/dist/types/cache/tag-invalidation.d.ts +0 -10
- package/dist/types/router/error-handling.d.ts +7 -1
- package/dist/types/router/match-context.d.ts +2 -0
- package/dist/types/router/match-middleware/background-revalidation.d.ts +3 -2
- package/dist/types/router/navigation-snapshot.d.ts +7 -0
- package/dist/types/router/segment-resolution/helpers.d.ts +19 -3
- package/dist/types/router/segment-resolution/resolution-pass.d.ts +2 -0
- package/dist/types/router/segment-resolution/streamed-handler-boundary.d.ts +2 -0
- package/dist/types/router/types.d.ts +1 -1
- package/dist/types/server/request-context.d.ts +30 -1
- package/dist/types/testing/render-route.d.ts +43 -8
- package/dist/types/testing/serve-shell-request.d.ts +8 -1
- package/dist/types/types/error-types.d.ts +27 -8
- package/dist/types/wire-params.d.ts +5 -0
- package/dist/vite/index.js +15 -19
- package/package.json +1 -1
- package/skills/client-urls/SKILL.md +4 -2
- package/skills/hooks/SKILL.md +1 -1
- package/skills/hooks/data.md +8 -1
- package/skills/hooks/handle-and-actions.md +33 -2
- package/skills/hooks/navigation.md +121 -15
- package/skills/hooks/state.md +6 -4
- package/skills/hooks/urls.md +16 -5
- package/skills/intercept/SKILL.md +6 -0
- package/skills/observability/SKILL.md +30 -0
- package/skills/server-actions/SKILL.md +8 -1
- package/skills/testing/client-components.md +25 -18
- package/skills/testing/serve-shell-request.md +7 -7
- package/skills/view-transitions/SKILL.md +1 -1
- package/src/browser/action-fence.ts +2 -1
- package/src/browser/event-controller.ts +32 -10
- package/src/browser/navigation-bridge.ts +45 -13
- package/src/browser/navigation-client.ts +2 -0
- package/src/browser/network-error-handler.ts +4 -0
- package/src/browser/partial-update.ts +33 -6
- package/src/browser/react/use-action.ts +24 -48
- package/src/browser/react/use-handle.ts +22 -18
- package/src/browser/react/use-link-status.ts +48 -60
- package/src/browser/react/use-navigation.ts +92 -41
- package/src/browser/segment-reconciler.ts +43 -0
- package/src/browser/server-action-bridge.ts +35 -8
- package/src/browser/types.ts +23 -4
- package/src/browser/wire-protocol.ts +2 -0
- package/src/cache/cache-runtime.ts +28 -31
- package/src/cache/document-cache.ts +34 -37
- package/src/cache/read-through-swr.ts +20 -18
- package/src/cache/tag-invalidation.ts +3 -18
- package/src/router/error-handling.ts +18 -6
- package/src/router/intercept-resolution.ts +9 -4
- package/src/router/match-api.ts +1 -0
- package/src/router/match-context.ts +3 -0
- package/src/router/match-middleware/background-revalidation.ts +5 -4
- package/src/router/match-middleware/cache-lookup.ts +5 -3
- package/src/router/navigation-snapshot.ts +10 -0
- package/src/router/segment-resolution/helpers.ts +49 -13
- package/src/router/segment-resolution/loader-cache.ts +4 -3
- package/src/router/segment-resolution/resolution-pass.ts +2 -0
- package/src/router/segment-resolution/revalidation.ts +57 -28
- package/src/router/segment-resolution/streamed-handler-boundary.ts +3 -0
- package/src/router/types.ts +1 -1
- package/src/router.ts +7 -4
- package/src/rsc/request-stages.ts +4 -2
- package/src/rsc/shell-capture.ts +63 -58
- package/src/server/request-context.ts +49 -0
- package/src/testing/dispatch.ts +3 -0
- package/src/testing/render-route.tsx +199 -13
- package/src/testing/serve-shell-request.ts +19 -10
- package/src/types/error-types.ts +27 -8
- 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
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
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
|
|
80
|
-
* page's own commit
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
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
|
-
|
|
106
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
61
|
+
navigation: NavigationEntry | null,
|
|
51
62
|
origin: string,
|
|
52
63
|
): boolean {
|
|
53
|
-
if (linkTo === null ||
|
|
64
|
+
if (linkTo === null || !navigation) {
|
|
54
65
|
return false;
|
|
55
66
|
}
|
|
56
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
ctx.eventController
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
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
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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 {
|
|
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 [
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
ctx.eventController.
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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).
|
|
78
|
-
//
|
|
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
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
//
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
|
264
|
-
*
|
|
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(
|
|
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
|
-
//
|
|
798
|
-
//
|
|
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) => !
|
|
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
|
|
package/src/browser/types.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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);
|