From 590e888ed9a6ce476fb4d3f07e854ad4baba7560 Mon Sep 17 00:00:00 2001 From: Paolo Tranquilli Date: Tue, 17 Feb 2026 08:52:45 +0000 Subject: [PATCH] Don't intercept hash-only links inside frames Hash-only links (e.g. ``) inside a `` were being intercepted, triggering a frame fetch request instead of letting the browser scroll natively. Skip interception in `#shouldInterceptNavigation` (frames) and `willFollowLinkToLocation` (Drive) when the link's `href` starts with `#`. Fixes hotwired/turbo#598 --- src/core/frames/frame_controller.js | 6 +++++- src/core/session.js | 3 ++- src/core/url.js | 4 ++++ src/tests/fixtures/frames.html | 2 ++ src/tests/functional/frame_tests.js | 9 +++++++++ 5 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/core/frames/frame_controller.js b/src/core/frames/frame_controller.js index 7487e4363..9edad47ac 100644 --- a/src/core/frames/frame_controller.js +++ b/src/core/frames/frame_controller.js @@ -14,7 +14,7 @@ import { } from "../../util" import { FormSubmission } from "../drive/form_submission" import { Snapshot } from "../snapshot" -import { getAction, expandURL, urlsAreEqual, locationIsVisitable } from "../url" +import { getAction, expandURL, urlsAreEqual, locationIsVisitable, isHashLink } from "../url" import { FormSubmitObserver } from "../../observers/form_submit_observer" import { FrameView } from "./frame_view" import { LinkInterceptor } from "./link_interceptor" @@ -480,6 +480,10 @@ export class FrameController { } #shouldInterceptNavigation(element, submitter) { + if (isHashLink(element)) { + return false + } + const id = getAttribute("data-turbo-frame", submitter, element) || this.element.getAttribute("target") if (element instanceof HTMLFormElement && !this.#formActionIsVisitable(element, submitter)) { diff --git a/src/core/session.js b/src/core/session.js index 1e9f1c7bd..86b2e8cbf 100644 --- a/src/core/session.js +++ b/src/core/session.js @@ -6,7 +6,7 @@ import { History } from "./drive/history" import { LinkPrefetchObserver } from "../observers/link_prefetch_observer" import { LinkClickObserver } from "../observers/link_click_observer" import { FormLinkClickObserver } from "../observers/form_link_click_observer" -import { getAction, expandURL, locationIsVisitable } from "./url" +import { getAction, expandURL, locationIsVisitable, isHashLink } from "./url" import { Navigator } from "./drive/navigator" import { PageObserver } from "../observers/page_observer" import { ScrollObserver } from "../observers/scroll_observer" @@ -252,6 +252,7 @@ export class Session { return ( this.elementIsNavigatable(link) && locationIsVisitable(location, this.snapshot.rootLocation) && + !isHashLink(link) && this.applicationAllowsFollowingLinkToLocation(link, location, event) ) } diff --git a/src/core/url.js b/src/core/url.js index 4b8f8ad68..86cef6f26 100644 --- a/src/core/url.js +++ b/src/core/url.js @@ -37,6 +37,10 @@ export function getLocationForLink(link) { return expandURL(link.getAttribute("href") || "") } +export function isHashLink(element) { + return element.getAttribute("href")?.startsWith("#") ?? false +} + export function getRequestURL(url) { const anchor = getAnchor(url) return anchor != null ? url.href.slice(0, -(anchor.length + 1)) : url.href diff --git a/src/tests/fixtures/frames.html b/src/tests/fixtures/frames.html index 86d19e633..173931d66 100644 --- a/src/tests/fixtures/frames.html +++ b/src/tests/fixtures/frames.html @@ -40,6 +40,7 @@

Frames: #frame

Navigate #frame from within + Hash-only anchor link Navigate #frame with ?key=value Navigate #frame from within with a[data-turbo-action="advance"] Visit one.html @@ -160,6 +161,7 @@

Frames: #nested-child


+
Anchor target
Navigate #frame diff --git a/src/tests/functional/frame_tests.js b/src/tests/functional/frame_tests.js index 1987a74f6..ba3e46310 100644 --- a/src/tests/functional/frame_tests.js +++ b/src/tests/functional/frame_tests.js @@ -848,6 +848,15 @@ test("a turbo-frame that has been driven by a[data-turbo-action] can be navigate await expect(page).toHaveURL(withPathname("/src/tests/fixtures/frames/hello.html")) }) +test("clicking a hash-only anchor link inside a frame does not navigate the frame", async ({ page }) => { + await page.click("#link-hash-only") + + expect(await noNextEventOnTarget(page, "frame", "turbo:before-fetch-request")).toBeTruthy() + expect(await noNextEventNamed(page, "turbo:load")).toBeTruthy() + await expect(page).toHaveURL(/#anchor-target$/) + await expect(page.locator("#frame h2")).toHaveText("Frames: #frame") +}) + test("navigating turbo-frame from within with a[data-turbo-action=advance] pushes URL state", async ({ page }) => { await page.click("#link-nested-frame-action-advance") await nextEventNamed(page, "turbo:load")