From a4205e971418cd4c08d0c72ec3ca8e1cfd3059da Mon Sep 17 00:00:00 2001 From: Chris Sauve Date: Tue, 14 Jul 2026 15:17:06 -0400 Subject: [PATCH 1/2] Add a scroll option to Navigation.navigate() and Aligns the default scroll behavior with React Router, Next.js, and TanStack Router: every forward navigation (push or replace) resets the scroll position to the top of the page, or to the URL's hash target. Previously, replace navigations kept the page where it was. Pass `scroll: false` (or ``) to keep the current scroll position - useful when the URL encodes UI state (filters, tabs, a selected calendar day) and the page should stay put. The kept offset is recorded against the new history entry so a later back/forward return still restores it. Co-Authored-By: Claude Fable 5 --- .changeset/navigate-scroll-option.md | 19 ++ .../preact-router/source/Navigation.test.ts | 187 ++++++++++++++++++ packages/preact-router/source/Navigation.ts | 30 ++- .../preact-router/source/components/Link.tsx | 15 +- 4 files changed, 242 insertions(+), 9 deletions(-) create mode 100644 .changeset/navigate-scroll-option.md create mode 100644 packages/preact-router/source/Navigation.test.ts diff --git a/.changeset/navigate-scroll-option.md b/.changeset/navigate-scroll-option.md new file mode 100644 index 000000000..398584651 --- /dev/null +++ b/.changeset/navigate-scroll-option.md @@ -0,0 +1,19 @@ +--- +'@quilted/preact-router': minor +--- + +Added a `scroll` option to `Navigation.navigate()` and ``, and aligned the default scroll behavior of replace navigations with other routers. + +Every forward navigation — push or replace — now resets the scroll position to the top of the page (or to the URL's hash target), matching the defaults of React Router (`preventScrollReset`), Next.js (`scroll`), and TanStack Router (`resetScroll`). Previously, replace navigations kept the page where it was. + +To keep the current scroll position — for example, when the URL encodes UI state like filters, tabs, or a selected calendar day — pass `scroll: false`: + +```ts +navigation.navigate(url, {replace: true, 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..acd9c1d5e --- /dev/null +++ b/packages/preact-router/source/Navigation.test.ts @@ -0,0 +1,187 @@ +// @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 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..0aea32077 100644 --- a/packages/preact-router/source/Navigation.ts +++ b/packages/preact-router/source/Navigation.ts @@ -41,8 +41,9 @@ 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) resets to the top (or scrolls + * to the URL hash target, if present), unless it opted out with + * `navigate(to, {scroll: false})`; * - 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 +57,20 @@ 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 instead, which is useful when the URL encodes UI state + * (filters, tabs, a selected calendar day) and the page should stay + * where it is. + * + * Defaults to `true` for both push and replace navigations, matching + * React Router (`preventScrollReset`), Next.js (`scroll`), and TanStack + * Router (`resetScroll`). Has no effect when the `Navigation` was + * created with `scrollRestoration: false`. + */ + scroll?: boolean; } const STATE_ID_FIELD_KEY = '_id'; @@ -160,7 +175,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,11 +247,10 @@ 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); + if (scroll === false) { + // 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); } else { this.#restoreScrollPosition(request); } diff --git a/packages/preact-router/source/components/Link.tsx b/packages/preact-router/source/components/Link.tsx index ef479cceb..a502d85da 100644 --- a/packages/preact-router/source/components/Link.tsx +++ b/packages/preact-router/source/components/Link.tsx @@ -13,6 +13,13 @@ 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. Forwarded to + * `navigation.navigate()`'s `scroll` option. + */ + scroll?: boolean; } export function Link({ @@ -22,6 +29,7 @@ export function Link({ target, base, onClick, + scroll, external: explicitlyExternal = target != null, ...rest }: RenderableProps) { @@ -62,7 +70,12 @@ export function Link({ } event.preventDefault(); - navigation.navigate(to); + + if (scroll == null) { + navigation.navigate(to); + } else { + navigation.navigate(to, {scroll}); + } }; const href = From 99853dace493b691c7f3fe3bb34bb14c982f57d6 Mon Sep 17 00:00:00 2001 From: Chris Sauve Date: Tue, 14 Jul 2026 15:32:44 -0400 Subject: [PATCH 2/2] Keep scroll for search-param-only navigations by default The URL change, not the history operation, now decides the default: push and replace both reset on a pathname/hash change and both stay put when only the search params change. Search-only navigations almost always encode UI state (filters, tabs, a selected day) rather than a new location. `scroll: true` / `scroll: false` force either behavior. Co-Authored-By: Claude Fable 5 --- .changeset/navigate-scroll-option.md | 11 +++-- .../preact-router/source/Navigation.test.ts | 49 +++++++++++++++++++ packages/preact-router/source/Navigation.ts | 37 +++++++++----- .../preact-router/source/components/Link.tsx | 5 +- 4 files changed, 83 insertions(+), 19 deletions(-) diff --git a/.changeset/navigate-scroll-option.md b/.changeset/navigate-scroll-option.md index 398584651..98a26f2b2 100644 --- a/.changeset/navigate-scroll-option.md +++ b/.changeset/navigate-scroll-option.md @@ -2,14 +2,17 @@ '@quilted/preact-router': minor --- -Added a `scroll` option to `Navigation.navigate()` and ``, and aligned the default scroll behavior of replace navigations with other routers. +Added a `scroll` option to `Navigation.navigate()` and ``, and made the URL change — not the history operation — decide the default scroll behavior. -Every forward navigation — push or replace — now resets the scroll position to the top of the page (or to the URL's hash target), matching the defaults of React Router (`preventScrollReset`), Next.js (`scroll`), and TanStack Router (`resetScroll`). Previously, replace navigations kept the page where it was. +For push and replace navigations alike, the default is now: -To keep the current scroll position — for example, when the URL encodes UI state like filters, tabs, or a selected calendar day — pass `scroll: false`: +- 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, {replace: true, scroll: false}); +navigation.navigate(url, {scroll: false}); ``` ```tsx diff --git a/packages/preact-router/source/Navigation.test.ts b/packages/preact-router/source/Navigation.test.ts index acd9c1d5e..49345166d 100644 --- a/packages/preact-router/source/Navigation.test.ts +++ b/packages/preact-router/source/Navigation.test.ts @@ -102,6 +102,55 @@ describe('Navigation scroll restoration', () => { 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; diff --git a/packages/preact-router/source/Navigation.ts b/packages/preact-router/source/Navigation.ts index 0aea32077..972f1cea0 100644 --- a/packages/preact-router/source/Navigation.ts +++ b/packages/preact-router/source/Navigation.ts @@ -41,9 +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 (push or replace) resets to the top (or scrolls - * to the URL hash target, if present), unless it opted out with - * `navigate(to, {scroll: false})`; + * - 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. @@ -61,14 +62,15 @@ export interface NavigateOptions { * 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 instead, which is useful when the URL encodes UI state - * (filters, tabs, a selected calendar day) and the page should stay - * where it is. + * position, or `true` to force the reset. * - * Defaults to `true` for both push and replace navigations, matching - * React Router (`preventScrollReset`), Next.js (`scroll`), and TanStack - * Router (`resetScroll`). Has no effect when the `Navigation` was - * created with `scrollRestoration: false`. + * 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; } @@ -247,12 +249,21 @@ export class Navigation { this.#currentRequest.value = request; if (this.#scrollRestoration) { - if (scroll === false) { + // 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); - } else { - this.#restoreScrollPosition(request); } this.#persistScrollPositions(); diff --git a/packages/preact-router/source/components/Link.tsx b/packages/preact-router/source/components/Link.tsx index a502d85da..dab5ea983 100644 --- a/packages/preact-router/source/components/Link.tsx +++ b/packages/preact-router/source/components/Link.tsx @@ -16,8 +16,9 @@ interface Props extends Omit { /** * 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. Forwarded to - * `navigation.navigate()`'s `scroll` option. + * 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; }