diff --git a/.changeset/rare-seals-burn.md b/.changeset/rare-seals-burn.md new file mode 100644 index 000000000..6c6895a21 --- /dev/null +++ b/.changeset/rare-seals-burn.md @@ -0,0 +1,5 @@ +--- +"preact-iso": patch +--- + +[preact-iso] Router: reset page scroll position on forward navigations diff --git a/packages/preact-iso/router.js b/packages/preact-iso/router.js index 38a3ae2d6..7403f860f 100644 --- a/packages/preact-iso/router.js +++ b/packages/preact-iso/router.js @@ -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 || !/^(_?self)?$/i.test(link.target)) 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); @@ -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(() => { @@ -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(); @@ -115,6 +119,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) { diff --git a/packages/preact-iso/test/router.test.js b/packages/preact-iso/test/router.test.js index f3a00f1a6..52168aff2 100644 --- a/packages/preact-iso/test/router.test.js +++ b/packages/preact-iso/test/router.test.js @@ -4,6 +4,8 @@ import { html } 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() @@ -299,4 +301,50 @@ describe('Router', () => { }); } }); + + it('should scroll to top when navigating forward', async () => { + const scrollTo = jest.spyOn(window, 'scrollTo'); + + const Route = jest.fn(() => html`
link
`); + 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(); + }); });