sv-router 0.14.1 → 0.16.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.1",
3
+ "version": "0.16.0",
4
4
  "description": "Modern Svelte Routing",
5
5
  "keywords": [
6
6
  "svelte",
@@ -43,11 +43,12 @@
43
43
  "@testing-library/svelte": "^5.3.1",
44
44
  "@testing-library/user-event": "^14.6.1",
45
45
  "@types/node": "^24.12.0",
46
+ "@vitest/coverage-v8": "^4.1.0",
46
47
  "eslint": "^10.0.3",
47
48
  "eslint-config-prettier": "^10.1.8",
48
- "eslint-plugin-simple-import-sort": "^12.1.1",
49
+ "eslint-plugin-simple-import-sort": "^13.0.0",
49
50
  "eslint-plugin-svelte": "^3.15.2",
50
- "eslint-plugin-unicorn": "^63.0.0",
51
+ "eslint-plugin-unicorn": "^64.0.0",
51
52
  "globals": "^17.4.0",
52
53
  "happy-dom": "^20.8.4",
53
54
  "prettier": "^3.8.1",
@@ -55,7 +56,7 @@
55
56
  "prettier-plugin-svelte": "^3.5.1",
56
57
  "svelte-check": "^4.4.5",
57
58
  "type-testing": "^0.2.0",
58
- "typescript": "^5.9.3",
59
+ "typescript": "^6.0.2",
59
60
  "typescript-eslint": "^8.57.1",
60
61
  "vite": "^8.0.0",
61
62
  "vitest": "^4.1.0"
@@ -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,22 +1,33 @@
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 }} */
7
13
  let { base: basename } = $props();
8
14
 
15
+ // svelte-ignore state_referenced_locally
9
16
  init(basename);
10
17
 
11
18
  onNavigate();
12
19
 
13
20
  $effect(() => {
14
- const off1 = on(globalThis, 'popstate', () => onNavigate());
15
- const off2 = on(globalThis, 'click', onGlobalClick);
21
+ const cleanup = [
22
+ on(globalThis, 'popstate', () => onNavigate()),
23
+ on(globalThis, 'beforeunload', onBeforeUnload),
24
+ on(globalThis, 'click', onGlobalClick),
25
+ ];
16
26
 
17
27
  return () => {
18
- off1();
19
- off2();
28
+ for (const clean of cleanup) {
29
+ clean();
30
+ }
20
31
  };
21
32
  });
22
33
  </script>
@@ -23,25 +23,3 @@ export function isActiveLink({ className = 'is-active', startsWith = false } = {
23
23
  });
24
24
  };
25
25
  }
26
-
27
- /** @type {import('./index.d.ts').IsActiveLinkAction} */
28
- export function isActiveLinkAction(node, { className = 'is-active', startsWith = false } = {}) {
29
- if (node.tagName !== 'A') {
30
- throw new Error('isActiveLink can only be used on <a> elements');
31
- }
32
-
33
- $effect(() => {
34
- let pathname;
35
- if (base.name === '#') {
36
- pathname = new URL(node.href).hash.slice(1);
37
- } else {
38
- pathname = new URL(node.href).pathname;
39
- }
40
- const tokens = className.split(' ').filter(Boolean) ?? [];
41
- if (startsWith ? location.pathname.startsWith(pathname) : location.pathname === pathname) {
42
- node.classList.add(...tokens);
43
- } else {
44
- node.classList.remove(...tokens);
45
- }
46
- });
47
- }
@@ -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
  }
@@ -165,8 +165,14 @@ function mergeRouteGroup(result, childMap) {
165
165
  } else if (!Array.isArray(val)) {
166
166
  routeWithGroupFiles = { ...val };
167
167
  }
168
- if (layout) routeWithGroupFiles.layout = /** @type {string} */ (layout);
169
- if (hooks) routeWithGroupFiles.hooks = /** @type {string} */ (hooks);
168
+ if (layout) {
169
+ if (routeWithGroupFiles.layout) {
170
+ routeWithGroupFiles = { '/': routeWithGroupFiles, layout: layout };
171
+ } else {
172
+ routeWithGroupFiles.layout = layout;
173
+ }
174
+ }
175
+ if (hooks) routeWithGroupFiles.hooks = hooks;
170
176
  if (mergedMeta) routeWithGroupFiles.meta = /** @type {string | string[]} */ (mergedMeta);
