From 1c78f086dd17f442e9859d9de12213344610c159 Mon Sep 17 00:00:00 2001 From: Sven Rogge Date: Fri, 3 Apr 2026 00:04:44 +0200 Subject: [PATCH 1/2] fix: improve link click handling and code quality Enhance the link click handler to properly check for modifier keys (ctrl, meta, shift, alt) and respect the target attribute before intercepting clicks. Move preventDefault() call before navigation to ensure events are properly handled. Also fix a typo in NavMeta documentation and rename a variable for clarity in href parsing. --- packages/esroute/src/nav-opts.ts | 6 ++-- packages/esroute/src/router.spec.ts | 33 ++++++++++++++++++++++ packages/esroute/src/router.ts | 17 +++++++++-- packages/esroute/src/scroll-restoration.ts | 12 ++++---- 4 files changed, 57 insertions(+), 11 deletions(-) diff --git a/packages/esroute/src/nav-opts.ts b/packages/esroute/src/nav-opts.ts index 59ffe83..f6f2f51 100644 --- a/packages/esroute/src/nav-opts.ts +++ b/packages/esroute/src/nav-opts.ts @@ -7,7 +7,7 @@ export interface NavMeta { state?: S | null; /** The location hash. */ hash?: string; - /** Whethe the history state shall be replaced. */ + /** Whether the history state shall be replaced. */ replace?: boolean; /** Whether the resolution was triggered by a popstate event. */ pop?: boolean; @@ -50,7 +50,7 @@ export class NavOpts implements NavMeta { href ??= target as string; if (!href.startsWith("/")) href = `/${href}`; if (!search) this._h = href; - const [, pathString, , searchString, , hash] = href.match( + const [, pathString, , searchString, , parsedHash] = href.match( /([^?#]+)(\?([^#]+))?(#(.+))?/ )!; this.path = pathString.split("/").filter(Boolean); @@ -58,7 +58,7 @@ export class NavOpts implements NavMeta { this.search = Object.fromEntries( new URLSearchParams(searchString).entries() ); - if (hash) this.hash = hash; + if (parsedHash) this.hash = parsedHash; } else this.path = target as string[]; if (hash != null) this.hash = hash; if (pop != null) this.pop = pop; diff --git a/packages/esroute/src/router.spec.ts b/packages/esroute/src/router.spec.ts index fdbfade..9dee536 100644 --- a/packages/esroute/src/router.spec.ts +++ b/packages/esroute/src/router.spec.ts @@ -63,6 +63,39 @@ describe("Router", () => { opts: expect.any(NavOpts), }); }); + + it("should not intercept clicks with modifier keys", async () => { + router.init(); + await router.resolution; + onResolve.mockClear(); + const anchor = document.createElement("a"); + document.body.appendChild(anchor); + anchor.href = "http://localhost/foo"; + vi.spyOn(location, "origin", "get").mockReturnValue("http://localhost"); + + for (const key of ["ctrlKey", "metaKey", "shiftKey", "altKey"] as const) { + anchor.dispatchEvent(new MouseEvent("click", { [key]: true, bubbles: true })); + } + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(onResolve).not.toHaveBeenCalled(); + }); + + it("should not intercept clicks on anchors with target attribute", async () => { + router.init(); + await router.resolution; + onResolve.mockClear(); + const anchor = document.createElement("a"); + document.body.appendChild(anchor); + anchor.href = "http://localhost/foo"; + anchor.target = "_blank"; + vi.spyOn(location, "origin", "get").mockReturnValue("http://localhost"); + + anchor.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(onResolve).not.toHaveBeenCalled(); + }); }); describe("go()", () => { diff --git a/packages/esroute/src/router.ts b/packages/esroute/src/router.ts index 259d396..c53bdb3 100644 --- a/packages/esroute/src/router.ts +++ b/packages/esroute/src/router.ts @@ -192,15 +192,28 @@ export const createRouter = ( }; const linkClickListener = (e: MouseEvent) => { + if ( + e.defaultPrevented || + e.button !== 0 || + e.metaKey || + e.ctrlKey || + e.shiftKey || + e.altKey + ) + return; const target = isAnchorElement(e.target) ? e.target : e.composedPath?.().find(isAnchorElement); - if (target && target.origin === location.origin) { + if ( + target && + (!target.target || target.target === "_self") && + target.origin === location.origin + ) { + e.preventDefault(); r.go({ href: target.href.substring(location.origin.length), replace: "replace" in target.dataset, }); - e.preventDefault(); } }; diff --git a/packages/esroute/src/scroll-restoration.ts b/packages/esroute/src/scroll-restoration.ts index b5689ef..33bb90c 100644 --- a/packages/esroute/src/scroll-restoration.ts +++ b/packages/esroute/src/scroll-restoration.ts @@ -25,12 +25,12 @@ export const restoreHandling = ({ }; const getStatePos = () => (history.state && history.state[stateProp]) ?? undefined; - const getHashPos = - hashScroll && - ((hash: string) => - (find(hash)?.getBoundingClientRect().top ?? 0) - - offset + - container.scrollTop); + const getHashPos = hashScroll + ? (hash: string) => + (find(hash)?.getBoundingClientRect().top ?? 0) - + offset + + container.scrollTop + : undefined; window.addEventListener("beforeunload", save); document.addEventListener("visibilitychange", save); From 9d5a521128156c8c4f83a7d4baed6edd6327b7d1 Mon Sep 17 00:00:00 2001 From: Sven Rogge Date: Fri, 3 Apr 2026 00:10:03 +0200 Subject: [PATCH 2/2] docs: add link click handler fix to changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8720a2e..0c31e7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- Anchor click handler now ignores modifier keys (Ctrl, Meta, Shift, Alt), non-left clicks, and links with a `target` attribute (e.g. `target="_blank"`), allowing native browser behavior for opening links in new tabs/windows + ## [0.12.3] - 2026-04-02 ### Fixed