Part 2: Working Around Turbo's Support for Redirects with Anchors
In this post we'll look at how to make Turbo redirect with anchors work for your Rails application in the least gross way. 
          Chris Young
October 26, 2023In Part 1 of this series we took a look at the unexpected behaviour of Turbo when combined with redirects that include anchors. The net, they don't work how we would like them to!
        First, let's dig a little deeper into the root cause. The problem appears to stem from the browser implementation 
        of fetch(). When a redirect is processed, the default mode is follow, which essentially 
        renders all intermediary information inaccessible (i.e. the anchor associated with the redirect!). There is an 
        alternate mode called manual, however, it is intended for service workers and actually doesn't provide 
        any more helpful information, like the Location header.
      
        The first article listed a number of discussions about the limitations of fetch but there is no concrete 
        path forward and even if there was, it would take some time for the browser to adopt the standard. Thus, we are stuck 
        to the land of "workarounds".
      
Our Approach
The workaround that we have created is based on the idea that a developer should not need to learn anything new. That is, an anchor works how you would expect an anchor to work! We also want to minimize the potential negative side effects and avoid causing hard to debug pain down the road.
        With that said, to keep things tidy we have resorted to overriding the redirect_to method and introducing
        a new sprinkle of global javascript event handlers.
      
The main outcome that we want to achieve is that a redirect with an anchor correctly scrolls the browser to the redirected url's anchor (fragment or hash).
        Given the immovable object that is fetch(), we're going to move away from anchors and replace the value with a 
        query parameter which is correctly forwarded along in the redirect chain. The anchor specified in the 
        redirect_to call, will be transformed into a query parameter called _anchor with a value of the 
        anchor. The global event handler then makes use of replace state to clean-up the URL so that the user is none-the-wiser.
        This also makes sharing the URL via copy & paste a no-brainer.
      
The new workflow is going to look like the following
- User clicks a link_towith a methodpatchto view a notification ("hey, somebody mentioned you in a comment").
- Controller marks the notification as read.
- Controller calls redirect_towith an anchor value of the related object's dom_id
- Override code transforms the anchormethod parameter into a_anchorquery parameter.
- Redirect is sent to the client browser.
- Event handler detects the turbo:load.
- The _anchorquery parameter is converted into a location hash.
- The page is scrolled to the element identified by the hash value.
It isn't pretty, but it seems to beat out all other options.
The Code
The code for this lands in two places:
- application_controller.rb
- application.js
        In application_controller.rb we store our redirect_to override which looks like:
      
# Custom redirect_to logic to transparently support redirects with anchors so Turbo
# works as expected. The general approach is to leverage a query parameter to proxy the anchor value
# (as the anchor/fragment is lost when using Turbo and the browser fetch() follow code).
#
# This code looks for an anchor (#comment_100), if it finds one it will add a new query parameter of
# "_anchor=comment_100" and then remove the anchor value.
#
# The resulting URL is then passed through to the redirect_to call
def redirect_to(options = {}, response_options = {})
  # https://edgeapi.rubyonrails.org/classes/ActionController/Redirecting.html
  # We want to be conservative on when this is applied. Only a string path is allowed,
  # a limited set of methods and only the 303/see_other status code
  if options.is_a?(String) &&
      %w[GET PATCH PUT POST DELETE].include?(request.request_method) &&
      [:see_other, 303].include?(response_options[:status])
    # parse the uri, where options is the string of the url
    uri = URI.parse(options)
    # check if there is a fragment present
    if uri.fragment.present?
      params = uri.query.present? ? CGI.parse(uri.query) : {}
      # set a new query parameter of _anchor, with the anchor value
      params["_anchor"] = uri.fragment
      # re-encode the query parameters
      uri.query = URI.encode_www_form(params)
      # clear the fragment
      uri.fragment = ""
    end
    options = uri.to_s
  end
  # call the regular redirect_to method
  super
end
      Then in application.js we store our global helpers and event handler:
    
// Whenever render is called, we want to see if there is a rails _anchor query parameter,
// if so, we want to transform it into a proper hash and then try to scroll to it. Find
// the associated server side code in a custom "redirect_to" method.
addEventListener('turbo:load', transformAnchorParamToHash)
function transformAnchorParamToHash (event) {
const url = new URL(location.href)
const urlParams = new URLSearchParams(url.search)
// _anchor is a special query parameter added by a custom rails redirect_to
const anchorParam = urlParams.get('_anchor')
// only continue if we found a rails anchor
if (anchorParam) {
  urlParams.delete('_anchor')
  // update the hash to be the custom anchor
  url.hash = anchorParam
  // create a new URL with the new parameters
  let searchString = ''
  if (urlParams.size > 0) {
    searchString = '?' + urlParams.toString()
  }
  // the new relative path
  const newPath = url.pathname + searchString + url.hash
  // rewrite the history to remove the custom _anchor query parameter and include the hash
  history.replaceState({}, document.title, newPath)
}
// scroll to the anchor
if (location.hash) {
  const anchorId = location.hash.replace('#', '')
  const element = document.getElementById(anchorId)
  if (element) {
    const stickyHeaderHeight = calculcateStickyHeaderHeight()
    const elementTop = element.getBoundingClientRect().top
    const elementTopWithHeaderOffset = elementTop + window.scrollY - stickyHeaderHeight
    // for whatever reason we can't scroll to the element immediately, giving in a slight
    // delay corrects the issue
    setTimeout(function () {
      window.scrollTo({ top: elementTopWithHeaderOffset, behavior: 'smooth' })
    }, 100)
  } else {
    console.error(`scrollToAnchor: element was not found with id ${anchorId}`)
  }
}
}
// take into account any possible sticky elements (which are assumed to be headers) and sum up their
// heights to use as an offset
function calculcateStickyHeaderHeight () {
let stickyHeaderHeight = 0
const allElements = document.querySelectorAll('*')
const stickyElements = [].filter.call(allElements, el => getComputedStyle(el).position === 'sticky')
stickyElements.forEach(el => { stickyHeaderHeight += el.getBoundingClientRect().height })
return stickyHeaderHeight
}
It is a bit of a mouthful, however, it does get turbo to respect anchors on redirects without asking your development team to contort themselves.
Looking Forward
Hopefully this issue will be resolved natively within Turbo (or the fetch spec?) so that we can avoid this type of hackery. In the meantime, happy anchoring!
Interested in joining a mission-driven team with a passion for Ruby on Rails? If so, please take a moment to look at our open positions!.
We hope you've found this post helpful! If you have any feedback please reach out on X and we would be happy to chat. 🙏
About the author
 
    Chris is dedicated to driving meaningful change in the world through software. He has taken dozens of projects from napkin to production in fast yet measured way. Chris has experience delivering solutions to clients spanning fortune 100, not-for-profit and Government.