Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve handling of anchor links and hashchange events #1125

Open
wants to merge 5 commits into
base: main
Choose a base branch
from

Conversation

Matt-Yorkley
Copy link
Contributor

@Matt-Yorkley Matt-Yorkley commented Jan 9, 2024

When navigating around between multiple anchor links on the same page there's a number of weird behaviours that can come up with how the navigations are handled and the subsequent hashchange events that get fired:

  • The hashchange event's value for event.oldURL often contains an incorrect URL (wrong hash)

  • Using the back or forwards buttons (restoration visits) causes two hashchange events to be fired each time instead of one, and the duplicate contains incorrect information

  • Moving between multiple anchors on the same page can trigger an unexpected page visit in some cases, including a network request and repainting the DOM (turbo:load) when it shouldn't

Related issues/discussions here: #539 and here: #876 (comment)

Copy link
Contributor

@domchristie domchristie left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The whole navigate-to-fragment stuff is pretty complex. Not only Turbo's code, but also browser behaviours. I'm pretty sure there's good reason for the conditionals here as it was well tested at the time, however it still throws up bugs in some situations.

So I think it may result in whack-a-mole style development.

In terms of the behaviour, I think our best option is to do something like: #592 (comment) where we create an <a> with the hash and programatically click it. That way we get scrolling, element focussing, and :target styling.

We'll still need to spec out the different cases when this is needed (e.g. a clicked link vs a programatic visit, vs a restoration visit), but hopefully this might tidy thing up a bit?

@Matt-Yorkley
Copy link
Contributor Author

Matt-Yorkley commented Jan 10, 2024

Interesting! I've never used the :target pseudo-class before. That's mostly for styling though, right?

The above examples are relevant also to just clicking simple anchor links on the same page (without doing the navigate-to-fragment across separate pages). It's a bit sticky due to the way this.view.lastRenderedLocation is used in the comparison in locationWithActionIsSamePage().

locationWithActionIsSamePage(location, action) {
const anchor = getAnchor(location)
const currentAnchor = getAnchor(this.view.lastRenderedLocation)
const isRestorationToTop = action === "restore" && typeof anchor === "undefined"
return (
action !== "replace" &&
getRequestURL(location) === getRequestURL(this.view.lastRenderedLocation) &&
(isRestorationToTop || (anchor != null && anchor !== currentAnchor))
)
}

That value (this.view.lastRenderedLocation) only gets set/updated when a full visit or page load happens, and it also includes the current hash (at that point), but it doesn't change if you click an anchor link. Eg: on /page, click #hash1, then refresh the page, this.view.lastRenderedLocation will be <url>/page#hash1. Click #hash2, and this.view.lastRenderedLocation will still be <url>/page#hash1 even though the current URL is now <url>/page#hash2 and the current hash is #hash2. Know what I mean?

So using it here to determine "currentAnchor" on line 132 can produce inconsistent results, because it doesn't necessarily hold the current anchor, but whatever the anchor was last time the page was fully rendered. This leads to the event.oldURL being wrong in hashchange events, depending on what value was last stored in this.view.lastRenderedLocation. Does that make sense?

It's also the underlying cause of this issue: #876 (comment) (and related weird behaviours).

@Matt-Yorkley
Copy link
Contributor Author

Minimal repro/testing page for anchor links and hashchange events:

<!DOCTYPE html>
<html>
<head>
  <script src="https://cdn.jsdelivr.net/npm/@hotwired/[email protected]/dist/turbo.es2017-umd.min.js"></script>
  <script type="text/javascript">
    window.addEventListener("hashchange", (event) => {
      console.log("hashchange: ", event)
      document.getElementById("events").insertAdjacentHTML("beforeend",
        `<p>Hashchange: { oldURL: ${event.oldURL}, newURL: ${event.newURL} }</p>`
      )
    });
    window.addEventListener("turbo:before-fetch-request", (event) => {
      console.log("turbo:before-fetch-request: ", event)
      document.getElementById("events").insertAdjacentHTML("beforeend",
        `<p>Fetch request: ${event.detail.url} (cancelled for demonstration)</p>`
      )
      event.preventDefault()
      history.replaceState({}, "", event.detail.url)
    });
    function clearEvents() { document.getElementById("events").innerHTML = "" }
  </script>
  <meta content="no-cache" name="turbo-cache-control" />
</head>
<body>

<h1>Same-page anchors</h1>
<ul>
  <li><a href="">No anchor</a></li>
  <li><a href="#one">Anchor #one</a></li>
  <li><a href="#two">Anchor #two</a></li>
  <li><a href="#three">Anchor #three</a></li>
</ul>

<h2>Event log:</h2>
<div id="events">
</div>

<button onclick="clearEvents()">Clear events</button>
</body>
</html>

@Matt-Yorkley Matt-Yorkley changed the title Improve handling of named anchor links and hashchange events Improve handling of anchor links and hashchange events Jan 10, 2024
@Ambient-Impact
Copy link

Ambient-Impact commented Jan 16, 2024

Interesting! I've never used the :target pseudo-class before. That's mostly for styling though, right?

It can be used for just about anything you can do with CSS, including layout stuff. For example, it's a crucial part of our mobile menu on Omnipedia, which actually works if our JavaScript fails to execute because the link to open it just points to #menu, which is the in-page ID. The selectors can get pretty ugly and require a very specific page structure, but it works - just try turning off JavaScript and resize the browser so it's narrow like a phone, and click the Menu link in the header.

Anyways, I'm actually working on integrating Turbo into our site and ran across some of these issues with 7.3.0, notably the double hashchange and the turbo:load on the same page when changing the hash under some circumstances. Glad to see this being worked on.

Edit: Just had a chance to try this out in 8.0.0-beta.3 and it does seem to fix the issue we had with an unexpected page visit when just the hash changed. Good work!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

Successfully merging this pull request may close these issues.

3 participants