@solidjs/router 0.5.0 → 0.5.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/README.md CHANGED
@@ -28,6 +28,7 @@ It supports all of Solid's SSR methods and has Solid's transitions baked in, so
28
28
  - [useRouteData](#useroutedata)
29
29
  - [useMatch](#usematch)
30
30
  - [useRoutes](#useroutes)
31
+ - [useBeforeLeave](#usebeforeleave)
31
32
 
32
33
  ## Getting Started
33
34
 
@@ -538,3 +539,32 @@ return <div classList={{ active: Boolean(match()) }} />;
538
539
  ### useRoutes
539
540
 
540
541
  Used to define routes via a config object instead of JSX. See [Config Based Routing](#config-based-routing).
542
+
543
+ ### useBeforeLeave
544
+
545
+ `useBeforeLeave` takes a function that will be called prior to leaving a route. The function will be called with:
546
+
547
+ - from (_Location_): current location (before change).
548
+ - to (_string | number_}: path passed to `navigate`.
549
+ - options (_NavigateOptions_}: options passed to `navigate`.
550
+ - preventDefault (_void function_): call to block the route change.
551
+ - defaultPrevented (_readonly boolean_): true if any previously called leave handlers called preventDefault().
552
+ - retry (_void function_, _force?: boolean_ ): call to retry the same navigation, perhaps after confirming with the user. Pass `true` to skip running the leave handlers again (ie force navigate without confirming).
553
+
554
+ Example usage:
555
+ ```js
556
+ useBeforeLeave((e: BeforeLeaveEventArgs) => {
557
+ if (form.isDirty && !e.defaultPrevented) {
558
+ // preventDefault to block immediately and prompt user async
559
+ e.preventDefault();
560
+ setTimeout(() => {
561
+ if (window.confirm("Discard unsaved changes - are you sure?")) {
562
+ // user wants to proceed anyway so retry with force=true
563
+ e.retry(true);
564
+ }
565
+ }, 100);
566
+ }
567
+ });
568
+ ```
569
+
570
+
@@ -3,7 +3,7 @@ import { children, createMemo, createRoot, mergeProps, on, Show, splitProps } fr
3
3
  import { isServer } from "solid-js/web";
4
4
  import { pathIntegration, staticIntegration } from "./integration";
5
5
  import { createBranches, createRouteContext, createRouterContext, getRouteMatches, RouteContextObj, RouterContextObj, useHref, useLocation, useNavigate, useResolvedPath, useRoute, useRouter } from "./routing";
6
- import { joinPaths } from "./utils";
6
+ import { joinPaths, normalizePath } from "./utils";
7
7
  export const Router = (props) => {
8
8
  const { source, url, base, data, out } = props;
9
9
  const integration = source || (isServer ? staticIntegration({ value: url || "" }) : pathIntegration());
@@ -76,7 +76,7 @@ export const Outlet = () => {
76
76
  };
77
77
  export function A(props) {
78
78
  props = mergeProps({ inactiveClass: "inactive", activeClass: "active" }, props);
79
- const [, rest] = splitProps(props, ["href", "state", "activeClass", "inactiveClass", "end"]);
79
+ const [, rest] = splitProps(props, ["href", "state", "class", "activeClass", "inactiveClass", "end"]);
80
80
  const to = useResolvedPath(() => props.href);
81
81
  const href = useHref(to);
82
82
  const location = useLocation();
@@ -84,11 +84,12 @@ export function A(props) {
84
84
  const to_ = to();
85
85
  if (to_ === undefined)
86
86
  return false;
87
- const path = to_.split(/[?#]/, 1)[0].toLowerCase();
88
- const loc = location.pathname.toLowerCase();
87
+ const path = normalizePath(to_.split(/[?#]/, 1)[0]).toLowerCase();
88
+ const loc = normalizePath(location.pathname).toLowerCase();
89
89
  return props.end ? path === loc : loc.startsWith(path);
90
90
  });
91
91
  return (<a link {...rest} href={href() || props.href} state={JSON.stringify(props.state)} classList={{
92
+ ...(props.class && { [props.class]: true }),
92
93
  [props.inactiveClass]: !isActive(),
93
94
  [props.activeClass]: isActive(),
94
95
  ...rest.classList
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from "./components";
2
2
  export * from "./integration";
3
- export { useRouteData, useHref, useIsRouting, useLocation, useMatch, useNavigate, useParams, useResolvedPath, useSearchParams } from "./routing";
3
+ export * from "./lifecycle";
4
+ export { useRouteData, useHref, useIsRouting, useLocation, useMatch, useNavigate, useParams, useResolvedPath, useSearchParams, useBeforeLeave, } from "./routing";
4
5
  export { mergeSearchString as _mergeSearchString } from "./utils";
5
- export type { Location, LocationChange, LocationChangeSignal, NavigateOptions, Navigator, OutputMatch, Params, RouteDataFunc, RouteDataFuncArgs, RouteDefinition, RouterIntegration, RouterOutput, RouterUtils, SetParams } from "./types";
6
+ export type { Location, LocationChange, LocationChangeSignal, NavigateOptions, Navigator, OutputMatch, Params, RouteDataFunc, RouteDataFuncArgs, RouteDefinition, RouterIntegration, RouterOutput, RouterUtils, SetParams, BeforeLeaveEventArgs } from "./types";
package/dist/index.js CHANGED
@@ -1,15 +1,13 @@
1
- import { isServer, delegateEvents, createComponent as createComponent$1, spread, effect, setAttribute, classList, template } from 'solid-js/web';
1
+ import { isServer, delegateEvents, createComponent as createComponent$1, spread, mergeProps as mergeProps$1, template } from 'solid-js/web';
2
2
  import { createSignal, onCleanup, getOwner, runWithOwner, createMemo, createContext, useContext, untrack, createRenderEffect, createComponent, on, startTransition, resetErrorBoundaries, children, createRoot, Show, mergeProps, splitProps } from 'solid-js';
3
3
 
4
4
  function bindEvent(target, type, handler) {
5
5
  target.addEventListener(type, handler);
6
6
  return () => target.removeEventListener(type, handler);
7
7
  }
8
-
9
8
  function intercept([value, setValue], get, set) {
10
9
  return [get ? () => get(value()) : value, set ? v => setValue(set(v)) : setValue];
11
10
  }
12
-
13
11
  function querySelector(selector) {
14
12
  // Guard against selector being an invalid CSS selector
15
13
  try {
@@ -18,24 +16,19 @@ function querySelector(selector) {
18
16
  return null;
19
17
  }
20
18
  }
21
-
22
19
  function scrollToHash(hash, fallbackTop) {
23
20
  const el = querySelector(`#${hash}`);
24
-
25
21
  if (el) {
26
22
  el.scrollIntoView();
27
23
  } else if (fallbackTop) {
28
24
  window.scrollTo(0, 0);
29
25
  }
30
26
  }
31
-
32
27
  function createIntegration(get, set, init, utils) {
33
28
  let ignore = false;
34
-
35
29
  const wrap = value => typeof value === "string" ? {
36
30
  value
37
31
  } : value;
38
-
39
32
  const signal = intercept(createSignal(wrap(get()), {
40
33
  equals: (a, b) => a.value === b.value
41
34
  }), undefined, next => {
@@ -64,7 +57,6 @@ function normalizeIntegration(integration) {
64
57
  signal: integration
65
58
  };
66
59
  }
67
-
68
60
  return integration;
69
61
  }
70
62
  function staticIntegration(obj) {
@@ -87,7 +79,6 @@ function pathIntegration() {
87
79
  } else {
88
80
  window.history.pushState(state, "", value);
89
81
  }
90
-
91
82
  scrollToHash(window.location.hash.slice(1), scroll);
92
83
  }, notify => bindEvent(window, "popstate", () => notify()), {
93
84
  go: delta => window.history.go(delta)
@@ -105,7 +96,6 @@ function hashIntegration() {
105
96
  } else {
106
97
  window.location.hash = value;
107
98
  }
108
-
109
99
  const hashIndex = value.indexOf("#");
110
100
  const hash = hashIndex >= 0 ? value.slice(hashIndex + 1) : "";
111
101
  scrollToHash(hash, scroll);
@@ -113,37 +103,63 @@ function hashIntegration() {
113
103
  go: delta => window.history.go(delta),
114
104
  renderPath: path => `#${path}`,
115
105
  parsePath: str => {
116
- const to = str.replace(/^.*?#/, ""); // Hash-only hrefs like `#foo` from plain anchors will come in as `/#foo` whereas a link to
106
+ const to = str.replace(/^.*?#/, "");
107
+ // Hash-only hrefs like `#foo` from plain anchors will come in as `/#foo` whereas a link to
117
108
  // `/foo` will be `/#/foo`. Check if the to starts with a `/` and if not append it as a hash
118
109
  // to the current path so we can handle these in-page anchors correctly.
119
-
120
110
  if (!to.startsWith("/")) {
121
111
  const [, path = "/"] = window.location.hash.split("#", 2);
122
112
  return `${path}#${to}`;
123
113
  }
124
-
125
114
  return to;
126
115
  }
127
116
  });
128
117
  }
129
118
 
119
+ function createBeforeLeave() {
120
+ let listeners = new Set();
121
+ function subscribe(listener) {
122
+ listeners.add(listener);
123
+ return () => listeners.delete(listener);
124
+ }
125
+ let ignore = false;
126
+ function confirm(to, options) {
127
+ if (ignore) return !(ignore = false);
128
+ const e = {
129
+ to,
130
+ options,
131
+ defaultPrevented: false,
132
+ preventDefault: () => e.defaultPrevented = true
133
+ };
134
+ for (const l of listeners) l.listener({
135
+ ...e,
136
+ from: l.location,
137
+ retry: force => {
138
+ force && (ignore = true);
139
+ l.navigate(to, options);
140
+ }
141
+ });
142
+ return !e.defaultPrevented;
143
+ }
144
+ return {
145
+ subscribe,
146
+ confirm
147
+ };
148
+ }
149
+
130
150
  const hasSchemeRegex = /^(?:[a-z0-9]+:)?\/\//i;
131
151
  const trimPathRegex = /^\/+|\/+$/g;
132
-
133
- function normalize(path, omitSlash = false) {
152
+ function normalizePath(path, omitSlash = false) {
134
153
  const s = path.replace(trimPathRegex, "");
135
154
  return s ? omitSlash || /^[?#]/.test(s) ? s : "/" + s : "";
136
155
  }
137
-
138
156
  function resolvePath(base, path, from) {
139
157
  if (hasSchemeRegex.test(path)) {
140
158
  return undefined;
141
159
  }
142
-
143
- const basePath = normalize(base);
144
- const fromPath = from && normalize(from);
160
+ const basePath = normalizePath(base);
161
+ const fromPath = from && normalizePath(from);
145
162
  let result = "";
146
-
147
163
  if (!fromPath || path.startsWith("/")) {
148
164
  result = basePath;
149
165
  } else if (fromPath.toLowerCase().indexOf(basePath.toLowerCase()) !== 0) {
@@ -151,18 +167,16 @@ function resolvePath(base, path, from) {
151
167
  } else {
152
168
  result = fromPath;
153
169
  }
154
-
155
- return (result || "/") + normalize(path, !result);
170
+ return (result || "/") + normalizePath(path, !result);
156
171
  }
157
172
  function invariant(value, message) {
158
173
  if (value == null) {
159
174
  throw new Error(message);
160
175
  }
161
-
162
176
  return value;
163
177
  }
164
178
  function joinPaths(from, to) {
165
- return normalize(from).replace(/\/*(\*.*)?$/g, "") + normalize(to);
179
+ return normalizePath(from).replace(/\/*(\*.*)?$/g, "") + normalizePath(to);
166
180
  }
167
181
  function extractSearchParams(url) {
168
182
  const params = {};
@@ -181,20 +195,16 @@ function createMatcher(path, partial) {
181
195
  return location => {
182
196
  const locSegments = location.split("/").filter(Boolean);
183
197
  const lenDiff = locSegments.length - len;
184
-
185
198
  if (lenDiff < 0 || lenDiff > 0 && splat === undefined && !partial) {
186
199
  return null;
187
200
  }
188
-
189
201
  const match = {
190
202
  path: len ? "" : "/",
191
203
  params: {}
192
204
  };
193
-
194
205
  for (let i = 0; i < len; i++) {
195
206
  const segment = segments[i];
196
207
  const locSegment = locSegments[i];
197
-
198
208
  if (segment[0] === ":") {
199
209
  match.params[segment.slice(1)] = locSegment;
200
210
  } else if (segment.localeCompare(locSegment, undefined, {
@@ -202,14 +212,11 @@ function createMatcher(path, partial) {
202
212
  }) !== 0) {
203
213
  return null;
204
214
  }
205
-
206
215
  match.path += `/${locSegment}`;
207
216
  }
208
-
209
217
  if (splat) {
210
218
  match.params[splat] = lenDiff ? locSegments.slice(-lenDiff).join("/") : "";
211
219
  }
212
-
213
220
  return match;
214
221
  };
215
222
  }
@@ -226,21 +233,17 @@ function createMemoObject(fn) {
226
233
  if (!map.has(property)) {
227
234
  runWithOwner(owner, () => map.set(property, createMemo(() => fn()[property])));
228
235
  }
229
-
230
236
  return map.get(property)();
231
237
  },
232
-
233
238
  getOwnPropertyDescriptor() {
234
239
  return {
235
240
  enumerable: true,
236
241
  configurable: true
237
242
  };
238
243
  },
239
-
240
244
  ownKeys() {
241
245
  return Reflect.ownKeys(fn());
242
246
  }
243
-
244
247
  });
245
248
  }
246
249
  function mergeSearchString(search, params) {
@@ -260,17 +263,17 @@ function expandOptionals(pattern) {
260
263
  if (!match) return [pattern];
261
264
  let prefix = pattern.slice(0, match.index);
262
265
  let suffix = pattern.slice(match.index + match[0].length);
263
- const prefixes = [prefix, prefix += match[1]]; // This section handles adjacent optional params. We don't actually want all permuations since
266
+ const prefixes = [prefix, prefix += match[1]];
267
+
268
+ // This section handles adjacent optional params. We don't actually want all permuations since
264
269
  // that will lead to equivalent routes which have the same number of params. For example
265
270
  // `/:a?/:b?/:c`? only has the unique expansion: `/`, `/:a`, `/:a/:b`, `/:a/:b/:c` and we can
266
271
  // discard `/:b`, `/:c`, `/:b/:c` by building them up in order and not recursing. This also helps
267
272
  // ensure predictability where earlier params have precidence.
268
-
269
273
  while (match = /^(\/\:[^\/]+)\?/.exec(suffix)) {
270
274
  prefixes.push(prefix += match[1]);
271
275
  suffix = suffix.slice(match[0].length);
272
276
  }
273
-
274
277
  return expandOptionals(suffix).reduce((results, expansion) => [...results, ...prefixes.map(p => p + expansion)], []);
275
278
  }
276
279
 
@@ -304,17 +307,24 @@ const useRouteData = () => useRoute().data;
304
307
  const useSearchParams = () => {
305
308
  const location = useLocation();
306
309
  const navigate = useNavigate();
307
-
308
310
  const setSearchParams = (params, options) => {
309
311
  const searchString = untrack(() => mergeSearchString(location.search, params));
310
- navigate(location.pathname + searchString, {
312
+ navigate(location.pathname + searchString + location.hash, {
311
313
  scroll: false,
314
+ resolve: false,
312
315
  ...options
313
316
  });
314
317
  };
315
-
316
318
  return [location.query, setSearchParams];
317
319
  };
320
+ const useBeforeLeave = listener => {
321
+ const s = useRouter().beforeLeave.subscribe({
322
+ listener,
323
+ location: useLocation(),
324
+ navigate: useNavigate()
325
+ });
326
+ onCleanup(s);
327
+ };
318
328
  function createRoutes(routeDef, base = "", fallback) {
319
329
  const {
320
330
  component,
@@ -337,13 +347,13 @@ function createRoutes(routeDef, base = "", fallback) {
337
347
  for (const originalPath of expandOptionals(path)) {
338
348
  const path = joinPaths(base, originalPath);
339
349
  const pattern = isLeaf ? path : path.split("/*", 1)[0];
340
- acc.push({ ...shared,
350
+ acc.push({
351
+ ...shared,
341
352
  originalPath,
342
353
  pattern,
343
354
  matcher: createMatcher(pattern, !isLeaf)
344
355
  });
345
356
  }
346
-
347
357
  return acc;
348
358
  }, []);
349
359
  }
@@ -351,76 +361,62 @@ function createBranch(routes, index = 0) {
351
361
  return {
352
362
  routes,
353
363
  score: scoreRoute(routes[routes.length - 1]) * 10000 - index,
354
-
355
364
  matcher(location) {
356
365
  const matches = [];
357
-
358
366
  for (let i = routes.length - 1; i >= 0; i--) {
359
367
  const route = routes[i];
360
368
  const match = route.matcher(location);
361
-
362
369
  if (!match) {
363
370
  return null;
364
371
  }
365
-
366
- matches.unshift({ ...match,
372
+ matches.unshift({
373
+ ...match,
367
374
  route
368
375
  });
369
376
  }
370
-
371
377
  return matches;
372
378
  }
373
-
374
379
  };
375
380
  }
376
-
377
381
  function asArray(value) {
378
382
  return Array.isArray(value) ? value : [value];
379
383
  }
380
-
381
384
  function createBranches(routeDef, base = "", fallback, stack = [], branches = []) {
382
385
  const routeDefs = asArray(routeDef);
383
-
384
386
  for (let i = 0, len = routeDefs.length; i < len; i++) {
385
387
  const def = routeDefs[i];
386
-
387
388
  if (def && typeof def === "object" && def.hasOwnProperty("path")) {
388
389
  const routes = createRoutes(def, base, fallback);
389
-
390
390
  for (const route of routes) {
391
391
  stack.push(route);
392
-
393
- if (def.children) {
392
+ const isEmptyArray = Array.isArray(def.children) && def.children.length === 0;
393
+ if (def.children && !isEmptyArray) {
394
394
  createBranches(def.children, route.pattern, fallback, stack, branches);
395
395
  } else {
396
396
  const branch = createBranch([...stack], branches.length);
397
397
  branches.push(branch);
398
398
  }
399
-
400
399
  stack.pop();
401
400
  }
402
401
  }
403
- } // Stack will be empty on final return
404
-
402
+ }
405
403
 
404
+ // Stack will be empty on final return
406
405
  return stack.length ? branches : branches.sort((a, b) => b.score - a.score);
407
406
  }
408
407
  function getRouteMatches(branches, location) {
409
408
  for (let i = 0, len = branches.length; i < len; i++) {
410
409
  const match = branches[i].matcher(location);
411
-
412
410
  if (match) {
413
411
  return match;
414
412
  }
415
413
  }
416
-
417
414
  return [];
418
415
  }
419
416
  function createLocation(path, state) {
420
417
  const origin = new URL("http://sar");
421
418
  const url = createMemo(prev => {
422
419
  const path_ = path();
423
-
424
420
  try {
425
421
  return new URL(path_, origin);
426
422
  } catch (err) {
@@ -438,23 +434,18 @@ function createLocation(path, state) {
438
434
  get pathname() {
439
435
  return pathname();
440
436
  },
441
-
442
437
  get search() {
443
438
  return search();
444
439
  },
445
-
446
440
  get hash() {
447
441
  return hash();
448
442
  },
449
-
450
443
  get state() {
451
444
  return state();
452
445
  },
453
-
454
446
  get key() {
455
447
  return key();
456
448
  },
457
-
458
449
  query: createMemoObject(on(search, () => extractSearchParams(url())))
459
450
  };
460
451
  }
@@ -463,17 +454,14 @@ function createRouterContext(integration, base = "", data, out) {
463
454
  signal: [source, setSource],
464
455
  utils = {}
465
456
  } = normalizeIntegration(integration);
466
-
467
457
  const parsePath = utils.parsePath || (p => p);
468
-
469
458
  const renderPath = utils.renderPath || (p => p);
470
-
459
+ const beforeLeave = utils.beforeLeave || createBeforeLeave();
471
460
  const basePath = resolvePath("", base);
472
461
  const output = isServer && out ? Object.assign(out, {
473
462
  matches: [],
474
463
  url: undefined
475
464
  }) : undefined;
476
-
477
465
  if (basePath === undefined) {
478
466
  throw new Error(`${basePath} is not a valid base path`);
479
467
  } else if (basePath && !source().value) {
@@ -483,19 +471,15 @@ function createRouterContext(integration, base = "", data, out) {
483
471
  scroll: false
484
472
  });
485
473
  }
486
-
487
474
  const [isRouting, setIsRouting] = createSignal(false);
488
-
489
475
  const start = async callback => {
490
476
  setIsRouting(true);
491
-
492
477
  try {
493
478
  await startTransition(callback);
494
479
  } finally {
495
480
  setIsRouting(false);
496
481
  }
497
482
  };
498
-
499
483
  const [reference, setReference] = createSignal(source().value);
500
484
  const [state, setState] = createSignal(source().state);
501
485
  const location = createLocation(reference, state);
@@ -505,13 +489,10 @@ function createRouterContext(integration, base = "", data, out) {
505
489
  params: {},
506
490
  path: () => basePath,
507
491
  outlet: () => null,
508
-
509
492
  resolvePath(to) {
510
493
  return resolvePath(basePath, to);
511
494
  }
512
-
513
495
  };
514
-
515
496
  if (data) {
516
497
  try {
517
498
  TempRoute = baseRoute;
@@ -525,20 +506,17 @@ function createRouterContext(integration, base = "", data, out) {
525
506
  TempRoute = undefined;
526
507
  }
527
508
  }
528
-
529
509
  function navigateFromRoute(route, to, options) {
530
510
  // Untrack in case someone navigates in an effect - don't want to track `reference` or route paths
531
511
  untrack(() => {
532
512
  if (typeof to === "number") {
533
513
  if (!to) ; else if (utils.go) {
534
- utils.go(to);
514
+ beforeLeave.confirm(to, options) && utils.go(to);
535
515
  } else {
536
516
  console.warn("Router integration does not support relative routing");
537
517
  }
538
-
539
518
  return;
540
519
  }
541
-
542
520
  const {
543
521
  replace,
544
522
  resolve,
@@ -551,28 +529,24 @@ function createRouterContext(integration, base = "", data, out) {
551
529
  ...options
552
530
  };
553
531
  const resolvedTo = resolve ? route.resolvePath(to) : resolvePath("", to);
554
-
555
532
  if (resolvedTo === undefined) {
556
533
  throw new Error(`Path '${to}' is not a routable path`);
557
534
  } else if (referrers.length >= MAX_REDIRECTS) {
558
535
  throw new Error("Too many redirects");
559
536
  }
560
-
561
537
  const current = reference();
562
-
563
538
  if (resolvedTo !== current || nextState !== state()) {
564
539
  if (isServer) {
565
540
  if (output) {
566
541
  output.url = resolvedTo;
567
542
  }
568
-
569
543
  setSource({
570
544
  value: resolvedTo,
571
545
  replace,
572
546
  scroll,
573
547
  state: nextState
574
548
  });
575
- } else {
549
+ } else if (beforeLeave.confirm(resolvedTo, options)) {
576
550
  const len = referrers.push({
577
551
  value: current,
578
552
  replace,
@@ -595,34 +569,30 @@ function createRouterContext(integration, base = "", data, out) {
595
569
  }
596
570
  });
597
571
  }
598
-
599
572
  function navigatorFactory(route) {
600
573
  // Workaround for vite issue (https://github.com/vitejs/vite/issues/3803)
601
574
  route = route || useContext(RouteContextObj) || baseRoute;
602
575
  return (to, options) => navigateFromRoute(route, to, options);
603
576
  }
604
-
605
577
  function navigateEnd(next) {
606
578
  const first = referrers[0];
607
-
608
579
  if (first) {
609
580
  if (next.value !== first.value || next.state !== first.state) {
610
- setSource({ ...next,
581
+ setSource({
582
+ ...next,
611
583
  replace: first.replace,
612
584
  scroll: first.scroll
613
585
  });
614
586
  }
615
-
616
587
  referrers.length = 0;
617
588
  }
618
589
  }
619
-
620
590
  createRenderEffect(() => {
621
591
  const {
622
592
  value,
623
593
  state
624
- } = source(); // Untrack this whole block so `start` doesn't cause Solid's Listener to be preserved
625
-
594
+ } = source();
595
+ // Untrack this whole block so `start` doesn't cause Solid's Listener to be preserved
626
596
  untrack(() => {
627
597
  if (value !== reference()) {
628
598
  start(() => {
@@ -632,7 +602,6 @@ function createRouterContext(integration, base = "", data, out) {
632
602
  }
633
603
  });
634
604
  });
635
-
636
605
  if (!isServer) {
637
606
  function handleAnchorClick(evt) {
638
607
  if (evt.defaultPrevented || evt.button !== 0 || evt.metaKey || evt.altKey || evt.ctrlKey || evt.shiftKey) return;
@@ -645,7 +614,7 @@ function createRouterContext(integration, base = "", data, out) {
645
614
  const url = new URL(href);
646
615
  const pathname = urlDecode(url.pathname);
647
616
  if (url.origin !== window.location.origin || basePath && pathname && !pathname.toLowerCase().startsWith(basePath.toLowerCase())) return;
648
- const to = parsePath(pathname + urlDecode(url.search, true) + urlDecode(url.hash));
617
+ const to = parsePath(url.pathname + url.search + url.hash);
649
618
  const state = a.getAttribute("state");
650
619
  evt.preventDefault();
651
620
  navigateFromRoute(baseRoute, to, {
@@ -654,14 +623,13 @@ function createRouterContext(integration, base = "", data, out) {
654
623
  scroll: !a.hasAttribute("noscroll"),
655
624
  state: state && JSON.parse(state)
656
625
  });
657
- } // ensure delegated events run first
658
-
626
+ }
659
627
 
628
+ // ensure delegated events run first
660
629
  delegateEvents(["click"]);
661
630
  document.addEventListener("click", handleAnchorClick);
662
631
  onCleanup(() => document.removeEventListener("click", handleAnchorClick));
663
632
  }
664
-
665
633
  return {
666
634
  base: baseRoute,
667
635
  out: output,
@@ -669,7 +637,8 @@ function createRouterContext(integration, base = "", data, out) {
669
637
  isRouting,
670
638
  renderPath,
671
639
  parsePath,
672
- navigatorFactory
640
+ navigatorFactory,
641
+ beforeLeave
673
642
  };
674
643
  }
675
644
  function createRouteContext(router, parent, child, match) {
@@ -690,22 +659,17 @@ function createRouteContext(router, parent, child, match) {
690
659
  const route = {
691
660
  parent,
692
661
  pattern,
693
-
694
662
  get child() {
695
663
  return child();
696
664
  },
697
-
698
665
  path,
699
666
  params,
700
667
  data: parent.data,
701
668
  outlet,
702
-
703
669
  resolvePath(to) {
704
670
  return resolvePath(base.path(), to, path());
705
671
  }
706
-
707
672
  };
708
-
709
673
  if (data) {
710
674
  try {
711
675
  TempRoute = route;
@@ -719,7 +683,6 @@ function createRouteContext(router, parent, child, match) {
719
683
  TempRoute = undefined;
720
684
  }
721
685
  }
722
-
723
686
  return route;
724
687
  }
725
688
 
@@ -738,11 +701,9 @@ const Router = props => {
738
701
  const routerState = createRouterContext(integration, base, data, out);
739
702
  return createComponent$1(RouterContextObj.Provider, {
740
703
  value: routerState,
741
-
742
704
  get children() {
743
705
  return props.children;
744
706
  }
745
-
746
707
  });
747
708
  };
748
709
  const Routes = props => {
@@ -751,7 +712,6 @@ const Routes = props => {
751
712
  const routeDefs = children(() => props.children);
752
713
  const branches = createMemo(() => createBranches(routeDefs(), joinPaths(parentRoute.pattern, props.base || ""), Outlet));
753
714
  const matches = createMemo(() => getRouteMatches(branches(), router.location.pathname));
754
-
755
715
  if (router.out) {
756
716
  router.out.matches.push(matches().map(({
757
717
  route,
@@ -764,39 +724,31 @@ const Routes = props => {
764
724
  params
765
725
  })));
766
726
  }
767
-
768
727
  const disposers = [];
769
728
  let root;
770
729
  const routeStates = createMemo(on(matches, (nextMatches, prevMatches, prev) => {
771
730
  let equal = prevMatches && nextMatches.length === prevMatches.length;
772
731
  const next = [];
773
-
774
732
  for (let i = 0, len = nextMatches.length; i < len; i++) {
775
733
  const prevMatch = prevMatches && prevMatches[i];
776
734
  const nextMatch = nextMatches[i];
777
-
778
735
  if (prev && prevMatch && nextMatch.route.key === prevMatch.route.key) {
779
736
  next[i] = prev[i];
780
737
  } else {
781
738
  equal = false;
782
-
783
739
  if (disposers[i]) {
784
740
  disposers[i]();
785
741
  }
786
-
787
742
  createRoot(dispose => {
788
743
  disposers[i] = dispose;
789
744
  next[i] = createRouteContext(router, next[i - 1] || parentRoute, () => routeStates()[i + 1], () => matches()[i]);
790
745
  });
791
746
  }
792
747
  }
793
-
794
748
  disposers.splice(nextMatches.length).forEach(dispose => dispose());
795
-
796
749
  if (prev && equal) {
797
750
  return prev;
798
751
  }
799
-
800
752
  root = next[0];
801
753
  return next;
802
754
  }));
@@ -804,14 +756,11 @@ const Routes = props => {
804
756
  get when() {
805
757
  return routeStates() && root;
806
758
  },
807
-
808
759
  children: route => createComponent$1(RouteContextObj.Provider, {
809
760
  value: route,
810
-
811
761
  get children() {
812
762
  return route.outlet();
813
763
  }
814
-
815
764
  })
816
765
  });
817
766
  };
@@ -827,7 +776,6 @@ const Route = props => {
827
776
  get children() {
828
777
  return childRoutes();
829
778
  }
830
-
831
779
  });
832
780
  };
833
781
  const Outlet = () => {
@@ -836,14 +784,11 @@ const Outlet = () => {
836
784
  get when() {
837
785
  return route.child;
838
786
  },
839
-
840
787
  children: child => createComponent$1(RouteContextObj.Provider, {
841
788
  value: child,
842
-
843
789
  get children() {
844
790
  return child.outlet();
845
791
  }
846
-
847
792
  })
848
793
  });
849
794
  };
@@ -852,47 +797,43 @@ function A(props) {
852
797
  inactiveClass: "inactive",
853
798
  activeClass: "active"
854
799
  }, props);
855
- const [, rest] = splitProps(props, ["href", "state", "activeClass", "inactiveClass", "end"]);
800
+ const [, rest] = splitProps(props, ["href", "state", "class", "activeClass", "inactiveClass", "end"]);
856
801
  const to = useResolvedPath(() => props.href);
857
802
  const href = useHref(to);
858
803
  const location = useLocation();
859
804
  const isActive = createMemo(() => {
860
805
  const to_ = to();
861
806
  if (to_ === undefined) return false;
862
- const path = to_.split(/[?#]/, 1)[0].toLowerCase();
863
- const loc = location.pathname.toLowerCase();
807
+ const path = normalizePath(to_.split(/[?#]/, 1)[0]).toLowerCase();
808
+ const loc = normalizePath(location.pathname).toLowerCase();
864
809
  return props.end ? path === loc : loc.startsWith(path);
865
810
  });
866
811
  return (() => {
867
812
  const _el$ = _tmpl$.cloneNode(true);
868
-
869
- spread(_el$, rest, false, false);
870
-
871
- effect(_p$ => {
872
- const _v$ = href() || props.href,
873
- _v$2 = JSON.stringify(props.state),
874
- _v$3 = {
875
- [props.inactiveClass]: !isActive(),
876
- [props.activeClass]: isActive(),
877
- ...rest.classList
813
+ spread(_el$, mergeProps$1(rest, {
814
+ get href() {
815
+ return href() || props.href;
878
816
  },
879
- _v$4 = isActive() ? "page" : undefined;
880
-
881
- _v$ !== _p$._v$ && setAttribute(_el$, "href", _p$._v$ = _v$);
882
- _v$2 !== _p$._v$2 && setAttribute(_el$, "state", _p$._v$2 = _v$2);
883
- _p$._v$3 = classList(_el$, _v$3, _p$._v$3);
884
- _v$4 !== _p$._v$4 && setAttribute(_el$, "aria-current", _p$._v$4 = _v$4);
885
- return _p$;
886
- }, {
887
- _v$: undefined,
888
- _v$2: undefined,
889
- _v$3: undefined,
890
- _v$4: undefined
891
- });
892
-
817
+ get state() {
818
+ return JSON.stringify(props.state);
819
+ },
820
+ get classList() {
821
+ return {
822
+ ...(props.class && {
823
+ [props.class]: true
824
+ }),
825
+ [props.inactiveClass]: !isActive(),
826
+ [props.activeClass]: isActive(),
827
+ ...rest.classList
828
+ };
829
+ },
830
+ get ["aria-current"]() {
831
+ return isActive() ? "page" : undefined;
832
+ }
833
+ }), false, false);
893
834
  return _el$;
894
835
  })();
895
- } // deprecated alias exports
836
+ }
896
837
  function Navigate(props) {
897
838
  const navigate = useNavigate();
898
839
  const location = useLocation();
@@ -911,4 +852,4 @@ function Navigate(props) {
911
852
  return null;
912
853
  }
913
854
 
914
- export { A, A as Link, A as NavLink, Navigate, Outlet, Route, Router, Routes, mergeSearchString as _mergeSearchString, createIntegration, hashIntegration, normalizeIntegration, pathIntegration, staticIntegration, useHref, useIsRouting, useLocation, useMatch, useNavigate, useParams, useResolvedPath, useRouteData, useRoutes, useSearchParams };
855
+ export { A, A as Link, A as NavLink, Navigate, Outlet, Route, Router, Routes, mergeSearchString as _mergeSearchString, createBeforeLeave, createIntegration, hashIntegration, normalizeIntegration, pathIntegration, staticIntegration, useBeforeLeave, useHref, useIsRouting, useLocation, useMatch, useNavigate, useParams, useResolvedPath, useRouteData, useRoutes, useSearchParams };
package/dist/index.jsx CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from "./components";
2
2
  export * from "./integration";
3
- export { useRouteData, useHref, useIsRouting, useLocation, useMatch, useNavigate, useParams, useResolvedPath, useSearchParams } from "./routing";
3
+ export * from "./lifecycle";
4
+ export { useRouteData, useHref, useIsRouting, useLocation, useMatch, useNavigate, useParams, useResolvedPath, useSearchParams, useBeforeLeave, } from "./routing";
4
5
  export { mergeSearchString as _mergeSearchString } from "./utils";
@@ -0,0 +1,2 @@
1
+ import { BeforeLeaveLifecycle } from "./types";
2
+ export declare function createBeforeLeave(): BeforeLeaveLifecycle;
@@ -0,0 +1,32 @@
1
+ export function createBeforeLeave() {
2
+ let listeners = new Set();
3
+ function subscribe(listener) {
4
+ listeners.add(listener);
5
+ return () => listeners.delete(listener);
6
+ }
7
+ let ignore = false;
8
+ function confirm(to, options) {
9
+ if (ignore)
10
+ return !(ignore = false);
11
+ const e = {
12
+ to,
13
+ options,
14
+ defaultPrevented: false,
15
+ preventDefault: () => (e.defaultPrevented = true)
16
+ };
17
+ for (const l of listeners)
18
+ l.listener({
19
+ ...e,
20
+ from: l.location,
21
+ retry: (force) => {
22
+ force && (ignore = true);
23
+ l.navigate(to, options);
24
+ }
25
+ });
26
+ return !e.defaultPrevented;
27
+ }
28
+ return {
29
+ subscribe,
30
+ confirm
31
+ };
32
+ }
package/dist/routing.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Component, Accessor } from "solid-js";
2
- import type { Branch, Location, LocationChangeSignal, NavigateOptions, Navigator, Params, Route, RouteContext, RouteDataFunc, RouteDefinition, RouteMatch, RouterContext, RouterIntegration, SetParams } from "./types";
2
+ import type { BeforeLeaveEventArgs, Branch, Location, LocationChangeSignal, NavigateOptions, Navigator, Params, Route, RouteContext, RouteDataFunc, RouteDefinition, RouteMatch, RouterContext, RouterIntegration, SetParams } from "./types";
3
3
  export declare const RouterContextObj: import("solid-js").Context<RouterContext | undefined>;
4
4
  export declare const RouteContextObj: import("solid-js").Context<RouteContext | undefined>;
5
5
  export declare const useRouter: () => RouterContext;
@@ -14,6 +14,7 @@ export declare const useParams: <T extends Params>() => T;
14
14
  declare type MaybeReturnType<T> = T extends (...args: any) => infer R ? R : T;
15
15
  export declare const useRouteData: <T>() => MaybeReturnType<T>;
16
16
  export declare const useSearchParams: <T extends Params>() => [T, (params: SetParams, options?: Partial<NavigateOptions>) => void];
17
+ export declare const useBeforeLeave: (listener: (e: BeforeLeaveEventArgs) => void) => void;
17
18
  export declare function createRoutes(routeDef: RouteDefinition, base?: string, fallback?: Component): Route[];
18
19
  export declare function createBranch(routes: Route[], index?: number): Branch;
19
20
  export declare function createBranches(routeDef: RouteDefinition | RouteDefinition[], base?: string, fallback?: Component, stack?: Route[], branches?: Branch[]): Branch[];
package/dist/routing.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createComponent, createContext, createMemo, createRenderEffect, createSignal, on, onCleanup, untrack, useContext, startTransition, resetErrorBoundaries } from "solid-js";
2
2
  import { isServer, delegateEvents } from "solid-js/web";
3
3
  import { normalizeIntegration } from "./integration";
4
+ import { createBeforeLeave } from "./lifecycle";
4
5
  import { createMemoObject, extractSearchParams, invariant, resolvePath, createMatcher, joinPaths, scoreRoute, mergeSearchString, urlDecode, expandOptionals } from "./utils";
5
6
  const MAX_REDIRECTS = 100;
6
7
  export const RouterContextObj = createContext();
@@ -34,10 +35,14 @@ export const useSearchParams = () => {
34
35
  const navigate = useNavigate();
35
36
  const setSearchParams = (params, options) => {
36
37
  const searchString = untrack(() => mergeSearchString(location.search, params));
37
- navigate(location.pathname + searchString, { scroll: false, ...options });
38
+ navigate(location.pathname + searchString + location.hash, { scroll: false, resolve: false, ...options });
38
39
  };
39
40
  return [location.query, setSearchParams];
40
41
  };
42
+ export const useBeforeLeave = (listener) => {
43
+ const s = useRouter().beforeLeave.subscribe({ listener, location: useLocation(), navigate: useNavigate() });
44
+ onCleanup(s);
45
+ };
41
46
  export function createRoutes(routeDef, base = "", fallback) {
42
47
  const { component, data, children } = routeDef;
43
48
  const isLeaf = !children || (Array.isArray(children) && !children.length);
@@ -102,7 +107,8 @@ export function createBranches(routeDef, base = "", fallback, stack = [], branch
102
107
  const routes = createRoutes(def, base, fallback);
103
108
  for (const route of routes) {
104
109
  stack.push(route);
105
- if (def.children) {
110
+ const isEmptyArray = Array.isArray(def.children) && def.children.length === 0;
111
+ if (def.children && !isEmptyArray) {
106
112
  createBranches(def.children, route.pattern, fallback, stack, branches);
107
113
  }
108
114
  else {
@@ -166,6 +172,7 @@ export function createRouterContext(integration, base = "", data, out) {
166
172
  const { signal: [source, setSource], utils = {} } = normalizeIntegration(integration);
167
173
  const parsePath = utils.parsePath || (p => p);
168
174
  const renderPath = utils.renderPath || (p => p);
175
+ const beforeLeave = utils.beforeLeave || createBeforeLeave();
169
176
  const basePath = resolvePath("", base);
170
177
  const output = isServer && out
171
178
  ? Object.assign(out, {
@@ -224,7 +231,7 @@ export function createRouterContext(integration, base = "", data, out) {
224
231
  // A delta of 0 means stay at the current location, so it is ignored
225
232
  }
226
233
  else if (utils.go) {
227
- utils.go(to);
234
+ beforeLeave.confirm(to, options) && utils.go(to);
228
235
  }
229
236
  else {
230
237
  console.warn("Router integration does not support relative routing");
@@ -252,7 +259,7 @@ export function createRouterContext(integration, base = "", data, out) {
252
259
  }
253
260
  setSource({ value: resolvedTo, replace, scroll, state: nextState });
254
261
  }
255
- else {
262
+ else if (beforeLeave.confirm(resolvedTo, options)) {
256
263
  const len = referrers.push({ value: current, replace, scroll, state: state() });
257
264
  start(() => {
258
265
  setReference(resolvedTo);
@@ -325,7 +332,7 @@ export function createRouterContext(integration, base = "", data, out) {
325
332
  if (url.origin !== window.location.origin ||
326
333
  (basePath && pathname && !pathname.toLowerCase().startsWith(basePath.toLowerCase())))
327
334
  return;
328
- const to = parsePath(pathname + urlDecode(url.search, true) + urlDecode(url.hash));
335
+ const to = parsePath(url.pathname + url.search + url.hash);
329
336
  const state = a.getAttribute("state");
330
337
  evt.preventDefault();
331
338
  navigateFromRoute(baseRoute, to, {
@@ -347,7 +354,8 @@ export function createRouterContext(integration, base = "", data, out) {
347
354
  isRouting,
348
355
  renderPath,
349
356
  parsePath,
350
- navigatorFactory
357
+ navigatorFactory,
358
+ beforeLeave
351
359
  };
352
360
  }
353
361
  export function createRouteContext(router, parent, child, match) {
package/dist/types.d.ts CHANGED
@@ -93,6 +93,7 @@ export interface RouterUtils {
93
93
  renderPath(path: string): string;
94
94
  parsePath(str: string): string;
95
95
  go(delta: number): void;
96
+ beforeLeave: BeforeLeaveLifecycle;
96
97
  }
97
98
  export interface OutputMatch {
98
99
  originalPath: string;
@@ -112,4 +113,22 @@ export interface RouterContext {
112
113
  isRouting: () => boolean;
113
114
  renderPath(path: string): string;
114
115
  parsePath(str: string): string;
116
+ beforeLeave: BeforeLeaveLifecycle;
117
+ }
118
+ export interface BeforeLeaveEventArgs {
119
+ from: Location;
120
+ to: string | number;
121
+ options?: Partial<NavigateOptions>;
122
+ readonly defaultPrevented: boolean;
123
+ preventDefault(): void;
124
+ retry(force?: boolean): void;
125
+ }
126
+ export interface BeforeLeaveListener {
127
+ listener(e: BeforeLeaveEventArgs): void;
128
+ location: Location;
129
+ navigate: Navigator;
130
+ }
131
+ export interface BeforeLeaveLifecycle {
132
+ subscribe(listener: BeforeLeaveListener): () => void;
133
+ confirm(to: string | number, options?: Partial<NavigateOptions>): boolean;
115
134
  }
package/dist/utils.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Params, PathMatch, Route, SetParams } from "./types";
2
+ export declare function normalizePath(path: string, omitSlash?: boolean): string;
2
3
  export declare function resolvePath(base: string, path: string, from?: string): string | undefined;
3
4
  export declare function invariant<T>(value: T | null | undefined, message: string): T;
4
5
  export declare function joinPaths(from: string, to: string): string;
package/dist/utils.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createMemo, getOwner, runWithOwner } from "solid-js";
2
2
  const hasSchemeRegex = /^(?:[a-z0-9]+:)?\/\//i;
3
3
  const trimPathRegex = /^\/+|\/+$/g;
4
- function normalize(path, omitSlash = false) {
4
+ export function normalizePath(path, omitSlash = false) {
5
5
  const s = path.replace(trimPathRegex, "");
6
6
  return s ? (omitSlash || /^[?#]/.test(s) ? s : "/" + s) : "";
7
7
  }
@@ -9,8 +9,8 @@ export function resolvePath(base, path, from) {
9
9
  if (hasSchemeRegex.test(path)) {
10
10
  return undefined;
11
11
  }
12
- const basePath = normalize(base);
13
- const fromPath = from && normalize(from);
12
+ const basePath = normalizePath(base);
13
+ const fromPath = from && normalizePath(from);
14
14
  let result = "";
15
15
  if (!fromPath || path.startsWith("/")) {
16
16
  result = basePath;
@@ -21,7 +21,7 @@ export function resolvePath(base, path, from) {
21
21
  else {
22
22
  result = fromPath;
23
23
  }
24
- return (result || "/") + normalize(path, !result);
24
+ return (result || "/") + normalizePath(path, !result);
25
25
  }
26
26
  export function invariant(value, message) {
27
27
  if (value == null) {
@@ -30,7 +30,7 @@ export function invariant(value, message) {
30
30
  return value;
31
31
  }
32
32
  export function joinPaths(from, to) {
33
- return normalize(from).replace(/\/*(\*.*)?$/g, "") + normalize(to);
33
+ return normalizePath(from).replace(/\/*(\*.*)?$/g, "") + normalizePath(to);
34
34
  }
35
35
  export function extractSearchParams(url) {
36
36
  const params = {};
package/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "Ryan Turnquist"
7
7
  ],
8
8
  "license": "MIT",
9
- "version": "0.5.0",
9
+ "version": "0.5.1",
10
10
  "homepage": "https://github.com/solidjs/solid-router#readme",
11
11
  "repository": {
12
12
  "type": "git",
@@ -46,7 +46,7 @@
46
46
  "@types/node": "^18.7.14",
47
47
  "babel-preset-solid": "^1.5.3",
48
48
  "jest": "^29.0.1",
49
- "jest-environment-jsdom": "^29.1.2",
49
+ "jest-environment-jsdom": "^29.2.1",
50
50
  "prettier": "^2.7.1",
51
51
  "rollup": "^2.79.0",
52
52
  "rollup-plugin-terser": "^7.0.2",