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 CHANGED
@@ -6,6 +6,7 @@
6
6
 
7
7
  [![npm](https://badgen.net/npm/v/sv-router)](https://www.npmjs.com/package/sv-router)
8
8
  [![install size](https://packagephobia.com/badge?p=sv-router)](https://packagephobia.com/result?p=sv-router)
9
+ [![codecov](https://codecov.io/github/colinlienard/sv-router/graph/badge.svg?token=C9RBSEFO9S)](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.14.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.29.8",
39
+ "@changesets/cli": "^2.30.0",
40
40
  "@eslint/js": "^10.0.1",
41
- "@sveltejs/vite-plugin-svelte": "^6.2.4",
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.10.13",
46
- "eslint": "^10.0.0",
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.0",
50
- "eslint-plugin-unicorn": "^63.0.0",
51
- "globals": "^17.3.0",
52
- "happy-dom": "^20.6.0",
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.4.1",
56
- "svelte-check": "^4.3.6",
56
+ "prettier-plugin-svelte": "^3.5.1",
57
+ "svelte-check": "^4.4.5",
57
58
  "type-testing": "^0.2.0",
58
- "typescript": "^5.9.3",
59
- "typescript-eslint": "^8.55.0",
60
- "vite": "^7.3.1",
61
- "vitest": "^4.0.18"
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 { componentTree, init, onGlobalClick, onNavigate } from './create-router.svelte.js';
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 off1 = on(globalThis, 'popstate', () => onNavigate());
15
- const off2 = on(globalThis, 'click', onGlobalClick);
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
- off1();
19
- off2();
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
- /** @type {(() => boolean) | null} */
47
- let navigationBlocker = null;
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(history.state || {}, '', url.toString());
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(history.state || {}, '', url.toString());
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 (navigationBlocker) {
164
- if (!navigationBlocker()) {
165
- const url = new URL(globalThis.location.toString());
166
- url.search = location.search;
167
- url.hash = location.hash;
168
- if (base.name === '#') {
169
- url.hash = location.pathname;
170
- } else {
171
- url.pathname = location.pathname;
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
- navigationBlocker = null;
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
- globalThis.history[historyMethod](options.state || {}, '', url.toString());
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
- /** @param {() => boolean} callback */
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
- navigationBlocker = callback;
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
- /** @type {Record<string, string>} */
43
- let params = {};
33
+ const pathParts = pathname.split('/').slice(1);
34
+ const sortedRoutes = sortRoutes(Object.keys(routes));
44
35
 
45
36
  /** @type {RouteMeta} */
46
- let meta = {};
47
-
37
+ let baseMeta = {};
48
38
  const rootRoute = routes['/'];
49
39
  if (rootRoute && typeof rootRoute === 'object' && 'meta' in rootRoute && rootRoute.meta) {
50
- meta = { ...meta, ...rootRoute.meta };
40
+ baseMeta = { ...rootRoute.meta };
51
41
  }
52
42
 
53
- let breakFromLayouts = false;
43
+ /** @type {MatchResult | undefined} */
44
+ let catchAllFallback;
54
45
 
55
- outer: for (const route of allRoutes) {
56
- const routeParts = route.split('/');
57
- if (routeParts[0] === '') routeParts.shift();
46
+ for (const route of sortedRoutes) {
47
+ const attempt = tryMatch(route, pathParts, pathname, routes, baseMeta);
48
+ if (!attempt) continue;
58
49
 
59
- for (let [index, routePart] of routeParts.entries()) {
60
- breakFromLayouts = routePart.startsWith('(') && routePart.endsWith(')');
61
- if (breakFromLayouts) {
62
- routePart = routePart.slice(1, -1);
63
- }
50
+ if (attempt.fallback) {
51
+ if (!catchAllFallback) catchAllFallback = attempt.result;
52
+ continue;
53
+ }
64
54
 
65
- const pathPart = pathParts[index];
66
- if (routePart.startsWith(':')) {
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
- if (index !== routeParts.length - 1) {
88
- continue;
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
- const routeMatch = /** @type {RouteComponent} */ (
92
- routes[/** @type {keyof Routes} */ ('/' + routeParts.join('/'))]
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
- if (typeof routeMatch === 'function' && routeParts.length !== pathParts.length) {
96
- continue;
97
- }
96
+ const pathPart = pathParts[index];
97
+ const isLayoutGroup = routePart === '' && typeof routes['/'] !== 'function';
98
98
 
99
- if (!breakFromLayouts && 'layout' in routes && routes.layout) {
100
- layouts.push(routes.layout);
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
- if ('hooks' in routes && routes.hooks) {
104
- hooks.push(routes.hooks);
105
- }
127
+ // Continue matching next segment
128
+ if (index !== routeParts.length - 1) continue;
106
129
 
107
- if ('meta' in routes && routes.meta) {
108
- meta = { ...meta, ...routes.meta };
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
- if (typeof routeMatch === 'function') {
112
- if (routeParts.length === pathParts.length) {
113
- match = routeMatch;
114
- break outer;
115
- }
116
- continue;
117
- }
134
+ if (typeof routeMatch === 'function' && routeParts.length !== pathParts.length) {
135
+ return null;
136
+ }
118
137
 
119
- const nestedPathname = '/' + pathParts.slice(index + 1).join('/');
120
- const result = matchRoute(nestedPathname, routeMatch);
121
- if (result.match) {
122
- match = result.match;
123
- params = { ...params, ...result.params };
124
- hooks.push(...result.hooks);
125
- meta = { ...meta, ...result.meta };
126
- if (result.breakFromLayouts) {
127
- layouts = [];
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 { match, layouts, hooks, params, meta, breakFromLayouts };
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
  /**
@@ -157,6 +157,7 @@ export function preloadOnHover(routes) {
157
157
 
158
158
  switch (link.dataset.preload) {
159
159
  case '':
160
+ case 'true':
160
161
  case 'hover': {
161
162
  link.addEventListener('mouseenter', function callback() {
162
163
  link.removeEventListener('mouseenter', callback);
@@ -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
- * Block navigation until the callback returns `false`.
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
- * blockNavigation(() => confirm('Are you sure you want to leave?'));
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(callback: () => boolean): void;
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
- `${Prefix}${AnyParam extends true ? ReplaceParamWithString<K> : K}`,
368
+ NormalizeSlashes<`${Prefix}${AnyParam extends true ? ReplaceParamWithString<K> : K}`>,
337
369
  AnyParam
338
370
  >
339
- : `${Prefix}${AnyParam extends true ? ReplaceParamWithString<K> : K}`
371
+ : NormalizeSlashes<`${Prefix}${AnyParam extends true ? ReplaceParamWithString<K> : K}`>
340
372
  : never;
341
373
  }[keyof T];
342
374