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
Chris Young
October 26, 2023

In 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

  1. User clicks a link_to with a method patch to view a notification ("hey, somebody mentioned you in a comment").
  2. Controller marks the notification as read.
  3. Controller calls redirect_to with an anchor value of the related object's dom_id
  4. Override code transforms the anchor method parameter into a _anchor query parameter.
  5. Redirect is sent to the client browser.
  6. Event handler detects the turbo:load.
  7. The _anchor query parameter is converted into a location hash.
  8. 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!.

About the author

Chris Young

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.