@rangojs/router 0.24.1 → 0.24.2
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/types/client-urls/navigation.d.ts +8 -1
- package/dist/types/client-urls/types.d.ts +6 -0
- package/dist/vite/index.js +2 -2
- package/package.json +1 -1
- package/skills/client-urls/SKILL.md +4 -1
- package/src/client-urls/client-root.tsx +47 -42
- package/src/client-urls/client-urls.ts +4 -0
- package/src/client-urls/navigation.ts +24 -16
- package/src/client-urls/types.ts +6 -0
|
@@ -2,6 +2,13 @@ import type { ClientUrlPatterns } from "./types.js";
|
|
|
2
2
|
import type { TransitionWhenFn } from "../types/segments.js";
|
|
3
3
|
export interface ClientUrlNavigationIntent {
|
|
4
4
|
readonly routeId: string;
|
|
5
|
+
/**
|
|
6
|
+
* The navigation began while an optimistic frame was on screen (a
|
|
7
|
+
* superseded navigation's): ClientUrlsRoot presents it even when its route
|
|
8
|
+
* is the committed one, so the committed params' data never comes back on
|
|
9
|
+
* screen.
|
|
10
|
+
*/
|
|
11
|
+
readonly fromFrame: boolean;
|
|
5
12
|
/** Destination params from the local trie match (definition-local). */
|
|
6
13
|
readonly params: Readonly<Record<string, string>>;
|
|
7
14
|
/** Absolute destination pathname (mount included) and search ("?..." or ""). */
|
|
@@ -39,7 +46,7 @@ export interface ClientUrlNavigationPresentation {
|
|
|
39
46
|
* gated off), or undefined when nothing was swapped (a same-route
|
|
40
47
|
* navigation decides at the canonical commit).
|
|
41
48
|
*/
|
|
42
|
-
readonly transitionGatedOff
|
|
49
|
+
readonly transitionGatedOff?: boolean;
|
|
43
50
|
clear(): void;
|
|
44
51
|
}
|
|
45
52
|
export declare function registerClientUrlGroup(definition: ClientUrlPatterns, mount: string, namePrefix: string, setIntent: (intent: ClientUrlNavigationIntent | null) => void, routeId?: string): () => void;
|
|
@@ -67,6 +67,12 @@ export interface ClientUrlRouteRecord {
|
|
|
67
67
|
*/
|
|
68
68
|
readonly layoutKeys: readonly number[];
|
|
69
69
|
readonly loaders: readonly ClientUrlLoaderRecord[];
|
|
70
|
+
/**
|
|
71
|
+
* The leading `loaders` its layout()s declare, the same record objects for
|
|
72
|
+
* every route under a layout (flattenLayout creates them once): ClientUrlsRoot
|
|
73
|
+
* keeps a shared one's committed data in the optimistic frame.
|
|
74
|
+
*/
|
|
75
|
+
readonly layoutLoaders: readonly ClientUrlLoaderRecord[];
|
|
70
76
|
readonly loading: ReactNode | undefined;
|
|
71
77
|
readonly transition: Readonly<ClientTransitionConfig> | undefined;
|
|
72
78
|
}
|
package/dist/vite/index.js
CHANGED
|
@@ -5929,7 +5929,7 @@ import { resolve } from "node:path";
|
|
|
5929
5929
|
// package.json
|
|
5930
5930
|
var package_default = {
|
|
5931
5931
|
name: "@rangojs/router",
|
|
5932
|
-
version: "0.24.
|
|
5932
|
+
version: "0.24.2",
|
|
5933
5933
|
description: "Django-inspired RSC router with composable URL patterns",
|
|
5934
5934
|
keywords: [
|
|
5935
5935
|
"react",
|
|
@@ -10900,7 +10900,7 @@ import {
|
|
|
10900
10900
|
Fragment as Fragment4,
|
|
10901
10901
|
Suspense as Suspense3,
|
|
10902
10902
|
useDeferredValue,
|
|
10903
|
-
|
|
10903
|
+
useLayoutEffect as useLayoutEffect3,
|
|
10904
10904
|
useMemo as useMemo10,
|
|
10905
10905
|
useState as useState14
|
|
10906
10906
|
} from "react";
|
package/package.json
CHANGED
|
@@ -527,7 +527,10 @@ a hard load of the target URL renders the full route.
|
|
|
527
527
|
`startTransition` when no new segments mount, and the wrapping layout sees
|
|
528
528
|
`useOutlet().pending === true` for the held window; a route-level
|
|
529
529
|
`loading()` does not re-flash there. Across different routes, a `layout()`
|
|
530
|
-
keeps its instance while the navigation stays under it
|
|
530
|
+
keeps its instance while the navigation stays under it, and its own
|
|
531
|
+
`layout()` loaders keep their data on the optimistic frame (a cart count does
|
|
532
|
+
not suspend); only the destination's loaders and those of layouts it mounts
|
|
533
|
+
anew wait for the server. `transition()` is not needed for the
|
|
531
534
|
hold; it adds the view-transition animation. Inline `<Suspense>` above each
|
|
532
535
|
`useLoader` read is still the finer-grained tool when different reads on
|
|
533
536
|
one route should wait independently.
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
Fragment,
|
|
6
6
|
Suspense,
|
|
7
7
|
useDeferredValue,
|
|
8
|
-
|
|
8
|
+
useLayoutEffect,
|
|
9
9
|
useMemo,
|
|
10
10
|
useState,
|
|
11
11
|
type ReactNode,
|
|
@@ -17,10 +17,7 @@ import {
|
|
|
17
17
|
registerClientUrlGroup,
|
|
18
18
|
type ClientUrlNavigationIntent,
|
|
19
19
|
} from "./navigation.js";
|
|
20
|
-
import {
|
|
21
|
-
OptimisticLocationContext,
|
|
22
|
-
type OptimisticLocation,
|
|
23
|
-
} from "./optimistic-location.js";
|
|
20
|
+
import { OptimisticLocationContext } from "./optimistic-location.js";
|
|
24
21
|
import type {
|
|
25
22
|
ClientUrlInterceptRecord,
|
|
26
23
|
ClientUrlPatterns,
|
|
@@ -124,11 +121,6 @@ export function ClientUrlsRoot({
|
|
|
124
121
|
// same way client href()/useMount resolve include-relative URLs.
|
|
125
122
|
const mount = useMount();
|
|
126
123
|
const [intent, setIntent] = useState<ClientUrlNavigationIntent | null>(null);
|
|
127
|
-
useEffect(
|
|
128
|
-
() =>
|
|
129
|
-
registerClientUrlGroup(definition, mount, namePrefix, setIntent, routeId),
|
|
130
|
-
[definition, mount, namePrefix, routeId],
|
|
131
|
-
);
|
|
132
124
|
|
|
133
125
|
// Optimistic destination (design: docs/design/client-urls-optimistic-destination.md).
|
|
134
126
|
// `intent` is set urgently at navigation start so `pending` flips at once
|
|
@@ -137,46 +129,58 @@ export function ClientUrlsRoot({
|
|
|
137
129
|
// own keeps the previous content visible (React's transition hold — the
|
|
138
130
|
// pre-existing contract for routes without loading()), one with loading()
|
|
139
131
|
// or inline <Suspense> at its reads presents immediately. Same-route intents
|
|
140
|
-
// never swap: held data + transition() own that
|
|
141
|
-
//
|
|
142
|
-
//
|
|
132
|
+
// from the committed page never swap: held data + transition() own that
|
|
133
|
+
// case; one that began on an optimistic frame does (fromFrame). A
|
|
134
|
+
// navigation its transition({ when }) gated off at the swap presents
|
|
135
|
+
// urgently: no hold, no view transition, the destination's loading()
|
|
136
|
+
// streams.
|
|
143
137
|
const pending = intent !== null;
|
|
144
138
|
const deferred = useDeferredValue(intent);
|
|
145
139
|
const presented = intent?.transitionGatedOff ? intent : deferred;
|
|
146
140
|
const optimisticRoute =
|
|
147
|
-
presented && presented.routeId !== routeId
|
|
141
|
+
presented && (presented.routeId !== routeId || presented.fromFrame)
|
|
148
142
|
? findRoute(definition, presented.routeId)
|
|
149
143
|
: null;
|
|
150
144
|
const route = optimisticRoute ?? findRoute(definition, routeId);
|
|
151
145
|
|
|
146
|
+
// At commit, so a navigation reads what is on screen (no routeId while a
|
|
147
|
+
// frame is): React renders transition lanes one at a time, so the render
|
|
148
|
+
// that presents the next intent may still lack the canonical commit of the
|
|
149
|
+
// one it supersedes.
|
|
150
|
+
useLayoutEffect(
|
|
151
|
+
() =>
|
|
152
|
+
registerClientUrlGroup(
|
|
153
|
+
definition,
|
|
154
|
+
mount,
|
|
155
|
+
namePrefix,
|
|
156
|
+
setIntent,
|
|
157
|
+
optimisticRoute ? undefined : routeId,
|
|
158
|
+
),
|
|
159
|
+
[definition, mount, namePrefix, routeId, optimisticRoute],
|
|
160
|
+
);
|
|
161
|
+
|
|
152
162
|
// Pending entries for the destination's loaders: useLoader use()s a Promise
|
|
153
|
-
// found in `loaderStreams
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
location: OptimisticLocation;
|
|
161
|
-
} | null>(
|
|
163
|
+
// found in `loaderStreams`, so a read suspends instead of throwing "not
|
|
164
|
+
// found in context"; the canonical commit brings the real data. Skips the
|
|
165
|
+
// committed route's layout() records: flattenLayout creates them once per
|
|
166
|
+
// layout, so one the destination holds too is a layout both keep mounted
|
|
167
|
+
// (#1124), reading committed data. Route-own loaders always wait, also on
|
|
168
|
+
// a return to the committed route (fromFrame).
|
|
169
|
+
const loaderStreams = useMemo(
|
|
162
170
|
() =>
|
|
163
|
-
optimisticRoute
|
|
164
|
-
?
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
record
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
},
|
|
177
|
-
}
|
|
178
|
-
: null,
|
|
179
|
-
[optimisticRoute, presented],
|
|
171
|
+
optimisticRoute
|
|
172
|
+
? Object.fromEntries(
|
|
173
|
+
optimisticRoute.loaders
|
|
174
|
+
.filter(
|
|
175
|
+
(record) =>
|
|
176
|
+
!findRoute(definition, routeId).layoutLoaders.includes(
|
|
177
|
+
record,
|
|
178
|
+
),
|
|
179
|
+
)
|
|
180
|
+
.map((record) => [record.loader.$$id, PENDING_FOREVER]),
|
|
181
|
+
)
|
|
182
|
+
: undefined,
|
|
183
|
+
[optimisticRoute, definition, routeId],
|
|
180
184
|
);
|
|
181
185
|
|
|
182
186
|
// The wrapper chain below is IDENTICAL in the optimistic and the canonical
|
|
@@ -206,10 +210,11 @@ export function ClientUrlsRoot({
|
|
|
206
210
|
|
|
207
211
|
return createElement(OutletProvider, {
|
|
208
212
|
content: null,
|
|
209
|
-
loaderStreams
|
|
213
|
+
loaderStreams,
|
|
210
214
|
pending,
|
|
211
215
|
children: createElement(OptimisticLocationContext.Provider, {
|
|
212
|
-
|
|
216
|
+
// The intent carries every OptimisticLocation field.
|
|
217
|
+
value: optimisticRoute ? presented : null,
|
|
213
218
|
children: content,
|
|
214
219
|
}),
|
|
215
220
|
});
|
|
@@ -566,6 +566,9 @@ function createHelpers(): ClientUrlHelpers {
|
|
|
566
566
|
});
|
|
567
567
|
}
|
|
568
568
|
|
|
569
|
+
// A layout()'s loader records are created once here and shared by every
|
|
570
|
+
// route under it (ClientUrlRouteRecord.layoutLoaders); ClientUrlsRoot relies
|
|
571
|
+
// on that identity (client-root.tsx).
|
|
569
572
|
function applyConfig(
|
|
570
573
|
context: FlattenContext,
|
|
571
574
|
items: readonly InternalItem[],
|
|
@@ -619,6 +622,7 @@ function addPathRecord(
|
|
|
619
622
|
layouts: Object.freeze([...routeContext.layouts]),
|
|
620
623
|
layoutKeys: Object.freeze([...routeContext.layoutKeys]),
|
|
621
624
|
loaders: Object.freeze([...routeContext.loaders]),
|
|
625
|
+
layoutLoaders: context.loaders,
|
|
622
626
|
loading: routeContext.loading,
|
|
623
627
|
transition: transitionItem
|
|
624
628
|
? Object.freeze({ ...transitionItem.config })
|
|
@@ -6,6 +6,13 @@ import type { TransitionWhenFn } from "../types/segments.js";
|
|
|
6
6
|
|
|
7
7
|
export interface ClientUrlNavigationIntent {
|
|
8
8
|
readonly routeId: string;
|
|
9
|
+
/**
|
|
10
|
+
* The navigation began while an optimistic frame was on screen (a
|
|
11
|
+
* superseded navigation's): ClientUrlsRoot presents it even when its route
|
|
12
|
+
* is the committed one, so the committed params' data never comes back on
|
|
13
|
+
* screen.
|
|
14
|
+
*/
|
|
15
|
+
readonly fromFrame: boolean;
|
|
9
16
|
/** Destination params from the local trie match (definition-local). */
|
|
10
17
|
readonly params: Readonly<Record<string, string>>;
|
|
11
18
|
/** Absolute destination pathname (mount included) and search ("?..." or ""). */
|
|
@@ -43,10 +50,13 @@ interface ActiveClientUrlGroup {
|
|
|
43
50
|
readonly mount: string;
|
|
44
51
|
/** include() route-name prefix ("" at root) for canonical name composition. */
|
|
45
52
|
readonly namePrefix: string;
|
|
46
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* The group route on screen (ClientUrlsRoot's committed routeId), undefined
|
|
55
|
+
* while an optimistic frame is. Registered at commit.
|
|
56
|
+
*/
|
|
47
57
|
readonly routeId: string | undefined;
|
|
48
58
|
readonly setIntent: (intent: ClientUrlNavigationIntent | null) => void;
|
|
49
|
-
intent
|
|
59
|
+
intent?: ClientUrlNavigationIntent | null;
|
|
50
60
|
}
|
|
51
61
|
|
|
52
62
|
/**
|
|
@@ -74,7 +84,7 @@ export interface ClientUrlNavigationPresentation {
|
|
|
74
84
|
* gated off), or undefined when nothing was swapped (a same-route
|
|
75
85
|
* navigation decides at the canonical commit).
|
|
76
86
|
*/
|
|
77
|
-
readonly transitionGatedOff
|
|
87
|
+
readonly transitionGatedOff?: boolean;
|
|
78
88
|
clear(): void;
|
|
79
89
|
}
|
|
80
90
|
|
|
@@ -110,7 +120,6 @@ export function registerClientUrlGroup(
|
|
|
110
120
|
namePrefix,
|
|
111
121
|
routeId,
|
|
112
122
|
setIntent,
|
|
113
|
-
intent: null,
|
|
114
123
|
};
|
|
115
124
|
activeGroup = group;
|
|
116
125
|
|
|
@@ -151,9 +160,10 @@ export function beginClientUrlNavigation(
|
|
|
151
160
|
|
|
152
161
|
const state = buildState?.();
|
|
153
162
|
|
|
154
|
-
// A same-route intent never swaps (ClientUrlsRoot),
|
|
155
|
-
// decides at the canonical commit;
|
|
156
|
-
// navigation's first presentation.
|
|
163
|
+
// A same-route intent from the committed page never swaps (ClientUrlsRoot),
|
|
164
|
+
// so transition({ when }) decides at the canonical commit; one that swaps
|
|
165
|
+
// decides here, at the navigation's first presentation. While a frame is on
|
|
166
|
+
// screen, group.routeId is undefined and every intent swaps.
|
|
157
167
|
let transitionGatedOff: boolean | undefined;
|
|
158
168
|
if (decideTransition && match.routeKey !== group.routeId) {
|
|
159
169
|
const originLocal = stripMountPrefix(window.location.pathname, group.mount);
|
|
@@ -171,6 +181,10 @@ export function beginClientUrlNavigation(
|
|
|
171
181
|
|
|
172
182
|
const intent: ClientUrlNavigationIntent = {
|
|
173
183
|
routeId: match.routeKey,
|
|
184
|
+
// From what React committed, not group.intent: a superseded intent is
|
|
185
|
+
// cleared when its canonical commit is handed over, before React renders
|
|
186
|
+
// it, and its frame is still on screen until then.
|
|
187
|
+
fromFrame: !group.routeId,
|
|
174
188
|
params: match.params,
|
|
175
189
|
pathname: targetUrl.pathname,
|
|
176
190
|
search: targetUrl.search,
|
|
@@ -192,16 +206,10 @@ export function beginClientUrlNavigation(
|
|
|
192
206
|
// updates land in order, so this changes nothing there.
|
|
193
207
|
startTransition(() => group.setIntent(null));
|
|
194
208
|
};
|
|
195
|
-
|
|
209
|
+
// An abort after clear() finds another intent or none and does nothing.
|
|
210
|
+
signal.addEventListener("abort", clear);
|
|
196
211
|
|
|
197
|
-
return {
|
|
198
|
-
routeId: match.routeKey,
|
|
199
|
-
transitionGatedOff,
|
|
200
|
-
clear() {
|
|
201
|
-
signal.removeEventListener("abort", clear);
|
|
202
|
-
clear();
|
|
203
|
-
},
|
|
204
|
-
};
|
|
212
|
+
return { ...intent, clear };
|
|
205
213
|
}
|
|
206
214
|
|
|
207
215
|
export function clearClientUrlNavigationRegistry(): void {
|
package/src/client-urls/types.ts
CHANGED
|
@@ -114,6 +114,12 @@ export interface ClientUrlRouteRecord {
|
|
|
114
114
|
*/
|
|
115
115
|
readonly layoutKeys: readonly number[];
|
|
116
116
|
readonly loaders: readonly ClientUrlLoaderRecord[];
|
|
117
|
+
/**
|
|
118
|
+
* The leading `loaders` its layout()s declare, the same record objects for
|
|
119
|
+
* every route under a layout (flattenLayout creates them once): ClientUrlsRoot
|
|
120
|
+
* keeps a shared one's committed data in the optimistic frame.
|
|
121
|
+
*/
|
|
122
|
+
readonly layoutLoaders: readonly ClientUrlLoaderRecord[];
|
|
117
123
|
readonly loading: ReactNode | undefined;
|
|
118
124
|
readonly transition: Readonly<ClientTransitionConfig> | undefined;
|
|
119
125
|
}
|