diff --git a/.changeset/preact-router-scroll-restoration.md b/.changeset/preact-router-scroll-restoration.md new file mode 100644 index 000000000..711773cc5 --- /dev/null +++ b/.changeset/preact-router-scroll-restoration.md @@ -0,0 +1,21 @@ +--- +'@quilted/preact-router': minor +--- + +Added automatic scroll restoration to `Navigation` + +In a single-page app the browser's native scroll restoration is unreliable: on a back/forward navigation it restores the scroll offset against the document as it exists at `popstate` time, but an async route hasn't rendered its content yet, so it restores against the wrong (usually shorter) document and lands at the wrong offset. Forward navigations are also frequently left at the previous page's offset instead of the top. + +`Navigation` now owns scroll restoration. In the browser it switches `history.scrollRestoration` to `'manual'` and keeps its own per-entry scroll offsets, keyed by navigation id and persisted to `sessionStorage` (so they survive a reload within the tab session): + +- a forward navigation resets to the top, or scrolls to the URL hash target when present; +- a back/forward navigation restores the offset the entry was last left at; +- a reload restores the offset of the entry it lands on. + +Offsets that need the destination route's content committed (a restore or a hash target) are applied on the next animation frame; a plain reset to the top is applied synchronously to avoid a flash of the new route at the previous offset. + +This is enabled by default. Pass `scrollRestoration: false` to leave scrolling entirely to the browser/your app: + +```ts +const navigation = new Navigation(initialURL, {scrollRestoration: false}); +``` diff --git a/packages/preact-router/source/Navigation.ts b/packages/preact-router/source/Navigation.ts index 266f93cf1..29a8dd4c4 100644 --- a/packages/preact-router/source/Navigation.ts +++ b/packages/preact-router/source/Navigation.ts @@ -19,6 +19,21 @@ export interface NavigationOptions { | boolean; base?: string | URL; isExternal?(url: URL, currentUrl: URL): boolean; + /** + * Whether the router manages scroll position across navigations. When + * enabled (the default in the browser), the router switches the browser to + * manual scroll restoration and keeps its own per-entry scroll offsets, + * keyed by navigation id and persisted to `sessionStorage`: + * + * - a forward navigation resets to the top (or scrolls to the URL hash + * target, if present); + * - a back/forward navigation restores the offset the entry was last left + * at; + * - a reload restores the offset of the entry it lands on. + * + * Pass `false` to leave scrolling entirely to the browser/your app. + */ + scrollRestoration?: boolean; } export interface NavigateOptions { @@ -29,6 +44,14 @@ export interface NavigateOptions { const STATE_ID_FIELD_KEY = '_id'; +const SCROLL_POSITIONS_STORAGE_KEY = 'quilt:navigation:scroll-positions'; + +// Cap the persisted set so a long-lived tab session can't grow it without +// bound; the most-recent entries are the ones a user is likely to return to. +const MAX_STORED_SCROLL_POSITIONS = 50; + +type ScrollPosition = readonly [x: number, y: number]; + export class Navigation { readonly base: string; readonly cache: RouterNavigationCache; @@ -45,9 +68,19 @@ export class Navigation { #navigationRequests = new Map(); #isExternal: NavigationOptions['isExternal']; + #scrollRestoration: boolean; + #scrollPositions = new Map(); + #scrollRestorationFrame: number | undefined; + #scrollRecordFrame: number | undefined; + constructor( initial?: string | URL | Partial, - {cache = true, base, isExternal}: NavigationOptions = {}, + { + cache = true, + base, + isExternal, + scrollRestoration = true, + }: NavigationOptions = {}, ) { this.base = base ? (typeof base === 'string' ? base : base.pathname) : '/'; this.cache = @@ -59,6 +92,7 @@ export class Navigation { ? cache : new RouterNavigationCache(this, {entries: cache}); this.#isExternal = isExternal; + this.#scrollRestoration = scrollRestoration; const currentRequest = new BrowserNavigationRequest(initial); this.#currentRequest = signal(currentRequest); @@ -67,6 +101,26 @@ export class Navigation { if (typeof window !== 'undefined') { window.addEventListener('popstate', this.#handlePopstate); + + if (this.#scrollRestoration) { + // Own scroll restoration outright: the browser's heuristic restores + // against the document height at popstate time, which an async SPA + // route hasn't rendered yet, landing at the wrong offset. + if ('scrollRestoration' in history) { + history.scrollRestoration = 'manual'; + } + + this.#scrollPositions = readStoredScrollPositions(); + window.addEventListener('scroll', this.#handleScroll, {passive: true}); + window.addEventListener('pagehide', this.#persistScrollPositions); + + // Restore the offset for the entry we loaded into. Covers a reload + // partway down a page: the id survives in `history.state` and the + // SSR'd markup is already at full height, so the offset is reachable. + if (this.#scrollPositions.has(currentRequest.id)) { + this.#restoreScrollPosition(currentRequest); + } + } } } @@ -80,6 +134,10 @@ export class Navigation { const currentRequest = this.#currentRequest.peek(); + // Capture where we're leaving from before the URL changes, so a later + // back/forward to this entry restores the offset the user left it at. + if (this.#scrollRestoration) this.#recordScrollPosition(currentRequest.id); + const id = createNavigationRequestID(); const url = resolveURL(to, currentRequest.url, base ?? this.base); const finalState = {...state, [STATE_ID_FIELD_KEY]: id}; @@ -139,6 +197,19 @@ export class Navigation { this.#navigationRequests.set(id, request); this.#currentRequest.value = request; + if (this.#scrollRestoration) { + if (replace) { + // A replace keeps the user in place, but it mints a fresh entry id; + // carry the current offset onto it so a later return still restores. + const previous = this.#scrollPositions.get(currentRequest.id); + if (previous) this.#scrollPositions.set(id, previous); + } else { + this.#restoreScrollPosition(request); + } + + this.#persistScrollPositions(); + } + return request; }; @@ -175,6 +246,12 @@ export class Navigation { } #handlePopstate = () => { + // The browser keeps the outgoing page's scroll offset until we re-render + // (manual restoration), so record it against the entry we're leaving. + if (this.#scrollRestoration) { + this.#recordScrollPosition(this.#currentRequest.peek().id); + } + const navigationIDs = this.#navigationIDs; const fallbackNavigationID = navigationIDs[0]!; @@ -214,6 +291,78 @@ export class Navigation { // this.#forceNextNavigation = false; this.#currentRequest.value = request; + + if (this.#scrollRestoration) { + this.#persistScrollPositions(); + this.#restoreScrollPosition(request); + } + }; + + #handleScroll = () => { + // Coalesce the high-frequency scroll event into a single write per frame. + if (this.#scrollRecordFrame != null) return; + this.#scrollRecordFrame = requestAnimationFrame(() => { + this.#scrollRecordFrame = undefined; + this.#recordScrollPosition(); + }); + }; + + #recordScrollPosition(id = this.#currentRequest.peek().id) { + this.#scrollPositions.set(id, [window.scrollX, window.scrollY]); + } + + #restoreScrollPosition(request: NavigationRequest) { + if (this.#scrollRestorationFrame != null) { + cancelAnimationFrame(this.#scrollRestorationFrame); + this.#scrollRestorationFrame = undefined; + } + + const saved = this.#scrollPositions.get(request.id); + const {hash} = request.url; + + // Fresh forward navigation with no hash target: reset to the top, and do + // it synchronously so there's no flash of the new route rendered at the + // previous page's offset before the frame callback fires. + if (saved == null && hash.length <= 1) { + window.scrollTo(0, 0); + return; + } + + // A saved offset (back/forward, reload) or a hash target both need the + // destination route's content committed — and tall enough — before we can + // scroll to it, so defer to the next frame. + this.#scrollRestorationFrame = requestAnimationFrame(() => { + this.#scrollRestorationFrame = undefined; + + if (saved != null) { + window.scrollTo(saved[0], saved[1]); + return; + } + + const target = document.getElementById(decodeURIComponent(hash.slice(1))); + if (target) { + target.scrollIntoView(); + } else { + window.scrollTo(0, 0); + } + }); + } + + #persistScrollPositions = () => { + try { + const entries = [...this.#scrollPositions]; + const trimmed = + entries.length > MAX_STORED_SCROLL_POSITIONS + ? entries.slice(entries.length - MAX_STORED_SCROLL_POSITIONS) + : entries; + sessionStorage.setItem( + SCROLL_POSITIONS_STORAGE_KEY, + JSON.stringify(trimmed), + ); + } catch { + // `sessionStorage` can throw (privacy mode, sandboxed iframe, quota). + // Scroll restoration is a progressive enhancement — swallow and move on. + } }; } @@ -510,3 +659,13 @@ function createNavigationRequestID() { function urlToPath(url: URL) { return `${url.pathname}${url.search}${url.hash}`; } + +function readStoredScrollPositions(): Map { + try { + const stored = sessionStorage.getItem(SCROLL_POSITIONS_STORAGE_KEY); + if (!stored) return new Map(); + return new Map(JSON.parse(stored) as [string, ScrollPosition][]); + } catch { + return new Map(); + } +} diff --git a/packages/preact-router/source/tests/Navigation.test.ts b/packages/preact-router/source/tests/Navigation.test.ts index 0fe9b7166..2fdea23ee 100644 --- a/packages/preact-router/source/tests/Navigation.test.ts +++ b/packages/preact-router/source/tests/Navigation.test.ts @@ -133,4 +133,127 @@ describe('Navigation', () => { }); }); }); + + describe('scroll restoration', () => { + const SCROLL_STORAGE_KEY = 'quilt:navigation:scroll-positions'; + + // Navigation instances share the global `window`, and never detach their + // listeners, so a stray `popstate`/`scroll` handler from one test would + // fire in the next. Track every listener each instance attaches and + // remove them after the test. + const addedListeners: [string, EventListenerOrEventListenerObject][] = []; + let originalAddEventListener: typeof window.addEventListener; + + const scrollOffset = {x: 0, y: 0}; + let scrollTo: ReturnType; + + beforeEach(() => { + originalAddEventListener = window.addEventListener; + window.addEventListener = function ( + this: Window, + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions, + ) { + addedListeners.push([type, listener]); + return originalAddEventListener.call(this, type, listener, options); + } as typeof window.addEventListener; + + scrollOffset.x = 0; + scrollOffset.y = 0; + Object.defineProperty(window, 'scrollX', { + configurable: true, + get: () => scrollOffset.x, + }); + Object.defineProperty(window, 'scrollY', { + configurable: true, + get: () => scrollOffset.y, + }); + + scrollTo = vi.fn(); + vi.stubGlobal('scrollTo', scrollTo); + // Run the deferred (next-frame) restore synchronously. + vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { + cb(0); + return 1; + }); + vi.stubGlobal('cancelAnimationFrame', () => {}); + + window.history.scrollRestoration = 'auto'; + window.history.replaceState(null, '', '/home'); + window.sessionStorage.clear(); + }); + + afterEach(() => { + for (const [type, listener] of addedListeners) { + window.removeEventListener(type, listener); + } + addedListeners.length = 0; + window.addEventListener = originalAddEventListener; + delete (window as any).scrollX; + delete (window as any).scrollY; + vi.unstubAllGlobals(); + window.sessionStorage.clear(); + }); + + it('switches the browser to manual scroll restoration by default', () => { + new Navigation('https://example.com/home'); + + expect(window.history.scrollRestoration).toBe('manual'); + }); + + it('leaves scrolling to the browser when disabled', () => { + const navigation = new Navigation('https://example.com/home', { + scrollRestoration: false, + }); + + navigation.navigate('/next'); + + expect(window.history.scrollRestoration).toBe('auto'); + expect(scrollTo).not.toHaveBeenCalled(); + expect(window.sessionStorage.getItem(SCROLL_STORAGE_KEY)).toBeNull(); + }); + + it('resets to the top on a forward navigation', () => { + const navigation = new Navigation('https://example.com/home'); + scrollOffset.y = 420; + + navigation.navigate('/next'); + + expect(scrollTo).toHaveBeenCalledWith(0, 0); + }); + + it('restores the saved offset when navigating back', () => { + const navigation = new Navigation('https://example.com/home'); + const homeId = navigation.currentRequest.id; + + // User scrolls down the home page, then navigates forward. + scrollOffset.y = 420; + navigation.navigate('/next'); + scrollTo.mockClear(); + + // The browser pops back to the home entry, carrying its id in state. + // (jsdom's document origin is localhost, so replaceState uses a path.) + window.history.replaceState({_id: homeId}, '', '/home'); + window.dispatchEvent( + new PopStateEvent('popstate', {state: {_id: homeId}}), + ); + + expect(scrollTo).toHaveBeenCalledWith(0, 420); + }); + + it('persists scroll positions to sessionStorage keyed by navigation id', () => { + const navigation = new Navigation('https://example.com/home'); + const homeId = navigation.currentRequest.id; + scrollOffset.y = 420; + + navigation.navigate('/next'); + + const stored = window.sessionStorage.getItem(SCROLL_STORAGE_KEY); + expect(stored).not.toBeNull(); + + const entries = new Map(JSON.parse(stored!)); + expect(entries.get(homeId)).toEqual([0, 420]); + }); + }); });