diff --git a/package-lock.json b/package-lock.json index 832c8f0..3b6dd52 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "chai": "^5.1.1", "htm": "^3.1.1", "kleur": "^4.1.5", + "navigation-api-types": "^0.6.1", "preact": "^10.24.3", "preact-render-to-string": "^6.5.11", "sinon": "^18.0.0", @@ -3023,6 +3024,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/navigation-api-types": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/navigation-api-types/-/navigation-api-types-0.6.1.tgz", + "integrity": "sha512-e1BbABfPRKLkBbZfAVuRFR2CLFWOtSt8e0ryJivjvLdw8yxD7ASPgyfl+klcGYvrPcP4zhOtZ4KpmQcEo1FgQw==", + "dev": true, + "license": "MIT" + }, "node_modules/negotiator": { "version": "0.6.3", "dev": true, diff --git a/package.json b/package.json index 509a3c0..3b4d17b 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "chai": "^5.1.1", "htm": "^3.1.1", "kleur": "^4.1.5", + "navigation-api-types": "^0.6.1", "preact": "^10.24.3", "preact-render-to-string": "^6.5.11", "sinon": "^18.0.0", diff --git a/src/internal.d.ts b/src/internal.d.ts index efcd0f0..dfe2b99 100644 --- a/src/internal.d.ts +++ b/src/internal.d.ts @@ -1,3 +1,5 @@ +/// + export interface AugmentedComponent extends Component { __v: VNode; __c: (error: Promise, suspendingVNode: VNode) => void; diff --git a/src/router.d.ts b/src/router.d.ts index d142a08..33560d2 100644 --- a/src/router.d.ts +++ b/src/router.d.ts @@ -40,7 +40,6 @@ interface LocationHook { path: string; pathParams: Record; searchParams: Record; - route: (url: string, replace?: boolean) => void; } export const useLocation: () => LocationHook; diff --git a/src/router.js b/src/router.js index 90290cc..cc126d3 100644 --- a/src/router.js +++ b/src/router.js @@ -7,46 +7,46 @@ import { useContext, useMemo, useReducer, useLayoutEffect, useRef } from 'preact * @typedef {import('./internal.d.ts').VNode} VNode */ -let push, scope; -const UPDATE = (state, url) => { - push = undefined; - if (url && url.type === 'click') { - // ignore events the browser takes care of already: - if (url.ctrlKey || url.metaKey || url.altKey || url.shiftKey || url.button !== 0) { - return state; - } +/** @type {string | RegExp | undefined} */ +let scope; - const link = url.target.closest('a[href]'), - href = link && link.getAttribute('href'); - if ( - !link || - link.origin != location.origin || - /^#/.test(href) || - !/^(_?self)?$/i.test(link.target) || - scope && (typeof scope == 'string' - ? !href.startsWith(scope) - : !scope.test(href) - ) - ) { - return state; - } +/** + * @param {URL} url + * @returns {boolean} + */ +function isInScope(url) { + return !scope || (typeof scope == 'string' + ? url.pathname.startsWith(scope) + : scope.test(url.pathname) + ); +} - push = true; - url.preventDefault(); - url = link.href.replace(location.origin, ''); - } else if (typeof url === 'string') { - push = true; - } else if (url && url.url) { - push = !url.replace; - url = url.url; - } else { - url = location.pathname + location.search; +/** + * @param {string} state + * @param {NavigateEvent} e + */ +function handleNav(state, e) { + // TODO: Double-check this can't fail to parse. + // `.destination` is read-only, so I'm hoping it guarantees a valid URL. + const url = new URL(e.destination.url); + + if ( + !e.canIntercept || + e.hashChange || + e.downloadRequest !== null || + // Not yet implemented by Chrome, but coming? + //!/^(_?self)?$/i.test(/** @type {HTMLAnchorElement} */ (e.sourceElement).target) || + !isInScope(url) + ) { + // We only set this for our tests, it's otherwise very difficult to + // determine if a navigation was intercepted or not externally. + e['preact-iso-ignored'] = true; + return state; } - if (push === true) history.pushState(null, '', url); - else if (push === false) history.replaceState(null, '', url); - return url; -}; + e.intercept(); + return url.href.replace(url.origin, ''); +} export const exec = (url, route, matches = {}) => { url = url.split('/').filter(Boolean); @@ -80,9 +80,8 @@ export const exec = (url, route, matches = {}) => { * @type {import('./router.d.ts').LocationProvider} */ export function LocationProvider(props) { - const [url, route] = useReducer(UPDATE, location.pathname + location.search); + const [url, route] = useReducer(handleNav, location.pathname + location.search); if (props.scope) scope = props.scope; - const wasPush = push === true; const value = useMemo(() => { const u = new URL(url, location.origin); @@ -93,18 +92,14 @@ export function LocationProvider(props) { path, pathParams: {}, searchParams: Object.fromEntries(u.searchParams), - route: (url, replace) => route({ url, replace }), - wasPush }; }, [url]); useLayoutEffect(() => { - addEventListener('click', route); - addEventListener('popstate', route); + navigation.addEventListener('navigate', route); return () => { - removeEventListener('click', route); - removeEventListener('popstate', route); + navigation.removeEventListener('navigate', route); }; }, []); @@ -116,7 +111,7 @@ const RESOLVED = Promise.resolve(); export function Router(props) { const [c, update] = useReducer(c => c + 1, 0); - const { url, path, pathParams, searchParams, wasPush } = useLocation(); + const { url, path, pathParams, searchParams } = useLocation(); const { rest = path } = useContext(RouterContext); const isLoading = useRef(false); @@ -237,7 +232,7 @@ export function Router(props) { // The route is loaded and rendered. if (prevRoute.current !== path) { - if (wasPush) scrollTo(0, 0); + scrollTo(0, 0); if (props.onRouteChange) props.onRouteChange(url); prevRoute.current = path; @@ -245,7 +240,7 @@ export function Router(props) { if (props.onLoadEnd && isLoading.current) props.onLoadEnd(url); isLoading.current = false; - }, [path, wasPush, c]); + }, [path, c]); // Note: cur MUST render first in order to set didSuspend & prev. return routeChanged @@ -262,7 +257,7 @@ const RenderRef = ({ r }) => r.current; Router.Provider = LocationProvider; LocationProvider.ctx = createContext( - /** @type {import('./router.d.ts').LocationHook & { wasPush: boolean }} */ ({}) + /** @type {import('./router.d.ts').LocationHook}} */ ({}) ); const RouterContext = createContext( /** @type {{ rest: string }} */ ({}) diff --git a/test/router.test.js b/test/router.test.js index cb587d8..37d5b34 100644 --- a/test/router.test.js +++ b/test/router.test.js @@ -1,5 +1,5 @@ import { h, Fragment, render, hydrate, options } from 'preact'; -import { useState } from 'preact/hooks'; +import { useEffect, useState } from 'preact/hooks'; import * as chai from 'chai'; import * as sinon from 'sinon'; import sinonChai from 'sinon-chai'; @@ -50,7 +50,8 @@ describe('Router', () => { scratch ); - loc.route('/a/'); + navigation.navigate('/a/'); + await sleep(1); expect(loc).to.deep.include({ @@ -154,7 +155,7 @@ describe('Router', () => { }); Home.resetHistory(); - loc.route('/profiles'); + navigation.navigate('/profiles'); await sleep(1); expect(scratch).to.have.property('textContent', 'Profiles'); @@ -170,7 +171,7 @@ describe('Router', () => { }); Profiles.resetHistory(); - loc.route('/profiles/bob'); + navigation.navigate('/profiles/bob'); await sleep(1); expect(scratch).to.have.property('textContent', 'Profile: bob'); @@ -188,7 +189,7 @@ describe('Router', () => { }); Profile.resetHistory(); - loc.route('/other?a=b&c=d'); + navigation.navigate('/other?a=b&c=d'); await sleep(1); expect(scratch).to.have.property('textContent', 'Fallback'); @@ -242,7 +243,7 @@ describe('Router', () => { expect(A).to.have.been.calledWith({ path: '/', searchParams: {}, pathParams: {} }); A.resetHistory(); - loc.route('/b'); + navigation.navigate('/b'); expect(scratch).to.have.property('innerHTML', '

A

hello

'); expect(A).not.to.have.been.called; @@ -263,18 +264,18 @@ describe('Router', () => { expect(B).to.have.been.calledWith({ path: '/b', searchParams: {}, pathParams: {} }); B.resetHistory(); - loc.route('/c'); - loc.route('/c?1'); - loc.route('/c'); + navigation.navigate('/c'); + navigation.navigate('/c?1'); + navigation.navigate('/c'); expect(scratch).to.have.property('innerHTML', '

B

hello

'); expect(B).not.to.have.been.called; await sleep(1); - loc.route('/c'); - loc.route('/c?2'); - loc.route('/c'); + navigation.navigate('/c'); + navigation.navigate('/c?2'); + navigation.navigate('/c'); expect(scratch).to.have.property('innerHTML', '

B

hello

'); // We should never re-invoke while loading (that would be a remount of the old route): @@ -293,7 +294,7 @@ describe('Router', () => { C.resetHistory(); B.resetHistory(); - loc.route('/b'); + navigation.navigate('/b'); await sleep(1); expect(scratch).to.have.property('innerHTML', '

B

hello

'); @@ -303,7 +304,7 @@ describe('Router', () => { A.resetHistory(); B.resetHistory(); - loc.route('/'); + navigation.navigate('/'); await sleep(1); expect(scratch).to.have.property('innerHTML', '

A

hello

'); @@ -347,21 +348,21 @@ describe('Router', () => { expect(renderRefCount).to.equal(2); renderRefCount = 0; - loc.route('/b/a'); + navigation.navigate('/b/a'); await sleep(10); expect(scratch).to.have.property('innerHTML', '

b/a

'); expect(renderRefCount).to.equal(4); renderRefCount = 0; - loc.route('/b/b'); + navigation.navigate('/b/b'); await sleep(10); expect(scratch).to.have.property('innerHTML', '

b/b

'); expect(renderRefCount).to.equal(1); renderRefCount = 0; - loc.route('/'); + navigation.navigate('/'); await sleep(10); expect(scratch).to.have.property('innerHTML', '

a

'); @@ -451,7 +452,8 @@ describe('Router', () => { loadEnd.resetHistory(); routeChange.resetHistory(); - loc.route('/b'); + navigation.navigate('/b'); + await sleep(1); expect(loadStart).to.have.been.calledWith('/b'); @@ -508,14 +510,13 @@ describe('Router', () => { expect(loadEnd).not.to.have.been.called; }); - describe('intercepted VS external links', () => { + // TODO: Relies on upcoming property being added to navigation events + describe.skip('intercepted VS external links', () => { const shouldIntercept = [null, '', '_self', 'self', '_SELF']; const shouldNavigate = ['_top', '_parent', '_blank', 'custom', '_BLANK']; - const clickHandler = sinon.fake(e => e.preventDefault()); - - const Route = sinon.fake( - () =>
+ const Route = () => ( +
{[...shouldIntercept, ...shouldNavigate].map((target, i) => { const url = '/' + i + '/' + target; if (target === null) return target = {target + ''}; @@ -524,31 +525,32 @@ describe('Router', () => {
); - let pushState; - - before(() => { - pushState = sinon.spy(history, 'pushState'); - addEventListener('click', clickHandler); - }); - - after(() => { - pushState.restore(); - removeEventListener('click', clickHandler); - }); + let triedToNavigate = false; + const handler = (e) => { + e.intercept(); + if (e['preact-iso-ignored']) { + triedToNavigate = true; + } + } beforeEach(async () => { - render( - - - - - - , - scratch - ); - Route.resetHistory(); - clickHandler.resetHistory(); - pushState.resetHistory(); + const App = () => { + useEffect(() => { + navigation.addEventListener('navigate', handler); + return () => navigation.removeEventListener('navigate', handler); + }, []); + + return ( + + + + + + + ); + } + render(, scratch); + await sleep(10); }); const getName = target => (target == null ? 'no target attribute' : `target="${target}"`); @@ -563,9 +565,9 @@ describe('Router', () => { el.click(); await sleep(1); expect(loc).to.deep.include({ url }); - expect(Route).to.have.been.calledOnce; - expect(pushState).to.have.been.calledWith(null, '', url); - expect(clickHandler).to.have.been.called; + expect(triedToNavigate).to.be.false; + + triedToNavigate = false; }); } @@ -577,9 +579,9 @@ describe('Router', () => { if (!el) throw Error(`Unable to find link: ${sel}`); el.click(); await sleep(1); - expect(Route).not.to.have.been.called; - expect(pushState).not.to.have.been.called; - expect(clickHandler).to.have.been.called; + expect(triedToNavigate).to.be.true; + + triedToNavigate = false; }); } }); @@ -588,8 +590,6 @@ describe('Router', () => { const shouldIntercept = ['/app', '/app/deeper']; const shouldNavigate = ['/site', '/site/deeper']; - const clickHandler = sinon.fake(e => e.preventDefault()); - const Links = () => ( <> Internal Link @@ -599,102 +599,81 @@ describe('Router', () => { ); - let pushState; - - before(() => { - pushState = sinon.spy(history, 'pushState'); - addEventListener('click', clickHandler); - }); - - after(() => { - pushState.restore(); - removeEventListener('click', clickHandler); - }); - - beforeEach(async () => { - clickHandler.resetHistory(); - pushState.resetHistory(); - }); + let triedToNavigate = false; + const handler = (e) => { + e.intercept(); + if (e['preact-iso-ignored']) { + triedToNavigate = true; + } + } - it('should intercept clicks on links matching the `scope` props (string)', async () => { - render( - - - - , - scratch - ); + it('should support the `scope` prop (string)', async () => { + const App = () => { + useEffect(() => { + navigation.addEventListener('navigate', handler); + return () => navigation.removeEventListener('navigate', handler); + }, []); + + return ( + + + + + ); + } + render(, scratch); + await sleep(10); for (const url of shouldIntercept) { scratch.querySelector(`a[href="${url}"]`).click(); await sleep(1); expect(loc).to.deep.include({ url }); - expect(pushState).to.have.been.calledWith(null, '', url); - expect(clickHandler).to.have.been.called; + expect(triedToNavigate).to.be.false; - pushState.resetHistory(); - clickHandler.resetHistory(); + triedToNavigate = false; } - }); - - it('should allow default browser navigation for links not matching the `scope` props (string)', async () => { - render( - - - - , - scratch - ); for (const url of shouldNavigate) { scratch.querySelector(`a[href="${url}"]`).click(); await sleep(1); - expect(pushState).not.to.have.been.called; - expect(clickHandler).to.have.been.called; + expect(triedToNavigate).to.be.true; - pushState.resetHistory(); - clickHandler.resetHistory(); + triedToNavigate = false; } }); - it('should intercept clicks on links matching the `scope` props (regex)', async () => { - render( - - - - , - scratch - ); + it('should support the `scope` prop (regex)', async () => { + const App = () => { + useEffect(() => { + navigation.addEventListener('navigate', handler); + return () => navigation.removeEventListener('navigate', handler); + }, []); + + return ( + + + + + ); + } + render(, scratch); + await sleep(10); for (const url of shouldIntercept) { scratch.querySelector(`a[href="${url}"]`).click(); await sleep(1); expect(loc).to.deep.include({ url }); - expect(pushState).to.have.been.calledWith(null, '', url); - expect(clickHandler).to.have.been.called; + expect(triedToNavigate).to.be.false; - pushState.resetHistory(); - clickHandler.resetHistory(); + triedToNavigate = false; } - }); - - it('should allow default browser navigation for links not matching the `scope` props (regex)', async () => { - render( - - - - , - scratch - ); for (const url of shouldNavigate) { scratch.querySelector(`a[href="${url}"]`).click(); await sleep(1); - expect(pushState).not.to.have.been.called; - expect(clickHandler).to.have.been.called; + expect(triedToNavigate).to.be.true; - pushState.resetHistory(); - clickHandler.resetHistory(); + triedToNavigate = false; } }); }); @@ -702,7 +681,14 @@ describe('Router', () => { it('should scroll to top when navigating forward', async () => { const scrollTo = sinon.spy(window, 'scrollTo'); - const Route = sinon.fake(() => ); + const Route = sinon.fake( + () => ( +
+ link +
+ ) + ); + render( @@ -717,7 +703,7 @@ describe('Router', () => { expect(Route).to.have.been.calledOnce; Route.resetHistory(); - loc.route('/programmatic'); + navigation.navigate('/programmatic'); await sleep(1); expect(loc).to.deep.include({ url: '/programmatic' }); @@ -740,14 +726,13 @@ describe('Router', () => { }); it('should ignore clicks on document fragment links', async () => { - const pushState = sinon.spy(history, 'pushState'); - const Route = sinon.fake( () => ); + render( @@ -760,7 +745,6 @@ describe('Router', () => { scratch ); - expect(Route).to.have.been.calledOnce; Route.resetHistory(); scratch.querySelector('a[href="#foo"]').click(); @@ -769,7 +753,6 @@ describe('Router', () => { // NOTE: we don't (currently) propagate in-page anchor navigations into context, to avoid useless renders. expect(loc).to.deep.include({ url: '/' }); expect(Route).not.to.have.been.called; - expect(pushState).not.to.have.been.called; expect(location.hash).to.equal('#foo'); scratch.querySelector('a[href="/other#bar"]').click(); @@ -777,14 +760,10 @@ describe('Router', () => { expect(Route).to.have.been.calledOnce; expect(loc).to.deep.include({ url: '/other#bar', path: '/other' }); - expect(pushState).to.have.been.called; expect(location.hash).to.equal('#bar'); - - pushState.restore(); }); it('should normalize children', async () => { - const pushState = sinon.spy(history, 'pushState'); const Route = sinon.fake(() => foo); const routes = ['/foo', '/bar']; @@ -807,9 +786,6 @@ describe('Router', () => { expect(Route).to.have.been.calledOnce; expect(loc).to.deep.include({ url: '/foo#foo', path: '/foo' }); - expect(pushState).to.have.been.called; - - pushState.restore(); }); it('should match nested routes', async () => { @@ -866,12 +842,37 @@ describe('Router', () => { }); it('should replace the current URL', async () => { - const pushState = sinon.spy(history, 'pushState'); - const replaceState = sinon.spy(history, 'replaceState'); + render( + + + null} /> + null} /> + null} /> + + + , + scratch + ); + + navigation.navigate('/foo'); + navigation.navigate('/bar', { history: 'replace' }); + + const entries = navigation.entries(); + + // Top of the stack + const last = new URL(entries[entries.length - 1].url); + expect(last.pathname).to.equal('/bar'); + // Entry before + const secondLast = new URL(entries[entries.length - 2].url); + expect(secondLast.pathname).to.equal('/'); + }); + + it('should support navigating backwards and forwards', async () => { render( + null} /> null} /> @@ -879,12 +880,20 @@ describe('Router', () => { scratch ); - loc.route("/foo", true); - expect(pushState).not.to.have.been.called; - expect(replaceState).to.have.been.calledWith(null, "", "/foo"); + navigation.navigate('/foo'); + await sleep(10); + + expect(loc).to.deep.include({ url: '/foo', path: '/foo', searchParams: {} }); + + navigation.back(); + await sleep(10); + + expect(loc).to.deep.include({ url: '/', path: '/', searchParams: {} }); + + navigation.forward(); + await sleep(10); - pushState.restore(); - replaceState.restore(); + expect(loc).to.deep.include({ url: '/foo', path: '/foo', searchParams: {} }); }); });