Skip to content

Commit 31501fd

Browse files
Add opt-in scroll restoration for back/forward navigation (#577)
The browser's same-document heuristic loses the saved offset when the destination route forces a layout while the document is short. With <Router scrollRestoration> the router owns it: manual mode, positions captured per history entry depth, persisted across reloads, restored after navigation settles with a growth guard for late content. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent f3992e2 commit 31501fd

4 files changed

Lines changed: 281 additions & 11 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@solidjs/router": patch
3+
---
4+
5+
Add opt-in explicit scroll restoration for back/forward navigation: `<Router scrollRestoration>` (#577). The browser's native same-document heuristic loses the saved offset when the destination route forces a layout while the document is still short — any component that measures itself on mount is enough to trigger it. When enabled the router sets `history.scrollRestoration = "manual"`, captures positions continuously keyed by the history entry depth it already tracks, persists them across reloads, and restores after the navigation settles — retrying as the document grows if the target offset isn't reachable yet, cancelled by the first user scroll. Off by default on 0.x; no behavior changes unless enabled.

src/routers/Router.ts

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,26 @@ import { setupNativeEvents } from "../data/events.js";
55
import type { BaseRouterProps } from "./components.jsx";
66
import type { JSX } from "solid-js";
77
import { createBeforeLeave, keepDepth, notifyIfNotBlocked, saveCurrentDepth } from "../lifecycle.js";
8+
import { createScrollRestoration } from "./scrollRestoration.js";
89

9-
export type RouterProps = BaseRouterProps & { url?: string, actionBase?: string, explicitLinks?: boolean, preload?: boolean };
10+
export type RouterProps = BaseRouterProps & {
11+
url?: string,
12+
actionBase?: string,
13+
explicitLinks?: boolean,
14+
preload?: boolean,
15+
/**
16+
* Opt-in explicit scroll restoration for back/forward navigation (read
17+
* once, not reactive). Sets `history.scrollRestoration = "manual"` and
18+
* restores saved positions after the navigation settles, replacing the
19+
* browser heuristic that loses offsets when the destination route forces
20+
* a layout while rendering (#577).
21+
*/
22+
scrollRestoration?: boolean
23+
};
1024

1125
export function Router(props: RouterProps): JSX.Element {
1226
if (isServer) return StaticRouter(props);
27+
const restoration = props.scrollRestoration ? createScrollRestoration() : undefined;
1328
const getSource = () => {
1429
const url = window.location.pathname + window.location.search;
1530
const state = window.history.state && window.history.state._depth && Object.keys(window.history.state).length === 1 ? undefined : window.history.state;
@@ -29,18 +44,28 @@ export function Router(props: RouterProps): JSX.Element {
2944
}
3045
scrollToHash(decodeURIComponent(window.location.hash.slice(1)), scroll);
3146
saveCurrentDepth();
47+
restoration && !replace && restoration.onPush();
3248
},
33-
init: notify => bindEvent(window, "popstate",
34-
notifyIfNotBlocked(notify, delta => {
35-
if (delta) {
36-
return !beforeLeave.confirm(delta);
37-
} else {
38-
const s = getSource();
39-
return !beforeLeave.confirm(s.value, { state: s.state });
49+
init: notify => {
50+
const handler = notifyIfNotBlocked(notify, delta => {
51+
if (delta) {
52+
return !beforeLeave.confirm(delta);
53+
} else {
54+
const s = getSource();
55+
return !beforeLeave.confirm(s.value, { state: s.state });
56+
}
57+
});
58+
return bindEvent(window, "popstate", restoration
59+
? () => {
60+
restoration.onPop();
61+
handler();
4062
}
41-
})
42-
),
43-
create: setupNativeEvents({ preload: props.preload, explicitLinks: props.explicitLinks, actionBase: props.actionBase, transformUrl: props.transformUrl }),
63+
: handler);
64+
},
65+
create: router => {
66+
setupNativeEvents({ preload: props.preload, explicitLinks: props.explicitLinks, actionBase: props.actionBase, transformUrl: props.transformUrl })(router);
67+
restoration && restoration.create(router);
68+
},
4469
utils: {
4570
go: delta => window.history.go(delta),
4671
beforeLeave

src/routers/scrollRestoration.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { createEffect, on, onCleanup } from "solid-js";
2+
import { saveCurrentDepth } from "../lifecycle.js";
3+
import type { RouterContext } from "../types.js";
4+
import { bindEvent } from "./createRouter.js";
5+
6+
const STORAGE_KEY = "solid-router:scroll";
7+
8+
/**
9+
* Explicit scroll restoration for back/forward navigation. The browser's
10+
* native same-document heuristic is unreliable for suspense-driven rendering:
11+
* if the destination route forces a layout while the document is still short,
12+
* the saved offset for the previous entry is clamped and lost (#577).
13+
*
14+
* Positions are captured continuously from the scroll event, keyed by the
15+
* `_depth` the router already stamps on every history entry — capturing at
16+
* scroll time (rather than at exit) stays correct through `useBeforeLeave`
17+
* blocked/reverted traversals. The map persists to sessionStorage on pagehide
18+
* so restoration survives reloads, which `scrollRestoration = "manual"`
19+
* otherwise disables. Restoration runs once routing settles; if the document
20+
* is still shorter than the target (a boundary below the fold hasn't
21+
* resolved), a ResizeObserver retries as content grows, cancelled by the
22+
* first user scroll.
23+
*/
24+
export function createScrollRestoration() {
25+
window.history.scrollRestoration = "manual";
26+
// the current entry needs its depth stamp for captures to have a key, even
27+
// if something replaced history.state after the lifecycle module loaded
28+
saveCurrentDepth();
29+
let positions: Record<string, number> = {};
30+
try {
31+
positions = JSON.parse(sessionStorage.getItem(STORAGE_KEY)!) || {};
32+
} catch {}
33+
34+
const depth = (): number | undefined => window.history.state && window.history.state._depth;
35+
36+
let programmatic = false;
37+
let pending: number | undefined;
38+
let disconnect: (() => void) | undefined;
39+
const cancelGuard = () => {
40+
disconnect && disconnect();
41+
disconnect = undefined;
42+
};
43+
44+
const unbind = [
45+
bindEvent(window, "scroll", () => {
46+
const d = depth();
47+
if (d != null) positions[d] = window.scrollY;
48+
if (!programmatic) {
49+
// the user took over — a pending or chasing restore would yank them
50+
pending = undefined;
51+
cancelGuard();
52+
}
53+
}),
54+
bindEvent(window, "pagehide", () => {
55+
try {
56+
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(positions));
57+
} catch {}
58+
})
59+
];
60+
61+
const restore = () => {
62+
if (pending == null) return;
63+
const y = positions[pending];
64+
pending = undefined;
65+
if (y == null) return;
66+
cancelGuard();
67+
const attempt = () => {
68+
programmatic = true;
69+
window.scrollTo(0, y);
70+
programmatic = false;
71+
// reachable once the document is tall enough to hold the offset
72+
return document.documentElement.scrollHeight - window.innerHeight >= y;
73+
};
74+
if (!attempt() && typeof ResizeObserver !== "undefined") {
75+
const observer = new ResizeObserver(() => attempt() && cancelGuard());
76+
observer.observe(document.documentElement);
77+
disconnect = () => observer.disconnect();
78+
}
79+
};
80+
81+
return {
82+
/** Before the router reacts to a popstate: mark the traversal target. */
83+
onPop() {
84+
pending = depth();
85+
},
86+
/** After a push: forward entries died, and this depth may be reused. */
87+
onPush() {
88+
const d = depth();
89+
if (d != null) for (const k in positions) +k >= d && delete positions[k];
90+
},
91+
create(router: RouterContext) {
92+
createEffect(on(router.isRouting, routing => routing || restore(), { defer: true }));
93+
onCleanup(() => {
94+
unbind.forEach(u => u());
95+
cancelGuard();
96+
});
97+
// reload/back_forward document loads land on an existing entry; a fresh
98+
// navigation starts a new one and belongs at the top
99+
const [nav] = (performance.getEntriesByType &&
100+
performance.getEntriesByType("navigation")) as PerformanceNavigationTiming[];
101+
if (nav && nav.type !== "navigate") {
102+
pending = depth();
103+
restore();
104+
}
105+
}
106+
};
107+
}

test/scroll-restoration.spec.tsx

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { render } from "solid-js/web";
2+
import { vi } from "vitest";
3+
import { Router, Route, useNavigate } from "../src/index.js";
4+
import type { Navigator } from "../src/index.js";
5+
6+
// jsdom implements history traversal but not scrolling — stub the primitives
7+
// so the restoration path (capture on scroll, restore via scrollTo) is
8+
// observable.
9+
function stubScrolling() {
10+
let y = 0;
11+
Object.defineProperty(window, "scrollY", { configurable: true, get: () => y });
12+
const scrollTo = vi.fn((_x: number, newY: number) => {
13+
y = newY;
14+
window.dispatchEvent(new Event("scroll"));
15+
});
16+
window.scrollTo = scrollTo as any;
17+
return {
18+
scrollTo,
19+
scrollUserTo(newY: number) {
20+
y = newY;
21+
window.dispatchEvent(new Event("scroll"));
22+
}
23+
};
24+
}
25+
26+
describe("scroll restoration (#577)", () => {
27+
beforeEach(() => {
28+
window.history.replaceState(null, "", "/");
29+
sessionStorage.clear();
30+
});
31+
32+
test("is off by default", () => {
33+
window.history.scrollRestoration = "auto";
34+
const dispose = render(
35+
() => (
36+
<Router>
37+
<Route path="/" component={() => null} />
38+
</Router>
39+
),
40+
document.body
41+
);
42+
try {
43+
expect(window.history.scrollRestoration).toBe("auto");
44+
} finally {
45+
document.body.innerHTML = "";
46+
dispose();
47+
}
48+
});
49+
50+
test("restores the saved position on back navigation", async () => {
51+
const scrolling = stubScrolling();
52+
let navigate!: Navigator;
53+
54+
const Long = () => {
55+
navigate = useNavigate();
56+
return <div data-testid="long">long</div>;
57+
};
58+
59+
const dispose = render(
60+
() => (
61+
<Router scrollRestoration>
62+
<Route path="/" component={Long} />
63+
<Route path="/short" component={() => <div data-testid="short">short</div>} />
64+
</Router>
65+
),
66+
document.body
67+
);
68+
69+
try {
70+
expect(window.history.scrollRestoration).toBe("manual");
71+
72+
// user scrolls down the long page, then navigates away
73+
scrolling.scrollUserTo(3000);
74+
navigate("/short");
75+
await vi.waitFor(() => expect(document.querySelector("[data-testid=short]")).toBeTruthy());
76+
// push navigations still scroll to the top
77+
expect(scrolling.scrollTo).toHaveBeenLastCalledWith(0, 0);
78+
79+
window.history.back();
80+
await vi.waitFor(() => expect(document.querySelector("[data-testid=long]")).toBeTruthy());
81+
await vi.waitFor(() => expect(scrolling.scrollTo).toHaveBeenLastCalledWith(0, 3000));
82+
} finally {
83+
document.body.innerHTML = "";
84+
dispose();
85+
}
86+
});
87+
88+
test("a push prunes saved positions for truncated forward entries", async () => {
89+
const scrolling = stubScrolling();
90+
let navigate!: Navigator;
91+
92+
const Page = () => {
93+
navigate = useNavigate();
94+
return <div data-testid="page">page</div>;
95+
};
96+
97+
const dispose = render(
98+
() => (
99+
<Router scrollRestoration>
100+
<Route path="/" component={Page} />
101+
<Route path="/a" component={Page} />
102+
<Route path="/b" component={Page} />
103+
</Router>
104+
),
105+
document.body
106+
);
107+
108+
try {
109+
navigate("/a");
110+
await vi.waitFor(() => expect(window.location.pathname).toBe("/a"));
111+
scrolling.scrollUserTo(500);
112+
113+
window.history.back();
114+
await vi.waitFor(() => expect(window.location.pathname).toBe("/"));
115+
116+
// pushing /b truncates the forward entry (/a at the same depth) — its
117+
// saved position must not leak into the fresh /b entry
118+
navigate("/b");
119+
await vi.waitFor(() => expect(window.location.pathname).toBe("/b"));
120+
scrolling.scrollTo.mockClear();
121+
122+
window.history.back();
123+
await vi.waitFor(() => expect(window.location.pathname).toBe("/"));
124+
window.history.forward();
125+
await vi.waitFor(() => expect(window.location.pathname).toBe("/b"));
126+
await Promise.resolve();
127+
expect(scrolling.scrollTo).not.toHaveBeenCalledWith(0, 500);
128+
} finally {
129+
document.body.innerHTML = "";
130+
dispose();
131+
}
132+
});
133+
});

0 commit comments

Comments
 (0)