171
177
  if (result[key]) {
172
178
  throw new Error(`Route conflict at \`${key}\``);
@@ -1,5 +1,6 @@
1
1
  /* eslint-disable no-console */
2
2
  import fs from 'node:fs';
3
+ import { createRequire } from 'node:module';
3
4
  import { genConfig } from './config.js';
4
5
  import { generateRouterCode } from './generate-router-code.js';
5
6
 
@@ -10,14 +11,18 @@ export function writeRouterCode() {
10
11
  }
11
12
 
12
13
  // Write `.router/tsconfig.json` file
14
+ const tsMajor = getTypeScriptMajorVersion();
15
+ const useBaseUrl = tsMajor === undefined || tsMajor < 6;
16
+ let alias = genConfig.routerPath;
17
+ if (!useBaseUrl) {
18
+ alias = alias.replace(genConfig.genCodeDirPath, '.');
19
+ }
13
20
  const tsConfig = {
14
21
  compilerOptions: {
15
22
  module: 'preserve',
16
23
  moduleResolution: 'bundler',
17
- baseUrl: '..',
18
- paths: {
19
- [genConfig.genCodeAlias]: [genConfig.routerPath],
20
- },
24
+ ...(useBaseUrl ? { baseUrl: '..' } : {}),
25
+ paths: { [genConfig.genCodeAlias]: [alias] },
21
26
  },
22
27
  include: [
23
28
  '../src/**/*.js',
@@ -62,3 +67,14 @@ function writeFileIfDifferent(filePath, content) {
62
67
  return true;
63
68
  }
64
69
  }
70
+
71
+ function getTypeScriptMajorVersion() {
72
+ try {
73
+ const require = createRequire(process.cwd() + '/package.json');
74
+ const tsPackagePath = require.resolve('typescript/package.json');
75
+ const tsPackage = JSON.parse(fs.readFileSync(tsPackagePath, 'utf8'));
76
+ return Number(tsPackage.version.split('.')[0]);
77
+ } catch {
78
+ return;
79
+ }
80
+ }
@@ -8,137 +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
- const isLayoutGroup = routePart === '' && typeof routes['/'] !== 'function';
67
-
68
- if (routePart.startsWith(':')) {
69
- params[routePart.slice(1)] = decodeURIComponent(pathPart);
70
- } else if (routePart.startsWith('*')) {
71
- const param = routePart.slice(1);
72
- if (param) {
73
- params[param] = pathParts.slice(index).map(decodeURIComponent).join('/');
74
- }
75
- if (breakFromLayouts) {
76
- layouts = [];
77
- } else if ('layout' in routes && routes.layout) {
78
- layouts.push(routes.layout);
79
- }
80
- const resolvedPath = /** @type {keyof Routes} */ (
81
- (index ? '/' : '') + routeParts.join('/')
82
- );
83
- match = /** @type {RouteComponent} */ (routes[resolvedPath]);
84
- break outer;
85
- } else if (routePart.toLowerCase() !== pathPart?.toLowerCase() && !isLayoutGroup) {
86
- break;
87
- }
55
+ return attempt.result;
56
+ }
88
57
 
89
- if (index !== routeParts.length - 1) {
90
- continue;
91
- }
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();
92
84
 
93
- const routeMatch = /** @type {RouteComponent} */ (
94
- routes[/** @type {keyof Routes} */ ('/' + routeParts.join('/'))]
95
- );
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
+ }
96
95
 
97
- if (typeof routeMatch === 'function' && routeParts.length !== pathParts.length) {
98
- continue;
99
- }
96
+ const pathPart = pathParts[index];
97
+ const isLayoutGroup = routePart === '' && typeof routes['/'] !== 'function';
100
98
 
101
- if (!breakFromLayouts && 'layout' in routes && routes.layout) {
102
- 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('/');
103
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
+ }
104
126
 
105
- if ('hooks' in routes && routes.hooks) {
106
- hooks.push(routes.hooks);
107
- }
127
+ // Continue matching next segment
128
+ if (index !== routeParts.length - 1) continue;
108
129
 
109
- if ('meta' in routes && routes.meta) {
110
- meta = { ...meta, ...routes.meta };
111
- }
130
+ // Last segment resolve the route value
131
+ const routeKey = /** @type {keyof Routes} */ ('/' + routeParts.join('/'));
132
+ const routeMatch = /** @type {RouteComponent} */ (routes[routeKey]);
112
133
 
113
- if (typeof routeMatch === 'function') {
114
- if (routeParts.length === pathParts.length) {
115
- match = routeMatch;
116
- break outer;
117
- }
118
- continue;
119
- }
134
+ if (typeof routeMatch === 'function' && routeParts.length !== pathParts.length) {
135
+ return null;
136
+ }
120
137
 
