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_to
with a methodpatch
to view a notification ("hey, somebody mentioned you in a comment"). - Controller marks the notification as read.
- Controller calls
redirect_to
with an anchor value of the related object's dom_id - Override code transforms the
anchor
method parameter into a_anchor
query parameter. - Redirect is sent to the client browser.
- Event handler detects the
turbo:load
. - The
_anchor
query 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!.
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.