@solidjs/router 0.16.1 → 0.16.3

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.
@@ -18,8 +18,9 @@ export function A(props) {
18
18
  const to_ = to();
19
19
  if (to_ === undefined)
20
20
  return [false, false];
21
- const path = normalizePath(to_.split(/[?#]/, 1)[0]).toLowerCase();
22
- const loc = decodeURI(normalizePath(location.pathname).toLowerCase());
21
+ // trailing slashes are ignored so `/route` and `/route/` share active state
22
+ const path = normalizePath(to_.split(/[?#]/, 1)[0]).toLowerCase().replace(/\/$/, "");
23
+ const loc = decodeURI(normalizePath(location.pathname).toLowerCase().replace(/\/$/, ""));
23
24
  return [props.end ? path === loc : loc.startsWith(path + "/") || loc === path, path === loc];
24
25
  });
25
26
  return (<a {...rest} href={href() || props.href} state={JSON.stringify(props.state)} classList={{
@@ -24,7 +24,7 @@ export function useSubmission(fn, filter) {
24
24
  const submissions = useSubmissions(fn, filter);
25
25
  return new Proxy({}, {
26
26
  get(_, property) {
27
- if ((submissions.length === 0 && property === "clear") || property === "retry")
27
+ if (submissions.length === 0 && (property === "clear" || property === "retry"))
28
28
  return () => { };
29
29
  return submissions[submissions.length - 1]?.[property];
30
30
  }
@@ -118,7 +118,10 @@ function toAction(fn, url) {
118
118
  fn.url = url;
119
119
  if (!isServer) {
120
120
  actions.set(url, fn);
121
- getOwner() && onCleanup(() => actions.delete(url));
121
+ // Only remove the registration if it still belongs to this instance —
122
+ // a re-created action (e.g. a new `.with()` binding after revalidation)
123
+ // may have registered itself under the same URL since.
124
+ getOwner() && onCleanup(() => actions.get(url) === fn && actions.delete(url));
122
125
  }
123
126
  return fn;
124
127
  }
@@ -28,12 +28,12 @@ function getCache() {
28
28
  * Revalidates the given cache entry/entries.
29
29
  */
30
30
  export function revalidate(key, force = true) {
31
+ const now = Date.now();
32
+ // force the cache miss synchronously so a `refetch` in the same tick sees it —
33
+ // startTransition defers its callback to a microtask (#497)
34
+ force && cacheKeyOp(key, entry => (entry[0] = 0));
31
35
  return startTransition(() => {
32
- const now = Date.now();
33
- cacheKeyOp(key, entry => {
34
- force && (entry[0] = 0); //force cache miss
35
- entry[4][1](now); // retrigger live signals
36
- });
36
+ cacheKeyOp(key, entry => entry[4][1](now)); // retrigger live signals
37
37
  });
38
38
  }
39
39
  export function cacheKeyOp(key, fn) {
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { isServer, getRequestEvent, createComponent as createComponent$1, memo, delegateEvents, spread, mergeProps as mergeProps$1, template } from 'solid-js/web';
2
- import { getOwner, runWithOwner, createMemo, createContext, onCleanup, useContext, untrack, createSignal, createRenderEffect, on, startTransition, resetErrorBoundaries, batch, createComponent, children, mergeProps, Show, createRoot, sharedConfig, getListener, $TRACK, splitProps, createResource, catchError } from 'solid-js';
2
+ import { getOwner, runWithOwner, createMemo, createContext, onCleanup, useContext, untrack, createSignal, createRenderEffect, on, startTransition, resetErrorBoundaries, batch, createComponent, children, mergeProps, Show, createRoot, sharedConfig, getListener, $TRACK, createEffect, splitProps, createResource, catchError } from 'solid-js';
3
3
  import { createStore, reconcile, unwrap } from 'solid-js/store';
4
4
 
5
5
  function createBeforeLeave() {
@@ -18,7 +18,14 @@ function createBeforeLeave() {
18
18
  preventDefault: () => e.defaultPrevented = true
19
19
  };
20
20
  for (const l of listeners) l.listener({
21
- ...e,
21
+ to,
22
+ options,
23
+ // delegate to the shared event so later listeners' preventDefault
24
+ // calls are observable from earlier listeners
25
+ get defaultPrevented() {
26
+ return e.defaultPrevented;
27
+ },
28
+ preventDefault: e.preventDefault,
22
29
  from: l.location,
23
30
  retry: force => {
24
31
  force && (ignore = true);
@@ -122,7 +129,12 @@ function createMatcher(path, partial, matchFilters) {
122
129
  const segments = pattern.split("/").filter(Boolean);
123
130
  const len = segments.length;
124
131
  return location => {
125
- const locSegments = location.split("/").filter(Boolean);
132
+ const locSegments = location.split("/");
133
+ // tolerate a single leading and trailing slash, but reject empty interior
134
+ // segments so `/foo//bar` doesn't silently match `/foo/bar` (#567)
135
+ if (locSegments[0] === "") locSegments.shift();
136
+ if (locSegments.length && locSegments[locSegments.length - 1] === "") locSegments.pop();
137
+ if (locSegments.includes("")) return null;
126
138
  const lenDiff = locSegments.length - len;
127
139
  if (lenDiff < 0 || lenDiff > 0 && splat === undefined && !partial) {
128
140
  return null;
@@ -364,7 +376,11 @@ const useMatch = (path, matchFilters) => {
364
376
  * const breadcrumbs = createMemo(() => matches().map(m => m.route.info.breadcrumb))
365
377
  * ```
366
378
  */
367
- const useCurrentMatches = () => useRouter().matches;
379
+ const useCurrentMatches = () => {
380
+ const router = useRouter();
381
+ // return a copy so user mutations (eg. `.reverse()`) can't corrupt router state
382
+ return () => router.matches().slice();
383
+ };
368
384
 
369
385
  /**
370
386
  * Retrieves a reactive, store-like object containing the current route path parameters as defined in the Route.
@@ -407,11 +423,20 @@ const useParams = () => useRouter().params;
407
423
  * ```
408
424
  */
409
425
  const useSearchParams = () => {
410
- const location = useLocation();
426
+ const router = useRouter();
427
+ const location = router.location;
411
428
  const navigate = useNavigate();
412
429
  const setSearchParams = (params, options) => {
413
- const searchString = untrack(() => mergeSearchString(location.search, params) + location.hash);
414
- navigate(searchString, {
430
+ const to = untrack(() => {
431
+ // merge onto the in-flight navigation target (if any) so consecutive
432
+ // synchronous calls compose instead of the later one winning
433
+ const pending = router.pendingTarget && new URL(router.pendingTarget.value, mockBase);
434
+ const pathname = pending ? pending.pathname : location.pathname;
435
+ const search = pending ? pending.search : location.search;
436
+ const hash = pending ? pending.hash : location.hash;
437
+ return pathname + mergeSearchString(search, params) + hash;
438
+ });
439
+ navigate(to, {
415
440
  scroll: false,
416
441
  resolve: false,
417
442
  ...options
@@ -455,6 +480,12 @@ const useBeforeLeave = listener => {
455
480
  });
456
481
  onCleanup(s);
457
482
  };
483
+
484
+ // Encodes a static path segment like `encodeURIComponent`, but leaves RFC 3986
485
+ // pchar characters (sub-delims / ":" / "@") literal, matching how browsers
486
+ // report them in `location.pathname`. Non-ASCII characters (eg. CJK paths) are
487
+ // still percent-encoded exactly as before, since browsers encode those too.
488
+ const encodeSegment = s => encodeURIComponent(s).replace(/%(2B|40|3A|24|26|2C|3B|3D)/g, m => decodeURIComponent(m));
458
489
  function createRoutes(routeDef, base = "") {
459
490
  const {
460
491
  component,
@@ -475,7 +506,7 @@ function createRoutes(routeDef, base = "") {
475
506
  const path = joinPaths(base, expandedPath);
476
507
  let pattern = isLeaf ? path : path.split("/*", 1)[0];
477
508
  pattern = pattern.split("/").map(s => {
478
- return s.startsWith(":") || s.startsWith("*") ? s : encodeURIComponent(s);
509
+ return s.startsWith(":") || s.startsWith("*") ? s : encodeSegment(s);
479
510
  }).join("/");
480
511
  acc.push({
481
512
  ...shared,
@@ -549,7 +580,9 @@ function createLocation(path, state, queryWrapper) {
549
580
  const url = createMemo(prev => {
550
581
  const path_ = path();
551
582
  try {
552
- return new URL(path_, origin);
583
+ // anchor rooted paths against the origin explicitly - a path with
584
+ // doubled leading slashes would otherwise parse as protocol-relative
585
+ return new URL(path_[0] === "/" ? mockBase + path_ : path_, origin);
553
586
  } catch (err) {
554
587
  console.error(`Invalid path ${path_}`);
555
588
  return prev;
@@ -677,6 +710,9 @@ function createRouterContext(integration, branches, getContext, options = {}) {
677
710
  location,
678
711
  params,
679
712
  isRouting,
713
+ get pendingTarget() {
714
+ return lastTransitionTarget;
715
+ },
680
716
  renderPath,
681
717
  parsePath,
682
718
  navigatorFactory,
@@ -925,6 +961,9 @@ function Routes(props) {
925
961
  }
926
962
  const disposers = [];
927
963
  let root;
964
+ // dispose the detached per-route roots when this component unmounts, otherwise
965
+ // they stay subscribed to `matches` and crash on a later navigation (#451)
966
+ onCleanup(() => disposers.forEach(dispose => dispose()));
928
967
  const routeStates = createMemo(on(props.routerState.matches, (nextMatches, prevMatches, prev) => {
929
968
  let equal = prevMatches && nextMatches.length === prevMatches.length;
930
969
  const next = [];
@@ -1078,12 +1117,12 @@ function getCache() {
1078
1117
  * Revalidates the given cache entry/entries.
1079
1118
  */
1080
1119
  function revalidate(key, force = true) {
1120
+ const now = Date.now();
1121
+ // force the cache miss synchronously so a `refetch` in the same tick sees it —
1122
+ // startTransition defers its callback to a microtask (#497)
1123
+ force && cacheKeyOp(key, entry => entry[0] = 0);
1081
1124
  return startTransition(() => {
1082
- const now = Date.now();
1083
- cacheKeyOp(key, entry => {
1084
- force && (entry[0] = 0); //force cache miss
1085
- entry[4][1](now); // retrigger live signals
1086
- });
1125
+ cacheKeyOp(key, entry => entry[4][1](now)); // retrigger live signals
1087
1126
  });
1088
1127
  }
1089
1128
  function cacheKeyOp(key, fn) {
@@ -1265,7 +1304,7 @@ function useSubmission(fn, filter) {
1265
1304
  const submissions = useSubmissions(fn, filter);
1266
1305
  return new Proxy({}, {
1267
1306
  get(_, property) {
1268
- if (submissions.length === 0 && property === "clear" || property === "retry") return () => {};
1307
+ if (submissions.length === 0 && (property === "clear" || property === "retry")) return () => {};
1269
1308
  return submissions[submissions.length - 1]?.[property];
1270
1309
  }
1271
1310
  });
@@ -1356,7 +1395,10 @@ function toAction(fn, url) {
1356
1395
  fn.url = url;
1357
1396
  if (!isServer) {
1358
1397
  actions.set(url, fn);
1359
- getOwner() && onCleanup(() => actions.delete(url));
1398
+ // Only remove the registration if it still belongs to this instance —
1399
+ // a re-created action (e.g. a new `.with()` binding after revalidation)
1400
+ // may have registered itself under the same URL since.
1401
+ getOwner() && onCleanup(() => actions.get(url) === fn && actions.delete(url));
1360
1402
  }
1361
1403
  return fn;
1362
1404
  }
@@ -1508,10 +1550,107 @@ function setupNativeEvents({
1508
1550
  };
1509
1551
  }
1510
1552
 
1553
+ const STORAGE_KEY = "solid-router:scroll";
1554
+
1555
+ /**
1556
+ * Explicit scroll restoration for back/forward navigation. The browser's
1557
+ * native same-document heuristic is unreliable for suspense-driven rendering:
1558
+ * if the destination route forces a layout while the document is still short,
1559
+ * the saved offset for the previous entry is clamped and lost (#577).
1560
+ *
1561
+ * Positions are captured continuously from the scroll event, keyed by the
1562
+ * `_depth` the router already stamps on every history entry — capturing at
1563
+ * scroll time (rather than at exit) stays correct through `useBeforeLeave`
1564
+ * blocked/reverted traversals. The map persists to sessionStorage on pagehide
1565
+ * so restoration survives reloads, which `scrollRestoration = "manual"`
1566
+ * otherwise disables. Restoration runs once routing settles; if the document
1567
+ * is still shorter than the target (a boundary below the fold hasn't
1568
+ * resolved), a ResizeObserver retries as content grows, cancelled by the
1569
+ * first user scroll.
1570
+ */
1571
+ function createScrollRestoration() {
1572
+ window.history.scrollRestoration = "manual";
1573
+ // the current entry needs its depth stamp for captures to have a key, even
1574
+ // if something replaced history.state after the lifecycle module loaded
1575
+ saveCurrentDepth();
1576
+ let positions = {};
1577
+ try {
1578
+ positions = JSON.parse(sessionStorage.getItem(STORAGE_KEY)) || {};
1579
+ } catch {}
1580
+ const depth = () => window.history.state && window.history.state._depth;
1581
+ let programmatic = false;
1582
+ let pending;
1583
+ let disconnect;
1584
+ const cancelGuard = () => {
1585
+ disconnect && disconnect();
1586
+ disconnect = undefined;
1587
+ };
1588
+ const unbind = [bindEvent(window, "scroll", () => {
1589
+ const d = depth();
1590
+ if (d != null) positions[d] = window.scrollY;
1591
+ if (!programmatic) {
1592
+ // the user took over — a pending or chasing restore would yank them
1593
+ pending = undefined;
1594
+ cancelGuard();
1595
+ }
1596
+ }), bindEvent(window, "pagehide", () => {
1597
+ try {
1598
+ sessionStorage.setItem(STORAGE_KEY, JSON.stringify(positions));
1599
+ } catch {}
1600
+ })];
1601
+ const restore = () => {
1602
+ if (pending == null) return;
1603
+ const y = positions[pending];
1604
+ pending = undefined;
1605
+ if (y == null) return;
1606
+ cancelGuard();
1607
+ const attempt = () => {
1608
+ programmatic = true;
1609
+ window.scrollTo(0, y);
1610
+ programmatic = false;
1611
+ // reachable once the document is tall enough to hold the offset
1612
+ return document.documentElement.scrollHeight - window.innerHeight >= y;
1613
+ };
1614
+ if (!attempt() && typeof ResizeObserver !== "undefined") {
1615
+ const observer = new ResizeObserver(() => attempt() && cancelGuard());
1616
+ observer.observe(document.documentElement);
1617
+ disconnect = () => observer.disconnect();
1618
+ }
1619
+ };
1620
+ return {
1621
+ /** Before the router reacts to a popstate: mark the traversal target. */
1622
+ onPop() {
1623
+ pending = depth();
1624
+ },
1625
+ /** After a push: forward entries died, and this depth may be reused. */
1626
+ onPush() {
1627
+ const d = depth();
1628
+ if (d != null) for (const k in positions) +k >= d && delete positions[k];
1629
+ },
1630
+ create(router) {
1631
+ createEffect(on(router.isRouting, routing => routing || restore(), {
1632
+ defer: true
1633
+ }));
1634
+ onCleanup(() => {
1635
+ unbind.forEach(u => u());
1636
+ cancelGuard();
1637
+ });
1638
+ // reload/back_forward document loads land on an existing entry; a fresh
1639
+ // navigation starts a new one and belongs at the top
1640
+ const [nav] = performance.getEntriesByType && performance.getEntriesByType("navigation");
1641
+ if (nav && nav.type !== "navigate") {
1642
+ pending = depth();
1643
+ restore();
1644
+ }
1645
+ }
1646
+ };
1647
+ }
1648
+
1511
1649
  function Router(props) {
1512
1650
  if (isServer) return StaticRouter(props);
1651
+ const restoration = props.scrollRestoration ? createScrollRestoration() : undefined;
1513
1652
  const getSource = () => {
1514
- const url = window.location.pathname.replace(/^\/+/, "/") + window.location.search;
1653
+ const url = window.location.pathname + window.location.search;
1515
1654
  const state = window.history.state && window.history.state._depth && Object.keys(window.history.state).length === 1 ? undefined : window.history.state;
1516
1655
  return {
1517
1656
  value: url + window.location.hash,
@@ -1534,23 +1673,33 @@ function Router(props) {
1534
1673
  }
1535
1674
  scrollToHash(decodeURIComponent(window.location.hash.slice(1)), scroll);
1536
1675
  saveCurrentDepth();
1676
+ restoration && !replace && restoration.onPush();
1677
+ },
1678
+ init: notify => {
1679
+ const handler = notifyIfNotBlocked(notify, delta => {
1680
+ if (delta) {
1681
+ return !beforeLeave.confirm(delta);
1682
+ } else {
1683
+ const s = getSource();
1684
+ return !beforeLeave.confirm(s.value, {
1685
+ state: s.state
1686
+ });
1687
+ }
1688
+ });
1689
+ return bindEvent(window, "popstate", restoration ? () => {
1690
+ restoration.onPop();
1691
+ handler();
1692
+ } : handler);
1693
+ },
1694
+ create: router => {
1695
+ setupNativeEvents({
1696
+ preload: props.preload,
1697
+ explicitLinks: props.explicitLinks,
1698
+ actionBase: props.actionBase,
1699
+ transformUrl: props.transformUrl
1700
+ })(router);
1701
+ restoration && restoration.create(router);
1537
1702
  },
1538
- init: notify => bindEvent(window, "popstate", notifyIfNotBlocked(notify, delta => {
1539
- if (delta) {
1540
- return !beforeLeave.confirm(delta);
1541
- } else {
1542
- const s = getSource();
1543
- return !beforeLeave.confirm(s.value, {
1544
- state: s.state
1545
- });
1546
- }
1547
- })),
1548
- create: setupNativeEvents({
1549
- preload: props.preload,
1550
- explicitLinks: props.explicitLinks,
1551
- actionBase: props.actionBase,
1552
- transformUrl: props.transformUrl
1553
- }),
1554
1703
  utils: {
1555
1704
  go: delta => window.history.go(delta),
1556
1705
  beforeLeave
@@ -1681,8 +1830,9 @@ function A(props) {
1681
1830
  const isActive = createMemo(() => {
1682
1831
  const to_ = to();
1683
1832
  if (to_ === undefined) return [false, false];
1684
- const path = normalizePath(to_.split(/[?#]/, 1)[0]).toLowerCase();
1685
- const loc = decodeURI(normalizePath(location.pathname).toLowerCase());
1833
+ // trailing slashes are ignored so `/route` and `/route/` share active state
1834
+ const path = normalizePath(to_.split(/[?#]/, 1)[0]).toLowerCase().replace(/\/$/, "");
1835
+ const loc = decodeURI(normalizePath(location.pathname).toLowerCase().replace(/\/$/, ""));
1686
1836
  return [props.end ? path === loc : loc.startsWith(path + "/") || loc === path, path === loc];
1687
1837
  });
1688
1838
  return (() => {
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);
@@ -5,5 +5,13 @@ export type RouterProps = BaseRouterProps & {
5
5
  actionBase?: string;
6
6
  explicitLinks?: boolean;
7
7
  preload?: boolean;
8
+ /**
9
+ * Opt-in explicit scroll restoration for back/forward navigation (read
10
+ * once, not reactive). Sets `history.scrollRestoration = "manual"` and
11
+ * restores saved positions after the navigation settles, replacing the
12
+ * browser heuristic that loses offsets when the destination route forces
13
+ * a layout while rendering (#577).
14
+ */
15
+ scrollRestoration?: boolean;
8
16
  };
9
17
  export declare function Router(props: RouterProps): JSX.Element;
@@ -3,11 +3,13 @@ import { createRouter, scrollToHash, bindEvent } from "./createRouter.js";
3
3
  import { StaticRouter } from "./StaticRouter.js";
4
4
  import { setupNativeEvents } from "../data/events.js";
5
5
  import { createBeforeLeave, keepDepth, notifyIfNotBlocked, saveCurrentDepth } from "../lifecycle.js";
6
+ import { createScrollRestoration } from "./scrollRestoration.js";
6
7
  export function Router(props) {
7
8
  if (isServer)
8
9
  return StaticRouter(props);
10
+ const restoration = props.scrollRestoration ? createScrollRestoration() : undefined;
9
11
  const getSource = () => {
10
- const url = window.location.pathname.replace(/^\/+/, "/") + window.location.search;
12
+ const url = window.location.pathname + window.location.search;
11
13
  const state = window.history.state && window.history.state._depth && Object.keys(window.history.state).length === 1 ? undefined : window.history.state;
12
14
  return {
13
15
  value: url + window.location.hash,
@@ -26,17 +28,29 @@ export function Router(props) {
26
28
  }
27
29
  scrollToHash(decodeURIComponent(window.location.hash.slice(1)), scroll);
28
30
  saveCurrentDepth();
31
+ restoration && !replace && restoration.onPush();
32
+ },
33
+ init: notify => {
34
+ const handler = notifyIfNotBlocked(notify, delta => {
35
+ if (delta) {
36
+ return !beforeLeave.confirm(delta);
37
+ }
38
+ else {
39
+ const s = getSource();
40
+ return !beforeLeave.confirm(s.value, { state: s.state });
41
+ }
42
+ });
43
+ return bindEvent(window, "popstate", restoration
44
+ ? () => {
45
+ restoration.onPop();
46
+ handler();
47
+ }
48
+ : handler);
49
+ },
50
+ create: router => {
51
+ setupNativeEvents({ preload: props.preload, explicitLinks: props.explicitLinks, actionBase: props.actionBase, transformUrl: props.transformUrl })(router);
52
+ restoration && restoration.create(router);
29
53
  },
30
- init: notify => bindEvent(window, "popstate", notifyIfNotBlocked(notify, delta => {
31
- if (delta) {
32
- return !beforeLeave.confirm(delta);
33
- }
34
- else {
35
- const s = getSource();
36
- return !beforeLeave.confirm(s.value, { state: s.state });
37
- }
38
- })),
39
- create: setupNativeEvents({ preload: props.preload, explicitLinks: props.explicitLinks, actionBase: props.actionBase, transformUrl: props.transformUrl }),
40
54
  utils: {
41
55
  go: delta => window.history.go(delta),
42
56
  beforeLeave
@@ -1,5 +1,5 @@
1
1
  import type { Component, JSX } from "solid-js";
2
- import type { MatchFilters, RouteDefinition, RoutePreloadFunc, RouterIntegration, RouteSectionProps } from "../types.js";
2
+ import type { MatchFilters, RouteDefinition, RoutePreloadFunc, RouterIntegration, RouteSectionComponent, RouteSectionProps } from "../types.js";
3
3
  export type BaseRouterProps = {
4
4
  base?: string;
5
5
  /**
@@ -19,7 +19,7 @@ export type RouteProps<S extends string, T = unknown> = {
19
19
  children?: JSX.Element;
20
20
  preload?: RoutePreloadFunc<T>;
21
21
  matchFilters?: MatchFilters<S>;
22
- component?: Component<RouteSectionProps<T>>;
22
+ component?: RouteSectionComponent<T>;
23
23
  info?: Record<string, any>;
24
24
  /** @deprecated use preload */
25
25
  load?: RoutePreloadFunc<T>;
@@ -1,5 +1,5 @@
1
1
  /*@refresh skip*/
2
- import { children, createMemo, createRoot, getOwner, mergeProps, on, Show, untrack } from "solid-js";
2
+ import { children, createMemo, createRoot, getOwner, mergeProps, on, onCleanup, Show, untrack } from "solid-js";
3
3
  import { getRequestEvent, isServer } from "solid-js/web";
4
4
  import { createBranches, createRouteContext, createRouterContext, getIntent, getRouteMatches, RouteContextObj, RouterContextObj, setInPreloadFn } from "../routing.js";
5
5
  export const createRouterComponent = (router) => (props) => {
@@ -54,6 +54,9 @@ function Routes(props) {
54
54
  }
55
55
  const disposers = [];
56
56
  let root;
57
+ // dispose the detached per-route roots when this component unmounts, otherwise
58
+ // they stay subscribed to `matches` and crash on a later navigation (#451)
59
+ onCleanup(() => disposers.forEach(dispose => dispose()));
57
60
  const routeStates = createMemo(on(props.routerState.matches, (nextMatches, prevMatches, prev) => {
58
61
  let equal = prevMatches && nextMatches.length === prevMatches.length;
59
62
  const next = [];
@@ -0,0 +1,24 @@
1
+ import type { RouterContext } from "../types.js";
2
+ /**
3
+ * Explicit scroll restoration for back/forward navigation. The browser's
4
+ * native same-document heuristic is unreliable for suspense-driven rendering:
5
+ * if the destination route forces a layout while the document is still short,
6
+ * the saved offset for the previous entry is clamped and lost (#577).
7
+ *
8
+ * Positions are captured continuously from the scroll event, keyed by the
9
+ * `_depth` the router already stamps on every history entry — capturing at
10
+ * scroll time (rather than at exit) stays correct through `useBeforeLeave`
11
+ * blocked/reverted traversals. The map persists to sessionStorage on pagehide
12
+ * so restoration survives reloads, which `scrollRestoration = "manual"`
13
+ * otherwise disables. Restoration runs once routing settles; if the document
14
+ * is still shorter than the target (a boundary below the fold hasn't
15
+ * resolved), a ResizeObserver retries as content grows, cancelled by the
16
+ * first user scroll.
17
+ */
18
+ export declare function createScrollRestoration(): {
19
+ /** Before the router reacts to a popstate: mark the traversal target. */
20
+ onPop(): void;
21
+ /** After a push: forward entries died, and this depth may be reused. */
22
+ onPush(): void;
23
+ create(router: RouterContext): void;
24
+ };
@@ -0,0 +1,106 @@
1
+ import { createEffect, on, onCleanup } from "solid-js";
2
+ import { saveCurrentDepth } from "../lifecycle.js";
3
+ import { bindEvent } from "./createRouter.js";
4
+ const STORAGE_KEY = "solid-router:scroll";
5
+ /**
6
+ * Explicit scroll restoration for back/forward navigation. The browser's
7
+ * native same-document heuristic is unreliable for suspense-driven rendering:
8
+ * if the destination route forces a layout while the document is still short,
9
+ * the saved offset for the previous entry is clamped and lost (#577).
10
+ *
11
+ * Positions are captured continuously from the scroll event, keyed by the
12
+ * `_depth` the router already stamps on every history entry — capturing at
13
+ * scroll time (rather than at exit) stays correct through `useBeforeLeave`
14
+ * blocked/reverted traversals. The map persists to sessionStorage on pagehide
15
+ * so restoration survives reloads, which `scrollRestoration = "manual"`
16
+ * otherwise disables. Restoration runs once routing settles; if the document
17
+ * is still shorter than the target (a boundary below the fold hasn't
18
+ * resolved), a ResizeObserver retries as content grows, cancelled by the
19
+ * first user scroll.
20
+ */
21
+ export function createScrollRestoration() {
22
+ window.history.scrollRestoration = "manual";
23
+ // the current entry needs its depth stamp for captures to have a key, even
24
+ // if something replaced history.state after the lifecycle module loaded
25
+ saveCurrentDepth();
26
+ let positions = {};
27
+ try {
28
+ positions = JSON.parse(sessionStorage.getItem(STORAGE_KEY)) || {};
29
+ }
30
+ catch { }
31
+ const depth = () => window.history.state && window.history.state._depth;
32
+ let programmatic = false;
33
+ let pending;
34
+ let disconnect;
35
+ const cancelGuard = () => {
36
+ disconnect && disconnect();
37
+ disconnect = undefined;
38
+ };
39
+ const unbind = [
40
+ bindEvent(window, "scroll", () => {
41
+ const d = depth();
42
+ if (d != null)
43
+ positions[d] = window.scrollY;
44
+ if (!programmatic) {
45
+ // the user took over — a pending or chasing restore would yank them
46
+ pending = undefined;
47
+ cancelGuard();
48
+ }
49
+ }),
50
+ bindEvent(window, "pagehide", () => {
51
+ try {
52
+ sessionStorage.setItem(STORAGE_KEY, JSON.stringify(positions));
53
+ }
54
+ catch { }
55
+ })
56
+ ];
57
+ const restore = () => {
58
+ if (pending == null)
59
+ return;
60
+ const y = positions[pending];
61
+ pending = undefined;
62
+ if (y == null)
63
+ return;
64
+ cancelGuard();
65
+ const attempt = () => {
66
+ programmatic = true;
67
+ window.scrollTo(0, y);
68
+ programmatic = false;
69
+ // reachable once the document is tall enough to hold the offset
70
+ return document.documentElement.scrollHeight - window.innerHeight >= y;
71
+ };
72
+ if (!attempt() && typeof ResizeObserver !== "undefined") {
73
+ const observer = new ResizeObserver(() => attempt() && cancelGuard());
74
+ observer.observe(document.documentElement);
75
+ disconnect = () => observer.disconnect();
76
+ }
77
+ };
78
+ return {
79
+ /** Before the router reacts to a popstate: mark the traversal target. */
80
+ onPop() {
81
+ pending = depth();
82
+ },
83
+ /** After a push: forward entries died, and this depth may be reused. */
84
+ onPush() {
85
+ const d = depth();
86
+ if (d != null)
87
+ for (const k in positions)
88
+ +k >= d && delete positions[k];
89
+ },
90
+ create(router) {
91
+ createEffect(on(router.isRouting, routing => routing || restore(), { defer: true }));
92
+ onCleanup(() => {
93
+ unbind.forEach(u => u());
94
+ cancelGuard();
95
+ });
96
+ // reload/back_forward document loads land on an existing entry; a fresh
97
+ // navigation starts a new one and belongs at the top
98
+ const [nav] = (performance.getEntriesByType &&
99
+ performance.getEntriesByType("navigation"));
100
+ if (nav && nav.type !== "navigate") {
101
+ pending = depth();
102
+ restore();
103
+ }
104
+ }
105
+ };
106
+ }
package/dist/routing.js CHANGED
@@ -116,7 +116,11 @@ export const useMatch = (path, matchFilters) => {
116
116
  * const breadcrumbs = createMemo(() => matches().map(m => m.route.info.breadcrumb))
117
117
  * ```
118
118
  */
119
- export const useCurrentMatches = () => useRouter().matches;
119
+ export const useCurrentMatches = () => {
120
+ const router = useRouter();
121
+ // return a copy so user mutations (eg. `.reverse()`) can't corrupt router state
122
+ return () => router.matches().slice();
123
+ };
120
124
  /**
121
125
  * Retrieves a reactive, store-like object containing the current route path parameters as defined in the Route.
122
126
  *
@@ -157,11 +161,20 @@ export const useParams = () => useRouter().params;
157
161
  * ```
158
162
  */
159
163
  export const useSearchParams = () => {
160
- const location = useLocation();
164
+ const router = useRouter();
165
+ const location = router.location;
161
166
  const navigate = useNavigate();
162
167
  const setSearchParams = (params, options) => {
163
- const searchString = untrack(() => mergeSearchString(location.search, params) + location.hash);
164
- navigate(searchString, {
168
+ const to = untrack(() => {
169
+ // merge onto the in-flight navigation target (if any) so consecutive
170
+ // synchronous calls compose instead of the later one winning
171
+ const pending = router.pendingTarget && new URL(router.pendingTarget.value, mockBase);
172
+ const pathname = pending ? pending.pathname : location.pathname;
173
+ const search = pending ? pending.search : location.search;
174
+ const hash = pending ? pending.hash : location.hash;
175
+ return pathname + mergeSearchString(search, params) + hash;
176
+ });
177
+ navigate(to, {
165
178
  scroll: false,
166
179
  resolve: false,
167
180
  ...options
@@ -204,6 +217,11 @@ export const useBeforeLeave = (listener) => {
204
217
  });
205
218
  onCleanup(s);
206
219
  };
220
+ // Encodes a static path segment like `encodeURIComponent`, but leaves RFC 3986
221
+ // pchar characters (sub-delims / ":" / "@") literal, matching how browsers
222
+ // report them in `location.pathname`. Non-ASCII characters (eg. CJK paths) are
223
+ // still percent-encoded exactly as before, since browsers encode those too.
224
+ const encodeSegment = (s) => encodeURIComponent(s).replace(/%(2B|40|3A|24|26|2C|3B|3D)/g, m => decodeURIComponent(m));
207
225
  export function createRoutes(routeDef, base = "") {
208
226
  const { component, preload, load, children, info } = routeDef;
209
227
  const isLeaf = !children || (Array.isArray(children) && !children.length);
@@ -220,7 +238,7 @@ export function createRoutes(routeDef, base = "") {
220
238
  pattern = pattern
221
239
  .split("/")
222
240
  .map((s) => {
223
- return s.startsWith(":") || s.startsWith("*") ? s : encodeURIComponent(s);
241
+ return s.startsWith(":") || s.startsWith("*") ? s : encodeSegment(s);
224
242
  })
225
243
  .join("/");
226
244
  acc.push({
@@ -296,7 +314,9 @@ function createLocation(path, state, queryWrapper) {
296
314
  const url = createMemo(prev => {
297
315
  const path_ = path();
298
316
  try {
299
- return new URL(path_, origin);
317
+ // anchor rooted paths against the origin explicitly - a path with
318
+ // doubled leading slashes would otherwise parse as protocol-relative
319
+ return new URL(path_[0] === "/" ? mockBase + path_ : path_, origin);
300
320
  }
301
321
  catch (err) {
302
322
  console.error(`Invalid path ${path_}`);
@@ -421,6 +441,9 @@ export function createRouterContext(integration, branches, getContext, options =
421
441
  location,
422
442
  params,
423
443
  isRouting,
444
+ get pendingTarget() {
445
+ return lastTransitionTarget;
446
+ },
424
447
  renderPath,
425
448
  parsePath,
426
449
  navigatorFactory,
package/dist/types.d.ts CHANGED
@@ -71,12 +71,13 @@ export interface RouteSectionProps<T = unknown> {
71
71
  data: T;
72
72
  children?: JSX.Element;
73
73
  }
74
- export type RouteDefinition<S extends string | string[] = any, T = unknown> = {
74
+ export type RouteSectionComponent<T = unknown> = Component<RouteSectionProps<T>> | Component<Omit<RouteSectionProps<T>, "children">> | Component<{}>;
75
+ export type RouteDefinition<S extends string | string[] = any, T = any> = {
75
76
  path?: S;
76
77
  matchFilters?: MatchFilters<S>;
77
78
  preload?: RoutePreloadFunc<T>;
78
79
  children?: RouteDefinition | RouteDefinition[];
79
- component?: Component<RouteSectionProps<T>>;
80
+ component?: RouteSectionComponent<T>;
80
81
  info?: Record<string, any>;
81
82
  /** @deprecated use preload */
82
83
  load?: RoutePreloadFunc;
@@ -137,6 +138,8 @@ export interface RouterContext {
137
138
  params: Params;
138
139
  navigatorFactory: NavigatorFactory;
139
140
  isRouting: () => boolean;
141
+ /** The target of the in-flight navigation transition, if any. Not reactive. */
142
+ readonly pendingTarget?: LocationChange;
140
143
  matches: () => RouteMatch[];
141
144
  renderPath(path: string): string;
142
145
  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.16.1",
9
+ "version": "0.16.3",
10
10
  "homepage": "https://github.com/solidjs/solid-router#readme",
11
11
  "repository": {
12
12
  "type": "git",