sv-router 0.12.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.12.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": "^16.5.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
+ }
@@ -43,8 +43,15 @@ let params = $state({ value: {} });
43
43
 
44
44
  let meta = $state({ value: {} });
45
45
 
46
- let navigationIndex = 0;
47
- let pendingNavigationIndex = 0;
46
+ /** @type {(() => boolean) | null} */
47
+ let navigationBlocker = null;
48
+
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;
48
55
 
49
56
  /** @param {string | undefined} basename */
50
57
  export function init(basename) {
@@ -126,7 +133,7 @@ export function createRouter(r) {
126
133
  * search?: import('./index.d.ts').Search;
127
134
  * }} options
128
135
  */
129
- function navigate(path, options = {}) {
136
+ async function navigate(path, options = {}) {
130
137
  if (typeof path === 'number') {
131
138
  globalThis.history.go(path);
132
139
  return new Navigation(`History entry: ${path}`);
@@ -138,7 +145,9 @@ function navigate(path, options = {}) {
138
145
  } else if (options.hash && !options.hash.startsWith('#')) {
139
146
  options.hash = '#' + options.hash;
140
147
  }
141
- onNavigate(path, options);
148
+ const promise = onNavigate(path, options);
149
+ currentNavigationPromise = promise;
150
+ await promise;
142
151
  return new Navigation(`${path}${serializeSearch(options?.search ?? '')}${options?.hash ?? ''}`);
143
152
  }
144
153
 
@@ -151,8 +160,29 @@ export async function onNavigate(path, options = {}) {
151
160
  throw new Error('Router not initialized: `createRouter` was not called.');
152
161
  }
153
162
 
154
- navigationIndex++;
155
- const currentNavigationIndex = navigationIndex;
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;
172
+ }
173
+ globalThis.history.replaceState($state.snapshot(location.state) || {}, '', url.toString());
174
+ return;
175
+ }
176
+ navigationBlocker = null;
177
+ }
178
+
179
+ if (pendingController && pendingController !== currentNavigationController) {
180
+ return;
181
+ }
182
+
183
+ currentNavigationController?.abort();
184
+ currentNavigationController = new AbortController();
185
+ const { signal } = currentNavigationController;
156
186
 
157
187
  const matchPath = getMatchPath(path);
158
188
  const { match, layouts, hooks, meta: newMeta, params: newParams } = matchRoute(matchPath, routes);
@@ -162,21 +192,23 @@ export async function onNavigate(path, options = {}) {
162
192
 
163
193
  let errorHooks = [];
164
194
  for (const hook of hooks) {
195
+ if (signal.aborted) return currentNavigationPromise;
165
196
  try {
166
197
  const { beforeLoad } = hook;
167
198
  errorHooks.push(hook);
168
- pendingNavigationIndex = currentNavigationIndex;
199
+ pendingController = currentNavigationController;
169
200
  await beforeLoad?.(hooksContext);
170
201
  } catch (error) {
202
+ if (signal.aborted) return currentNavigationPromise;
171
203
  for (const { onError } of errorHooks) {
172
204
  void onError?.(error, hooksContext);
173
205
  }
174
206
  return;
207
+ } finally {
208
+ pendingController = null;
175
209
  }
176
210
  }
177
211
 
178
- const fromBeforeLoadHook = new Error().stack?.includes('beforeLoad');
179
-
180
212
  let routeComponents;
181
213
  try {
182
214
  routeComponents = await resolveRouteComponents(match ? [...layouts, match] : layouts);
@@ -186,12 +218,7 @@ export async function onNavigate(path, options = {}) {
186
218
  }
187
219
  throw error;
188
220
  }
189
- if (
190
- navigationIndex !== currentNavigationIndex ||
191
- (fromBeforeLoadHook && pendingNavigationIndex + 1 !== currentNavigationIndex)
192
- ) {
193
- return;
194
- }
221
+ if (signal.aborted) return currentNavigationPromise;
195
222
 
196
223
  if (path) {
197
224
  const search = serializeSearch(options.search);
@@ -292,3 +319,8 @@ export function onGlobalClick(event) {
292
319
  viewTransition: viewTransition === '' || viewTransition === 'true',
293
320
  });
294
321
  }
322
+
323
+ /** @param {() => boolean} callback */
324
+ export function blockNavigation(callback) {
325
+ navigationBlocker = callback;
326
+ }
@@ -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) {
@@ -64,14 +64,13 @@ export function matchRoute(pathname, routes) {
64
64
 
65
65
  const pathPart = pathParts[index];
66
66
  if (routePart.startsWith(':')) {
67
- params[routePart.slice(1)] = pathPart;
67
+ params[routePart.slice(1)] = decodeURIComponent(pathPart);
68
68
  } else if (routePart.startsWith('*')) {
69
69
  const param = routePart.slice(1);
70
70
  if (param) {
71
- params[param] = pathParts.slice(index).join('/');
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;
@@ -28,6 +42,15 @@ export function serializeSearch(search: Search): string | undefined;
28
42
  */
29
43
  export function createRouter<T extends Routes>(r: T): RouterApi<T>;
30
44
 
45
+ /**
46
+ * Block navigation until the callback returns `false`.
47
+ *
48
+ * ```js
49
+ * blockNavigation(() => confirm('Are you sure you want to leave?'));
50
+ * ```
51
+ */
52
+ export function blockNavigation(callback: () => boolean): void;
53
+
31
54
  /**
32
55
  * The component that will render the current route. You can pass a `base` prop to set the base path
33
56
  * that is prepended to every url.
@@ -83,7 +106,12 @@ export type Routes = {
83
106
  meta?: RouteMeta;
84
107
  };
85
108
 
86
- export type IsActiveLink = Action<
109
+ export type IsActiveLink = (options?: {
110
+ className?: string;
111
+ startsWith?: boolean;
112
+ }) => Attachment<HTMLAnchorElement>;
113
+
114
+ export type IsActiveLinkAction = Action<
87
115
  HTMLAnchorElement,
88
116
  | {
89
117
  className?: string;
@@ -145,7 +173,7 @@ export type RouterApi<T extends Routes> = {
145
173
  * @param options The navigation options.
146
174
  * @returns {@link Navigation} For use with `throw navigate(...)` inside hooks.
147
175
  */
148
- navigate<U extends Path<T>>(...args: NavigateArgs<U>): Navigation;
176
+ navigate<U extends Path<T>>(...args: NavigateArgs<U>): Promise<Navigation>;
149
177
 
150
178
  /**
151
179
  * Will return `true` if the given path is active.
package/src/index.js CHANGED
@@ -1,5 +1,5 @@
1
- export { isActiveLink } from './actions.svelte.js';
2
- export { createRouter } from './create-router.svelte.js';
1
+ export { isActiveLink, isActiveLinkAction } from './attachments.svelte.js';
2
+ export { blockNavigation, createRouter } from './create-router.svelte.js';
3
3
  export { serializeSearch } from './helpers/utils.js';
4
4
  export { Navigation } from './navigation.js';
5
5
  export { default as Router } from './Router.svelte';
@@ -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
- }