Skip to content
This repository has been archived by the owner on Sep 25, 2021. It is now read-only.

Turbolinks should follow same-page anchor links without reloading the page #75

Closed
preetpalS opened this issue Apr 15, 2016 · 44 comments · Fixed by hotwired/turbo#298
Closed
Labels

Comments

@preetpalS
Copy link

preetpalS commented Apr 15, 2016

Consider the following HTML link to an element with an id of bookmark:

<a href="#bookmark" >Bookmark within page</a>

Turbolinks currently intercepts this link and prevents the browser from following it unless you add an annotation on the link (data-turbolinks="false"). Once Turbolinks has intercepted the link, it makes another HTTP request for the same location again (but with the id appended at the end (/url#bookmark)) instead of just scrolling to the anchored element. Since bookmark links within the same page normally do not result in an HTTP request, one could argue that the default behavior of Turbolinks for links that have an href attribute beginning with # results in worse performance (due to the network request) and violates the principle of least surprise.

Is it possible to disable Turbolinks for all links that have an href attribute beginning with # without having to manually annotate them with data-turbolinks="false"?

I am currently using turbolinks (5.0.0.beta2) (turbolinks-source (5.0.0.beta4)) with Rails 4.2.6.

@packagethief
Copy link
Member

Since bookmark links within the same page normally do not result in an HTTP request, one could argue that the default behavior of Turbolinks for links that have an href attribute beginning with # results in worse performance (due to the network request) and violates the principle of least surprise.

I agree. We should ignore clicks on links that are anchors to the current page, and let them fall through to the browser.

@preetpalS
Copy link
Author

preetpalS commented Apr 17, 2016

Here is a workaround I tried to write to prevent Turbolinks from making unnecessary network calls (it is a compiled version of a gist I wrote in TypeScript). It works except for the scenario where if you're on a page like http://example.com, click on a link with an href that is only a hash like #bookmark, and then click on a link to a different page like http://example.com/other, you will no longer be able to go back in the browser (like from http://example.com/other to http://example.com#bookmark).

document.addEventListener("turbolinks:before-visit", function (event) {
    // console.info(`Network Call Avoidance Workaround (for Turbolinks) attempting to short-circuit network calls.`);
    var origin = window.location.href; // Current URL
    var destination = event.data.url; // Destination URL
    // Make sure both destination and origin urls end with "/" unless they contain an "#"
    if ((origin.match(/#/) === null) && origin[origin.length - 1] !== "/")
        origin = origin + '/';
    if ((destination.match(/#/) === null) && destination[destination.length - 1] !== "/")
        destination = destination + '/';
    // console.info(`Origin: ${origin}`);
    // console.info(`Destination: ${destination}`);
    // Prevent Turbolinks from doing anything when clicking on a link to the current page
    if (origin === destination) {
        // console.info("Turbolinks stopped since origin and destination URLs are identical.");
        event.preventDefault();
        if (origin.match(/#/) !== null) {
            // console.info(`Letting browser navigate to: ${destination}`);
            window.location.href = destination;
        }
        else {
            // Gives user feedback for clicking link to current page.
            // console.info(`Giving user fake feedback since link points to current page.`)
            var tpb = new Turbolinks.ProgressBar();
            tpb.setValue(0);
            tpb.show();
            tpb.setValue(0.2);
            setTimeout(function () { tpb.setValue(1); tpb.hide(); }, 100);
        }
        return;
    }
    var shorterLength = Math.min(origin.length, destination.length);
    // To handle the cases where both origin and destination URLs contain "#".
    // Only intended to prevent page reloads for anchor links within current page.
    if (((origin.match(/#/) !== null) && (destination.match(/#/) !== null)) &&
        (origin.indexOf("#") === destination.indexOf("#"))) {
        // console.info("Detected that both origin and destination have same index for #");
        shorterLength = Math.min(origin.indexOf("#"), destination.indexOf("#"));
    }
    // console.info(`shorterLength: ${shorterLength}`);
    // See if the first `shorterLength` characters of both the origin and destination URLS are the same.
    // If these characters are not the same, do nothing.
    if (origin.substring(0, shorterLength) !== destination.substring(0, shorterLength)) {
        // console.info(`Turbolinks not stopped since first ${shorterLength} characters of origin and destination URL are different.`);
        return;
    }
    // console.info(`The first ${shorterLength} chars of both origin and destination URLs are identical`);
    // If the destination URL contains an "#" immediately after the first `shorterLength` characters, let
    // the browser navigate to the URL. In this case the origin URL would be desination URL without the hash.
    if (destination.length > shorterLength && destination[shorterLength] === "#") {
        // console.info("Turbolinks stopped since destination URL is a bookmark on the current page.");
        event.preventDefault();
        // console.info(`Letting browser navigate to: ${destination}`);
        window.location.href = destination;
        return;
    }
    // If the origin URL contains an "#" immediately after the first `shorterLength` characters, do nothing.
    // In this case the destination URL would be the origin URL without the hash.
    if (origin.length > shorterLength && origin[shorterLength] === "#") {
        // console.info("Turbolinks stopped since trying to navigate to current page's URL without hash.");
        event.preventDefault();
        return;
    }
    // Note that either destination or origin is substring within the other (starting at index 0) if this function hasn't already returned.
    // console.info("Turbolinks not stopped since there is no reason to do so.");
});

@preetpalS
Copy link
Author

preetpalS commented Apr 17, 2016

Here is another workaround (it is a compiled version of a gist (same gist mentioned in previous comment) I wrote in TypeScript). I will probably use this one in production. It just scrolls to bookmark links on the same page (it does not change the URL (or change browser history) but that's fine (for my purposes at least) since Turbolinks preserves scroll position while navigating through history anyways).

Warning: This workaround makes the assumption that your link will only contain a single # to mark the start of the hash (fragment (https://tools.ietf.org/html/rfc3986#section-3)) of your URL and that you are not using / as data in the query portion of your URL (https://tools.ietf.org/html/rfc3986#section-3.4).

/// <reference path="jquery/jquery.d.ts"/>
document.addEventListener("turbolinks:before-visit", function (event) {
    // console.info(`Network Call Avoidance Workaround (for Turbolinks) attempting to short-circuit network calls.`);
    var origin = window.location.href; // Current URL
    var destination = event.data.url; // Destination URL
    // console.info(`Original Origin: ${origin}`);
    // console.info(`Original Destination: ${destination}`);
    // Make sure both destination and origin urls do not end with "/"
    // If the contain a '#', ensure URLs do not have a '/' before the '#' (https://tools.ietf.org/html/rfc3986#section-3.4)
    if (origin.match(/#/) === null) {
        if (origin[origin.length - 1] === "/") {
            origin = origin.substring(0, origin.length - 1);
        }
    }
    else {
        var hashIndex = origin.indexOf('#');
        // console.info(hashIndex);
        if (hashIndex > 0 && origin[hashIndex - 1] === "/")
            origin = "" + origin.substring(0, (hashIndex - 1)) + origin.substring(hashIndex);
    }
    if (destination.match(/#/) === null) {
        if (destination[destination.length - 1] === "/") {
            destination = destination.substring(0, destination.length - 1);
        }
    }
    else {
        var hashIndex = destination.indexOf('#');
        // console.info(hashIndex);
        if (hashIndex > 0 && destination[hashIndex - 1] === "/")
            destination = "" + destination.substring(0, (hashIndex - 1)) + destination.substring(hashIndex);
    }
    // console.info(`Modified Origin: ${origin}`);
    // console.info(`Modified Destination: ${destination}`);
    // Do not prevent Turbolinks from following links to the same URL (can affect forms)
    if (origin === destination)
        return;
    var shorterLength = Math.min(origin.length, destination.length);
    // To handle the cases where both origin and destination URLs contain "#".
    // Only intended to prevent page reloads for anchor links within current page.
    if (((origin.match(/#/) !== null) && (destination.match(/#/) !== null)) &&
        (origin.indexOf("#") === destination.indexOf("#"))) {
        // console.info("Detected that both origin and destination have same index for #");
        shorterLength = Math.min(origin.indexOf("#"), destination.indexOf("#"));
    }
    // console.info(`shorterLength: ${shorterLength}`);
    // See if the first `shorterLength` characters of both the origin and destination URLS are the same.
    // If these characters are not the same, do nothing.
    if (origin.substring(0, shorterLength) !== destination.substring(0, shorterLength)) {
        // console.info(`Turbolinks not stopped since first ${shorterLength} characters of origin and destination URL are different.`);
        return;
    }
    // console.info(`The first ${shorterLength} chars of both origin and destination URLs are identical`);
    // If the destination URL contains an "#" immediately after the first `shorterLength` characters, let
    // the browser navigate to the URL. In this case the origin URL would be desination URL without the hash.
    if (destination.length > shorterLength && destination[shorterLength] === "#") {
        // console.info("Turbolinks stopped since destination URL is a bookmark on the current page.");
        event.preventDefault();
        // This will mess up browser history and prevent you from going back after moving forward
        // window.location.href = destination;
        var urlHashMinusHash = destination.substring(destination.indexOf('#')).substring(1);
        // console.info(`Letting browser scroll to: ${urlHashMinusHash}`);
        var elem = document.getElementById(urlHashMinusHash);
        if (elem != null)
            elem.scrollIntoView();
        return;
    }
    // If the origin URL contains an "#" immediately after the first `shorterLength` characters, do nothing.
    // In this case the destination URL would be the origin URL without the hash.
    if (origin.length > shorterLength && origin[shorterLength] === "#") {
        // console.info("Turbolinks stopped since trying to navigate to current page's URL without hash.");
        event.preventDefault();
        $("html, body").animate({ scrollTop: 0 }, "slow");
        return;
    }
    // Note that either destination or origin is substring within the other (starting at index 0) if this function hasn't already returned.
    // console.info("Turbolinks not stopped since there is no reason to do so.");
});

@glennfu
Copy link

glennfu commented Apr 19, 2016

@preetpalS I tried your 2nd code snippet on my app, and while it did indeed prevent Turbolinks from intercepting hash changes which was awesome, it had the side effect of blocking Turbolinks.visit("http://myapp.dev/current/page", {"action":"replace"})

I'm doing a remote form submission which reloads the same page after submit, and after adding this block of code, I still see the progress bar animate, but the Turbolinks visitProposedToLocationWithAction never gets triggered and the page doesn't refresh as it should.

A fix might be to change line 50 if (origin === destination) { to also take into account whether or not the visit's action was 'advance' or not, but this information isn't currently passed to the "turbolinks:before-visit" event, so I'm not sure what should be done here.

@preetpalS
Copy link
Author

preetpalS commented Apr 19, 2016

@glennfu Try out the updated version of the 2nd code snippet.

Another completely different workaround that might work better would be to add an event listener on the turbolinks:load event that searches the body of the page for all HTML link elements (ignoring links already annotated with data-turbolinks) which are just links to bookmarks on the current page (maybe just filter based on those which have an href attribute beginning with # or possibly filter based on a more involved comparison between the current URL and where those links point to) and annotates the found link elements with data-turbolinks="false".

@glennfu
Copy link

glennfu commented May 11, 2016

I just realized a 2nd use case for having the action passed along in this event: If I'm on url "/foo/bar" and I want to do Turbolinks.visit("/foo/bar#panel-this", {"action":"replace"}) I would expect the page to reload, and land me on the url "/foo/bar#panel-this".

I can't think of a way to update this code to satisfy that without having access to the action

@sstephenson sstephenson changed the title Can Turbolinks ignore all bookmark links that do not require making a HTTP request Turbolinks should follow same-page anchor links without reloading the page Jun 20, 2016
@wshostak
Copy link

wshostak commented Jul 5, 2016

The quick solution I have come up with (not fully tested);

Turbolinks.Controller.prototype.nodeIsVisitableOld = Turbolinks.Controller.prototype.nodeIsVisitable;

Turbolinks.Controller.prototype.nodeIsVisitable = function (e) {

  return !document.querySelector(elem.getAttribute('href')) && Turbolinks.Controller.prototype.nodeIsVisitableOld(e)
};

I figure if the anchor exist then the node is also not visitable.
Best to load this code right after loading turbolinks.

@vickodin
Copy link

Waiting for news 👍

@wshostak
Copy link

Thanks for reminding me to post the changes I have made.

Turbolinks.Controller.prototype.nodeIsVisitableOld = Turbolinks.Controller.prototype.nodeIsVisitable;

Turbolinks.Controller.prototype.nodeIsVisitable = function (elem) {

  var href = elem.getAttribute('href') || '',
      anchor = href[0] === "#"? anchor = document.querySelector(href): false;

  return !anchor && Turbolinks.Controller.prototype.nodeIsVisitableOld(elem);
};

@vtamara
Copy link

vtamara commented Jul 21, 2016

The only workaround that initially worked for me was to change in Gemfile:

 gem "turbolinks", '2.5.3' 

11.Jul.2017: After digging more, I found that the problem I was having was not with links to anchors but a feature in turbolinks-rails that shows in navigator what is sent by redirect_to even if using AJAX with POST. I could fix and now I'm using the most recent turbolinks, thank you.
More about the problem I had and the solution at: https://github.com/vtamara/turbolinks_prob50

@jeremylynch
Copy link

jeremylynch commented Aug 3, 2016

@wshostak your path has worked for me.

Any progress in patching this bug in the turbolinks master?

Update: @wshostak's solution works, except after clicking an anchor, the back event does not function correctly.

@jeremylynch
Copy link

jeremylynch commented Aug 29, 2016

@sstephenson, @packagethief, @wshostak & @preetpalS what can I do to help move this forward? Unfortunately I don't have the JS expertise to solve this. This is quite a critical flaw of turbolinks.

@domchristie
Copy link
Collaborator

domchristie commented Sep 6, 2016

Another fix which does not involve modifying Turbolinks internal methods (and I think does not break "Back" behaviour):

$(document).on('turbolinks:click', function (event) {
  if (event.target.getAttribute('href').charAt(0) === '#') {
    return event.preventDefault()
  }
})

@jeremylynch
Copy link

@domchristie, I have tested your solution and it still breaks back behaviour for me.

@domchristie
Copy link
Collaborator

@mrjlynch ah: it does not break "Back" behaviour … sometimes! I seem to get different behaviours, although I am not able to reproduce them consistently. Navigating "Back" either takes me back to the top of the page after a Turbolinks page load (which seems acceptable), or it updates the address bar to the previous URL but does not update the scroll position.

@wjdp
Copy link

wjdp commented Sep 8, 2016

@domchristie @mrjlynch I've just come across this issue, currently working around using data-turbolinks="false" on the links. I'm getting broken Back behaviour with this method. Seems as if unless a request goes through TL fully it doesn't get a network request on a browser back action.

@jeremylynch
Copy link

jeremylynch commented Sep 9, 2016

@domchristie's solution (below) is the best solution I have found yet.

$(document).on('turbolinks:click', function (event) {
  if (event.target.getAttribute('href').charAt(0) === '#') {
    return event.preventDefault()
  }
})

Why?

  1. It is the shortest solution.
  2. When anchor links have html child elements (see example below), clicking the child does not trigger reload (which is a fault of @wshostak solution).
<a href="#anchor">
    <i class="icon"></i>
</a>

The problem to fix:

  1. Clicking the back button

@glennfu
Copy link

glennfu commented Sep 9, 2016

Can anyone publish a small demo project demonstrating the problem? Right now in my app, with the latest version of Turbolinks, hash changes are ignored, which is actually correct and preferable for me. I don't actually want the back button to affect the hash in my project so I don't have a valid use case of my own to test against. However if someone can put up something that clearly shows the problem I'd be interested in tinkering with a solution that actually leverages the Turbolinks cache and the restorationData that stores the scroll position. My theory is that if in the "restore" process I can convince Turbolinks to skip the re-render if the only part of the url changing is the anchor, then maybe we can have a better solution. We'd also want to make sure that the default behavior of ignoring hash changes can remain as an option as well.

I think a good test case would be to have a page that goes from /url1 -> /url2 -> /url2#anchor1 -> /url2#anchor2 -> /url1 and make sure that the history and navigation all play nicely.

@domchristie
Copy link
Collaborator

domchristie commented Sep 11, 2016

@glennfu Here is the test case project as requested. As you can see by monitoring the XHRs, clicking on same-page anchor links causes requests to be made. This is probably acceptable in many cases, but given that the content should exist on the page, there shouldn't be a need to make extraneous requests.

To further demonstrate the problems, here is another case: a blog with lazily-loaded comments. Here is what happens:

  1. User visits a posts#show page
  2. Comments for that post are loaded via XHR
  3. User clicks to view Comment # 1 (an anchor on the same page)
  4. Turbolinks fetches the posts#show page again and replaces the <body> (without the comments)
  5. Turbolinks then tries to scroll to Comment # 1, which does not exist anymore, and so the fragment identifier is ignored :(

Clicking on a same-page anchor link with data-turbolinks=false prevents the reload and scrolls to the anchor as expected, however pressing Back does not always return the app to the previous state.

@aguynamedben
Copy link

aguynamedben commented Oct 24, 2016

Thanks for your work on Turbolinks 5. So far it's VERY awesome.

An important business side effect of this behavior is that analytics for pages with same-page anchor links can be overstated due to the 'turbolinks:load' event firing after clicks on same-page anchor links. This is common with a Table of Contents or other intra-page linking. For example, in order to implement Google Analytics (analytics.js) on a Turbolinks app, we're doing:

$(document).on('turbolinks:load', function(event) {
  ga('set', 'page', window.location.pathname);
  ga('send', 'pageview');
});

Because Turbolinks is intercepting the clicks on same-page anchor links (and doing more work than it should), the 'turbolinks:load' event is fired too many times and page views are being overstated.

With my Table of Contents use case, 'turbolinks:load' events fire for:

/guide
/guide#section3 (user clicks in TOC, same page)
/guide#section4 (user clicks in TOC, same page)

and 1 page view becomes 3 page views.

@Arjeno
Copy link

Arjeno commented Aug 12, 2019

FYI, you can simply add data-turbolinks="false" to the anchor link to opt-out of Turbolinks behavior.

javierm added a commit to consuldemocracy/consuldemocracy that referenced this issue Sep 13, 2019
Turbolinks 5 doesn't follow the browser's standard behaviour of ignoring
links pointing to "#", so we're preventing the turbolinks events in this
situation:

turbolinks/turbolinks#75
javierm added a commit to consuldemocracy/consuldemocracy that referenced this issue Sep 30, 2019
Turbolinks 5 doesn't follow the browser's standard behaviour of ignoring
links pointing to "#", so we're preventing the turbolinks events in this
situation:

turbolinks/turbolinks#75
javierm added a commit to consuldemocracy/consuldemocracy that referenced this issue Feb 28, 2020
Turbolinks 5 doesn't follow the browser's standard behaviour of ignoring
links pointing to "#", so we're preventing the turbolinks events in this
situation:

turbolinks/turbolinks#75
javierm added a commit to consuldemocracy/consuldemocracy that referenced this issue Apr 24, 2020
Turbolinks 5 doesn't follow the browser's standard behaviour of ignoring
links pointing to "#", so we're preventing the turbolinks events in this
situation:

turbolinks/turbolinks#75
javierm added a commit to consuldemocracy/consuldemocracy that referenced this issue Apr 24, 2020
Turbolinks 5 doesn't follow the browser's standard behaviour of ignoring
links pointing to "#", so we're preventing the turbolinks events in this
situation:

turbolinks/turbolinks#75
javierm added a commit to consuldemocracy/consuldemocracy that referenced this issue May 9, 2020
Turbolinks 5 doesn't follow the browser's standard behaviour of ignoring
links pointing to "#", so we're preventing the turbolinks events in this
situation:

turbolinks/turbolinks#75
javierm added a commit to consuldemocracy/consuldemocracy that referenced this issue May 11, 2020
Turbolinks 5 doesn't follow the browser's standard behaviour of ignoring
links pointing to "#", so we're preventing the turbolinks events in this
situation:

turbolinks/turbolinks#75
javierm added a commit to consuldemocracy/consuldemocracy that referenced this issue May 11, 2020
Turbolinks 5 doesn't follow the browser's standard behaviour of ignoring
links pointing to "#", so we're preventing the turbolinks events in this
situation:

turbolinks/turbolinks#75
javierm added a commit to consuldemocracy/consuldemocracy that referenced this issue May 26, 2020
Turbolinks 5 doesn't follow the browser's standard behaviour of ignoring
links pointing to "#", so we're preventing the turbolinks events in this
situation:

turbolinks/turbolinks#75
Senen pushed a commit to rockandror/consuldemocracy that referenced this issue Jun 11, 2020
Turbolinks 5 doesn't follow the browser's standard behaviour of ignoring
links pointing to "#", so we're preventing the turbolinks events in this
situation:

turbolinks/turbolinks#75
javierm added a commit to consuldemocracy/consuldemocracy that referenced this issue Jun 17, 2020
Turbolinks 5 doesn't follow the browser's standard behaviour of ignoring
links pointing to "#", so we're preventing the turbolinks events in this
situation:

turbolinks/turbolinks#75
@rafaeldev
Copy link

Hello everyone!
I need help with behavior in my PWA application with touch in this scenario.
So, in some parts of my application the user open modal (bootstrap framework modal) and menu, and to know these actions, I put a hash in URL. E.g: #modal or #menu

My target is to make the back button action to close the modal instead of return to the previous page. So, when I click on the browser back the for are full reloaded.

the doc says which turbolinks:before-visit does not are called for visits to history.

Do you know how can prevent reload pages by native navigation (browser back button)?

I hope which points are clear 🙂

Screen-Recording-2020-11-21-at-01 33 36

nfagerlund added a commit to hashicorp/terraform-website that referenced this issue Dec 5, 2020
Clicking an anchor link causes a full turbolinks visit. This is a longstanding
issue that might never be fixed.
(turbolinks/turbolinks#75)

But while a general solution might have various complexities yadda yadda, we
don't really care and can just do a simple fix, since we're running a vanilla
website and not a serious application.
@ghost
Copy link

ghost commented Feb 9, 2021

Couldn't this be resolved by adding :not([href^="\\#"]) to the getVisitableLinkForTarget function in order to skip any relative anchors? It already uses :not() selectors to skip links with target or download attributes.

getVisitableLinkForTarget(target: EventTarget | null) {
    if (target instanceof Element && this.elementIsVisitable(target)) {
      return closest(target, "a[href]:not([target]):not([download])")
    }
  }

https://github.com/turbolinks/turbolinks/blob/master/src/controller.ts#L299

clickbubbled already skips if no link is returned.

clickBubbled = (event: MouseEvent) => {
    if (this.enabled && this.clickEventIsSignificant(event)) {
      const link = this.getVisitableLinkForTarget(event.target)
      if (link) {
        const location = this.getVisitableLocationForLink(link)
        if (location && this.applicationAllowsFollowingLinkToLocation(link, location)) {
          event.preventDefault()
          const action = this.getActionForLink(link)
          this.visit(location, { action })
        }
      }
    }
  }

https://github.com/turbolinks/turbolinks/blob/master/src/controller.ts#L210

EDIT: Just learned one gotcha is hash changes trigger popstate events... As a workaround in my project, I'm keeping track of currentPath (window.location.pathname + window.location.search) and returning from the popstate handler if they match.

@preetpalS
Copy link
Author

Turbolinks is no longer under active development so no one should expect this issue to be resolved.

On a positive note, it looks like this issue was fixed in the successor to this library (hotwired/turbo#125).

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

Successfully merging a pull request may close this issue.