Turbo processed 303 redirect does not maintain or scroll to location anchor

159 views Asked by At

In a Turbo/Rails (7.1) application, Turbo does not respect the anchor value specified in the redirect_to method with a :see_other status. I would expect that the page automatically scrolls to the location of the anchor, however, I think due to some fetch() issues the anchor is lost and I end up at the top of the page.

This is true for GET and PATCH (POST) requests. The scenario that isn't working is as follows:

  1. link_to with turbo_method of patch
  2. controller receives the request and then redirects with an anchor to a new page
  3. the redirect occurs but the anchor is lost and the page doesn't scroll

An example of where this is particularly problematic is for notifications (which we do through a PATCH) to a discussion-like post. We want the user to click a notification, have the notification mark as read and then redirect to the specific comment on a page that the notification was associated with.

In the above scenario, I expect that clicking the link, then redirect_to with an anchor would result in the user landing on a page and automatically scrolling to the element with the anchor id.

I've created a repo to further demonstrate the issue, and hope to find that others are facing the same issue and/or have found a way around it: https://github.com/harled/turbo-anchor-issue

This same topic has been raised via the Turbo GitHub issues here: https://github.com/hotwired/turbo/issues/211

I've spent a far amount of time tracing through the Turbo code with the browser debugger and my conclusion is that fetch (and opaque redirects) are the issue which means some type of app-land workaround is necessary.

1

There are 1 answers

0
krsyoung On

Here is a workaround that functions Turbo GET/PATCH requests with redirect_to that include an anchor, something like:

redirect_to(discussion_path(anchor: dom_id(@comment), status: :see_other))

The gist of it is to switch the anchor to a query parameter (which is forwarded) and then use a little bit of javascript to clean-up the URL and scroll the page.

It doesn't feel clean, but it does seem to work. It also avoids having the developers learn a new way to interact with redirect_to and the anchor: parameter.

# application_controller.rb
class ApplicationController < ActionController::Base
  # 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
end
// application.js
// 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
}

The full repo is available in this branch: https://github.com/harled/turbo-anchor-issue/tree/turbo-anchors