From 0e0a2734d60e6141d507c8878d22b105b52b00fa Mon Sep 17 00:00:00 2001 From: jonathan Date: Mon, 16 Feb 2026 03:13:19 +0100 Subject: [PATCH] Fix hash navigation --- src/core/drive/history.js | 24 ++++++++++++++-- src/tests/functional/navigation_tests.js | 36 ++++++++++++++++++++++++ src/util.js | 5 ++-- 3 files changed, 60 insertions(+), 5 deletions(-) diff --git a/src/core/drive/history.js b/src/core/drive/history.js index 868d1fc9e..d86bc6ea8 100644 --- a/src/core/drive/history.js +++ b/src/core/drive/history.js @@ -78,18 +78,38 @@ export class History { // Event handlers onPopState = (event) => { + const newLocation = new URL(window.location.href) + + // Ignore popstate events triggered by hash-only changes not managed by Turbo + if (!event.state?.turbo && this.location && this.#onlyHashChanged(newLocation)) { + this.location = newLocation + return + } + const { turbo } = event.state || {} - this.location = new URL(window.location.href) + this.location = newLocation if (turbo) { const { restorationIdentifier, restorationIndex } = turbo this.restorationIdentifier = restorationIdentifier const direction = restorationIndex > this.currentIndex ? "forward" : "back" - this.delegate.historyPoppedToLocationWithRestorationIdentifierAndDirection(this.location, restorationIdentifier, direction) + this.delegate.historyPoppedToLocationWithRestorationIdentifierAndDirection( + this.location, + restorationIdentifier, + direction + ) this.currentIndex = restorationIndex } else { this.currentIndex++ this.delegate.historyPoppedWithEmptyState(this.location) } } + + #onlyHashChanged(newLocation) { + return ( + this.location.origin === newLocation.origin && + this.location.pathname === newLocation.pathname && + this.location.search === newLocation.search + ) + } } diff --git a/src/tests/functional/navigation_tests.js b/src/tests/functional/navigation_tests.js index 25d11b887..d8cab6c1a 100644 --- a/src/tests/functional/navigation_tests.js +++ b/src/tests/functional/navigation_tests.js @@ -408,6 +408,42 @@ test("clicking the back button after redirection", async ({ page }) => { expect(await visitAction(page)).toEqual("restore") }) +test("hash-only navigation does not trigger a visit via popstate", async ({ page }) => { + await page.click('a[href="#main"]') + await nextBeat() + + expect( + await noNextEventNamed(page, "turbo:before-visit"), + "hash-only click does not trigger turbo:before-visit" + ).toEqual(true) + + expect( + await noNextEventNamed(page, "turbo:load"), + "hash-only click does not trigger turbo:load" + ).toEqual(true) + + await expect(page).toHaveURL(withHash("#main")) +}) + +test("navigating back after hash-only change does not replace the body", async ({ page }) => { + await page.click('a[href="#main"]') + await nextBeat() + + await page.click('a[href="#ignored-link"]') + await nextBeat() + + await expect(page).toHaveURL(withHash("#ignored-link")) + + expect( + await willChangeBody(page, async () => { + await page.goBack() + await nextBeat() + }) + ).not.toBeTruthy() + + await expect(page).toHaveURL(withHash("#main")) +}) + test("same-page anchor visits do not trigger visit events", async ({ page }) => { const events = [ "turbo:before-visit", diff --git a/src/util.js b/src/util.js index 9a50cb387..5ffb77080 100644 --- a/src/util.js +++ b/src/util.js @@ -209,8 +209,7 @@ export function findClosestRecursively(element, selector) { } export function elementIsStylesheet(element) { - return element.localName === "style" || - (element.localName === "link" && element.relList.contains("stylesheet")) + return element.localName === "style" || (element.localName === "link" && element.relList.contains("stylesheet")) } export function elementIsFocusable(element) { @@ -253,7 +252,7 @@ export function findLinkFromClickTarget(target) { const link = findClosestRecursively(target, "a[href], a[xlink\\:href]") if (!link) return null - if (link.href.startsWith("#")) return null + if (link.getAttribute("href")?.startsWith("#")) return null if (link.hasAttribute("download")) return null const linkTarget = link.getAttribute("target")