sv-router 0.14.0 → 0.15.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/README.md +1 -0
- package/package.json +17 -15
- package/src/Router.svelte +15 -5
- package/src/create-router.svelte.js +75 -19
- package/src/helpers/match-route.js +167 -96
- package/src/helpers/preload.js +1 -0
- package/src/helpers/utils.js +9 -1
- package/src/index.d.ts +37 -5
package/README.md
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
[](https://www.npmjs.com/package/sv-router)
|
|
8
8
|
[](https://packagephobia.com/result?p=sv-router)
|
|
9
|
+
[](https://codecov.io/github/colinlienard/sv-router)
|
|
9
10
|
|
|
10
11
|
A feature-rich yet intuitive routing library for Svelte single-page apps.
|
|
11
12
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sv-router",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Modern Svelte Routing",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"svelte",
|
|
@@ -36,29 +36,30 @@
|
|
|
36
36
|
"esm-env": "^1.2.2"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
|
-
"@changesets/cli": "^2.
|
|
39
|
+
"@changesets/cli": "^2.30.0",
|
|
40
40
|
"@eslint/js": "^10.0.1",
|
|
41
|
-
"@sveltejs/vite-plugin-svelte": "^
|
|
41
|
+
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
|
42
42
|
"@testing-library/jest-dom": "^6.9.1",
|
|
43
43
|
"@testing-library/svelte": "^5.3.1",
|
|
44
44
|
"@testing-library/user-event": "^14.6.1",
|
|
45
|
-
"@types/node": "^24.
|
|
46
|
-
"
|
|
45
|
+
"@types/node": "^24.12.0",
|
|
46
|
+
"@vitest/coverage-v8": "^4.1.0",
|
|
47
|
+
"eslint": "^10.0.3",
|
|
47
48
|
"eslint-config-prettier": "^10.1.8",
|
|
48
49
|
"eslint-plugin-simple-import-sort": "^12.1.1",
|
|
49
|
-
"eslint-plugin-svelte": "^3.15.
|
|
50
|
-
"eslint-plugin-unicorn": "^
|
|
51
|
-
"globals": "^17.
|
|
52
|
-
"happy-dom": "^20.
|
|
50
|
+
"eslint-plugin-svelte": "^3.15.2",
|
|
51
|
+
"eslint-plugin-unicorn": "^64.0.0",
|
|
52
|
+
"globals": "^17.4.0",
|
|
53
|
+
"happy-dom": "^20.8.4",
|
|
53
54
|
"prettier": "^3.8.1",
|
|
54
55
|
"prettier-plugin-jsdoc": "^1.8.0",
|
|
55
|
-
"prettier-plugin-svelte": "^3.
|
|
56
|
-
"svelte-check": "^4.
|
|
56
|
+
"prettier-plugin-svelte": "^3.5.1",
|
|
57
|
+
"svelte-check": "^4.4.5",
|
|
57
58
|
"type-testing": "^0.2.0",
|
|
58
|
-
"typescript": "^
|
|
59
|
-
"typescript-eslint": "^8.
|
|
60
|
-
"vite": "^
|
|
61
|
-
"vitest": "^4.0
|
|
59
|
+
"typescript": "^6.0.2",
|
|
60
|
+
"typescript-eslint": "^8.57.1",
|
|
61
|
+
"vite": "^8.0.0",
|
|
62
|
+
"vitest": "^4.1.0"
|
|
62
63
|
},
|
|
63
64
|
"peerDependencies": {
|
|
64
65
|
"svelte": "^5"
|
|
@@ -70,6 +71,7 @@
|
|
|
70
71
|
"docs:build": "pnpm --filter docs build",
|
|
71
72
|
"docs:preview": "pnpm --filter docs preview",
|
|
72
73
|
"test": "vitest",
|
|
74
|
+
"test:coverage": "vitest run --coverage",
|
|
73
75
|
"check": "svelte-check && pnpm -r check",
|
|
74
76
|
"lint": "eslint . --max-warnings 0",
|
|
75
77
|
"lint:fix": "eslint . --fix",
|
package/src/Router.svelte
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
<script>
|
|
2
2
|
import { on } from 'svelte/events';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
componentTree,
|
|
5
|
+
init,
|
|
6
|
+
onBeforeUnload,
|
|
7
|
+
onGlobalClick,
|
|
8
|
+
onNavigate,
|
|
9
|
+
} from './create-router.svelte.js';
|
|
4
10
|
import RecursiveComponentTree from './RecursiveComponentTree.svelte';
|
|
5
11
|
|
|
6
12
|
/** @type {{ base?: string }} */
|
|
@@ -11,12 +17,16 @@
|
|
|
11
17
|
onNavigate();
|
|
12
18
|
|
|
13
19
|
$effect(() => {
|
|
14
|
-
const
|
|
15
|
-
|
|
20
|
+
const cleanup = [
|
|
21
|
+
on(globalThis, 'popstate', () => onNavigate()),
|
|
22
|
+
on(globalThis, 'beforeunload', onBeforeUnload),
|
|
23
|
+
on(globalThis, 'click', onGlobalClick),
|
|
24
|
+
];
|
|
16
25
|
|
|
17
26
|
return () => {
|
|
18
|
-
|
|
19
|
-
|
|
27
|
+
for (const clean of cleanup) {
|
|
28
|
+
clean();
|
|
29
|
+
}
|
|
20
30
|
};
|
|
21
31
|
});
|
|
22
32
|
</script>
|
|
@@ -43,8 +43,17 @@ let params = $state({ value: {} });
|
|
|
43
43
|
|
|
44
44
|
let meta = $state({ value: {} });
|
|
45
45
|
|
|
46
|
-
/**
|
|
47
|
-
|
|
46
|
+
/**
|
|
47
|
+
* @type {Map<
|
|
48
|
+
* number,
|
|
49
|
+
* | (() => boolean | Promise<boolean>)
|
|
50
|
+
* | { beforeUnload?(): boolean; onNavigate(): boolean | Promise<boolean> }
|
|
51
|
+
* >}
|
|
52
|
+
*/
|
|
53
|
+
const navigationBlockers = new Map();
|
|
54
|
+
|
|
55
|
+
let historyIndex = 0;
|
|
56
|
+
let skipNextPopstate = false;
|
|
48
57
|
|
|
49
58
|
/** @type {AbortController | null} */
|
|
50
59
|
let currentNavigationController = null;
|
|
@@ -61,16 +70,33 @@ export function init(basename) {
|
|
|
61
70
|
base.name = '#';
|
|
62
71
|
if (!globalThis.location.href.includes('#')) {
|
|
63
72
|
url.hash = '/';
|
|
64
|
-
history.replaceState(
|
|
73
|
+
history.replaceState(
|
|
74
|
+
{ _routerIndex: historyIndex, _userState: history.state ?? null },
|
|
75
|
+
'',
|
|
76
|
+
url.toString(),
|
|
77
|
+
);
|
|
65
78
|
}
|
|
66
79
|
} else {
|
|
67
80
|
base.name = (basename.startsWith('/') ? '' : '/') + basename;
|
|
68
81
|
if (!url.pathname.startsWith(base.name)) {
|
|
69
82
|
url.pathname = join(base.name, url.pathname);
|
|
70
|
-
history.replaceState(
|
|
83
|
+
history.replaceState(
|
|
84
|
+
{ _routerIndex: historyIndex, _userState: history.state ?? null },
|
|
85
|
+
'',
|
|
86
|
+
url.toString(),
|
|
87
|
+
);
|
|
71
88
|
}
|
|
72
89
|
}
|
|
73
90
|
}
|
|
91
|
+
if (history.state?._routerIndex === undefined) {
|
|
92
|
+
history.replaceState(
|
|
93
|
+
{ _routerIndex: historyIndex, _userState: history.state ?? null },
|
|
94
|
+
'',
|
|
95
|
+
globalThis.location.href,
|
|
96
|
+
);
|
|
97
|
+
} else {
|
|
98
|
+
historyIndex = history.state._routerIndex;
|
|
99
|
+
}
|
|
74
100
|
Object.assign(location, updatedLocation());
|
|
75
101
|
}
|
|
76
102
|
|
|
@@ -151,6 +177,16 @@ async function navigate(path, options = {}) {
|
|
|
151
177
|
return new Navigation(`${path}${serializeSearch(options?.search ?? '')}${options?.hash ?? ''}`);
|
|
152
178
|
}
|
|
153
179
|
|
|
180
|
+
/** @param {BeforeUnloadEvent} event */
|
|
181
|
+
export function onBeforeUnload(event) {
|
|
182
|
+
for (const blocker of navigationBlockers.values()) {
|
|
183
|
+
if (typeof blocker !== 'object' || !blocker.beforeUnload) continue;
|
|
184
|
+
if (!blocker.beforeUnload()) {
|
|
185
|
+
event.preventDefault();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
154
190
|
/**
|
|
155
191
|
* @param {string} [path]
|
|
156
192
|
* @param {import('./index.d.ts').NavigateOptions} options
|
|
@@ -160,20 +196,26 @@ export async function onNavigate(path, options = {}) {
|
|
|
160
196
|
throw new Error('Router not initialized: `createRouter` was not called.');
|
|
161
197
|
}
|
|
162
198
|
|
|
163
|
-
if (
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
199
|
+
if (!path && skipNextPopstate) {
|
|
200
|
+
skipNextPopstate = false;
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (navigationBlockers.size > 0) {
|
|
205
|
+
const popstateDelta = path ? 0 : historyIndex - (history.state?._routerIndex ?? 0);
|
|
206
|
+
for (const blocker of navigationBlockers.values()) {
|
|
207
|
+
const shouldNavigate = typeof blocker === 'object' ? blocker.onNavigate : blocker;
|
|
208
|
+
if (!(await shouldNavigate())) {
|
|
209
|
+
if (!path && popstateDelta !== 0) {
|
|
210
|
+
skipNextPopstate = true;
|
|
211
|
+
history.go(popstateDelta);
|
|
212
|
+
}
|
|
213
|
+
return;
|
|
172
214
|
}
|
|
173
|
-
globalThis.history.replaceState($state.snapshot(location.state) || {}, '', url.toString());
|
|
174
|
-
return;
|
|
175
215
|
}
|
|
176
|
-
|
|
216
|
+
if (!path) {
|
|
217
|
+
historyIndex = history.state?._routerIndex ?? historyIndex;
|
|
218
|
+
}
|
|
177
219
|
}
|
|
178
220
|
|
|
179
221
|
if (pendingController && pendingController !== currentNavigationController) {
|
|
@@ -233,7 +275,12 @@ export async function onNavigate(path, options = {}) {
|
|
|
233
275
|
url.pathname = path;
|
|
234
276
|
}
|
|
235
277
|
const historyMethod = options.replace ? 'replaceState' : 'pushState';
|
|
236
|
-
|
|
278
|
+
if (historyMethod === 'pushState') historyIndex++;
|
|
279
|
+
globalThis.history[historyMethod](
|
|
280
|
+
{ _routerIndex: historyIndex, _userState: options.state ?? null },
|
|
281
|
+
'',
|
|
282
|
+
url.toString(),
|
|
283
|
+
);
|
|
237
284
|
syncSearchParams(search);
|
|
238
285
|
} else {
|
|
239
286
|
syncSearchParams(globalThis.location.search);
|
|
@@ -320,7 +367,16 @@ export function onGlobalClick(event) {
|
|
|
320
367
|
});
|
|
321
368
|
}
|
|
322
369
|
|
|
323
|
-
|
|
370
|
+
let navigationBlockId = 0;
|
|
371
|
+
/**
|
|
372
|
+
* @param {(() => boolean | Promise<boolean>)
|
|
373
|
+
* | { beforeUnload?(): boolean; onNavigate(): boolean | Promise<boolean> }} callback
|
|
374
|
+
* @returns {() => void}
|
|
375
|
+
*/
|
|
324
376
|
export function blockNavigation(callback) {
|
|
325
|
-
|
|
377
|
+
const id = navigationBlockId++;
|
|
378
|
+
navigationBlockers.set(id, callback);
|
|
379
|
+
return () => {
|
|
380
|
+
navigationBlockers.delete(id);
|
|
381
|
+
};
|
|
326
382
|
}
|
|
@@ -8,135 +8,206 @@
|
|
|
8
8
|
* @typedef {import('../index.d.ts').Routes} Routes
|
|
9
9
|
*
|
|
10
10
|
* @typedef {import('../index.d.ts').RouteMeta} RouteMeta
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* @param {string} pathname
|
|
15
|
-
* @param {Routes} routes
|
|
16
|
-
* @returns {{
|
|
11
|
+
*
|
|
12
|
+
* @typedef {{
|
|
17
13
|
* match: RouteComponent | undefined;
|
|
18
14
|
* layouts: LayoutComponent[];
|
|
19
15
|
* hooks: Hooks[];
|
|
20
16
|
* meta: RouteMeta;
|
|
21
17
|
* params: Record<string, string>;
|
|
22
18
|
* breakFromLayouts: boolean;
|
|
23
|
-
*
|
|
19
|
+
* isCatchAll: boolean;
|
|
20
|
+
* }} MatchResult
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {string} pathname
|
|
25
|
+
* @param {Routes} routes
|
|
26
|
+
* @returns {MatchResult}
|
|
24
27
|
*/
|
|
25
28
|
export function matchRoute(pathname, routes) {
|
|
26
|
-
// Remove trailing slash
|
|
27
29
|
if (pathname.length > 1 && pathname.endsWith('/')) {
|
|
28
30
|
pathname = pathname.slice(0, -1);
|
|
29
31
|
}
|
|
30
|
-
const pathParts = pathname.split('/').slice(1);
|
|
31
|
-
const allRoutes = sortRoutes(Object.keys(routes));
|
|
32
|
-
|
|
33
|
-
/** @type {RouteComponent | undefined} */
|
|
34
|
-
let match;
|
|
35
|
-
|
|
36
|
-
/** @type {LayoutComponent[]} */
|
|
37
|
-
let layouts = [];
|
|
38
|
-
|
|
39
|
-
/** @type {Hooks[]} */
|
|
40
|
-
let hooks = [];
|
|
41
32
|
|
|
42
|
-
|
|
43
|
-
|
|
33
|
+
const pathParts = pathname.split('/').slice(1);
|
|
34
|
+
const sortedRoutes = sortRoutes(Object.keys(routes));
|
|
44
35
|
|
|
45
36
|
/** @type {RouteMeta} */
|
|
46
|
-
let
|
|
47
|
-
|
|
37
|
+
let baseMeta = {};
|
|
48
38
|
const rootRoute = routes['/'];
|
|
49
39
|
if (rootRoute && typeof rootRoute === 'object' && 'meta' in rootRoute && rootRoute.meta) {
|
|
50
|
-
|
|
40
|
+
baseMeta = { ...rootRoute.meta };
|
|
51
41
|
}
|
|
52
42
|
|
|
53
|
-
|
|
43
|
+
/** @type {MatchResult | undefined} */
|
|
44
|
+
let catchAllFallback;
|
|
54
45
|
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
if (
|
|
46
|
+
for (const route of sortedRoutes) {
|
|
47
|
+
const attempt = tryMatch(route, pathParts, pathname, routes, baseMeta);
|
|
48
|
+
if (!attempt) continue;
|
|
58
49
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
50
|
+
if (attempt.fallback) {
|
|
51
|
+
if (!catchAllFallback) catchAllFallback = attempt.result;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
64
54
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
params[routePart.slice(1)] = decodeURIComponent(pathPart);
|
|
68
|
-
} else if (routePart.startsWith('*')) {
|
|
69
|
-
const param = routePart.slice(1);
|
|
70
|
-
if (param) {
|
|
71
|
-
params[param] = pathParts.slice(index).map(decodeURIComponent).join('/');
|
|
72
|
-
}
|
|
73
|
-
if (breakFromLayouts) {
|
|
74
|
-
layouts = [];
|
|
75
|
-
} else if ('layout' in routes && routes.layout) {
|
|
76
|
-
layouts.push(routes.layout);
|
|
77
|
-
}
|
|
78
|
-
const resolvedPath = /** @type {keyof Routes} */ (
|
|
79
|
-
(index ? '/' : '') + routeParts.join('/')
|
|
80
|
-
);
|
|
81
|
-
match = /** @type {RouteComponent} */ (routes[resolvedPath]);
|
|
82
|
-
break outer;
|
|
83
|
-
} else if (routePart.toLowerCase() !== pathPart?.toLowerCase()) {
|
|
84
|
-
break;
|
|
85
|
-
}
|
|
55
|
+
return attempt.result;
|
|
56
|
+
}
|
|
86
57
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
58
|
+
return (
|
|
59
|
+
catchAllFallback || {
|
|
60
|
+
match: undefined,
|
|
61
|
+
layouts: [],
|
|
62
|
+
hooks: [],
|
|
63
|
+
params: {},
|
|
64
|
+
meta: baseMeta,
|
|
65
|
+
breakFromLayouts: false,
|
|
66
|
+
isCatchAll: false,
|
|
67
|
+
}
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Try to match a single route key against the path. Returns null if the route doesn't match.
|
|
73
|
+
*
|
|
74
|
+
* @param {string} route
|
|
75
|
+
* @param {string[]} pathParts
|
|
76
|
+
* @param {string} pathname
|
|
77
|
+
* @param {Routes} routes
|
|
78
|
+
* @param {RouteMeta} baseMeta
|
|
79
|
+
* @returns {{ result: MatchResult; fallback: boolean } | null}
|
|
80
|
+
*/
|
|
81
|
+
function tryMatch(route, pathParts, pathname, routes, baseMeta) {
|
|
82
|
+
const routeParts = route.split('/');
|
|
83
|
+
if (routeParts[0] === '') routeParts.shift();
|
|
90
84
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
85
|
+
/** @type {Record<string, string>} */
|
|
86
|
+
const params = {};
|
|
87
|
+
/** @type {boolean} */
|
|
88
|
+
let breakFromLayouts;
|
|
89
|
+
|
|
90
|
+
for (let [index, routePart] of routeParts.entries()) {
|
|
91
|
+
breakFromLayouts = routePart.startsWith('(') && routePart.endsWith(')');
|
|
92
|
+
if (breakFromLayouts) {
|
|
93
|
+
routePart = routePart.slice(1, -1);
|
|
94
|
+
}
|
|
94
95
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}
|
|
96
|
+
const pathPart = pathParts[index];
|
|
97
|
+
const isLayoutGroup = routePart === '' && typeof routes['/'] !== 'function';
|
|
98
98
|
|
|
99
|
-
|
|
100
|
-
|
|
99
|
+
// Dynamic segment
|
|
100
|
+
if (routePart.startsWith(':')) {
|
|
101
|
+
params[routePart.slice(1)] = decodeURIComponent(pathPart);
|
|
102
|
+
}
|
|
103
|
+
// Catch-all segment
|
|
104
|
+
else if (routePart.startsWith('*')) {
|
|
105
|
+
const param = routePart.slice(1);
|
|
106
|
+
if (param) {
|
|
107
|
+
params[param] = pathParts.slice(index).map(decodeURIComponent).join('/');
|
|
101
108
|
}
|
|
109
|
+
const context = collectContext(routes, breakFromLayouts, baseMeta);
|
|
110
|
+
const resolvedPath = /** @type {keyof Routes} */ ((index ? '/' : '') + routeParts.join('/'));
|
|
111
|
+
return {
|
|
112
|
+
result: {
|
|
113
|
+
match: /** @type {RouteComponent} */ (routes[resolvedPath]),
|
|
114
|
+
...context,
|
|
115
|
+
params,
|
|
116
|
+
breakFromLayouts,
|
|
117
|
+
isCatchAll: true,
|
|
118
|
+
},
|
|
119
|
+
fallback: false,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
// Static segment mismatch
|
|
123
|
+
else if (routePart.toLowerCase() !== pathPart?.toLowerCase() && !isLayoutGroup) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
102
126
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
127
|
+
// Continue matching next segment
|
|
128
|
+
if (index !== routeParts.length - 1) continue;
|
|
106
129
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
130
|
+
// Last segment — resolve the route value
|
|
131
|
+
const routeKey = /** @type {keyof Routes} */ ('/' + routeParts.join('/'));
|
|
132
|
+
const routeMatch = /** @type {RouteComponent} */ (routes[routeKey]);
|
|
110
133
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
break outer;
|
|
115
|
-
}
|
|
116
|
-
continue;
|
|
117
|
-
}
|
|
134
|
+
if (typeof routeMatch === 'function' && routeParts.length !== pathParts.length) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
118
137
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
breakFromLayouts = true;
|
|
129
|
-
} else {
|
|
130
|
-
layouts.push(...result.layouts);
|
|
131
|
-
}
|
|
132
|
-
} else {
|
|
133
|
-
continue;
|
|
134
|
-
}
|
|
135
|
-
break outer;
|
|
138
|
+
const context = collectContext(routes, breakFromLayouts, baseMeta);
|
|
139
|
+
|
|
140
|
+
// Leaf route (component function)
|
|
141
|
+
if (typeof routeMatch === 'function') {
|
|
142
|
+
if (routeParts.length !== pathParts.length) return null;
|
|
143
|
+
return {
|
|
144
|
+
result: { match: routeMatch, ...context, params, breakFromLayouts, isCatchAll: false },
|
|
145
|
+
fallback: false,
|
|
146
|
+
};
|
|
136
147
|
}
|
|
148
|
+
|
|
149
|
+
// Nested routes — recurse
|
|
150
|
+
const nestedPathname = isLayoutGroup ? pathname : '/' + pathParts.slice(index + 1).join('/');
|
|
151
|
+
const nested = matchRoute(nestedPathname, routeMatch);
|
|
152
|
+
if (!nested.match) return null;
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
result: mergeWithNested(context, nested, params, breakFromLayouts),
|
|
156
|
+
fallback: isLayoutGroup && nested.isCatchAll,
|
|
157
|
+
};
|
|
137
158
|
}
|
|
138
159
|
|
|
139
|
-
return
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Collect layouts, hooks, and meta from the current route level.
|
|
165
|
+
*
|
|
166
|
+
* @param {Routes} routes
|
|
167
|
+
* @param {boolean} breakFromLayouts
|
|
168
|
+
* @param {RouteMeta} baseMeta
|
|
169
|
+
* @returns {{ layouts: LayoutComponent[]; hooks: Hooks[]; meta: RouteMeta }}
|
|
170
|
+
*/
|
|
171
|
+
function collectContext(routes, breakFromLayouts, baseMeta) {
|
|
172
|
+
/** @type {LayoutComponent[]} */
|
|
173
|
+
const layouts = [];
|
|
174
|
+
/** @type {Hooks[]} */
|
|
175
|
+
const hooks = [];
|
|
176
|
+
let meta = { ...baseMeta };
|
|
177
|
+
|
|
178
|
+
if (!breakFromLayouts && 'layout' in routes && routes.layout) {
|
|
179
|
+
layouts.push(routes.layout);
|
|
180
|
+
}
|
|
181
|
+
if ('hooks' in routes && routes.hooks) {
|
|
182
|
+
hooks.push(routes.hooks);
|
|
183
|
+
}
|
|
184
|
+
if ('meta' in routes && routes.meta) {
|
|
185
|
+
meta = { ...meta, ...routes.meta };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return { layouts, hooks, meta };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Merge current level context with a nested match result.
|
|
193
|
+
*
|
|
194
|
+
* @param {{ layouts: LayoutComponent[]; hooks: Hooks[]; meta: RouteMeta }} context
|
|
195
|
+
* @param {MatchResult} nested
|
|
196
|
+
* @param {Record<string, string>} params
|
|
197
|
+
* @param {boolean} breakFromLayouts
|
|
198
|
+
* @returns {MatchResult}
|
|
199
|
+
*/
|
|
200
|
+
function mergeWithNested(context, nested, params, breakFromLayouts) {
|
|
201
|
+
const shouldBreak = nested.breakFromLayouts;
|
|
202
|
+
return {
|
|
203
|
+
match: nested.match,
|
|
204
|
+
layouts: shouldBreak ? [] : [...context.layouts, ...nested.layouts],
|
|
205
|
+
hooks: [...context.hooks, ...nested.hooks],
|
|
206
|
+
params: { ...params, ...nested.params },
|
|
207
|
+
meta: { ...context.meta, ...nested.meta },
|
|
208
|
+
breakFromLayouts: shouldBreak || breakFromLayouts,
|
|
209
|
+
isCatchAll: nested.isCatchAll,
|
|
210
|
+
};
|
|
140
211
|
}
|
|
141
212
|
|
|
142
213
|
/**
|
package/src/helpers/preload.js
CHANGED
package/src/helpers/utils.js
CHANGED
|
@@ -103,6 +103,14 @@ export function stripBase(pathname) {
|
|
|
103
103
|
return pathname;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/** @param {any} state */
|
|
107
|
+
function getUserState(state) {
|
|
108
|
+
if (state && '_userState' in state) {
|
|
109
|
+
return state._userState;
|
|
110
|
+
}
|
|
111
|
+
return state;
|
|
112
|
+
}
|
|
113
|
+
|
|
106
114
|
export function updatedLocation() {
|
|
107
115
|
const pathname =
|
|
108
116
|
base.name === '#' ? globalThis.location.hash.slice(1) : globalThis.location.pathname;
|
|
@@ -110,7 +118,7 @@ export function updatedLocation() {
|
|
|
110
118
|
return {
|
|
111
119
|
pathname,
|
|
112
120
|
search: globalThis.location.search,
|
|
113
|
-
state: history.state,
|
|
121
|
+
state: getUserState(history.state),
|
|
114
122
|
hash,
|
|
115
123
|
};
|
|
116
124
|
}
|
package/src/index.d.ts
CHANGED
|
@@ -43,13 +43,41 @@ export function serializeSearch(search: Search): string | undefined;
|
|
|
43
43
|
export function createRouter<T extends Routes>(r: T): RouterApi<T>;
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
|
-
*
|
|
46
|
+
* Blocks navigation as long as the callback returns `false`.
|
|
47
|
+
*
|
|
48
|
+
* Returns a function that clears the navigation block.
|
|
49
|
+
*
|
|
50
|
+
* ```js
|
|
51
|
+
* $effect(() => blockNavigation(() => confirm('Are you sure you want to leave?')));
|
|
52
|
+
* ```
|
|
53
|
+
*
|
|
54
|
+
* The callback can also be async:
|
|
47
55
|
*
|
|
48
56
|
* ```js
|
|
49
|
-
*
|
|
57
|
+
* $effect(() => blockNavigation(async () => await showConfirmModal()));
|
|
58
|
+
* ```
|
|
59
|
+
*
|
|
60
|
+
* If you also need to block tab close, use the object form to handle blocking for navigation and
|
|
61
|
+
* site unloading separately (site unloading cannot be blocked asynchronously):
|
|
62
|
+
*
|
|
63
|
+
* ```js
|
|
64
|
+
* $effect(() =>
|
|
65
|
+
* blockNavigation({
|
|
66
|
+
* beforeUnload() {
|
|
67
|
+
* return false;
|
|
68
|
+
* },
|
|
69
|
+
* async onNavigate() {
|
|
70
|
+
* return await askInModal();
|
|
71
|
+
* },
|
|
72
|
+
* }),
|
|
73
|
+
* );
|
|
50
74
|
* ```
|
|
51
75
|
*/
|
|
52
|
-
export function blockNavigation(
|
|
76
|
+
export function blockNavigation(
|
|
77
|
+
callback:
|
|
78
|
+
| (() => boolean | Promise<boolean>)
|
|
79
|
+
| { beforeUnload?(): boolean; onNavigate(): boolean | Promise<boolean> },
|
|
80
|
+
): () => void;
|
|
53
81
|
|
|
54
82
|
/**
|
|
55
83
|
* The component that will render the current route. You can pass a `base` prop to set the base path
|
|
@@ -324,6 +352,10 @@ type StripNonRoutes<T extends Routes> = {
|
|
|
324
352
|
: K]: T[K] extends Routes ? StripNonRoutes<T[K]> : T[K];
|
|
325
353
|
};
|
|
326
354
|
|
|
355
|
+
type NormalizeSlashes<T extends string> = T extends `${infer A}//${infer B}`
|
|
356
|
+
? NormalizeSlashes<`${A}/${B}`>
|
|
357
|
+
: T;
|
|
358
|
+
|
|
327
359
|
type RecursiveKeys<
|
|
328
360
|
T extends Routes,
|
|
329
361
|
Prefix extends string = '',
|
|
@@ -333,10 +365,10 @@ type RecursiveKeys<
|
|
|
333
365
|
? T[K] extends Routes
|
|
334
366
|
? RecursiveKeys<
|
|
335
367
|
T[K],
|
|
336
|
-
|
|
368
|
+
NormalizeSlashes<`${Prefix}${AnyParam extends true ? ReplaceParamWithString<K> : K}`>,
|
|
337
369
|
AnyParam
|
|
338
370
|
>
|
|
339
|
-
:
|
|
371
|
+
: NormalizeSlashes<`${Prefix}${AnyParam extends true ? ReplaceParamWithString<K> : K}`>
|
|
340
372
|
: never;
|
|
341
373
|
}[keyof T];
|
|
342
374
|
|