Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/navigate-scroll-option.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@quilted/preact-router': minor
---

Added a `scroll` option to `Navigation.navigate()` and `<Link>`, 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
<Link to={url} scroll={false}>
Select day
</Link>
```
236 changes: 236 additions & 0 deletions packages/preact-router/source/Navigation.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>;

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<string, [number, number]>(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<string, [number, number]>(JSON.parse(stored!));
expect(entries.get(initialId)).toEqual([0, 420]);
});
});
43 changes: 34 additions & 9 deletions packages/preact-router/source/Navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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';
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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();
Expand Down
16 changes: 15 additions & 1 deletion packages/preact-router/source/components/Link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ interface Props extends Omit<AnchorHTMLAttributes, 'href'> {
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({
Expand All @@ -22,6 +30,7 @@ export function Link({
target,
base,
onClick,
scroll,
external: explicitlyExternal = target != null,
...rest
}: RenderableProps<Props, HTMLAnchorElement>) {
Expand Down Expand Up @@ -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 =
Expand Down
Loading