sv-router 0.13.0 → 0.14.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sv-router",
3
- "version": "0.13.0",
3
+ "version": "0.14.1",
4
4
  "description": "Modern Svelte Routing",
5
5
  "keywords": [
6
6
  "svelte",
@@ -36,29 +36,29 @@
36
36
  "esm-env": "^1.2.2"
37
37
  },
38
38
  "devDependencies": {
39
- "@changesets/cli": "^2.29.8",
40
- "@eslint/js": "^9.39.1",
41
- "@sveltejs/vite-plugin-svelte": "^6.2.1",
39
+ "@changesets/cli": "^2.30.0",
40
+ "@eslint/js": "^10.0.1",
41
+ "@sveltejs/vite-plugin-svelte": "^7.0.0",
42
42
  "@testing-library/jest-dom": "^6.9.1",
43
- "@testing-library/svelte": "^5.2.9",
43
+ "@testing-library/svelte": "^5.3.1",
44
44
  "@testing-library/user-event": "^14.6.1",
45
- "@types/node": "^24.10.1",
46
- "eslint": "^9.39.1",
45
+ "@types/node": "^24.12.0",
46
+ "eslint": "^10.0.3",
47
47
  "eslint-config-prettier": "^10.1.8",
48
48
  "eslint-plugin-simple-import-sort": "^12.1.1",
49
- "eslint-plugin-svelte": "^3.13.1",
50
- "eslint-plugin-unicorn": "^62.0.0",
51
- "globals": "^17.0.0",
52
- "happy-dom": "^20.0.11",
53
- "prettier": "^3.7.4",
49
+ "eslint-plugin-svelte": "^3.15.2",
50
+ "eslint-plugin-unicorn": "^63.0.0",
51
+ "globals": "^17.4.0",
52
+ "happy-dom": "^20.8.4",
53
+ "prettier": "^3.8.1",
54
54
  "prettier-plugin-jsdoc": "^1.8.0",
55
- "prettier-plugin-svelte": "^3.4.0",
56
- "svelte-check": "^4.3.4",
55
+ "prettier-plugin-svelte": "^3.5.1",
56
+ "svelte-check": "^4.4.5",
57
57
  "type-testing": "^0.2.0",
58
58
  "typescript": "^5.9.3",
59
- "typescript-eslint": "^8.48.1",
60
- "vite": "^7.2.6",
61
- "vitest": "^4.0.15"
59
+ "typescript-eslint": "^8.57.1",
60
+ "vite": "^8.0.0",
61
+ "vitest": "^4.1.0"
62
62
  },
