diff --git a/.changeset/navigate-scroll-option.md b/.changeset/navigate-scroll-option.md new file mode 100644 index 000000000..98a26f2b2 --- /dev/null +++ b/.changeset/navigate-scroll-option.md @@ -0,0 +1,22 @@ +--- +'@quilted/preact-router': minor +--- + +Added a `scroll` option to `Navigation.navigate()` and ``, and made the URL change — not the history operation — decide the default scroll behavior. + +For push and replace navigations alike, the default is now: + +- Changing the **pathname or hash** resets the scroll position to the top of the page (or scrolls to the URL's hash target). Previously, replace navigations never reset. +- Changing **only the search params** keeps the current scroll position — search-only navigations usually encode UI state (filters, tabs, a selected calendar day) rather than a new location. Previously, push navigations always reset. + +Pass `scroll` to override the default in either direction: + +```ts +navigation.navigate(url, {scroll: false}); +``` + +```tsx + + Select day + +``` diff --git a/packages/preact-router/source/Navigation.test.ts b/packages/preact-router/source/Navigation.test.ts new file mode 100644 index 000000000..49345166d --- /dev/null +++ b/packages/preact-router/source/Navigation.test.ts @@ -0,0 +1,236 @@ +// @vitest-environment jsdom + +import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest'; + +import {Navigation} from './Navigation.ts'; + +const SCROLL_STORAGE_KEY = 'quilt:navigation:scroll-positions'; + +describe('Navigation scroll restoration', () => { + // 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 what each instance attaches and remove it after. + 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, + }); + + // jsdom has no real `scrollTo`; stub it so we can assert and stay quiet. + scrollTo = vi.fn(); + vi.stubGlobal('scrollTo', scrollTo); + // Run the deferred (next-frame) restore synchronously. + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callback(0); + return 1; + }); + vi.stubGlobal('cancelAnimationFrame', () => {}); + + history.scrollRestoration = 'auto'; + history.replaceState(null, '', '/'); + 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(); + sessionStorage.clear(); + }); + + it('switches the browser to manual scroll restoration by default', () => { + void new Navigation('https://example.com/'); + + expect(history.scrollRestoration).toBe('manual'); + }); + + it('leaves scrolling to the browser when disabled', () => { + const navigation = new Navigation('https://example.com/', { + scrollRestoration: false, + }); + + navigation.navigate('/next'); + + expect(history.scrollRestoration).toBe('auto'); + expect(scrollTo).not.toHaveBeenCalled(); + expect(sessionStorage.getItem(SCROLL_STORAGE_KEY)).toBeNull(); + }); + + it('resets to the top on a push navigation', () => { + const navigation = new Navigation('https://example.com/'); + scrollOffset.y = 420; + + navigation.navigate('/next'); + + expect(scrollTo).toHaveBeenCalledWith(0, 0); + }); + + it('resets to the top on a replace navigation', () => { + const navigation = new Navigation('https://example.com/'); + scrollOffset.y = 420; + + navigation.navigate('/next', {replace: true}); + + expect(scrollTo).toHaveBeenCalledWith(0, 0); + }); + + it('keeps the scroll position when only the search params change', () => { + const navigation = new Navigation('https://example.com/schedule'); + scrollOffset.y = 420; + + navigation.navigate('/schedule?day=2024-01-02'); + + expect(scrollTo).not.toHaveBeenCalled(); + }); + + it('keeps the scroll position when replacing with only a search param change', () => { + const navigation = new Navigation( + 'https://example.com/schedule?day=2024-01-01', + ); + scrollOffset.y = 420; + + navigation.navigate('/schedule?day=2024-01-02', {replace: true}); + + expect(scrollTo).not.toHaveBeenCalled(); + }); + + it('resets to the top for a search-param-only navigation with `scroll: true`', () => { + const navigation = new Navigation('https://example.com/schedule'); + scrollOffset.y = 420; + + navigation.navigate('/schedule?day=2024-01-02', {scroll: true}); + + expect(scrollTo).toHaveBeenCalledWith(0, 0); + }); + + it('scrolls to the hash target when the hash changes on the same path', () => { + const scrollIntoView = vi.fn(); + const target = document.createElement('div'); + target.id = 'section'; + target.scrollIntoView = scrollIntoView; + document.body.append(target); + + try { + const navigation = new Navigation('https://example.com/schedule'); + scrollOffset.y = 420; + + navigation.navigate('/schedule#section'); + + expect(scrollIntoView).toHaveBeenCalled(); + expect(scrollTo).not.toHaveBeenCalled(); + } finally { + target.remove(); + } + }); + + it('keeps the scroll position when navigating with `scroll: false`', () => { + const navigation = new Navigation('https://example.com/'); + scrollOffset.y = 420; + + navigation.navigate('/next', {scroll: false}); + + expect(scrollTo).not.toHaveBeenCalled(); + }); + + it('keeps the scroll position when replacing with `scroll: false`', () => { + const navigation = new Navigation('https://example.com/'); + scrollOffset.y = 420; + + navigation.navigate('/next', {replace: true, scroll: false}); + + expect(scrollTo).not.toHaveBeenCalled(); + }); + + it('records the kept position against the new entry when navigating with `scroll: false`', () => { + const navigation = new Navigation('https://example.com/'); + scrollOffset.y = 420; + + const request = navigation.navigate('/next', {scroll: false}); + + const stored = sessionStorage.getItem(SCROLL_STORAGE_KEY); + expect(stored).not.toBeNull(); + + const entries = new Map(JSON.parse(stored!)); + expect(entries.get(request.id)).toEqual([0, 420]); + }); + + it('scrolls to the hash target on a forward navigation', () => { + const scrollIntoView = vi.fn(); + const target = document.createElement('div'); + target.id = 'section'; + target.scrollIntoView = scrollIntoView; + document.body.append(target); + + try { + const navigation = new Navigation('https://example.com/'); + scrollOffset.y = 420; + + navigation.navigate('/next#section'); + + expect(scrollIntoView).toHaveBeenCalled(); + expect(scrollTo).not.toHaveBeenCalled(); + } finally { + target.remove(); + } + }); + + it('restores the saved offset when navigating back', () => { + const navigation = new Navigation('https://example.com/'); + const initialId = navigation.currentRequest.id; + + // User scrolls down the page, then navigates forward. + scrollOffset.y = 420; + navigation.navigate('/next'); + scrollTo.mockClear(); + + // The browser pops back to the initial entry, carrying its id in state. + history.replaceState({_id: initialId}, '', '/'); + window.dispatchEvent( + new PopStateEvent('popstate', {state: {_id: initialId}}), + ); + + expect(scrollTo).toHaveBeenCalledWith(0, 420); + }); + + it('persists scroll positions to sessionStorage keyed by navigation id', () => { + const navigation = new Navigation('https://example.com/'); + const initialId = navigation.currentRequest.id; + scrollOffset.y = 420; + + navigation.navigate('/next'); + + const stored = sessionStorage.getItem(SCROLL_STORAGE_KEY); + expect(stored).not.toBeNull(); + + const entries = new Map(JSON.parse(stored!)); + expect(entries.get(initialId)).toEqual([0, 420]); + }); +}); diff --git a/packages/preact-router/source/Navigation.ts b/packages/preact-router/source/Navigation.ts index adfd7715c..972f1cea0 100644 --- a/packages/preact-router/source/Navigation.ts +++ b/packages/preact-router/source/Navigation.ts @@ -41,8 +41,10 @@ export interface NavigationOptions { * 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 forward navigation (push or replace) that changes the pathname or + * hash resets to the top (or scrolls to the URL hash target, if + * present); one that only changes the search params keeps the current + * position (see `NavigateOptions['scroll']` to override either way); * - a back/forward navigation restores the offset the entry was last left * at; * - a reload restores the offset of the entry it lands on. @@ -56,6 +58,21 @@ export interface NavigateOptions { replace?: boolean; state?: NavigationRequest['state']; base?: string | URL; + /** + * Whether the router applies its default scroll behavior to this + * navigation — scrolling to the top of the page, or to the URL's hash + * target when it has one. Pass `false` to keep the current scroll + * position, or `true` to force the reset. + * + * When omitted, the shape of the URL change decides (for push and + * replace alike): a navigation that changes the pathname or hash + * applies the default scroll behavior, while one that only changes the + * search params keeps the current scroll position — search-only + * navigations usually encode UI state (filters, tabs, a selected + * calendar day) rather than a new location. Has no effect when the + * `Navigation` was created with `scrollRestoration: false`. + */ + scroll?: boolean; } const STATE_ID_FIELD_KEY = '_id'; @@ -160,7 +177,7 @@ export class Navigation { navigate = ( to: NavigateTo, - {state = {}, replace = false, base}: NavigateOptions = {}, + {state = {}, replace = false, base, scroll}: NavigateOptions = {}, ): NavigationRequest => { if (typeof history === 'undefined') { throw new Error('Cannot navigate in a non-browser environment'); @@ -232,13 +249,21 @@ export class Navigation { 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 { + // Unless the caller decided with `scroll`, the shape of the URL change + // decides: changing the pathname or hash is a new location and gets the + // default scroll behavior, while a search-param–only change is treated + // as UI state (filters, tabs, a selected day) and stays put. + const applyScroll = + scroll ?? + (url.pathname !== currentRequest.url.pathname || + url.hash !== currentRequest.url.hash); + + if (applyScroll) { this.#restoreScrollPosition(request); + } else { + // Keep the page where it is, and record the offset against the new + // entry id so a later back/forward return to this entry restores it. + this.#recordScrollPosition(id); } this.#persistScrollPositions(); diff --git a/packages/preact-router/source/components/Link.tsx b/packages/preact-router/source/components/Link.tsx index ef479cceb..dab5ea983 100644 --- a/packages/preact-router/source/components/Link.tsx +++ b/packages/preact-router/source/components/Link.tsx @@ -13,6 +13,14 @@ interface Props extends Omit { to: NavigateTo; base?: string | URL; external?: boolean; + /** + * Whether the router applies its default scroll behavior when this link + * navigates (scroll to the top, or to the URL's hash target). Pass `false` + * to keep the current scroll position, or `true` to force the reset; when + * omitted, navigations that only change the search params keep the current + * position. Forwarded to `navigation.navigate()`'s `scroll` option. + */ + scroll?: boolean; } export function Link({ @@ -22,6 +30,7 @@ export function Link({ target, base, onClick, + scroll, external: explicitlyExternal = target != null, ...rest }: RenderableProps) { @@ -62,7 +71,12 @@ export function Link({ } event.preventDefault(); - navigation.navigate(to); + + if (scroll == null) { + navigation.navigate(to); + } else { + navigation.navigate(to, {scroll}); + } }; const href =