@rangojs/router 0.24.0 → 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 +14 -0
- package/dist/vite/index.js +2 -2
- package/package.json +1 -1
- package/skills/client-urls/SKILL.md +5 -1
- package/src/client-urls/client-root.tsx +50 -45
- package/src/client-urls/client-urls.ts +12 -1
- package/src/client-urls/navigation.ts +24 -16
- package/src/client-urls/types.ts +14 -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;
|
|
@@ -58,7 +58,21 @@ export interface ClientUrlRouteRecord {
|
|
|
58
58
|
readonly options: Readonly<ClientPathOptions> | undefined;
|
|
59
59
|
readonly component: ComponentType;
|
|
60
60
|
readonly layouts: readonly ComponentType[];
|
|
61
|
+
/**
|
|
62
|
+
* One per entry in `layouts`: the layout() item's ordinal among its sibling
|
|
63
|
+
* layout()s, its React key. Routes under the same layout() share it, so
|
|
64
|
+
* ClientUrlsRoot keeps that layout's instance across them (#1124). Keys only
|
|
65
|
+
* need to be unique among siblings: a differing parent layout remounts the
|
|
66
|
+
* whole subtree anyway.
|
|
67
|
+
*/
|
|
68
|
+
readonly layoutKeys: readonly number[];
|
|
61
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[];
|
|
62
76
|
readonly loading: ReactNode | undefined;
|
|
63
77
|
readonly transition: Readonly<ClientTransitionConfig> | undefined;
|
|
64
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
|
@@ -526,7 +526,11 @@ a hard load of the target URL renders the full route.
|
|
|
526
526
|
reconciles instead of remounting. The canonical commit runs in a
|
|
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
|
-
`loading()` does not re-flash there. `
|
|
529
|
+
`loading()` does not re-flash there. Across different routes, a `layout()`
|
|
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
|
|
530
534
|
hold; it adds the view-transition animation. Inline `<Suspense>` above each
|
|
531
535
|
`useLoader` read is still the finer-grained tool when different reads on
|
|
532
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
|
|
@@ -194,22 +198,23 @@ export function ClientUrlsRoot({
|
|
|
194
198
|
});
|
|
195
199
|
}
|
|
196
200
|
|
|
201
|
+
// Keyed per layout() item, not per route (#1124).
|
|
197
202
|
for (let index = route.layouts.length - 1; index >= 0; index--) {
|
|
198
|
-
const layoutKey = `${route.id}-layout-${index}`;
|
|
199
203
|
content = createElement(OutletProvider, {
|
|
200
|
-
key:
|
|
204
|
+
key: route.layoutKeys[index],
|
|
201
205
|
content,
|
|
202
206
|
pending,
|
|
203
|
-
children: createElement(route.layouts[index]
|
|
207
|
+
children: createElement(route.layouts[index]),
|
|
204
208
|
});
|
|
205
209
|
}
|
|
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
|
});
|
|
@@ -154,12 +154,14 @@ interface LoadingItem extends ItemBase {
|
|
|
154
154
|
|
|
155
155
|
interface FlattenContext {
|
|
156
156
|
readonly layouts: readonly ComponentType[];
|
|
157
|
+
readonly layoutKeys: readonly number[];
|
|
157
158
|
readonly loaders: readonly ClientUrlLoaderRecord[];
|
|
158
159
|
readonly loading: ReactNode | undefined;
|
|
159
160
|
}
|
|
160
161
|
|
|
161
162
|
const EMPTY_CONTEXT: FlattenContext = {
|
|
162
163
|
layouts: [],
|
|
164
|
+
layoutKeys: [],
|
|
163
165
|
loaders: [],
|
|
164
166
|
loading: undefined,
|
|
165
167
|
};
|
|
@@ -564,6 +566,9 @@ function createHelpers(): ClientUrlHelpers {
|
|
|
564
566
|
});
|
|
565
567
|
}
|
|
566
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).
|
|
567
572
|
function applyConfig(
|
|
568
573
|
context: FlattenContext,
|
|
569
574
|
items: readonly InternalItem[],
|
|
@@ -588,6 +593,7 @@ function applyConfig(
|
|
|
588
593
|
|
|
589
594
|
return {
|
|
590
595
|
layouts: context.layouts,
|
|
596
|
+
layoutKeys: context.layoutKeys,
|
|
591
597
|
loaders,
|
|
592
598
|
loading,
|
|
593
599
|
};
|
|
@@ -614,7 +620,9 @@ function addPathRecord(
|
|
|
614
620
|
options,
|
|
615
621
|
component: item.component,
|
|
616
622
|
layouts: Object.freeze([...routeContext.layouts]),
|
|
623
|
+
layoutKeys: Object.freeze([...routeContext.layoutKeys]),
|
|
617
624
|
loaders: Object.freeze([...routeContext.loaders]),
|
|
625
|
+
layoutLoaders: context.loaders,
|
|
618
626
|
loading: routeContext.loading,
|
|
619
627
|
transition: transitionItem
|
|
620
628
|
? Object.freeze({ ...transitionItem.config })
|
|
@@ -625,6 +633,7 @@ function addPathRecord(
|
|
|
625
633
|
|
|
626
634
|
function flattenLayout(
|
|
627
635
|
item: LayoutItem,
|
|
636
|
+
ordinal: number,
|
|
628
637
|
context: FlattenContext,
|
|
629
638
|
routes: ClientUrlRouteRecord[],
|
|
630
639
|
): void {
|
|
@@ -633,6 +642,7 @@ function flattenLayout(
|
|
|
633
642
|
{
|
|
634
643
|
...context,
|
|
635
644
|
layouts: [...context.layouts, item.component],
|
|
645
|
+
layoutKeys: [...context.layoutKeys, ordinal],
|
|
636
646
|
},
|
|
637
647
|
item.children,
|
|
638
648
|
);
|
|
@@ -650,11 +660,12 @@ function flattenRoutes(
|
|
|
650
660
|
context: FlattenContext,
|
|
651
661
|
routes: ClientUrlRouteRecord[],
|
|
652
662
|
): void {
|
|
663
|
+
let layoutOrdinal = 0;
|
|
653
664
|
for (const item of items) {
|
|
654
665
|
if (item.type === "path") {
|
|
655
666
|
addPathRecord(item, context, routes);
|
|
656
667
|
} else if (item.type === "layout") {
|
|
657
|
-
flattenLayout(item, context, routes);
|
|
668
|
+
flattenLayout(item, layoutOrdinal++, context, routes);
|
|
658
669
|
}
|
|
659
670
|
}
|
|
660
671
|
}
|
|
@@ -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
|
@@ -105,7 +105,21 @@ export interface ClientUrlRouteRecord {
|
|
|
105
105
|
readonly options: Readonly<ClientPathOptions> | undefined;
|
|
106
106
|
readonly component: ComponentType;
|
|
107
107
|
readonly layouts: readonly ComponentType[];
|
|
108
|
+
/**
|
|
109
|
+
* One per entry in `layouts`: the layout() item's ordinal among its sibling
|
|
110
|
+
* layout()s, its React key. Routes under the same layout() share it, so
|
|
111
|
+
* ClientUrlsRoot keeps that layout's instance across them (#1124). Keys only
|
|
112
|
+
* need to be unique among siblings: a differing parent layout remounts the
|
|
113
|
+
* whole subtree anyway.
|
|
114
|
+
*/
|
|
115
|
+
readonly layoutKeys: readonly number[];
|
|
108
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[];
|
|
109
123
|
readonly loading: ReactNode | undefined;
|
|
110
124
|
readonly transition: Readonly<ClientTransitionConfig> | undefined;
|
|
111
125
|
}
|