@solidjs/router 0.16.2 → 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.
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() {
@@ -1550,8 +1550,105 @@ function setupNativeEvents({
1550
1550
  };
1551
1551
  }
1552
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
+
1553
1649
  function Router(props) {
1554
1650
  if (isServer) return StaticRouter(props);
1651
+ const restoration = props.scrollRestoration ? createScrollRestoration() : undefined;
1555
1652
  const getSource = () => {
1556
1653
  const url = window.location.pathname + window.location.search;
1557
1654
  const state = window.history.state && window.history.state._depth && Object.keys(window.history.state).length === 1 ? undefined : window.history.state;
@@ -1576,23 +1673,33 @@ function Router(props) {
1576
1673
  }
1577
1674
  scrollToHash(decodeURIComponent(window.location.hash.slice(1)), scroll);
1578
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);
1579
1702
  },
1580
- init: notify => bindEvent(window, "popstate", notifyIfNotBlocked(notify, delta => {
1581
- if (delta) {
1582
- return !beforeLeave.confirm(delta);
1583
- } else {
1584
- const s = getSource();
1585
- return !beforeLeave.confirm(s.value, {
1586
- state: s.state
1587
- });
1588
- }
1589
- })),
1590
- create: setupNativeEvents({
1591
- preload: props.preload,
1592
- explicitLinks: props.explicitLinks,
1593
- actionBase: props.actionBase,
1594
- transformUrl: props.transformUrl
1595
- }),
1596
1703
  utils: {
1597
1704
  go: delta => window.history.go(delta),
1598
1705
  beforeLeave
@@ -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,9 +3,11 @@ 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
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;
@@ -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
@@ -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/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "Ryan Turnquist"
7
7
  ],
8
8
  "license": "MIT",
9
- "version": "0.16.2",
9
+ "version": "0.16.3",
10
10
  "homepage": "https://github.com/solidjs/solid-router#readme",
11
11
  "repository": {
12
12
  "type": "git",