121
- const nestedPathname = isLayoutGroup ? pathname : '/' + pathParts.slice(index + 1).join('/');
122
- const result = matchRoute(nestedPathname, routeMatch);
123
- if (result.match) {
124
- match = result.match;
125
- params = { ...params, ...result.params };
126
- hooks.push(...result.hooks);
127
- meta = { ...meta, ...result.meta };
128
- if (result.breakFromLayouts) {
129
- layouts = [];
130
- breakFromLayouts = true;
131
- } else {
132
- layouts.push(...result.layouts);
133
- }
134
- } else {
135
- continue;
136
- }
137
- 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
+ };
138
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
+ };
139
158
  }
140
159
 
141
- 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
+ };
142
211
  }
143
212
 
144
213
  /**
@@ -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
@@ -2,19 +2,6 @@ import type { Component, Snippet } from 'svelte';
2
2
  import type { Action } from 'svelte/action';
3
3
  import type { Attachment } from 'svelte/attachments';
4
4
 
5
- /**
6
- * @deprecated Use the `isActiveLink` [attachment](https://svelte.dev/docs/svelte/@attach) instead.
7
- *
8
- * A Svelte action that will add a class to the anchor if its `href` matches the current route. It
9
- * can have an optional `className` parameter to specify the class to add, otherwise it will
10
- * default to `is-active`, and an optional `startsWith` parameter.
11
- *
12
- * ```svelte
13
- * <a href={p('/about')} use:isActiveLink={{ className: 'active-link' }}>
14
- * ```
15
- */
16
- export const isActiveLinkAction: IsActiveLinkAction;
17
-
18
5
  /**
19
6
  * A Svelte attachment that will add a class to the anchor if its `href` matches the current route.
20
7
  * It can have an optional `className` parameter to specify the class to add, otherwise it will
@@ -43,13 +30,41 @@ export function serializeSearch(search: Search): string | undefined;
43
30
  export function createRouter<T extends Routes>(r: T): RouterApi<T>;
44
31
 
45
32
  /**
46
- * Block navigation until the callback returns `false`.
33
+ * Blocks navigation as long as the callback returns `false`.
34
+ *
35
+ * Returns a function that clears the navigation block.
36
+ *
37
+ * ```js
38
+ * $effect(() => blockNavigation(() => confirm('Are you sure you want to leave?')));
39
+ * ```
40
+ *
41
+ * The callback can also be async:
42
+ *
43
+ * ```js
44
+ * $effect(() => blockNavigation(async () => await showConfirmModal()));
45
+ * ```
46
+ *
47
+ * If you also need to block tab close, use the object form to handle blocking for navigation and
48
+ * site unloading separately (site unloading cannot be blocked asynchronously):
47
49
  *
48
50
  * ```js
49
- * blockNavigation(() => confirm('Are you sure you want to leave?'));
51
+ * $effect(() =>
52
+ * blockNavigation({
53
+ * beforeUnload() {
54
+ * return false;
55
+ * },
56
+ * async onNavigate() {
57
+ * return await askInModal();
58
+ * },
59
+ * }),
60
+ * );
50
61
  * ```
51
62
  */
52
- export function blockNavigation(callback: () => boolean): void;
63
+ export function blockNavigation(
64
+ callback:
65
+ | (() => boolean | Promise<boolean>)
66
+ | { beforeUnload?(): boolean; onNavigate(): boolean | Promise<boolean> },
67
+ ): () => void;
53
68
 
54
69
  /**
55
70
  * The component that will render the current route. You can pass a `base` prop to set the base path
package/src/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { isActiveLink, isActiveLinkAction } from './attachments.svelte.js';
1
+ export { isActiveLink } from './attachments.svelte.js';
2
2
  export { blockNavigation, createRouter } from './create-router.svelte.js';
3
3
  export { serializeSearch } from './helpers/utils.js';
4
4
  export { Navigation } from './navigation.js';
@@ -66,7 +66,8 @@ export function syncSearchParams(search) {
66
66
  for (const [key, value] of newSearch) {
67
67
  searchParams.set(key, value);
68
68
  }
69
- for (const key of searchParams.keys()) {
69
+ // eslint-disable-next-line unicorn/no-useless-spread
70
+ for (const key of [...searchParams.keys()]) {
70
71
  if (!newSearch.has(key)) {
71
72
  searchParams.delete(key);
72
73
  }