63
63
  "peerDependencies": {
64
64
  "svelte": "^5"
@@ -0,0 +1,47 @@
1
+ import { base, location } from './create-router.svelte.js';
2
+
3
+ /** @type {import('./index.d.ts').IsActiveLink} */
4
+ export function isActiveLink({ className = 'is-active', startsWith = false } = {}) {
5
+ return (node) => {
6
+ if (node.tagName !== 'A') {
7
+ throw new Error('isActiveLink can only be used on <a> elements');
8
+ }
9
+
10
+ $effect(() => {
11
+ let pathname;
12
+ if (base.name === '#') {
13
+ pathname = new URL(node.href).hash.slice(1);
14
+ } else {
15
+ pathname = new URL(node.href).pathname;
16
+ }
17
+ const tokens = className.split(' ').filter(Boolean) ?? [];
18
+ if (startsWith ? location.pathname.startsWith(pathname) : location.pathname === pathname) {
19
+ node.classList.add(...tokens);
20
+ } else {
21
+ node.classList.remove(...tokens);
22
+ }
23
+ });
24
+ };
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
+ }
@@ -46,8 +46,12 @@ let meta = $state({ value: {} });
46
46
  /** @type {(() => boolean) | null} */
47
47
  let navigationBlocker = null;
48
48
 
49
- let navigationIndex = 0;
50
- let pendingNavigationIndex = 0;
49
+ /** @type {AbortController | null} */
50
+ let currentNavigationController = null;
51
+ let pendingController = /** @type {AbortController | null} */ (null);
52
+
53
+ /** @type {Promise<void | null> | null} */
54
+ let currentNavigationPromise = null;
51
55
 
52
56
  /** @param {string | undefined} basename */
53
57
  export function init(basename) {
@@ -129,7 +133,7 @@ export function createRouter(r) {
129
133
  * search?: import('./index.d.ts').Search;
130
134
  * }} options
131
135
  */
132
- function navigate(path, options = {}) {
136
+ async function navigate(path, options = {}) {
133
137
  if (typeof path === 'number') {
134
138
  globalThis.history.go(path);
135
139
  return new Navigation(`History entry: ${path}`);
@@ -141,7 +145,9 @@ function navigate(path, options = {}) {
141
145
  } else if (options.hash && !options.hash.startsWith('#')) {
142
146
  options.hash = '#' + options.hash;
143
147
  }
144
- onNavigate(path, options);
148
+ const promise = onNavigate(path, options);
149
+ currentNavigationPromise = promise;
150
+ await promise;
145
151
  return new Navigation(`${path}${serializeSearch(options?.search ?? '')}${options?.hash ?? ''}`);
146
152
  }
147
153
 
@@ -170,8 +176,13 @@ export async function onNavigate(path, options = {}) {
170
176
  navigationBlocker = null;
171
177
  }
172
178
 
173
- navigationIndex++;
174
- const currentNavigationIndex = navigationIndex;
179
+ if (pendingController && pendingController !== currentNavigationController) {
180
+ return;
181
+ }
182
+
183
+ currentNavigationController?.abort();
184
+ currentNavigationController = new AbortController();
185
+ const { signal } = currentNavigationController;
175
186
 
176
187
  const matchPath = getMatchPath(path);
177
188
  const { match, layouts, hooks, meta: newMeta, params: newParams } = matchRoute(matchPath, routes);
@@ -181,21 +192,23 @@ export async function onNavigate(path, options = {}) {
181
192
 
182
193
  let errorHooks = [];
183
194
  for (const hook of hooks) {
195
+ if (signal.aborted) return currentNavigationPromise;
184
196
  try {
185
197
  const { beforeLoad } = hook;
186
198
  errorHooks.push(hook);
187
- pendingNavigationIndex = currentNavigationIndex;
199
+ pendingController = currentNavigationController;
188
200
  await beforeLoad?.(hooksContext);
189
201
  } catch (error) {
202
+ if (signal.aborted) return currentNavigationPromise;
190
203
  for (const { onError } of errorHooks) {
191
204
  void onError?.(error, hooksContext);
192
205
  }
193
206
  return;
207
+ } finally {
208
+ pendingController = null;
194
209
  }
195
210
  }
196
211
 
197
- const fromBeforeLoadHook = new Error().stack?.includes('beforeLoad');
198
-
199
212
  let routeComponents;
200
213
  try {
201
214
  routeComponents = await resolveRouteComponents(match ? [...layouts, match] : layouts);
@@ -205,12 +218,7 @@ export async function onNavigate(path, options = {}) {
205
218
  }
206
219
  throw error;
207
220
  }
208
- if (
209
- navigationIndex !== currentNavigationIndex ||
210
- (fromBeforeLoadHook && pendingNavigationIndex + 1 !== currentNavigationIndex)
211
- ) {
212
- return;
213
- }
221
+ if (signal.aborted) return currentNavigationPromise;
214
222
 
215
223
  if (path) {
216
224
  const search = serializeSearch(options.search);
@@ -27,7 +27,8 @@ isActive.startsWith = (pathname, params) => {
27
27
  */
28
28
  function compare(compareFn, pathname, params) {
29
29
  if (!pathname.includes(':')) {
30
- return compareFn(location.pathname, pathname);
30
+ const target = base.name && base.name !== '#' ? constructPath(pathname) : pathname;
31
+ return compareFn(location.pathname, target);
31
32
  }
32
33
 
33
34
  if (params) {
@@ -63,6 +63,8 @@ export function matchRoute(pathname, routes) {
63
63
  }
64
64
 
65
65
  const pathPart = pathParts[index];
66
+ const isLayoutGroup = routePart === '' && typeof routes['/'] !== 'function';
67
+
66
68
  if (routePart.startsWith(':')) {
67
69
  params[routePart.slice(1)] = decodeURIComponent(pathPart);
68
70
  } else if (routePart.startsWith('*')) {
@@ -71,7 +73,6 @@ export function matchRoute(pathname, routes) {
71
73
  params[param] = pathParts.slice(index).map(decodeURIComponent).join('/');
72
74
  }
73
75
  if (breakFromLayouts) {
74
- routePart = `(${routePart})`;
75
76
  layouts = [];
76
77
  } else if ('layout' in routes && routes.layout) {
77
78
  layouts.push(routes.layout);
@@ -81,7 +82,7 @@ export function matchRoute(pathname, routes) {
81
82
  );
82
83
  match = /** @type {RouteComponent} */ (routes[resolvedPath]);
83
84
  break outer;
84
- } else if (routePart.toLowerCase() !== pathPart?.toLowerCase()) {
85
+ } else if (routePart.toLowerCase() !== pathPart?.toLowerCase() && !isLayoutGroup) {
85
86
  break;
86
87
  }
87
88
 
@@ -117,7 +118,7 @@ export function matchRoute(pathname, routes) {
117
118
  continue;
118
119
  }
119
120
 
120
- const nestedPathname = '/' + pathParts.slice(index + 1).join('/');
121
+ const nestedPathname = isLayoutGroup ? pathname : '/' + pathParts.slice(index + 1).join('/');
121
122
  const result = matchRoute(nestedPathname, routeMatch);
122
123
  if (result.match) {
123
124
  match = result.match;
@@ -126,6 +127,7 @@ export function matchRoute(pathname, routes) {
126
127
  meta = { ...meta, ...result.meta };
127
128
  if (result.breakFromLayouts) {
128
129
  layouts = [];
130
+ breakFromLayouts = true;
129
131
  } else {
130
132
  layouts.push(...result.layouts);
131
133
  }
@@ -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);
package/src/index.d.ts CHANGED
@@ -1,13 +1,27 @@
1
1
  import type { Component, Snippet } from 'svelte';
2
2
  import type { Action } from 'svelte/action';
3
+ import type { Attachment } from 'svelte/attachments';
3
4
 
4
5
  /**
5
- * A Svelte action that will add a class to the anchor if its `href` matches the current route. It
6
- * can have an optional `className` parameter to specify the class to add, otherwise it will default
7
- * to `is-active`.
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
+ /**
19
+ * A Svelte attachment that will add a class to the anchor if its `href` matches the current route.
20
+ * It can have an optional `className` parameter to specify the class to add, otherwise it will
21
+ * default to `is-active`, and an optional `startsWith` parameter.
8
22
  *
9
23
  * ```svelte
10
- * <a href={p('/about')} use:isActiveLink={{ className: 'active-link' }}>
24
+ * <a href={p('/about')} {@attach isActiveLink({ className: 'active-link' })}>
11
25
  * ```
12
26
  */
13
27
  export const isActiveLink: IsActiveLink;
@@ -92,7 +106,12 @@ export type Routes = {
92
106
  meta?: RouteMeta;
93
107
  };
94
108
 
95
- export type IsActiveLink = Action<
109
+ export type IsActiveLink = (options?: {
110
+ className?: string;
111
+ startsWith?: boolean;
112
+ }) => Attachment<HTMLAnchorElement>;
113
+
114
+ export type IsActiveLinkAction = Action<
96
115
  HTMLAnchorElement,
97
116
  | {
98
117
  className?: string;
@@ -154,7 +173,7 @@ export type RouterApi<T extends Routes> = {
154
173
  * @param options The navigation options.
155
174
  * @returns {@link Navigation} For use with `throw navigate(...)` inside hooks.
156
175
  */
157
- navigate<U extends Path<T>>(...args: NavigateArgs<U>): Navigation;
176
+ navigate<U extends Path<T>>(...args: NavigateArgs<U>): Promise<Navigation>;
158
177
 
159
178
  /**
160
179
  * Will return `true` if the given path is active.
@@ -305,6 +324,10 @@ type StripNonRoutes<T extends Routes> = {
305
324
  : K]: T[K] extends Routes ? StripNonRoutes<T[K]> : T[K];
306
325
  };
307
326
 
327
+ type NormalizeSlashes<T extends string> = T extends `${infer A}//${infer B}`
328
+ ? NormalizeSlashes<`${A}/${B}`>
329
+ : T;
330
+
308
331
  type RecursiveKeys<
309
332
  T extends Routes,
310
333
  Prefix extends string = '',
@@ -314,10 +337,10 @@ type RecursiveKeys<
314
337
  ? T[K] extends Routes
315
338
  ? RecursiveKeys<
316
339
  T[K],
317
- `${Prefix}${AnyParam extends true ? ReplaceParamWithString<K> : K}`,
340
+ NormalizeSlashes<`${Prefix}${AnyParam extends true ? ReplaceParamWithString<K> : K}`>,
318
341
  AnyParam
319
342
  >
320
- : `${Prefix}${AnyParam extends true ? ReplaceParamWithString<K> : K}`
343
+ : NormalizeSlashes<`${Prefix}${AnyParam extends true ? ReplaceParamWithString<K> : K}`>
321
344
  : never;
322
345
  }[keyof T];
323
346
 
package/src/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { isActiveLink } from './actions.svelte.js';
1
+ export { isActiveLink, isActiveLinkAction } 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';
@@ -1,27 +0,0 @@
1
- import { base, location } from './create-router.svelte.js';
2
- import { join } from './helpers/utils.js';
3
-
4
- /** @type {import('./index.d.ts').IsActiveLink} */
5
- export function isActiveLink(node, { className = 'is-active', startsWith = false } = {}) {
6
- if (node.tagName !== 'A') {
7
- throw new Error('isActiveLink can only be used on <a> elements');
8
- }
9
-
10
- $effect(() => {
11
- let pathname;
12
- if (base.name === '#') {
13
- pathname = new URL(node.href).hash.slice(1);
14
- } else {
15
- pathname = new URL(node.href).pathname;
16
- if (base.name) {
17
- pathname = join(base.name, pathname);
18
- }
19
- }
20
- const tokens = className.split(' ').filter(Boolean) ?? [];
21
- if (startsWith ? location.pathname.startsWith(pathname) : location.pathname === pathname) {
22
- node.classList.add(...tokens);
23
- } else {
24
- node.classList.remove(...tokens);
25
- }
26
- });
27
- }