sv-router 0.13.0 → 0.14.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sv-router",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Modern Svelte Routing",
5
5
  "keywords": [
6
6
  "svelte",
@@ -37,28 +37,28 @@
37
37
  },
38
38
  "devDependencies": {
39
39
  "@changesets/cli": "^2.29.8",
40
- "@eslint/js": "^9.39.1",
41
- "@sveltejs/vite-plugin-svelte": "^6.2.1",
40
+ "@eslint/js": "^10.0.1",
41
+ "@sveltejs/vite-plugin-svelte": "^6.2.4",
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.10.13",
46
+ "eslint": "^10.0.0",
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.0",
50
+ "eslint-plugin-unicorn": "^63.0.0",
51
+ "globals": "^17.3.0",
52
+ "happy-dom": "^20.6.0",
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.4.1",
56
+ "svelte-check": "^4.3.6",
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.55.0",
60
+ "vite": "^7.3.1",
61
+ "vitest": "^4.0.18"
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) {
@@ -71,7 +71,6 @@ export function matchRoute(pathname, routes) {
71
71
  params[param] = pathParts.slice(index).map(decodeURIComponent).join('/');
72
72
  }
73
73
  if (breakFromLayouts) {
74
- routePart = `(${routePart})`;
75
74
  layouts = [];
76
75
  } else if ('layout' in routes && routes.layout) {
77
76
  layouts.push(routes.layout);
@@ -126,6 +125,7 @@ export function matchRoute(pathname, routes) {
126
125
  meta = { ...meta, ...result.meta };
127
126
  if (result.breakFromLayouts) {
128
127
  layouts = [];
128
+ breakFromLayouts = true;
129
129
  } else {
130
130
  layouts.push(...result.layouts);
131
131
  }
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.
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
- }