Skip to content

Commit d3cf886

Browse files
authored
track scroll position without scroll listener (#3938)
* track scroll position without scroll listener * add a test * add explanatory comment * save scroll state on visibilitychange rather than beforeunload * tidy up * centralise scroll_positions update logic * belt and braces * group code * appease that whiny little funsucker eslint * huh * microscopically less code
1 parent b5cf676 commit d3cf886

File tree

3 files changed

+57
-25
lines changed

3 files changed

+57
-25
lines changed

.changeset/moody-dingos-happen.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sveltejs/kit': patch
3+
---
4+
5+
Track scroll position without scroll listener, and recover on reload

packages/kit/src/runtime/client/router.js

Lines changed: 39 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,21 @@ import { onMount } from 'svelte';
22
import { normalize_path } from '../../utils/url';
33
import { get_base_uri } from './utils';
44

5+
// We track the scroll position associated with each history entry in sessionStorage,
6+
// rather than on history.state itself, because when navigation is driven by
7+
// popstate it's too late to update the scroll position associated with the
8+
// state we're navigating from
9+
const SCROLL_KEY = 'sveltekit:scroll';
10+
11+
/** @typedef {{ x: number, y: number }} ScrollPosition */
12+
/** @type {Record<number, ScrollPosition>} */
13+
let scroll_positions = {};
14+
try {
15+
scroll_positions = JSON.parse(sessionStorage[SCROLL_KEY]);
16+
} catch {
17+
// do nothing
18+
}
19+
520
function scroll_state() {
621
return {
722
x: pageXOffset,
@@ -63,6 +78,11 @@ export class Router {
6378
history.replaceState({ ...history.state, 'sveltekit:index': 0 }, '', location.href);
6479
}
6580

81+
// if we reload the page, or Cmd-Shift-T back to it,
82+
// recover scroll position
83+
const scroll = scroll_positions[this.current_history_index];
84+
if (scroll) scrollTo(scroll.x, scroll.y);
85+
6686
this.hash_navigating = false;
6787

6888
this.callbacks = {
@@ -75,9 +95,7 @@ export class Router {
7595
}
7696

7797
init_listeners() {
78-
if ('scrollRestoration' in history) {
79-
history.scrollRestoration = 'manual';
80-
}
98+
history.scrollRestoration = 'manual';
8199

82100
// Adopted from Nuxt.js
83101
// Reset scrollRestoration to auto when leaving page, allowing page reload
@@ -102,28 +120,16 @@ export class Router {
102120
}
103121
});
104122

105-
// Setting scrollRestoration to manual again when returning to this page.
106-
addEventListener('load', () => {
107-
history.scrollRestoration = 'manual';
108-
});
109-
110-
// There's no API to capture the scroll location right before the user
111-
// hits the back/forward button, so we listen for scroll events
123+
addEventListener('visibilitychange', () => {
124+
if (document.visibilityState === 'hidden') {
125+
this.#update_scroll_positions();
112126

113-
/** @type {NodeJS.Timeout} */
114-
let scroll_timer;
115-
addEventListener('scroll', () => {
116-
clearTimeout(scroll_timer);
117-
scroll_timer = setTimeout(() => {
118-
// Store the scroll location in the history
119-
// This will persist even if we navigate away from the site and come back
120-
const new_state = {
121-
...(history.state || {}),
122-
'sveltekit:scroll': scroll_state()
123-
};
124-
history.replaceState(new_state, document.title, window.location.href);
125-
// iOS scroll event intervals happen between 30-150ms, sometimes around 200ms
126-
}, 200);
127+
try {
128+
sessionStorage[SCROLL_KEY] = JSON.stringify(scroll_positions);
129+
} catch {
130+
// do nothing
131+
}
132+
}
127133
});
128134

129135
/** @param {Event} event */
@@ -196,6 +202,8 @@ export class Router {
196202
// clicking a hash link and those triggered by popstate
197203
this.hash_navigating = true;
198204

205+
this.#update_scroll_positions();
206+
199207
const info = this.parse(url);
200208
if (info) {
201209
return this.renderer.update(info, [], false);
@@ -225,7 +233,7 @@ export class Router {
225233

226234
this._navigate({
227235
url: new URL(location.href),
228-
scroll: event.state['sveltekit:scroll'],
236+
scroll: scroll_positions[event.state['sveltekit:index']],
229237
keepfocus: false,
230238
chain: [],
231239
details: null,
@@ -254,6 +262,10 @@ export class Router {
254262
});
255263
}
256264

265+
#update_scroll_positions() {
266+
scroll_positions[this.current_history_index] = scroll_state();
267+
}
268+
257269
/**
258270
* Returns true if `url` has the same origin and basepath as the app
259271
* @param {URL} url
@@ -401,6 +413,8 @@ export class Router {
401413
});
402414
}
403415

416+
this.#update_scroll_positions();
417+
404418
accepted();
405419

406420
if (!this.navigating) {

packages/kit/test/apps/basics/test/test.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,19 @@ test.describe('Scrolling', () => {
253253
expect(await in_view('#input')).toBe(true);
254254
expect(await page.locator('#input')).toBeFocused();
255255
});
256+
257+
test('scroll positions are recovered on reloading the page', async ({ page, back, app }) => {
258+
await page.goto('/anchor');
259+
await page.evaluate(() => window.scrollTo(0, 1000));
260+
await app.goto('/anchor/anchor');
261+
await page.evaluate(() => window.scrollTo(0, 1000));
262+
263+
await page.reload();
264+
expect(await page.evaluate(() => window.scrollY)).toBe(1000);
265+
266+
await back();
267+
expect(await page.evaluate(() => window.scrollY)).toBe(1000);
268+
});
256269
});
257270

258271
test.describe.parallel('Imports', () => {

0 commit comments

Comments
 (0)