@solidjs/router 0.17.0-next.2 → 0.17.0-next.4

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.
@@ -1,6 +1,6 @@
1
- import type { JSX } from "solid-js";
1
+ import type { JSX } from "@solidjs/web";
2
2
  import type { Location, Navigator } from "./types.js";
3
- declare module "solid-js" {
3
+ declare module "@solidjs/web/jsx-runtime" {
4
4
  namespace JSX {
5
5
  interface AnchorHTMLAttributes<T> {
6
6
  state?: string;
@@ -26,8 +26,9 @@ export function A(props) {
26
26
  const to_ = to();
27
27
  if (to_ === undefined)
28
28
  return [false, false];
29
- const path = normalizePath(to_.split(/[?#]/, 1)[0]).toLowerCase();
30
- const loc = decodeURI(normalizePath(location.pathname).toLowerCase());
29
+ // trailing slashes are ignored so `/route` and `/route/` share active state
30
+ const path = normalizePath(to_.split(/[?#]/, 1)[0]).toLowerCase().replace(/\/$/, "");
31
+ const loc = decodeURI(normalizePath(location.pathname).toLowerCase().replace(/\/$/, ""));
31
32
  return [props.end ? path === loc : loc.startsWith(path + "/") || loc === path, path === loc];
32
33
  });
33
34
  const className = createMemo(() => [toClassName(props.class), isActive()[0] ? props.activeClass : props.inactiveClass]
@@ -1,4 +1,4 @@
1
- import { JSX } from "solid-js";
1
+ import { type JSX } from "@solidjs/web";
2
2
  import type { Submission, NarrowResponse } from "../types.js";
3
3
  export type Action<T extends Array<any>, U, V = T> = (T extends [FormData | URLSearchParams] | [] ? JSX.SerializableAttributeValue : unknown) & ((...vars: T) => Promise<NarrowResponse<U>>) & {
4
4
  url: string;
@@ -122,7 +122,11 @@ function toAction(invoke, url, boundArgs = [], base = url, submitHooks = new Map
122
122
  fn[invokeSymbol] = invoke;
123
123
  if (!isServer) {
124
124
  actions.set(url, fn);
125
- getOwner() && onCleanup(() => actions.delete(url));
125
+ // Only remove the registration if it still belongs to this instance —
126
+ // a re-created action (e.g. a new `.with()` binding after revalidation)
127
+ // may have registered itself under the same URL since.
128
+ getOwner() &&
129
+ onCleanup(() => actions.get(url) === fn && actions.delete(url));
126
130
  }
127
131
  return fn;
128
132
  }
@@ -113,7 +113,7 @@ export function query(fn, name) {
113
113
  !isServer && intent === "navigate" && cached[4][1](cached[0]); // update version
114
114
  }
115
115
  else {
116
- cache.set(key, (cached = [now, res, , intent, createSignal(now)]));
116
+ cache.set(key, (cached = [now, res, , intent, createSignal(now, { ownedWrite: true })]));
117
117
  cached[4].count = 0;
118
118
  }
119
119
  if (tracking) {
@@ -193,7 +193,7 @@ query.set = (key, value) => {
193
193
  cached[3] = "preload";
194
194
  }
195
195
  else {
196
- cache.set(key, (cached = [now, Promise.resolve(value), value, "preload", createSignal(now)]));
196
+ cache.set(key, (cached = [now, Promise.resolve(value), value, "preload", createSignal(now, { ownedWrite: true })]));
197
197
  cached[4].count = 0;
198
198
  }
199
199
  };
package/dist/index.js CHANGED
@@ -17,7 +17,14 @@ function createBeforeLeave() {
17
17
  preventDefault: () => e.defaultPrevented = true
18
18
  };
19
19
  for (const l of listeners) l.listener({
20
- ...e,
20
+ to,
21
+ options,
22
+ // delegate to the shared event so later listeners' preventDefault
23
+ // calls are observable from earlier listeners
24
+ get defaultPrevented() {
25
+ return e.defaultPrevented;
26
+ },
27
+ preventDefault: e.preventDefault,
21
28
  from: l.location,
22
29
  retry: force => {
23
30
  force && (ignore = true);
@@ -121,7 +128,12 @@ function createMatcher(path, partial, matchFilters) {
121
128
  const segments = pattern.split("/").filter(Boolean);
122
129
  const len = segments.length;
123
130
  return location => {
124
- const locSegments = location.split("/").filter(Boolean);
131
+ const locSegments = location.split("/");
132
+ // tolerate a single leading and trailing slash, but reject empty interior
133
+ // segments so `/foo//bar` doesn't silently match `/foo/bar` (#567)
134
+ if (locSegments[0] === "") locSegments.shift();
135
+ if (locSegments.length && locSegments[locSegments.length - 1] === "") locSegments.pop();
136
+ if (locSegments.includes("")) return null;
125
137
  const lenDiff = locSegments.length - len;
126
138
  if (lenDiff < 0 || lenDiff > 0 && splat === undefined && !partial) {
127
139
  return null;
@@ -370,7 +382,11 @@ const useMatch = (path, matchFilters) => {
370
382
  * const breadcrumbs = createMemo(() => matches().map(m => m.route.info.breadcrumb))
371
383
  * ```
372
384
  */
373
- const useCurrentMatches = () => useRouter().matches;
385
+ const useCurrentMatches = () => {
386
+ const router = useRouter();
387
+ // return a copy so user mutations (eg. `.reverse()`) can't corrupt router state
388
+ return () => router.matches().slice();
389
+ };
374
390
 
375
391
  /**
376
392
  * Retrieves a reactive, store-like object containing the current route path parameters as defined in the Route.
@@ -383,7 +399,7 @@ const useCurrentMatches = () => useRouter().matches;
383
399
  * const getUser = query(() => fetchUser(params.id), "user");
384
400
  * ```
385
401
  */
386
- const useParams = () => useRouter().params;
402
+ const useParams = () => useRoute().params;
387
403
 
388
404
  /**
389
405
  * Retrieves a tuple containing a reactive object to read the current location's query parameters and a method to update them.
@@ -413,11 +429,20 @@ const useParams = () => useRouter().params;
413
429
  * ```
414
430
  */
415
431
  const useSearchParams = () => {
416
- const location = useLocation();
432
+ const router = useRouter();
433
+ const location = router.location;
417
434
  const navigate = useNavigate();
418
435
  const setSearchParams = (params, options) => {
419
- const searchString = untrack(() => mergeSearchString(location.search, params) + location.hash);
420
- navigate(searchString, {
436
+ const to = untrack(() => {
437
+ // merge onto the in-flight navigation target (if any) so consecutive
438
+ // synchronous calls compose instead of the later one winning
439
+ const pending = router.pendingTarget && new URL(router.pendingTarget.value, mockBase);
440
+ const pathname = pending ? pending.pathname : location.pathname;
441
+ const search = pending ? pending.search : location.search;
442
+ const hash = pending ? pending.hash : location.hash;
443
+ return pathname + mergeSearchString(search, params) + hash;
444
+ });
445
+ navigate(to, {
421
446
  scroll: false,
422
447
  resolve: false,
423
448
  ...options
@@ -461,6 +486,12 @@ const useBeforeLeave = listener => {
461
486
  });
462
487
  onCleanup(s);
463
488
  };
489
+
490
+ // Encodes a static path segment like `encodeURIComponent`, but leaves RFC 3986
491
+ // pchar characters (sub-delims / ":" / "@") literal, matching how browsers
492
+ // report them in `location.pathname`. Non-ASCII characters (eg. CJK paths) are
493
+ // still percent-encoded exactly as before, since browsers encode those too.
494
+ const encodeSegment = s => encodeURIComponent(s).replace(/%(2B|40|3A|24|26|2C|3B|3D)/g, m => decodeURIComponent(m));
464
495
  function createRoutes(routeDef, base = "") {
465
496
  const {
466
497
  component,
@@ -480,7 +511,7 @@ function createRoutes(routeDef, base = "") {
480
511
  const path = joinPaths(base, expandedPath);
481
512
  let pattern = isLeaf ? path : path.split("/*", 1)[0];
482
513
  pattern = pattern.split("/").map(s => {
483
- return s.startsWith(":") || s.startsWith("*") ? s : encodeURIComponent(s);
514
+ return s.startsWith(":") || s.startsWith("*") ? s : encodeSegment(s);
484
515
  }).join("/");
485
516
  acc.push({
486
517
  ...shared,
@@ -549,12 +580,21 @@ function getRouteMatches(branches, location) {
549
580
  }
550
581
  return [];
551
582
  }
583
+ function mergeParams(matches) {
584
+ const params = {};
585
+ for (let i = 0; i < matches.length; i++) {
586
+ Object.assign(params, matches[i].params);
587
+ }
588
+ return params;
589
+ }
552
590
  function createLocation(path, state, queryWrapper) {
553
591
  const origin = new URL(mockBase);
554
592
  const url = createMemo((prev = origin) => {
555
593
  const path_ = path();
556
594
  try {
557
- return new URL(path_, origin);
595
+ // anchor rooted paths against the origin explicitly - a path with
596
+ // doubled leading slashes would otherwise parse as protocol-relative
597
+ return new URL(path_[0] === "/" ? mockBase + path_ : path_, origin);
558
598
  } catch (err) {
559
599
  console.error(`Invalid path ${path_}`);
560
600
  return prev;
@@ -642,17 +682,12 @@ function createRouterContext(integration, branches, getContext, options = {}) {
642
682
  }
643
683
  return getRouteMatches(branches(), location.pathname);
644
684
  });
645
- const buildParams = () => {
646
- const m = matches();
647
- const params = {};
648
- for (let i = 0; i < m.length; i++) {
649
- Object.assign(params, m[i].params);
650
- }
651
- return params;
652
- };
653
- const params = utils.paramsWrapper ? utils.paramsWrapper(buildParams, branches) : createMemoObject(buildParams);
685
+ const buildParams = () => mergeParams(matches());
686
+ const wrapParams = utils.paramsWrapper ? getParams => utils.paramsWrapper(getParams, branches) : getParams => createMemoObject(getParams);
687
+ const params = wrapParams(buildParams);
654
688
  const baseRoute = {
655
689
  pattern: basePath,
690
+ params,
656
691
  path: () => basePath,
657
692
  outlet: () => null,
658
693
  resolvePath(to) {
@@ -663,7 +698,11 @@ function createRouterContext(integration, branches, getContext, options = {}) {
663
698
  base: baseRoute,
664
699
  location,
665
700
  params,
701
+ wrapParams,
666
702
  isRouting,
703
+ get pendingTarget() {
704
+ return lastTransitionTarget;
705
+ },
667
706
  renderPath,
668
707
  parsePath,
669
708
  navigatorFactory,
@@ -808,11 +847,11 @@ function createRouterContext(integration, branches, getContext, options = {}) {
808
847
  }];
809
848
  }
810
849
  }
811
- function createRouteContext(router, parent, outlet, match) {
850
+ function createRouteContext(router, parent, outlet, match, matches = () => [match()]) {
812
851
  const {
813
852
  base,
814
853
  location,
815
- params
854
+ wrapParams
816
855
  } = router;
817
856
  const {
818
857
  pattern,
@@ -820,6 +859,10 @@ function createRouteContext(router, parent, outlet, match) {
820
859
  preload
821
860
  } = match().route;
822
861
  const path = createMemo(() => match().path);
862
+ // Params scoped to this route's lifetime. `matches` is expected to retain
863
+ // its last valid value while this route is being torn down, so outgoing
864
+ // components and preloads never observe another route's params.
865
+ const params = wrapParams(() => mergeParams(matches()));
823
866
  component && component.preload && component.preload();
824
867
  inPreloadFn = true;
825
868
  const data = preload ? preload({
@@ -831,6 +874,7 @@ function createRouteContext(router, parent, outlet, match) {
831
874
  const route = {
832
875
  parent,
833
876
  pattern,
877
+ params,
834
878
  path,
835
879
  outlet: () => component ? createComponent(component, {
836
880
  params,
@@ -940,6 +984,13 @@ function Routes(props) {
940
984
  const disposers = [];
941
985
  let root;
942
986
  let prevMatches;
987
+ // dispose the detached per-route roots when this component unmounts, otherwise
988
+ // they stay subscribed to `matches` and crash on a later navigation (#451)
989
+ onCleanup(() => disposers.forEach(dispose => dispose()));
990
+ // Route roots must outlive re-runs of the `routeStates` memo below, so they
991
+ // are created under the owner of this component rather than the memo's
992
+ // computation (which disposes its children every time it re-runs).
993
+ const owner = getOwner();
943
994
  const routeStates = createMemo(prev => {
944
995
  const nextMatches = props.routerState.matches();
945
996
  const previousMatches = prevMatches;
@@ -955,13 +1006,19 @@ function Routes(props) {
955
1006
  if (disposers[i]) {
956
1007
  disposers[i]();
957
1008
  }
958
- createRoot(dispose => {
1009
+ runWithOwner(owner, () => createRoot(dispose => {
959
1010
  disposers[i] = dispose;
960
- next[i] = createRouteContext(props.routerState, next[i - 1] || props.routerState.base, createOutlet(() => routeStates()?.[i + 1]), () => {
1011
+ const routeKey = nextMatch.route.key;
1012
+ // Retain the last matches in which this route participated so
1013
+ // that its components and preloads never observe another
1014
+ // route's params/path while this route is being torn down.
1015
+ const matchesAtLevel = createMemo(prev => {
961
1016
  const routeMatches = props.routerState.matches();
962
- return routeMatches[i] ?? routeMatches[0];
1017
+ const m = routeMatches[i];
1018
+ return m && m.route.key === routeKey ? routeMatches : prev || nextMatches;
963
1019
  });
964
- });
1020
+ next[i] = createRouteContext(props.routerState, next[i - 1] || props.routerState.base, createOutlet(() => routeStates()?.[i + 1]), () => matchesAtLevel()[i], matchesAtLevel);
1021
+ }));
965
1022
  }
966
1023
  }
967
1024
  disposers.splice(nextMatches.length).forEach(dispose => dispose());
@@ -1168,7 +1225,9 @@ function query(fn, name) {
1168
1225
  cached[3] = intent;
1169
1226
  !isServer && intent === "navigate" && cached[4][1](cached[0]); // update version
1170
1227
  } else {
1171
- cache.set(key, cached = [now, res,, intent, createSignal(now)]);
1228
+ cache.set(key, cached = [now, res,, intent, createSignal(now, {
1229
+ ownedWrite: true
1230
+ })]);
1172
1231
  cached[4].count = 0;
1173
1232
  }
1174
1233
  if (tracking) {
@@ -1232,7 +1291,9 @@ query.set = (key, value) => {
1232
1291
  cached[2] = value;
1233
1292
  cached[3] = "preload";
1234
1293
  } else {
1235
- cache.set(key, cached = [now, Promise.resolve(value), value, "preload", createSignal(now)]);
1294
+ cache.set(key, cached = [now, Promise.resolve(value), value, "preload", createSignal(now, {
1295
+ ownedWrite: true
1296
+ })]);
1236
1297
  cached[4].count = 0;
1237
1298
  }
1238
1299
  };
@@ -1382,7 +1443,10 @@ function toAction(invoke, url, boundArgs = [], base = url, submitHooks = new Map
1382
1443
  fn[invokeSymbol] = invoke;
1383
1444
  if (!isServer) {
1384
1445
  actions.set(url, fn);
1385
- getOwner() && onCleanup(() => actions.delete(url));
1446
+ // Only remove the registration if it still belongs to this instance —
1447
+ // a re-created action (e.g. a new `.with()` binding after revalidation)
1448
+ // may have registered itself under the same URL since.
1449
+ getOwner() && onCleanup(() => actions.get(url) === fn && actions.delete(url));
1386
1450
  }
1387
1451
  return fn;
1388
1452
  }
@@ -1552,7 +1616,7 @@ function setupNativeEvents({
1552
1616
  function Router(props) {
1553
1617
  if (isServer) return StaticRouter(props);
1554
1618
  const getSource = () => {
1555
- const url = window.location.pathname.replace(/^\/+/, "/") + window.location.search;
1619
+ const url = window.location.pathname + window.location.search;
1556
1620
  const state = window.history.state && window.history.state._depth && Object.keys(window.history.state).length === 1 ? undefined : window.history.state;
1557
1621
  return {
1558
1622
  value: url + window.location.hash,
@@ -1710,6 +1774,9 @@ function MemoryRouter(props) {
1710
1774
  }
1711
1775
 
1712
1776
  var _tmpl$ = /*#__PURE__*/template(`<a>`);
1777
+
1778
+ // the JSX namespace lives in @solidjs/web's jsx-runtime module as of 2.0
1779
+
1713
1780
  function toClassName(value) {
1714
1781
  if (!value) return "";
1715
1782
  if (typeof value === "string" || typeof value === "number") return String(value);
@@ -1731,30 +1798,29 @@ function A(props) {
1731
1798
  const isActive = createMemo(() => {
1732
1799
  const to_ = to();
1733
1800
  if (to_ === undefined) return [false, false];
1734
- const path = normalizePath(to_.split(/[?#]/, 1)[0]).toLowerCase();
1735
- const loc = decodeURI(normalizePath(location.pathname).toLowerCase());
1801
+ // trailing slashes are ignored so `/route` and `/route/` share active state
1802
+ const path = normalizePath(to_.split(/[?#]/, 1)[0]).toLowerCase().replace(/\/$/, "");
1803
+ const loc = decodeURI(normalizePath(location.pathname).toLowerCase().replace(/\/$/, ""));
1736
1804
  return [props.end ? path === loc : loc.startsWith(path + "/") || loc === path, path === loc];
1737
1805
  });
1738
1806
  const className = createMemo(() => [toClassName(props.class), isActive()[0] ? props.activeClass : props.inactiveClass].filter(Boolean).join(" "));
1739
- return (() => {
1740
- var _el$ = _tmpl$();
1741
- spread(_el$, mergeProps(rest, {
1742
- get href() {
1743
- return href() || props.href;
1744
- },
1745
- get state() {
1746
- return JSON.stringify(props.state);
1747
- },
1748
- get ["class"]() {
1749
- return className();
1750
- },
1751
- "link": true,
1752
- get ["aria-current"]() {
1753
- return isActive()[1] ? "page" : undefined;
1754
- }
1755
- }), false);
1756
- return _el$;
1757
- })();
1807
+ var _el$ = _tmpl$();
1808
+ spread(_el$, mergeProps(rest, {
1809
+ get href() {
1810
+ return href() || props.href;
1811
+ },
1812
+ get state() {
1813
+ return JSON.stringify(props.state);
1814
+ },
1815
+ get ["class"]() {
1816
+ return className();
1817
+ },
1818
+ "link": true,
1819
+ get ["aria-current"]() {
1820
+ return isActive()[1] ? "page" : undefined;
1821
+ }
1822
+ }), false);
1823
+ return _el$;
1758
1824
  }
1759
1825
  function Navigate(props) {
1760
1826
  const navigate = useNavigate();
package/dist/lifecycle.js CHANGED
@@ -17,7 +17,14 @@ export function createBeforeLeave() {
17
17
  };
18
18
  for (const l of listeners)
19
19
  l.listener({
20
- ...e,
20
+ to,
21
+ options,
22
+ // delegate to the shared event so later listeners' preventDefault
23
+ // calls are observable from earlier listeners
24
+ get defaultPrevented() {
25
+ return e.defaultPrevented;
26
+ },
27
+ preventDefault: e.preventDefault,
21
28
  from: l.location,
22
29
  retry: (force) => {
23
30
  force && (ignore = true);
@@ -1,4 +1,4 @@
1
- import type { JSX } from "solid-js";
1
+ import type { JSX } from "@solidjs/web";
2
2
  import type { BaseRouterProps } from "./components.jsx";
3
3
  export declare function hashParser(str: string): string;
4
4
  export type HashRouterProps = BaseRouterProps & {
@@ -1,6 +1,6 @@
1
1
  import type { LocationChange } from "../types.js";
2
2
  import type { BaseRouterProps } from "./components.jsx";
3
- import type { JSX } from "solid-js";
3
+ import type { JSX } from "@solidjs/web";
4
4
  export type MemoryHistory = {
5
5
  get: () => string;
6
6
  set: (change: LocationChange) => void;
@@ -1,5 +1,5 @@
1
1
  import type { BaseRouterProps } from "./components.jsx";
2
- import type { JSX } from "solid-js";
2
+ import type { JSX } from "@solidjs/web";
3
3
  export type RouterProps = BaseRouterProps & {
4
4
  url?: string;
5
5
  actionBase?: string;
@@ -7,7 +7,7 @@ export function Router(props) {
7
7
  if (isServer)
8
8
  return StaticRouter(props);
9
9
  const getSource = () => {
10
- const url = window.location.pathname.replace(/^\/+/, "/") + window.location.search;
10
+ const url = window.location.pathname + window.location.search;
11
11
  const state = window.history.state && window.history.state._depth && Object.keys(window.history.state).length === 1 ? undefined : window.history.state;
12
12
  return {
13
13
  value: url + window.location.hash,
@@ -1,5 +1,5 @@
1
1
  import { type BaseRouterProps } from "./components.jsx";
2
- import type { JSX } from "solid-js";
2
+ import type { JSX } from "@solidjs/web";
3
3
  export type StaticRouterProps = BaseRouterProps & {
4
4
  url?: string;
5
5
  };
@@ -1,5 +1,6 @@
1
- import type { Component, JSX } from "solid-js";
2
- import type { MatchFilters, RouteDefinition, RoutePreloadFunc, RouterIntegration, RouteSectionProps } from "../types.js";
1
+ import type { Component } from "solid-js";
2
+ import type { JSX } from "@solidjs/web";
3
+ import type { MatchFilters, RouteDefinition, RoutePreloadFunc, RouterIntegration, RouteSectionComponent, RouteSectionProps } from "../types.js";
3
4
  export type BaseRouterProps = {
4
5
  base?: string;
5
6
  /**
@@ -17,7 +18,7 @@ export type RouteProps<S extends string, T = unknown> = {
17
18
  children?: JSX.Element;
18
19
  preload?: RoutePreloadFunc<T>;
19
20
  matchFilters?: MatchFilters<S>;
20
- component?: Component<RouteSectionProps<T>>;
21
+ component?: RouteSectionComponent<T>;
21
22
  info?: Record<string, any>;
22
23
  };
23
24
  export declare const Route: <S extends string, T = unknown>(props: RouteProps<S, T>) => JSX.Element;
@@ -1,5 +1,5 @@
1
1
  /*@refresh skip*/
2
- import { children, createMemo, createRoot, getOwner, merge, untrack } from "solid-js";
2
+ import { children, createMemo, createRoot, getOwner, merge, onCleanup, runWithOwner, untrack } from "solid-js";
3
3
  import { getRequestEvent, isServer } from "@solidjs/web";
4
4
  import { createBranches, createRouteContext, createRouterContext, getIntent, getRouteMatches, RouteContextObj, RouterContextObj, setInPreloadFn } from "../routing.js";
5
5
  export const createRouterComponent = (router) => function IntegratedRouter(props) {
@@ -64,6 +64,13 @@ function Routes(props) {
64
64
  const disposers = [];
65
65
  let root;
66
66
  let prevMatches;
67
+ // dispose the detached per-route roots when this component unmounts, otherwise
68
+ // they stay subscribed to `matches` and crash on a later navigation (#451)
69
+ onCleanup(() => disposers.forEach(dispose => dispose()));
70
+ // Route roots must outlive re-runs of the `routeStates` memo below, so they
71
+ // are created under the owner of this component rather than the memo's
72
+ // computation (which disposes its children every time it re-runs).
73
+ const owner = getOwner();
67
74
  const routeStates = createMemo((prev) => {
68
75
  const nextMatches = props.routerState.matches();
69
76
  const previousMatches = prevMatches;
@@ -80,13 +87,19 @@ function Routes(props) {
80
87
  if (disposers[i]) {
81
88
  disposers[i]();
82
89
  }
83
- createRoot(dispose => {
90
+ runWithOwner(owner, () => createRoot(dispose => {
84
91
  disposers[i] = dispose;
85
- next[i] = createRouteContext(props.routerState, next[i - 1] || props.routerState.base, createOutlet(() => routeStates()?.[i + 1]), () => {
92
+ const routeKey = nextMatch.route.key;
93
+ // Retain the last matches in which this route participated so
94
+ // that its components and preloads never observe another
95
+ // route's params/path while this route is being torn down.
96
+ const matchesAtLevel = createMemo((prev) => {
86
97
  const routeMatches = props.routerState.matches();
87
- return routeMatches[i] ?? routeMatches[0];
98
+ const m = routeMatches[i];
99
+ return m && m.route.key === routeKey ? routeMatches : prev || nextMatches;
88
100
  });
89
- });
101
+ next[i] = createRouteContext(props.routerState, next[i - 1] || props.routerState.base, createOutlet(() => routeStates()?.[i + 1]), () => matchesAtLevel()[i], matchesAtLevel);
102
+ }));
90
103
  }
91
104
  }
92
105
  disposers.splice(nextMatches.length).forEach(dispose => dispose());
@@ -5,6 +5,6 @@ export declare function createRouter(config: {
5
5
  init?: (notify: (value?: string | LocationChange) => void) => () => void;
6
6
  create?: (router: RouterContext) => void;
7
7
  utils?: Partial<RouterUtils>;
8
- }): (props: import("./components.jsx").BaseRouterProps) => import("solid-js").JSX.Element;
8
+ }): (props: import("./components.jsx").BaseRouterProps) => import("@solidjs/web").JSX.Element;
9
9
  export declare function bindEvent(target: EventTarget, type: string, handler: EventListener): () => void;
10
10
  export declare function scrollToHash(hash: string, fallbackTop?: boolean): void;
package/dist/routing.d.ts CHANGED
@@ -1,11 +1,11 @@
1
- import { JSX, Accessor } from "solid-js";
1
+ import type { JSX } from "@solidjs/web";
2
2
  import type { BeforeLeaveEventArgs, Branch, Intent, Location, MatchFilters, NavigateOptions, Navigator, Params, RouteDescription, RouteContext, RouteDefinition, RouteMatch, RouterContext, RouterIntegration, SearchParams, SetSearchParams } from "./types.js";
3
3
  /** Consider this API opaque and internal. It is likely to change in the future. */
4
- export declare const RouterContextObj: import("solid-js").Context<RouterContext | undefined>;
5
- export declare const RouteContextObj: import("solid-js").Context<RouteContext | undefined>;
4
+ export declare const RouterContextObj: import("solid-js").Context<RouterContext>;
5
+ export declare const RouteContextObj: import("solid-js").Context<RouteContext>;
6
6
  export declare const useRouter: () => RouterContext;
7
7
  export declare const useRoute: () => RouteContext;
8
- export declare const useResolvedPath: (path: () => string) => Accessor<string | undefined>;
8
+ export declare const useResolvedPath: (path: () => string) => import("solid-js").SourceAccessor<string | undefined>;
9
9
  export declare const useHref: <T extends string | undefined>(to: () => T) => () => string | T;
10
10
  /**
11
11
  * Retrieves method to do navigation. The method accepts a path to navigate to and an optional object with the following options:
@@ -79,7 +79,7 @@ export declare const usePreloadRoute: () => (url: string | URL, options?: {
79
79
  * return <div classList={{ active: Boolean(match()) }} />;
80
80
  * ```
81
81
  */
82
- export declare const useMatch: <S extends string>(path: () => S, matchFilters?: MatchFilters<S>) => Accessor<import("./types.js").PathMatch | undefined>;
82
+ export declare const useMatch: <S extends string>(path: () => S, matchFilters?: MatchFilters<S>) => import("solid-js").SourceAccessor<import("./types.js").PathMatch | undefined>;
83
83
  /**
84
84
  * `useCurrentMatches` returns all the matches for the current matched route.
85
85
  * Useful for getting all the route information.
@@ -172,4 +172,4 @@ export declare function createRouterContext(integration: RouterIntegration, bran
172
172
  singleFlight?: boolean;
173
173
  transformUrl?: (url: string) => string;
174
174
  }): RouterContext;
175
- export declare function createRouteContext(router: RouterContext, parent: RouteContext, outlet: () => JSX.Element, match: () => RouteMatch): RouteContext;
175
+ export declare function createRouteContext(router: RouterContext, parent: RouteContext, outlet: () => JSX.Element, match: () => RouteMatch, matches?: () => RouteMatch[]): RouteContext;
package/dist/routing.js CHANGED
@@ -124,7 +124,11 @@ export const useMatch = (path, matchFilters) => {
124
124
  * const breadcrumbs = createMemo(() => matches().map(m => m.route.info.breadcrumb))
125
125
  * ```
126
126
  */
127
- export const useCurrentMatches = () => useRouter().matches;
127
+ export const useCurrentMatches = () => {
128
+ const router = useRouter();
129
+ // return a copy so user mutations (eg. `.reverse()`) can't corrupt router state
130
+ return () => router.matches().slice();
131
+ };
128
132
  /**
129
133
  * Retrieves a reactive, store-like object containing the current route path parameters as defined in the Route.
130
134
  *
@@ -136,7 +140,7 @@ export const useCurrentMatches = () => useRouter().matches;
136
140
  * const getUser = query(() => fetchUser(params.id), "user");
137
141
  * ```
138
142
  */
139
- export const useParams = () => useRouter().params;
143
+ export const useParams = () => useRoute().params;
140
144
  /**
141
145
  * Retrieves a tuple containing a reactive object to read the current location's query parameters and a method to update them.
142
146
  * The object is a proxy so you must access properties to subscribe to reactive updates.
@@ -165,11 +169,20 @@ export const useParams = () => useRouter().params;
165
169
  * ```
166
170
  */
167
171
  export const useSearchParams = () => {
168
- const location = useLocation();
172
+ const router = useRouter();
173
+ const location = router.location;
169
174
  const navigate = useNavigate();
170
175
  const setSearchParams = (params, options) => {
171
- const searchString = untrack(() => mergeSearchString(location.search, params) + location.hash);
172
- navigate(searchString, {
176
+ const to = untrack(() => {
177
+ // merge onto the in-flight navigation target (if any) so consecutive
178
+ // synchronous calls compose instead of the later one winning
179
+ const pending = router.pendingTarget && new URL(router.pendingTarget.value, mockBase);
180
+ const pathname = pending ? pending.pathname : location.pathname;
181
+ const search = pending ? pending.search : location.search;
182
+ const hash = pending ? pending.hash : location.hash;
183
+ return pathname + mergeSearchString(search, params) + hash;
184
+ });
185
+ navigate(to, {
173
186
  scroll: false,
174
187
  resolve: false,
175
188
  ...options
@@ -212,6 +225,11 @@ export const useBeforeLeave = (listener) => {
212
225
  });
213
226
  onCleanup(s);
214
227
  };
228
+ // Encodes a static path segment like `encodeURIComponent`, but leaves RFC 3986
229
+ // pchar characters (sub-delims / ":" / "@") literal, matching how browsers
230
+ // report them in `location.pathname`. Non-ASCII characters (eg. CJK paths) are
231
+ // still percent-encoded exactly as before, since browsers encode those too.
232
+ const encodeSegment = (s) => encodeURIComponent(s).replace(/%(2B|40|3A|24|26|2C|3B|3D)/g, m => decodeURIComponent(m));
215
233
  export function createRoutes(routeDef, base = "") {
216
234
  const { component, preload, children, info } = routeDef;
217
235
  const isLeaf = !children || (Array.isArray(children) && !children.length);
@@ -228,7 +246,7 @@ export function createRoutes(routeDef, base = "") {
228
246
  pattern = pattern
229
247
  .split("/")
230
248
  .map((s) => {
231
- return s.startsWith(":") || s.startsWith("*") ? s : encodeURIComponent(s);
249
+ return s.startsWith(":") || s.startsWith("*") ? s : encodeSegment(s);
232
250
  })
233
251
  .join("/");
234
252
  acc.push({
@@ -299,12 +317,21 @@ export function getRouteMatches(branches, location) {
299
317
  }
300
318
  return [];
301
319
  }
320
+ function mergeParams(matches) {
321
+ const params = {};
322
+ for (let i = 0; i < matches.length; i++) {
323
+ Object.assign(params, matches[i].params);
324
+ }
325
+ return params;
326
+ }
302
327
  function createLocation(path, state, queryWrapper) {
303
328
  const origin = new URL(mockBase);
304
329
  const url = createMemo((prev = origin) => {
305
330
  const path_ = path();
306
331
  try {
307
- return new URL(path_, origin);
332
+ // anchor rooted paths against the origin explicitly - a path with
333
+ // doubled leading slashes would otherwise parse as protocol-relative
334
+ return new URL(path_[0] === "/" ? mockBase + path_ : path_, origin);
308
335
  }
309
336
  catch (err) {
310
337
  console.error(`Invalid path ${path_}`);
@@ -382,19 +409,14 @@ export function createRouterContext(integration, branches, getContext, options =
382
409
  }
383
410
  return getRouteMatches(branches(), location.pathname);
384
411
  });
385
- const buildParams = () => {
386
- const m = matches();
387
- const params = {};
388
- for (let i = 0; i < m.length; i++) {
389
- Object.assign(params, m[i].params);
390
- }
391
- return params;
392
- };
393
- const params = utils.paramsWrapper
394
- ? utils.paramsWrapper(buildParams, branches)
395
- : createMemoObject(buildParams);
412
+ const buildParams = () => mergeParams(matches());
413
+ const wrapParams = utils.paramsWrapper
414
+ ? (getParams) => utils.paramsWrapper(getParams, branches)
415
+ : (getParams) => createMemoObject(getParams);
416
+ const params = wrapParams(buildParams);
396
417
  const baseRoute = {
397
418
  pattern: basePath,
419
+ params,
398
420
  path: () => basePath,
399
421
  outlet: () => null,
400
422
  resolvePath(to) {
@@ -405,7 +427,11 @@ export function createRouterContext(integration, branches, getContext, options =
405
427
  base: baseRoute,
406
428
  location,
407
429
  params,
430
+ wrapParams,
408
431
  isRouting,
432
+ get pendingTarget() {
433
+ return lastTransitionTarget;
434
+ },
409
435
  renderPath,
410
436
  parsePath,
411
437
  navigatorFactory,
@@ -539,10 +565,14 @@ export function createRouterContext(integration, branches, getContext, options =
539
565
  ];
540
566
  }
541
567
  }
542
- export function createRouteContext(router, parent, outlet, match) {
543
- const { base, location, params } = router;
568
+ export function createRouteContext(router, parent, outlet, match, matches = () => [match()]) {
569
+ const { base, location, wrapParams } = router;
544
570
  const { pattern, component, preload } = match().route;
545
571
  const path = createMemo(() => match().path);
572
+ // Params scoped to this route's lifetime. `matches` is expected to retain
573
+ // its last valid value while this route is being torn down, so outgoing
574
+ // components and preloads never observe another route's params.
575
+ const params = wrapParams(() => mergeParams(matches()));
546
576
  component &&
547
577
  component.preload &&
548
578
  component.preload();
@@ -552,6 +582,7 @@ export function createRouteContext(router, parent, outlet, match) {
552
582
  const route = {
553
583
  parent,
554
584
  pattern,
585
+ params,
555
586
  path,
556
587
  outlet: () => component
557
588
  ? createComponent(component, {
package/dist/types.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- import type { Component, JSX, Signal } from "solid-js";
1
+ import type { Component, Signal } from "solid-js";
2
+ import type { JSX } from "@solidjs/web";
2
3
  declare module "@solidjs/web" {
3
4
  interface RequestEvent {
4
5
  response: {
@@ -55,7 +56,7 @@ export interface LocationChange<S = unknown> {
55
56
  rawPath?: string;
56
57
  }
57
58
  export interface RouterIntegration {
58
- signal: Signal<LocationChange>;
59
+ signal: [get: () => LocationChange, set: (next: LocationChange) => void];
59
60
  create?: (router: RouterContext) => void;
60
61
  utils?: Partial<RouterUtils>;
61
62
  }
@@ -72,12 +73,13 @@ export interface RouteSectionProps<T = unknown> {
72
73
  data: T;
73
74
  children?: JSX.Element;
74
75
  }
75
- export type RouteDefinition<S extends string | string[] = any, T = unknown> = {
76
+ export type RouteSectionComponent<T = unknown> = Component<RouteSectionProps<T>> | Component<Omit<RouteSectionProps<T>, "children">> | Component<{}>;
77
+ export type RouteDefinition<S extends string | string[] = any, T = any> = {
76
78
  path?: S;
77
79
  matchFilters?: MatchFilters<S>;
78
80
  preload?: RoutePreloadFunc<T>;
79
81
  children?: RouteDefinition | RouteDefinition[];
80
- component?: Component<RouteSectionProps<T>>;
82
+ component?: RouteSectionComponent<T>;
81
83
  info?: Record<string, any>;
82
84
  };
83
85
  export type MatchFilter = readonly string[] | RegExp | ((s: string) => boolean);
@@ -118,6 +120,7 @@ export interface RouteContext {
118
120
  parent?: RouteContext;
119
121
  child?: RouteContext;
120
122
  pattern: string;
123
+ params: Params;
121
124
  path: () => string;
122
125
  outlet: () => JSX.Element;
123
126
  resolvePath(to: string): string | undefined;
@@ -134,8 +137,11 @@ export interface RouterContext {
134
137
  base: RouteContext;
135
138
  location: Location;
136
139
  params: Params;
140
+ wrapParams: (getParams: () => Params) => Params;
137
141
  navigatorFactory: NavigatorFactory;
138
142
  isRouting: () => boolean;
143
+ /** The target of the in-flight navigation transition, if any. Not reactive. */
144
+ readonly pendingTarget?: LocationChange;
139
145
  matches: () => RouteMatch[];
140
146
  renderPath(path: string): string;
141
147
  parsePath(str: string): string;
package/dist/utils.js CHANGED
@@ -52,7 +52,15 @@ export function createMatcher(path, partial, matchFilters) {
52
52
  const segments = pattern.split("/").filter(Boolean);
53
53
  const len = segments.length;
54
54
  return (location) => {
55
- const locSegments = location.split("/").filter(Boolean);
55
+ const locSegments = location.split("/");
56
+ // tolerate a single leading and trailing slash, but reject empty interior
57
+ // segments so `/foo//bar` doesn't silently match `/foo/bar` (#567)
58
+ if (locSegments[0] === "")
59
+ locSegments.shift();
60
+ if (locSegments.length && locSegments[locSegments.length - 1] === "")
61
+ locSegments.pop();
62
+ if (locSegments.includes(""))
63
+ return null;
56
64
  const lenDiff = locSegments.length - len;
57
65
  if (lenDiff < 0 || (lenDiff > 0 && splat === undefined && !partial)) {
58
66
  return null;
package/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "Ryan Turnquist"
7
7
  ],
8
8
  "license": "MIT",
9
- "version": "0.17.0-next.2",
9
+ "version": "0.17.0-next.4",
10
10
  "homepage": "https://github.com/solidjs/solid-router#readme",
11
11
  "repository": {
12
12
  "type": "git",
@@ -29,7 +29,7 @@
29
29
  ],
30
30
  "sideEffects": false,
31
31
  "devDependencies": {
32
- "@solidjs/web": "2.0.0-beta.7",
32
+ "@solidjs/web": "2.0.0-beta.18",
33
33
  "@babel/core": "^7.26.0",
34
34
  "@babel/preset-typescript": "^7.26.0",
35
35
  "@changesets/cli": "^2.27.10",
@@ -38,19 +38,19 @@
38
38
  "@rollup/plugin-terser": "0.4.4",
39
39
  "@types/jest": "^29.5.14",
40
40
  "@types/node": "^22.10.0",
41
- "babel-preset-solid": "2.0.0-beta.7",
41
+ "babel-preset-solid": "2.0.0-beta.18",
42
42
  "jsdom": "^25.0.1",
43
43
  "prettier": "^3.4.1",
44
44
  "rollup": "^4.27.4",
45
- "solid-js": "2.0.0-beta.7",
45
+ "solid-js": "2.0.0-beta.18",
46
46
  "typescript": "^5.7.2",
47
47
  "vite": "^6.0.0",
48
- "vite-plugin-solid": "3.0.0-next.4",
48
+ "vite-plugin-solid": "3.0.0-next.10",
49
49
  "vitest": "^2.1.6"
50
50
  },
51
51
  "peerDependencies": {
52
- "@solidjs/web": ">=2.0.0-beta.7 <2.0.0-experimental.0",
53
- "solid-js": ">=2.0.0-beta.7 <2.0.0-experimental.0"
52
+ "@solidjs/web": ">=2.0.0-beta.18 <2.0.0-experimental.0",
53
+ "solid-js": ">=2.0.0-beta.18 <2.0.0-experimental.0"
54
54
  },
55
55
  "scripts": {
56
56
  "build": "rm -rf dist && tsc && rollup -c",