@rangojs/router 0.28.0 → 0.30.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 +10 -4
- package/dist/types/browser/react/use-handle.d.ts +20 -16
- package/dist/types/browser/react/use-link-status.d.ts +5 -2
- package/dist/types/browser/react/use-route-state.d.ts +45 -0
- package/dist/types/browser/segment-reconciler.d.ts +26 -0
- package/dist/types/browser/types.d.ts +30 -8
- 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 +62 -67
- package/package.json +1 -1
- package/skills/client-urls/SKILL.md +6 -8
- package/skills/hooks/SKILL.md +1 -1
- package/skills/hooks/data.md +8 -1
- package/skills/hooks/handle-and-actions.md +40 -2
- package/skills/hooks/navigation.md +121 -15
- package/skills/hooks/state.md +6 -4
- package/skills/hooks/urls.md +17 -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 +78 -19
- 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 +27 -50
- package/src/browser/react/use-handle.ts +27 -18
- package/src/browser/react/use-link-status.ts +48 -60
- package/src/browser/react/use-navigation.ts +76 -51
- package/src/browser/react/use-params.ts +6 -35
- package/src/browser/react/use-pathname.ts +3 -32
- package/src/browser/react/use-route-state.ts +141 -0
- package/src/browser/react/use-search-params.ts +7 -40
- package/src/browser/segment-reconciler.ts +43 -0
- package/src/browser/server-action-bridge.ts +35 -8
- package/src/browser/types.ts +31 -8
- 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 +204 -16
- package/src/testing/serve-shell-request.ts +19 -10
- package/src/types/error-types.ts +27 -8
- package/src/wire-params.ts +5 -0
|
@@ -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
|
|
@@ -1,17 +1,14 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
useContext,
|
|
5
|
-
useState,
|
|
6
|
-
useEffect,
|
|
7
|
-
useLayoutEffect,
|
|
8
|
-
startTransition,
|
|
9
|
-
useRef,
|
|
10
|
-
} from "react";
|
|
3
|
+
import { useContext, useEffect, useRef } from "react";
|
|
11
4
|
import { NavigationStoreContext } from "./context.js";
|
|
12
5
|
import { shallowEqual } from "./shallow-equal.js";
|
|
6
|
+
import { useRouteState } from "./use-route-state.js";
|
|
13
7
|
import type { PublicNavigationState } from "../types.js";
|
|
14
|
-
import type {
|
|
8
|
+
import type {
|
|
9
|
+
DerivedNavigationState,
|
|
10
|
+
RouteStateSignal,
|
|
11
|
+
} from "../event-controller.js";
|
|
15
12
|
|
|
16
13
|
/**
|
|
17
14
|
* Convert derived state to public version (strips inflightActions)
|
|
@@ -21,6 +18,32 @@ function toPublicState(state: DerivedNavigationState): PublicNavigationState {
|
|
|
21
18
|
return publicState;
|
|
22
19
|
}
|
|
23
20
|
|
|
21
|
+
/** The state a value was selected from, and the value. */
|
|
22
|
+
interface Snapshot<T> {
|
|
23
|
+
state: PublicNavigationState;
|
|
24
|
+
value: T;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A notification while React holds a page: work that starts shows at once
|
|
29
|
+
* (loading, streaming, a newer click's URL), what settles waits for the
|
|
30
|
+
* page's commit (rango-d152), and location moves only with the notification
|
|
31
|
+
* that moves it (the page on screen until the next one shows).
|
|
32
|
+
*/
|
|
33
|
+
function hold(
|
|
34
|
+
base: PublicNavigationState,
|
|
35
|
+
live: PublicNavigationState,
|
|
36
|
+
moved: boolean,
|
|
37
|
+
): PublicNavigationState {
|
|
38
|
+
return {
|
|
39
|
+
state: live.state === "loading" ? live.state : base.state,
|
|
40
|
+
isStreaming: live.isStreaming || base.isStreaming,
|
|
41
|
+
isNavigating: live.isNavigating || base.isNavigating,
|
|
42
|
+
pendingUrl: live.pendingUrl ?? base.pendingUrl,
|
|
43
|
+
location: moved ? live.location : base.location,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
24
47
|
/**
|
|
25
48
|
* Hook to access reactive navigation state with optional selector for performance.
|
|
26
49
|
*
|
|
@@ -46,19 +69,20 @@ export function useNavigation<T>(
|
|
|
46
69
|
throw new Error("useNavigation must be used within NavigationProvider");
|
|
47
70
|
}
|
|
48
71
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
72
|
+
// A release is compared with the state React renders it on
|
|
73
|
+
// (useRouteState). The anchor is compared with the last set, as an
|
|
74
|
+
// ordinary notification is: it sets inside its page's transition, which
|
|
75
|
+
// renders every set so far.
|
|
76
|
+
const [snapshot, set] = useRouteState<Snapshot<T | PublicNavigationState>>(
|
|
77
|
+
() => {
|
|
78
|
+
const state = toPublicState(
|
|
79
|
+
ctx.eventController.getHydrationSnapshot()?.state ??
|
|
80
|
+
ctx.eventController.getState(),
|
|
81
|
+
);
|
|
82
|
+
return { state, value: selector ? selector(state) : state };
|
|
83
|
+
},
|
|
84
|
+
(a, b) => shallowEqual(a.value, b.value),
|
|
85
|
+
);
|
|
62
86
|
|
|
63
87
|
// Store selector in a ref so the subscription callback always uses the
|
|
64
88
|
// latest selector without re-subscribing on every render (inline functions
|
|
@@ -74,34 +98,28 @@ export function useNavigation<T>(
|
|
|
74
98
|
// the location hooks' values do: NavigationProvider flushes a navigation's
|
|
75
99
|
// notification into the transition that commits its tree, so idle commits
|
|
76
100
|
// with the destination's location (useSearchParams, usePathname,
|
|
77
|
-
// useParams).
|
|
78
|
-
//
|
|
101
|
+
// useParams). From that page's anchor to its release every other
|
|
102
|
+
// notification goes through hold(), urgently, so React rebases the same
|
|
103
|
+
// update onto the page when it commits. Never a transition of its own.
|
|
79
104
|
useEffect(() => {
|
|
80
|
-
let
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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);
|
|
104
|
-
}
|
|
105
|
+
let pageHeld = false;
|
|
106
|
+
let location: PublicNavigationState["location"] | undefined;
|
|
107
|
+
const select = (state: PublicNavigationState) =>
|
|
108
|
+
selectorRef.current ? selectorRef.current(state) : state;
|
|
109
|
+
const update = (signal?: RouteStateSignal) => {
|
|
110
|
+
if (signal) pageHeld = signal === "anchor";
|
|
111
|
+
const live = toPublicState(ctx.eventController.getState());
|
|
112
|
+
const moved = live.location !== location;
|
|
113
|
+
location = live.location;
|
|
114
|
+
const held = pageHeld && !signal;
|
|
115
|
+
set(
|
|
116
|
+
(base) => {
|
|
117
|
+
const state = held ? hold(base.state, live, moved) : live;
|
|
118
|
+
return { state, value: select(state) };
|
|
119
|
+
},
|
|
120
|
+
signal,
|
|
121
|
+
held,
|
|
122
|
+
);
|
|
105
123
|
};
|
|
106
124
|
|
|
107
125
|
// Catch-up: re-read state synchronously on mount before subscribing, so a
|
|
@@ -110,8 +128,15 @@ export function useNavigation<T>(
|
|
|
110
128
|
// useSearchParams.
|
|
111
129
|
update();
|
|
112
130
|
|
|
113
|
-
|
|
131
|
+
const unsubscribe = ctx.eventController.subscribe(update);
|
|
132
|
+
// The anchor and release signals ride the handle channel. One without a
|
|
133
|
+
// signal re-reads the same state, as the state channel's own would.
|
|
134
|
+
const unsubscribeSignals = ctx.eventController.subscribeToHandles(update);
|
|
135
|
+
return () => {
|
|
136
|
+
unsubscribe();
|
|
137
|
+
unsubscribeSignals();
|
|
138
|
+
};
|
|
114
139
|
}, []);
|
|
115
140
|
|
|
116
|
-
return value as T | PublicNavigationState;
|
|
141
|
+
return snapshot.value as T | PublicNavigationState;
|
|
117
142
|
}
|
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useContext
|
|
4
|
-
import { NavigationStoreContext } from "./context.js";
|
|
3
|
+
import { useContext } from "react";
|
|
5
4
|
import { shallowEqual } from "./shallow-equal.js";
|
|
5
|
+
import { useUrlValue } from "./use-route-state.js";
|
|
6
6
|
import { OptimisticLocationContext } from "../../client-urls/optimistic-location.js";
|
|
7
7
|
|
|
8
|
-
const EMPTY_PARAMS: Record<string, string> = Object.freeze({});
|
|
9
|
-
|
|
10
8
|
/**
|
|
11
9
|
* Hook to access the current route params.
|
|
12
10
|
*
|
|
@@ -45,38 +43,11 @@ export function useParams<T>(
|
|
|
45
43
|
export function useParams<T>(
|
|
46
44
|
selector?: (params: Record<string, string | undefined>) => T,
|
|
47
45
|
): T | Record<string, string | undefined> {
|
|
48
|
-
const ctx = useContext(NavigationStoreContext);
|
|
49
46
|
const optimistic = useContext(OptimisticLocationContext);
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
ctx.eventController.getParams())
|
|
55
|
-
: EMPTY_PARAMS;
|
|
56
|
-
return selector ? selector(params) : params;
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
const prevValue = useRef(value);
|
|
60
|
-
const selectorRef = useRef(selector);
|
|
61
|
-
selectorRef.current = selector;
|
|
62
|
-
|
|
63
|
-
useEffect(() => {
|
|
64
|
-
if (!ctx) return;
|
|
65
|
-
|
|
66
|
-
const update = () => {
|
|
67
|
-
const params = ctx.eventController.getParams();
|
|
68
|
-
const next = selectorRef.current ? selectorRef.current(params) : params;
|
|
69
|
-
|
|
70
|
-
if (!shallowEqual(next, prevValue.current)) {
|
|
71
|
-
prevValue.current = next;
|
|
72
|
-
setValue(next);
|
|
73
|
-
}
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
update();
|
|
77
|
-
|
|
78
|
-
return ctx.eventController.subscribe(update);
|
|
79
|
-
}, []);
|
|
47
|
+
const value = useUrlValue<T | Record<string, string>>(
|
|
48
|
+
({ params }) => (selector ? selector(params) : params),
|
|
49
|
+
shallowEqual,
|
|
50
|
+
);
|
|
80
51
|
|
|
81
52
|
if (optimistic) {
|
|
82
53
|
return selector ? selector(optimistic.params) : optimistic.params;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useContext
|
|
4
|
-
import {
|
|
3
|
+
import { useContext } from "react";
|
|
4
|
+
import { useUrlValue } from "./use-route-state.js";
|
|
5
5
|
import { OptimisticLocationContext } from "../../client-urls/optimistic-location.js";
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -19,36 +19,7 @@ import { OptimisticLocationContext } from "../../client-urls/optimistic-location
|
|
|
19
19
|
* ```
|
|
20
20
|
*/
|
|
21
21
|
export function usePathname(): string {
|
|
22
|
-
const ctx = useContext(NavigationStoreContext);
|
|
23
22
|
const optimistic = useContext(OptimisticLocationContext);
|
|
24
|
-
|
|
25
|
-
const [pathname, setPathname] = useState<string>(() => {
|
|
26
|
-
if (!ctx) {
|
|
27
|
-
return "/";
|
|
28
|
-
}
|
|
29
|
-
const state =
|
|
30
|
-
ctx.eventController.getHydrationSnapshot()?.state ??
|
|
31
|
-
ctx.eventController.getState();
|
|
32
|
-
return (state.location as URL).pathname;
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
const prevPathname = useRef(pathname);
|
|
36
|
-
|
|
37
|
-
useEffect(() => {
|
|
38
|
-
if (!ctx) return;
|
|
39
|
-
|
|
40
|
-
const update = () => {
|
|
41
|
-
const next = (ctx.eventController.getState().location as URL).pathname;
|
|
42
|
-
if (next !== prevPathname.current) {
|
|
43
|
-
prevPathname.current = next;
|
|
44
|
-
setPathname(next);
|
|
45
|
-
}
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
update();
|
|
49
|
-
|
|
50
|
-
return ctx.eventController.subscribe(update);
|
|
51
|
-
}, []);
|
|
52
|
-
|
|
23
|
+
const pathname = useUrlValue((route) => route.location.pathname, Object.is);
|
|
53
24
|
return optimistic ? optimistic.pathname : pathname;
|
|
54
25
|
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
useContext,
|
|
5
|
+
useEffect,
|
|
6
|
+
useLayoutEffect,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
} from "react";
|
|
10
|
+
import { NavigationStoreContext } from "./context.js";
|
|
11
|
+
import type { EventController, RouteStateSignal } from "../event-controller.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The value a reader rendered in React's last commit of it, written by its
|
|
15
|
+
* layout effect. A page's release runs in the provider's layout effect, after
|
|
16
|
+
* every reader's (children first), so it reads the commit that brings the
|
|
17
|
+
* page.
|
|
18
|
+
*/
|
|
19
|
+
export function useCommitted<T>(value: T): { readonly current: T } {
|
|
20
|
+
const committed = useRef(value);
|
|
21
|
+
useLayoutEffect(() => {
|
|
22
|
+
committed.current = value;
|
|
23
|
+
}, [value]);
|
|
24
|
+
return committed;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Sets a route-state reader's value. `next` maps the value React ends on once
|
|
29
|
+
* every set so far commits (`latest`) to the reader's value; `rebase` sets
|
|
30
|
+
* `next` itself, so React recomputes it on whatever base it renders. The value
|
|
31
|
+
* must not be a function.
|
|
32
|
+
*/
|
|
33
|
+
export type RouteStateSetter<T> = (
|
|
34
|
+
next: (base: T) => T,
|
|
35
|
+
signal?: RouteStateSignal,
|
|
36
|
+
rebase?: boolean,
|
|
37
|
+
) => void;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Route state a reader sets as the router store notifies it, in the lane of
|
|
41
|
+
* the notification that carries it. A notification equal to `latest` sets
|
|
42
|
+
* nothing, so a reader renders only when its value moves.
|
|
43
|
+
*
|
|
44
|
+
* A release is compared with what React committed, not with `latest`: while
|
|
45
|
+
* React holds a page, its set is not on screen, and an urgent page that ends
|
|
46
|
+
* the hold brings an equal value. A release that finds every set committed
|
|
47
|
+
* with its value sets nothing.
|
|
48
|
+
*/
|
|
49
|
+
export function useRouteState<T>(
|
|
50
|
+
init: () => T,
|
|
51
|
+
same: (a: T, b: T) => boolean,
|
|
52
|
+
): [T, RouteStateSetter<T>] {
|
|
53
|
+
const [value, setValue] = useState(init);
|
|
54
|
+
const latest = useRef(value);
|
|
55
|
+
const committed = useCommitted(value);
|
|
56
|
+
const [set] = useState(
|
|
57
|
+
(): RouteStateSetter<T> => (next, signal, rebase) => {
|
|
58
|
+
const target = next(latest.current);
|
|
59
|
+
if (signal === "release") {
|
|
60
|
+
if (
|
|
61
|
+
latest.current === committed.current &&
|
|
62
|
+
same(target, latest.current)
|
|
63
|
+
)
|
|
64
|
+
return;
|
|
65
|
+
latest.current = target;
|
|
66
|
+
setValue((prev) => (same(prev, target) ? prev : target));
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (same(target, latest.current)) return;
|
|
70
|
+
latest.current = target;
|
|
71
|
+
setValue(rebase ? next : target);
|
|
72
|
+
},
|
|
73
|
+
);
|
|
74
|
+
return [value, set];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The route a URL hook reads. */
|
|
78
|
+
export interface UrlRoute {
|
|
79
|
+
location: URL;
|
|
80
|
+
params: Record<string, string>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const NO_ROUTE: UrlRoute = {
|
|
84
|
+
location: new URL("/", "http://localhost"),
|
|
85
|
+
params: Object.freeze({}) as Record<string, string>,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const routeOf = (controller: EventController): UrlRoute => ({
|
|
89
|
+
location: controller.getState().location,
|
|
90
|
+
params: controller.getParams(),
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A URL hook's value (usePathname, useParams, useSearchParams): `read` of the
|
|
95
|
+
* route, seeded from the hydration snapshot while the document hydrates, then
|
|
96
|
+
* set as the store notifies it and at a page's release (useRouteState). The
|
|
97
|
+
* release rides the handle channel. `read` applies when the store changes,
|
|
98
|
+
* never in a later render: a new one takes effect from the next change.
|
|
99
|
+
*
|
|
100
|
+
* A read equal to the value it is set over keeps that value, so an object
|
|
101
|
+
* reader changes identity once per value: a release that ends a hold sets the
|
|
102
|
+
* held page's object, which React's render of the held lane keeps.
|
|
103
|
+
*/
|
|
104
|
+
export function useUrlValue<T>(
|
|
105
|
+
read: (route: UrlRoute) => T,
|
|
106
|
+
same: (a: T, b: T) => boolean,
|
|
107
|
+
): T {
|
|
108
|
+
const ctx = useContext(NavigationStoreContext);
|
|
109
|
+
const [value, set] = useRouteState(() => {
|
|
110
|
+
if (!ctx) return read(NO_ROUTE);
|
|
111
|
+
const hydration = ctx.eventController.getHydrationSnapshot();
|
|
112
|
+
return read(
|
|
113
|
+
hydration
|
|
114
|
+
? { location: hydration.state.location, params: hydration.params }
|
|
115
|
+
: routeOf(ctx.eventController),
|
|
116
|
+
);
|
|
117
|
+
}, same);
|
|
118
|
+
const readRef = useRef(read);
|
|
119
|
+
readRef.current = read;
|
|
120
|
+
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
if (!ctx) return;
|
|
123
|
+
const controller = ctx.eventController;
|
|
124
|
+
const update = (signal?: RouteStateSignal): void => {
|
|
125
|
+
const next = readRef.current(routeOf(controller));
|
|
126
|
+
set((base) => (same(next, base) ? base : next), signal);
|
|
127
|
+
};
|
|
128
|
+
// Catch up with a change between the seeding render and this effect.
|
|
129
|
+
update();
|
|
130
|
+
const unsubscribe = controller.subscribe(update);
|
|
131
|
+
const unsubscribeRelease = controller.subscribeToHandles((signal) => {
|
|
132
|
+
if (signal === "release") update(signal);
|
|
133
|
+
});
|
|
134
|
+
return () => {
|
|
135
|
+
unsubscribe();
|
|
136
|
+
unsubscribeRelease();
|
|
137
|
+
};
|
|
138
|
+
}, []);
|
|
139
|
+
|
|
140
|
+
return value;
|
|
141
|
+
}
|
|
@@ -1,14 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
useCallback,
|
|
5
|
-
useContext,
|
|
6
|
-
useEffect,
|
|
7
|
-
useMemo,
|
|
8
|
-
useRef,
|
|
9
|
-
useState,
|
|
10
|
-
} from "react";
|
|
3
|
+
import { useCallback, useContext, useMemo } from "react";
|
|
11
4
|
import { NavigationStoreContext } from "./context.js";
|
|
5
|
+
import { useUrlValue } from "./use-route-state.js";
|
|
12
6
|
import type { ReadonlyURLSearchParams } from "../types.js";
|
|
13
7
|
import { OptimisticLocationContext } from "../../client-urls/optimistic-location.js";
|
|
14
8
|
|
|
@@ -113,38 +107,11 @@ export function useSearchParams(): [ReadonlyURLSearchParams, SetSearchParams] {
|
|
|
113
107
|
const ctx = useContext(NavigationStoreContext);
|
|
114
108
|
const optimistic = useContext(OptimisticLocationContext);
|
|
115
109
|
|
|
116
|
-
//
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
useState<ReadonlyURLSearchParams>(() => {
|
|
122
|
-
const state = ctx
|
|
123
|
-
? (ctx.eventController.getHydrationSnapshot()?.state ??
|
|
124
|
-
ctx.eventController.getState())
|
|
125
|
-
: undefined;
|
|
126
|
-
const location = state?.location as URL | undefined;
|
|
127
|
-
return new URLSearchParams(location?.searchParams);
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
const prevSearch = useRef(searchParams.toString());
|
|
131
|
-
|
|
132
|
-
useEffect(() => {
|
|
133
|
-
if (!ctx) return;
|
|
134
|
-
|
|
135
|
-
const update = () => {
|
|
136
|
-
const location = ctx.eventController.getState().location as URL;
|
|
137
|
-
const nextSearch = location.searchParams.toString();
|
|
138
|
-
if (nextSearch !== prevSearch.current) {
|
|
139
|
-
prevSearch.current = nextSearch;
|
|
140
|
-
setSearchParamsState(new URLSearchParams(nextSearch));
|
|
141
|
-
}
|
|
142
|
-
};
|
|
143
|
-
|
|
144
|
-
update();
|
|
145
|
-
|
|
146
|
-
return ctx.eventController.subscribe(update);
|
|
147
|
-
}, []);
|
|
110
|
+
// A new object only when the string changes.
|
|
111
|
+
const searchParams = useUrlValue<ReadonlyURLSearchParams>(
|
|
112
|
+
(route) => new URLSearchParams(route.location.searchParams),
|
|
113
|
+
(a, b) => a.toString() === b.toString(),
|
|
114
|
+
);
|
|
148
115
|
|
|
149
116
|
// Reads the committed location at CALL time (not the render closure), so a
|
|
150
117
|
// functional init always merges against fresh params even when the caller's
|
|
@@ -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
|
+
}
|