iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
↩️

Handling Redirects in Turbo Frames

に公開

About the environment in this article:

  • rails (8.0.2)
  • turbo-rails (2.0.13)
  • devise (4.9.4)

First, as a bit of background, this article proceeds with a "login screen" in mind, which is based on a typical devise configuration. When an unauthenticated user accesses a screen where before_action :authenticate_user! is set to require login, devise redirects them to the login screen before the controller action.

When using Turbo Rails in a Rails app and utilizing Turbo Frames to update only parts of a page, you will eventually realize (as seems to be a common experience) that problems can occur when a Turbo Frame link destination returns a server-side redirect.

For example, consider a case where a page that requires a login is being displayed. Suppose that, while it is displayed, the session expires. If you click a Turbo Frame link on that page in that state, the server will redirect to the login screen because the destination requires authentication.

Since Turbo expects the response to contain a <turbo-frame> for replacement, it results in an exception if a login screen without it is returned. Consequently, only the message "Content missing" is displayed in the frame. This is not the desired outcome.

In other words, if a Turbo Frame request is redirected and a login screen is returned, we want it to replace the entire page instead of trying to replace the frame (part of the page). How should this be handled? That is the topic I want to cover today.

Solution (1): turbo-visit-control

The first method that came to mind was to place a turbo-visit-control meta tag on the login screen page.

<meta name="turbo-visit-control" content="reload">

This is introduced in the official documentation under the section “Breaking out” from a Frame. This feature seems to be provided specifically to meet requirements like this one.

Quote from the official handbook:

In fact, there are times when you want a response to a <turbo-frame> request to be treated as a new page that effectively “breaks out” of the frame, instead of being a substitute for a full-page navigation. A classic example is when a session is lost and the application redirects to a login screen. In this case, it’s better for Turbo to show the login screen than to treat it as a session-missing error.

While you could just write the meta tag directly, Turbo Rails provides a helper. From the perspective of preparing for future changes in Turbo Rails, it is better to use the helper. By placing this helper in the login screen template, the task is complete.

<%= turbo_page_requires_reload %>

This helper's content is as follows. It seems to expect a content insertion point named :head in the template layout.

https://github.com/hotwired/turbo-rails/blob/v2.0.13/app/helpers/turbo/drive_helper.rb#L42-L49

Turbo is configured to use a special layout instead of the usual application.html.erb when it receives a Turbo Frame request. That layout is just a skeleton as shown below, but since :head is set as a content insertion point, the helper method will perform its role without issue. In fact, one could argue that you should use the helper specifically to ensure it fulfills this role.

https://github.com/hotwired/turbo-rails/blob/v2.0.13/app/views/layouts/turbo_rails/frame.html.erb

Solution (2): Checking Turbo's fetch processing and events

Since login screens are typically not displayed as a frame within some other page, the approach of embedding a meta tag as shown above is sufficient for its purpose. However, what if you don't want to embed meta tags in other screens? Let's explore that scenario.

First, since the logic where Turbo inserts a response into a frame is handled on the JavaScript side, I decided to look for a hook point.

Turbo provides several events related to fetch processing. Among them, I found that turbo:before-fetch-response is an event fired just before the fetch response is received, and the response details are contained in the instance's detail object.

I thought I might be able to detect a redirect using this, so I decided to test it out.

Detecting redirects and triggering full-page navigation

I tried using the turbo:before-fetch-response event to detect a redirect response and manually update window.top.location.

document.addEventListener("turbo:before-fetch-response", (event) => {
  const response = event.detail.fetchResponse.response;

  if (response.redirected) {
    window.top.location = response.url;
  }
});

With this code, you can transition the entire page to the intended URL when a redirect occurs. Instead of Turbo performing an insertion into a frame, you control the navigation yourself.

Redirects also occur during prefetching

However, this approach led to another problem. Turbo performs prefetching, where it automatically sends a GET request to a link destination just by hovering over it.

The turbo:before-fetch-response event is also fired for these prefetches, leading to the undesirable behavior where a redirect occurs before the user even clicks the link.

The next point to solve is how to distinguish whether a request is a prefetch or not.

Determining if it's a prefetch

Upon checking the behavior with log output, I noticed that the content of the event target event.target differs between prefetching and an actual link click. While event.target is an <a> tag during prefetching, it becomes a <turbo-frame> tag when an actual fetch occurs upon clicking.

Based on this, I thought I could distinguish between a prefetch and a click using event.target.tagName. Ultimately, I was able to write code that ignores prefetches and refreshes the entire page when a redirect occurs:

document.addEventListener("turbo:before-fetch-response", (event) => {
  const response = event.detail.fetchResponse.response;
  const tagName = event.target.tagName;
  const isPrefetch = tagName.toLowerCase() === "a";

  if (response.redirected && !isPrefetch) {
    document.documentElement.style.visibility = "hidden";
    window.top.location = response.url;
  }
});

Here, I make the entire page invisible just before replacing it. Since Turbo inserts the response into the frame as soon as it is received, the page replacement via location might not be fast enough, causing "Content missing" to be visible for a split second. This prevents that.

At first glance, it seems to work well. However, while the prefetch detection using event.target.tagName is currently effective, it's not its intended purpose. Since it depends on Turbo's internal structure, the concern remains that it might stop working with future updates.

Summary

You must be mindful that Turbo Frame link destinations may redirect. In particular, exception messages like "Content missing" have enough of an impact to confuse users and might cause them to doubt the reliability of the application (or service).

As a countermeasure, if it is an independent page such as a login screen, it is easy to handle it by placing a meta tag.

On the other hand, if you cannot place a meta tag, it is possible to detect the redirect using events and replace the entire page. However, it must be said that this is currently a workaround-like solution. Nevertheless, if you can accept using it limitedly with that understanding, it seems possible to implement a relatively flexible countermeasure using your own logic from there.

Discussion