@bromscandium/router 1.0.0 → 1.0.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/LICENSE +20 -20
- package/README.md +91 -91
- package/package.json +42 -42
- package/src/components.tsx +411 -411
- package/src/hooks.ts +180 -180
- package/src/index.ts +31 -31
- package/src/router.ts +448 -448
package/src/components.tsx
CHANGED
|
@@ -1,411 +1,411 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Router components: Link, RouterView, Redirect, and Navigate.
|
|
3
|
-
* @module
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import {ref} from '@bromscandium/core';
|
|
7
|
-
import {jsx, VNode} from '@bromscandium/runtime';
|
|
8
|
-
import {useRoute, useRouter} from './hooks.js';
|
|
9
|
-
import {NavigationTarget} from './router.js';
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Props for the Link component.
|
|
13
|
-
*/
|
|
14
|
-
interface LinkProps {
|
|
15
|
-
/** The target path or navigation object */
|
|
16
|
-
to: string | NavigationTarget;
|
|
17
|
-
/** Child elements to render inside the link */
|
|
18
|
-
children?: any;
|
|
19
|
-
/** CSS class name */
|
|
20
|
-
className?: string;
|
|
21
|
-
/** Class name to add when route is active (includes nested) */
|
|
22
|
-
activeClassName?: string;
|
|
23
|
-
/** Class name to add when route exactly matches */
|
|
24
|
-
exactActiveClassName?: string;
|
|
25
|
-
/** If true, replace current history entry instead of pushing */
|
|
26
|
-
replace?: boolean;
|
|
27
|
-
/** HTML target attribute (e.g., "_blank") */
|
|
28
|
-
target?: string;
|
|
29
|
-
/** HTML rel attribute for external links */
|
|
30
|
-
rel?: string;
|
|
31
|
-
/** Additional click handler */
|
|
32
|
-
onClick?: (e: MouseEvent) => void;
|
|
33
|
-
/** Additional attributes passed to the anchor element */
|
|
34
|
-
[key: string]: any;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* A navigation link component that integrates with the router.
|
|
39
|
-
* Handles click events to perform client-side navigation.
|
|
40
|
-
*
|
|
41
|
-
* @param props - Link component props
|
|
42
|
-
* @returns An anchor element VNode
|
|
43
|
-
*
|
|
44
|
-
* @example
|
|
45
|
-
* ```tsx
|
|
46
|
-
* <Link to="/about">About</Link>
|
|
47
|
-
*
|
|
48
|
-
* <Link to="/dashboard" activeClassName="active">Dashboard</Link>
|
|
49
|
-
*
|
|
50
|
-
* <Link to={{ path: '/user', query: { id: '123' } }}>User</Link>
|
|
51
|
-
* ```
|
|
52
|
-
*/
|
|
53
|
-
export function Link(props: LinkProps): VNode {
|
|
54
|
-
const router = useRouter();
|
|
55
|
-
const route = useRoute();
|
|
56
|
-
|
|
57
|
-
const {
|
|
58
|
-
to,
|
|
59
|
-
children,
|
|
60
|
-
className = '',
|
|
61
|
-
activeClassName = '',
|
|
62
|
-
exactActiveClassName = '',
|
|
63
|
-
replace = false,
|
|
64
|
-
target,
|
|
65
|
-
rel,
|
|
66
|
-
onClick,
|
|
67
|
-
...rest
|
|
68
|
-
} = props;
|
|
69
|
-
|
|
70
|
-
const href = typeof to === 'string' ? to : (to.path || '/');
|
|
71
|
-
|
|
72
|
-
const currentPath = route.value.path;
|
|
73
|
-
const base = router.base || '';
|
|
74
|
-
const comparePath = base ? href.replace(new RegExp(`^${base}`), '') || '/' : href;
|
|
75
|
-
|
|
76
|
-
const isExactActive = currentPath === comparePath;
|
|
77
|
-
const isActive = isExactActive || currentPath.startsWith(comparePath + '/');
|
|
78
|
-
|
|
79
|
-
let finalClassName = className;
|
|
80
|
-
if (isActive && activeClassName) {
|
|
81
|
-
finalClassName = `${finalClassName} ${activeClassName}`.trim();
|
|
82
|
-
}
|
|
83
|
-
if (isExactActive && exactActiveClassName) {
|
|
84
|
-
finalClassName = `${finalClassName} ${exactActiveClassName}`.trim();
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function handleClick(e: MouseEvent) {
|
|
88
|
-
onClick?.(e);
|
|
89
|
-
|
|
90
|
-
if (e.defaultPrevented) return;
|
|
91
|
-
|
|
92
|
-
if (e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) return;
|
|
93
|
-
|
|
94
|
-
if (target) return;
|
|
95
|
-
|
|
96
|
-
e.preventDefault();
|
|
97
|
-
|
|
98
|
-
if (replace) {
|
|
99
|
-
router.replace(to);
|
|
100
|
-
} else {
|
|
101
|
-
router.push(to);
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
return jsx('a', {
|
|
106
|
-
href,
|
|
107
|
-
className: finalClassName || undefined,
|
|
108
|
-
target,
|
|
109
|
-
rel,
|
|
110
|
-
onClick: handleClick,
|
|
111
|
-
children,
|
|
112
|
-
...rest,
|
|
113
|
-
});
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Props for the RouterView component.
|
|
118
|
-
*/
|
|
119
|
-
interface RouterViewProps {
|
|
120
|
-
/** The nesting depth for nested RouterViews (usually auto-detected) */
|
|
121
|
-
depth?: number;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
let routerViewDepth = 0;
|
|
125
|
-
|
|
126
|
-
const componentCache = new Map<string, any>();
|
|
127
|
-
|
|
128
|
-
const layoutCache = new Map<string, any>();
|
|
129
|
-
|
|
130
|
-
const pendingLoads = new Map<string, Promise<any>>();
|
|
131
|
-
|
|
132
|
-
const renderVersion = ref(0);
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* Renders the component for the current matched route.
|
|
136
|
-
* Handles lazy loading and layout wrapping automatically.
|
|
137
|
-
*
|
|
138
|
-
* @param props - RouterView component props
|
|
139
|
-
* @returns The rendered route component or null
|
|
140
|
-
*
|
|
141
|
-
* @example
|
|
142
|
-
* ```tsx
|
|
143
|
-
* function App() {
|
|
144
|
-
* return (
|
|
145
|
-
* <div>
|
|
146
|
-
* <nav>...</nav>
|
|
147
|
-
* <RouterView />
|
|
148
|
-
* </div>
|
|
149
|
-
* );
|
|
150
|
-
* }
|
|
151
|
-
* ```
|
|
152
|
-
*/
|
|
153
|
-
export function RouterView(props: RouterViewProps): VNode | null {
|
|
154
|
-
const { depth: explicitDepth } = props;
|
|
155
|
-
const route = useRoute();
|
|
156
|
-
|
|
157
|
-
void renderVersion.value;
|
|
158
|
-
|
|
159
|
-
const currentDepth = explicitDepth ?? routerViewDepth;
|
|
160
|
-
const matched = route.value.matched[currentDepth];
|
|
161
|
-
|
|
162
|
-
if (!matched) {
|
|
163
|
-
return null;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
const routePath = matched.path;
|
|
167
|
-
const routeConfig = matched.route;
|
|
168
|
-
|
|
169
|
-
if (routeConfig.layout) {
|
|
170
|
-
const layoutKey = `layout:${routePath}`;
|
|
171
|
-
return loadAndRenderLayoutWithPage(
|
|
172
|
-
routeConfig.layout,
|
|
173
|
-
layoutKey,
|
|
174
|
-
routeConfig.component,
|
|
175
|
-
routePath,
|
|
176
|
-
route.value.fullPath,
|
|
177
|
-
currentDepth
|
|
178
|
-
);
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
return loadAndRenderComponent(
|
|
182
|
-
routeConfig.component,
|
|
183
|
-
routePath,
|
|
184
|
-
route.value.fullPath,
|
|
185
|
-
currentDepth
|
|
186
|
-
);
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
function loadAndRenderComponent(
|
|
190
|
-
componentModule: () => Promise<{ default: any }> | { default: any },
|
|
191
|
-
routePath: string,
|
|
192
|
-
fullPath: string,
|
|
193
|
-
currentDepth: number
|
|
194
|
-
): VNode | null {
|
|
195
|
-
if (componentCache.has(routePath)) {
|
|
196
|
-
const Component = componentCache.get(routePath);
|
|
197
|
-
routerViewDepth = currentDepth + 1;
|
|
198
|
-
try {
|
|
199
|
-
return jsx(Component, {
|
|
200
|
-
key: fullPath,
|
|
201
|
-
});
|
|
202
|
-
} finally {
|
|
203
|
-
routerViewDepth = currentDepth;
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
if (typeof componentModule === 'function') {
|
|
208
|
-
if (!pendingLoads.has(routePath)) {
|
|
209
|
-
const loadPromise = componentModule();
|
|
210
|
-
|
|
211
|
-
if (loadPromise && typeof (loadPromise as any).then === 'function') {
|
|
212
|
-
pendingLoads.set(routePath, loadPromise as Promise<any>);
|
|
213
|
-
|
|
214
|
-
(loadPromise as Promise<any>).then((result) => {
|
|
215
|
-
const Component = result.default || result;
|
|
216
|
-
componentCache.set(routePath, Component);
|
|
217
|
-
pendingLoads.delete(routePath);
|
|
218
|
-
renderVersion.value++;
|
|
219
|
-
}).catch((error) => {
|
|
220
|
-
console.error('Failed to load route component:', error);
|
|
221
|
-
pendingLoads.delete(routePath);
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
return jsx('div', {
|
|
225
|
-
className: 'router-loading',
|
|
226
|
-
children: '',
|
|
227
|
-
});
|
|
228
|
-
} else {
|
|
229
|
-
const Component = (loadPromise as any).default || loadPromise;
|
|
230
|
-
componentCache.set(routePath, Component);
|
|
231
|
-
|
|
232
|
-
routerViewDepth = currentDepth + 1;
|
|
233
|
-
try {
|
|
234
|
-
return jsx(Component, {
|
|
235
|
-
key: fullPath,
|
|
236
|
-
});
|
|
237
|
-
} finally {
|
|
238
|
-
routerViewDepth = currentDepth;
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
} else {
|
|
242
|
-
return jsx('div', {
|
|
243
|
-
className: 'router-loading',
|
|
244
|
-
children: '',
|
|
245
|
-
});
|
|
246
|
-
}
|
|
247
|
-
} else {
|
|
248
|
-
const Component = (componentModule as any).default || componentModule;
|
|
249
|
-
componentCache.set(routePath, Component);
|
|
250
|
-
|
|
251
|
-
routerViewDepth = currentDepth + 1;
|
|
252
|
-
try {
|
|
253
|
-
return jsx(Component, {
|
|
254
|
-
key: fullPath,
|
|
255
|
-
});
|
|
256
|
-
} finally {
|
|
257
|
-
routerViewDepth = currentDepth;
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
return null;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
function loadAndRenderLayoutWithPage(
|
|
265
|
-
layoutModule: () => Promise<{ default: any }> | { default: any },
|
|
266
|
-
layoutKey: string,
|
|
267
|
-
pageModule: () => Promise<{ default: any }> | { default: any },
|
|
268
|
-
routePath: string,
|
|
269
|
-
fullPath: string,
|
|
270
|
-
currentDepth: number
|
|
271
|
-
): VNode | null {
|
|
272
|
-
const layoutLoaded = layoutCache.has(layoutKey);
|
|
273
|
-
const pageLoaded = componentCache.has(routePath);
|
|
274
|
-
|
|
275
|
-
if (layoutLoaded && pageLoaded) {
|
|
276
|
-
const Layout = layoutCache.get(layoutKey);
|
|
277
|
-
const PageComponent = componentCache.get(routePath);
|
|
278
|
-
|
|
279
|
-
routerViewDepth = currentDepth + 1;
|
|
280
|
-
try {
|
|
281
|
-
const pageElement = jsx(PageComponent, { key: fullPath });
|
|
282
|
-
return jsx(Layout, { children: pageElement });
|
|
283
|
-
} finally {
|
|
284
|
-
routerViewDepth = currentDepth;
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
if (!layoutLoaded && typeof layoutModule === 'function' && !pendingLoads.has(layoutKey)) {
|
|
289
|
-
const loadPromise = layoutModule();
|
|
290
|
-
|
|
291
|
-
if (loadPromise && typeof (loadPromise as any).then === 'function') {
|
|
292
|
-
pendingLoads.set(layoutKey, loadPromise as Promise<any>);
|
|
293
|
-
|
|
294
|
-
(loadPromise as Promise<any>).then((result) => {
|
|
295
|
-
const Layout = result.default || result;
|
|
296
|
-
layoutCache.set(layoutKey, Layout);
|
|
297
|
-
pendingLoads.delete(layoutKey);
|
|
298
|
-
renderVersion.value++;
|
|
299
|
-
}).catch((error) => {
|
|
300
|
-
console.error('Failed to load layout:', error);
|
|
301
|
-
pendingLoads.delete(layoutKey);
|
|
302
|
-
});
|
|
303
|
-
} else {
|
|
304
|
-
const Layout = (loadPromise as any).default || loadPromise;
|
|
305
|
-
layoutCache.set(layoutKey, Layout);
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
if (!pageLoaded && typeof pageModule === 'function' && !pendingLoads.has(routePath)) {
|
|
310
|
-
const loadPromise = pageModule();
|
|
311
|
-
|
|
312
|
-
if (loadPromise && typeof (loadPromise as any).then === 'function') {
|
|
313
|
-
pendingLoads.set(routePath, loadPromise as Promise<any>);
|
|
314
|
-
|
|
315
|
-
(loadPromise as Promise<any>).then((result) => {
|
|
316
|
-
const PageComponent = result.default || result;
|
|
317
|
-
componentCache.set(routePath, PageComponent);
|
|
318
|
-
pendingLoads.delete(routePath);
|
|
319
|
-
renderVersion.value++;
|
|
320
|
-
}).catch((error) => {
|
|
321
|
-
console.error('Failed to load page component:', error);
|
|
322
|
-
pendingLoads.delete(routePath);
|
|
323
|
-
});
|
|
324
|
-
} else {
|
|
325
|
-
const PageComponent = (loadPromise as any).default || loadPromise;
|
|
326
|
-
componentCache.set(routePath, PageComponent);
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
return jsx('div', {
|
|
331
|
-
className: 'router-loading',
|
|
332
|
-
children: '',
|
|
333
|
-
});
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
/**
|
|
337
|
-
* Props for the Redirect component.
|
|
338
|
-
*/
|
|
339
|
-
interface RedirectProps {
|
|
340
|
-
/** The target path or navigation object */
|
|
341
|
-
to: string | NavigationTarget;
|
|
342
|
-
/** If true, replace current history entry (default: true) */
|
|
343
|
-
replace?: boolean;
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
/**
|
|
347
|
-
* A component that performs a redirect when rendered.
|
|
348
|
-
* Useful for redirecting from one route to another.
|
|
349
|
-
*
|
|
350
|
-
* @param props - Redirect component props
|
|
351
|
-
* @returns null (performs navigation as side effect)
|
|
352
|
-
*
|
|
353
|
-
* @example
|
|
354
|
-
* ```tsx
|
|
355
|
-
* // In a route component
|
|
356
|
-
* if (!isAuthenticated) {
|
|
357
|
-
* return <Redirect to="/login" />;
|
|
358
|
-
* }
|
|
359
|
-
* ```
|
|
360
|
-
*/
|
|
361
|
-
export function Redirect(props: RedirectProps): null {
|
|
362
|
-
const router = useRouter();
|
|
363
|
-
const { to, replace = true } = props;
|
|
364
|
-
|
|
365
|
-
if (replace) {
|
|
366
|
-
router.replace(to);
|
|
367
|
-
} else {
|
|
368
|
-
router.push(to);
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
return null;
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
/**
|
|
375
|
-
* Props for the Navigate component.
|
|
376
|
-
*/
|
|
377
|
-
interface NavigateProps {
|
|
378
|
-
/** The target path or navigation object */
|
|
379
|
-
to: string | NavigationTarget;
|
|
380
|
-
/** If true, replace current history entry instead of pushing */
|
|
381
|
-
replace?: boolean;
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
/**
|
|
385
|
-
* A component that performs navigation when rendered.
|
|
386
|
-
* Alternative to using the router imperatively.
|
|
387
|
-
*
|
|
388
|
-
* @param props - Navigate component props
|
|
389
|
-
* @returns null (performs navigation as side effect)
|
|
390
|
-
*
|
|
391
|
-
* @example
|
|
392
|
-
* ```tsx
|
|
393
|
-
* function AfterSubmit({ success }) {
|
|
394
|
-
* if (success) {
|
|
395
|
-
* return <Navigate to="/success" />;
|
|
396
|
-
* }
|
|
397
|
-
* return <div>Form</div>;
|
|
398
|
-
* }
|
|
399
|
-
* ```
|
|
400
|
-
*/
|
|
401
|
-
export function Navigate(props: NavigateProps): null {
|
|
402
|
-
const router = useRouter();
|
|
403
|
-
|
|
404
|
-
if (props.replace) {
|
|
405
|
-
router.replace(props.to);
|
|
406
|
-
} else {
|
|
407
|
-
router.push(props.to);
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
return null;
|
|
411
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Router components: Link, RouterView, Redirect, and Navigate.
|
|
3
|
+
* @module
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {ref} from '@bromscandium/core';
|
|
7
|
+
import {jsx, VNode} from '@bromscandium/runtime';
|
|
8
|
+
import {useRoute, useRouter} from './hooks.js';
|
|
9
|
+
import {NavigationTarget} from './router.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Props for the Link component.
|
|
13
|
+
*/
|
|
14
|
+
interface LinkProps {
|
|
15
|
+
/** The target path or navigation object */
|
|
16
|
+
to: string | NavigationTarget;
|
|
17
|
+
/** Child elements to render inside the link */
|
|
18
|
+
children?: any;
|
|
19
|
+
/** CSS class name */
|
|
20
|
+
className?: string;
|
|
21
|
+
/** Class name to add when route is active (includes nested) */
|
|
22
|
+
activeClassName?: string;
|
|
23
|
+
/** Class name to add when route exactly matches */
|
|
24
|
+
exactActiveClassName?: string;
|
|
25
|
+
/** If true, replace current history entry instead of pushing */
|
|
26
|
+
replace?: boolean;
|
|
27
|
+
/** HTML target attribute (e.g., "_blank") */
|
|
28
|
+
target?: string;
|
|
29
|
+
/** HTML rel attribute for external links */
|
|
30
|
+
rel?: string;
|
|
31
|
+
/** Additional click handler */
|
|
32
|
+
onClick?: (e: MouseEvent) => void;
|
|
33
|
+
/** Additional attributes passed to the anchor element */
|
|
34
|
+
[key: string]: any;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A navigation link component that integrates with the router.
|
|
39
|
+
* Handles click events to perform client-side navigation.
|
|
40
|
+
*
|
|
41
|
+
* @param props - Link component props
|
|
42
|
+
* @returns An anchor element VNode
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* ```tsx
|
|
46
|
+
* <Link to="/about">About</Link>
|
|
47
|
+
*
|
|
48
|
+
* <Link to="/dashboard" activeClassName="active">Dashboard</Link>
|
|
49
|
+
*
|
|
50
|
+
* <Link to={{ path: '/user', query: { id: '123' } }}>User</Link>
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
export function Link(props: LinkProps): VNode {
|
|
54
|
+
const router = useRouter();
|
|
55
|
+
const route = useRoute();
|
|
56
|
+
|
|
57
|
+
const {
|
|
58
|
+
to,
|
|
59
|
+
children,
|
|
60
|
+
className = '',
|
|
61
|
+
activeClassName = '',
|
|
62
|
+
exactActiveClassName = '',
|
|
63
|
+
replace = false,
|
|
64
|
+
target,
|
|
65
|
+
rel,
|
|
66
|
+
onClick,
|
|
67
|
+
...rest
|
|
68
|
+
} = props;
|
|
69
|
+
|
|
70
|
+
const href = typeof to === 'string' ? to : (to.path || '/');
|
|
71
|
+
|
|
72
|
+
const currentPath = route.value.path;
|
|
73
|
+
const base = router.base || '';
|
|
74
|
+
const comparePath = base ? href.replace(new RegExp(`^${base}`), '') || '/' : href;
|
|
75
|
+
|
|
76
|
+
const isExactActive = currentPath === comparePath;
|
|
77
|
+
const isActive = isExactActive || currentPath.startsWith(comparePath + '/');
|
|
78
|
+
|
|
79
|
+
let finalClassName = className;
|
|
80
|
+
if (isActive && activeClassName) {
|
|
81
|
+
finalClassName = `${finalClassName} ${activeClassName}`.trim();
|
|
82
|
+
}
|
|
83
|
+
if (isExactActive && exactActiveClassName) {
|
|
84
|
+
finalClassName = `${finalClassName} ${exactActiveClassName}`.trim();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function handleClick(e: MouseEvent) {
|
|
88
|
+
onClick?.(e);
|
|
89
|
+
|
|
90
|
+
if (e.defaultPrevented) return;
|
|
91
|
+
|
|
92
|
+
if (e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) return;
|
|
93
|
+
|
|
94
|
+
if (target) return;
|
|
95
|
+
|
|
96
|
+
e.preventDefault();
|
|
97
|
+
|
|
98
|
+
if (replace) {
|
|
99
|
+
router.replace(to);
|
|
100
|
+
} else {
|
|
101
|
+
router.push(to);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return jsx('a', {
|
|
106
|
+
href,
|
|
107
|
+
className: finalClassName || undefined,
|
|
108
|
+
target,
|
|
109
|
+
rel,
|
|
110
|
+
onClick: handleClick,
|
|
111
|
+
children,
|
|
112
|
+
...rest,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Props for the RouterView component.
|
|
118
|
+
*/
|
|
119
|
+
interface RouterViewProps {
|
|
120
|
+
/** The nesting depth for nested RouterViews (usually auto-detected) */
|
|
121
|
+
depth?: number;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let routerViewDepth = 0;
|
|
125
|
+
|
|
126
|
+
const componentCache = new Map<string, any>();
|
|
127
|
+
|
|
128
|
+
const layoutCache = new Map<string, any>();
|
|
129
|
+
|
|
130
|
+
const pendingLoads = new Map<string, Promise<any>>();
|
|
131
|
+
|
|
132
|
+
const renderVersion = ref(0);
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Renders the component for the current matched route.
|
|
136
|
+
* Handles lazy loading and layout wrapping automatically.
|
|
137
|
+
*
|
|
138
|
+
* @param props - RouterView component props
|
|
139
|
+
* @returns The rendered route component or null
|
|
140
|
+
*
|
|
141
|
+
* @example
|
|
142
|
+
* ```tsx
|
|
143
|
+
* function App() {
|
|
144
|
+
* return (
|
|
145
|
+
* <div>
|
|
146
|
+
* <nav>...</nav>
|
|
147
|
+
* <RouterView />
|
|
148
|
+
* </div>
|
|
149
|
+
* );
|
|
150
|
+
* }
|
|
151
|
+
* ```
|
|
152
|
+
*/
|
|
153
|
+
export function RouterView(props: RouterViewProps): VNode | null {
|
|
154
|
+
const { depth: explicitDepth } = props;
|
|
155
|
+
const route = useRoute();
|
|
156
|
+
|
|
157
|
+
void renderVersion.value;
|
|
158
|
+
|
|
159
|
+
const currentDepth = explicitDepth ?? routerViewDepth;
|
|
160
|
+
const matched = route.value.matched[currentDepth];
|
|
161
|
+
|
|
162
|
+
if (!matched) {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const routePath = matched.path;
|
|
167
|
+
const routeConfig = matched.route;
|
|
168
|
+
|
|
169
|
+
if (routeConfig.layout) {
|
|
170
|
+
const layoutKey = `layout:${routePath}`;
|
|
171
|
+
return loadAndRenderLayoutWithPage(
|
|
172
|
+
routeConfig.layout,
|
|
173
|
+
layoutKey,
|
|
174
|
+
routeConfig.component,
|
|
175
|
+
routePath,
|
|
176
|
+
route.value.fullPath,
|
|
177
|
+
currentDepth
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return loadAndRenderComponent(
|
|
182
|
+
routeConfig.component,
|
|
183
|
+
routePath,
|
|
184
|
+
route.value.fullPath,
|
|
185
|
+
currentDepth
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function loadAndRenderComponent(
|
|
190
|
+
componentModule: () => Promise<{ default: any }> | { default: any },
|
|
191
|
+
routePath: string,
|
|
192
|
+
fullPath: string,
|
|
193
|
+
currentDepth: number
|
|
194
|
+
): VNode | null {
|
|
195
|
+
if (componentCache.has(routePath)) {
|
|
196
|
+
const Component = componentCache.get(routePath);
|
|
197
|
+
routerViewDepth = currentDepth + 1;
|
|
198
|
+
try {
|
|
199
|
+
return jsx(Component, {
|
|
200
|
+
key: fullPath,
|
|
201
|
+
});
|
|
202
|
+
} finally {
|
|
203
|
+
routerViewDepth = currentDepth;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (typeof componentModule === 'function') {
|
|
208
|
+
if (!pendingLoads.has(routePath)) {
|
|
209
|
+
const loadPromise = componentModule();
|
|
210
|
+
|
|
211
|
+
if (loadPromise && typeof (loadPromise as any).then === 'function') {
|
|
212
|
+
pendingLoads.set(routePath, loadPromise as Promise<any>);
|
|
213
|
+
|
|
214
|
+
(loadPromise as Promise<any>).then((result) => {
|
|
215
|
+
const Component = result.default || result;
|
|
216
|
+
componentCache.set(routePath, Component);
|
|
217
|
+
pendingLoads.delete(routePath);
|
|
218
|
+
renderVersion.value++;
|
|
219
|
+
}).catch((error) => {
|
|
220
|
+
console.error('Failed to load route component:', error);
|
|
221
|
+
pendingLoads.delete(routePath);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
return jsx('div', {
|
|
225
|
+
className: 'router-loading',
|
|
226
|
+
children: '',
|
|
227
|
+
});
|
|
228
|
+
} else {
|
|
229
|
+
const Component = (loadPromise as any).default || loadPromise;
|
|
230
|
+
componentCache.set(routePath, Component);
|
|
231
|
+
|
|
232
|
+
routerViewDepth = currentDepth + 1;
|
|
233
|
+
try {
|
|
234
|
+
return jsx(Component, {
|
|
235
|
+
key: fullPath,
|
|
236
|
+
});
|
|
237
|
+
} finally {
|
|
238
|
+
routerViewDepth = currentDepth;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
} else {
|
|
242
|
+
return jsx('div', {
|
|
243
|
+
className: 'router-loading',
|
|
244
|
+
children: '',
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
} else {
|
|
248
|
+
const Component = (componentModule as any).default || componentModule;
|
|
249
|
+
componentCache.set(routePath, Component);
|
|
250
|
+
|
|
251
|
+
routerViewDepth = currentDepth + 1;
|
|
252
|
+
try {
|
|
253
|
+
return jsx(Component, {
|
|
254
|
+
key: fullPath,
|
|
255
|
+
});
|
|
256
|
+
} finally {
|
|
257
|
+
routerViewDepth = currentDepth;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function loadAndRenderLayoutWithPage(
|
|
265
|
+
layoutModule: () => Promise<{ default: any }> | { default: any },
|
|
266
|
+
layoutKey: string,
|
|
267
|
+
pageModule: () => Promise<{ default: any }> | { default: any },
|
|
268
|
+
routePath: string,
|
|
269
|
+
fullPath: string,
|
|
270
|
+
currentDepth: number
|
|
271
|
+
): VNode | null {
|
|
272
|
+
const layoutLoaded = layoutCache.has(layoutKey);
|
|
273
|
+
const pageLoaded = componentCache.has(routePath);
|
|
274
|
+
|
|
275
|
+
if (layoutLoaded && pageLoaded) {
|
|
276
|
+
const Layout = layoutCache.get(layoutKey);
|
|
277
|
+
const PageComponent = componentCache.get(routePath);
|
|
278
|
+
|
|
279
|
+
routerViewDepth = currentDepth + 1;
|
|
280
|
+
try {
|
|
281
|
+
const pageElement = jsx(PageComponent, { key: fullPath });
|
|
282
|
+
return jsx(Layout, { children: pageElement });
|
|
283
|
+
} finally {
|
|
284
|
+
routerViewDepth = currentDepth;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (!layoutLoaded && typeof layoutModule === 'function' && !pendingLoads.has(layoutKey)) {
|
|
289
|
+
const loadPromise = layoutModule();
|
|
290
|
+
|
|
291
|
+
if (loadPromise && typeof (loadPromise as any).then === 'function') {
|
|
292
|
+
pendingLoads.set(layoutKey, loadPromise as Promise<any>);
|
|
293
|
+
|
|
294
|
+
(loadPromise as Promise<any>).then((result) => {
|
|
295
|
+
const Layout = result.default || result;
|
|
296
|
+
layoutCache.set(layoutKey, Layout);
|
|
297
|
+
pendingLoads.delete(layoutKey);
|
|
298
|
+
renderVersion.value++;
|
|
299
|
+
}).catch((error) => {
|
|
300
|
+
console.error('Failed to load layout:', error);
|
|
301
|
+
pendingLoads.delete(layoutKey);
|
|
302
|
+
});
|
|
303
|
+
} else {
|
|
304
|
+
const Layout = (loadPromise as any).default || loadPromise;
|
|
305
|
+
layoutCache.set(layoutKey, Layout);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (!pageLoaded && typeof pageModule === 'function' && !pendingLoads.has(routePath)) {
|
|
310
|
+
const loadPromise = pageModule();
|
|
311
|
+
|
|
312
|
+
if (loadPromise && typeof (loadPromise as any).then === 'function') {
|
|
313
|
+
pendingLoads.set(routePath, loadPromise as Promise<any>);
|
|
314
|
+
|
|
315
|
+
(loadPromise as Promise<any>).then((result) => {
|
|
316
|
+
const PageComponent = result.default || result;
|
|
317
|
+
componentCache.set(routePath, PageComponent);
|
|
318
|
+
pendingLoads.delete(routePath);
|
|
319
|
+
renderVersion.value++;
|
|
320
|
+
}).catch((error) => {
|
|
321
|
+
console.error('Failed to load page component:', error);
|
|
322
|
+
pendingLoads.delete(routePath);
|
|
323
|
+
});
|
|
324
|
+
} else {
|
|
325
|
+
const PageComponent = (loadPromise as any).default || loadPromise;
|
|
326
|
+
componentCache.set(routePath, PageComponent);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return jsx('div', {
|
|
331
|
+
className: 'router-loading',
|
|
332
|
+
children: '',
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Props for the Redirect component.
|
|
338
|
+
*/
|
|
339
|
+
interface RedirectProps {
|
|
340
|
+
/** The target path or navigation object */
|
|
341
|
+
to: string | NavigationTarget;
|
|
342
|
+
/** If true, replace current history entry (default: true) */
|
|
343
|
+
replace?: boolean;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* A component that performs a redirect when rendered.
|
|
348
|
+
* Useful for redirecting from one route to another.
|
|
349
|
+
*
|
|
350
|
+
* @param props - Redirect component props
|
|
351
|
+
* @returns null (performs navigation as side effect)
|
|
352
|
+
*
|
|
353
|
+
* @example
|
|
354
|
+
* ```tsx
|
|
355
|
+
* // In a route component
|
|
356
|
+
* if (!isAuthenticated) {
|
|
357
|
+
* return <Redirect to="/login" />;
|
|
358
|
+
* }
|
|
359
|
+
* ```
|
|
360
|
+
*/
|
|
361
|
+
export function Redirect(props: RedirectProps): null {
|
|
362
|
+
const router = useRouter();
|
|
363
|
+
const { to, replace = true } = props;
|
|
364
|
+
|
|
365
|
+
if (replace) {
|
|
366
|
+
router.replace(to);
|
|
367
|
+
} else {
|
|
368
|
+
router.push(to);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Props for the Navigate component.
|
|
376
|
+
*/
|
|
377
|
+
interface NavigateProps {
|
|
378
|
+
/** The target path or navigation object */
|
|
379
|
+
to: string | NavigationTarget;
|
|
380
|
+
/** If true, replace current history entry instead of pushing */
|
|
381
|
+
replace?: boolean;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* A component that performs navigation when rendered.
|
|
386
|
+
* Alternative to using the router imperatively.
|
|
387
|
+
*
|
|
388
|
+
* @param props - Navigate component props
|
|
389
|
+
* @returns null (performs navigation as side effect)
|
|
390
|
+
*
|
|
391
|
+
* @example
|
|
392
|
+
* ```tsx
|
|
393
|
+
* function AfterSubmit({ success }) {
|
|
394
|
+
* if (success) {
|
|
395
|
+
* return <Navigate to="/success" />;
|
|
396
|
+
* }
|
|
397
|
+
* return <div>Form</div>;
|
|
398
|
+
* }
|
|
399
|
+
* ```
|
|
400
|
+
*/
|
|
401
|
+
export function Navigate(props: NavigateProps): null {
|
|
402
|
+
const router = useRouter();
|
|
403
|
+
|
|
404
|
+
if (props.replace) {
|
|
405
|
+
router.replace(props.to);
|
|
406
|
+
} else {
|
|
407
|
+
router.push(props.to);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return null;
|
|
411
|
+
}
|