Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
5 changes: 5 additions & 0 deletions .changeset/rare-seals-burn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"preact-iso": patch
---

[preact-iso] Router: reset page scroll position on forward navigations
15 changes: 10 additions & 5 deletions packages/preact-iso/router.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
import { h, createContext, cloneElement } from 'preact';
import { useContext, useMemo, useReducer, useEffect, useLayoutEffect, useRef } from 'preact/hooks';

let push;
const UPDATE = (state, url) => {
let push = true;
push = undefined;
if (url && url.type === 'click') {
const link = url.target.closest('a[href]');
if (!link || link.origin != location.origin) return state;

push = true;
url.preventDefault();
url = link.href.replace(location.origin, '');
} else if (typeof url !== 'string') {
} else if (typeof url === 'string') {
push = true;
} else {
url = location.pathname + location.search;
push = undefined;
}

if (push === true) history.pushState(null, '', url);
Expand Down Expand Up @@ -43,12 +46,13 @@ export const exec = (url, route, matches) => {

export function LocationProvider(props) {
const [url, route] = useReducer(UPDATE, location.pathname + location.search);
const wasPush = push === true;

const value = useMemo(() => {
const u = new URL(url, location.origin);
const path = u.pathname.replace(/(.)\/$/g, '$1');
// @ts-ignore-next
return { url, path, query: Object.fromEntries(u.searchParams), route };
return { url, path, query: Object.fromEntries(u.searchParams), route, wasPush };
}, [url]);

useEffect(() => {
Expand All @@ -70,7 +74,7 @@ export function Router(props) {

const loc = useLocation();

const { url, path, query } = loc;
const { url, path, query, wasPush } = loc;

const cur = useRef(loc);
const prev = useRef();
Expand Down Expand Up @@ -112,6 +116,7 @@ export function Router(props) {
prev.current = prevChildren.current = pending.current = null;
if (props.onLoadEnd) props.onLoadEnd(url);
update(0);
if (wasPush) scrollTo(0, 0);
};

if (p) {
Expand Down
48 changes: 48 additions & 0 deletions packages/preact-iso/test/router.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { h, html, render } from 'htm/preact';
import { LocationProvider, Router, useLocation } from '../router.js';
import lazy, { ErrorBoundary } from '../lazy.js';

Object.defineProperty(window, 'scrollTo', { value() {} });

const sleep = ms => new Promise(r => setTimeout(r, ms));

// delayed lazy()
Expand Down Expand Up @@ -206,4 +208,50 @@ describe('Router', () => {
// expect(A).toHaveBeenCalledTimes(1);
expect(A).toHaveBeenCalledWith({ path: '/', query: {} }, expect.anything());
});

it('should scroll to top when navigating forward', async () => {
const scrollTo = jest.spyOn(window, 'scrollTo');

const Route = jest.fn(() => html`<div style=${{ height: '1000px' }}><a href="/link">link</a></div>`);
let loc;
render(
html`
<${LocationProvider}>
<${Router}>
<${Route} default />
<//>
<${() => {
loc = useLocation();
}} />
<//>
`,
scratch
);

await sleep(20);

expect(scrollTo).not.toHaveBeenCalled();
expect(Route).toHaveBeenCalledTimes(1);
Route.mockClear();

loc.route('/programmatic');
await sleep(10);
expect(loc).toMatchObject({ url: '/programmatic' });
expect(scrollTo).toHaveBeenCalledWith(0, 0);
expect(scrollTo).toHaveBeenCalledTimes(1);
expect(Route).toHaveBeenCalledTimes(1);
Route.mockClear();
scrollTo.mockClear();

scratch.querySelector('a').click();
await sleep(10);
expect(loc).toMatchObject({ url: '/link' });
expect(scrollTo).toHaveBeenCalledWith(0, 0);
expect(scrollTo).toHaveBeenCalledTimes(1);
expect(Route).toHaveBeenCalledTimes(1);
Route.mockClear();

await sleep(10);
scrollTo.mockRestore();
});
});