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 =