How to break out of a Turbo Frame and redirect?

stuck in a turbo frame

I want to re-render within the Turbo frame except for when I want to break out and redirect. Setting the container frame target to _top doesn’t work because it applies to every request

Requests that originate from a Turbo frame (form submits, link clicks etc) are constrained. They are expected to fetch content either for that frame, or for a frame specified in the target or data-turbo-frame attributes (e.g. <turbo-frame target: "_top">. If the response does not contain this frame, Turbo considers that to be an error and will replace your frame’s contents with a “Content missing” message.

Consider a typical form in a Rails app. The form is rendered at the :new or :edit route. There are two paths the user can take after filling out and submitting the form. Either there are validation errors that the user must fix before re-submitting, or the form submit succeeds and the user is redirected to the :show or :index route by a controller making use of the redirect_to method (with something like redirect_to things_url, notice: "Thing was successfully created")

In an app running Turbo, Turbo Drive intercepts form submissions so that it can perform them in the background and render the response on the page without doing a full reload. If our form is not wrapped in a <turbo-frame> tag, then all is well, and Turbo Drive will perform redirects to anywhere correctly.

However, if our form is wrapped in a <turbo-frame> tag, then we will run into the constraint above whenever the form submission is successful and a redirect needs to happen. On submission, Turbo will see that the response does not contain the frame that generated the request and cry “Content missing”. Validation errors will render correctly, because a typical Rails form renders validation errors inline with the form so that it is easy for users to see what they need to fix. Since the form is wrapped in a <turbo-frame> tag, a response containing the form (with errors) probably also has the requisite Turbo frame.

You may reasonably ask, “Why would or should I wrap a form in a <turbo-frame> and then break out of that frame?”. This is an important and interesting question, but for the purposes of this article I’m going to assume that you have a good reason for doing so. A common use-case for this is when on form submission, your app realizes that the user session is no longer valid, and needs to redirect to the login page.

You may also ask, “For the typical form described earlier, where we are redirecting to an index or show page, does it make sense to wrap it in a Turbo frame?”. In this case, the answer is a bit more straightforward - I don’t think it does. To quote the Turbo Handbook:

Frames serve a specific purpose: to compartmentalize the content and navigation for a fragment of the document. Their presence has ramifications on any <a> elements or <form> elements contained by their child content, and shouldn’t be introduced unnecessarily.

However, I do think it is a useful scenario to keep in mind to understand how Turbo frames work and how we might break out of them. So I’m going to stick with it.

So, how do we break out of a Turbo frame and redirect on a successful form submit?

At this point in time (mid 2023), Turbo does not natively support redirects “out” of a frame (when submitting forms). However, Turbo provides what are known as “escape hatches” to enable this behaviour. Let’s take a look at some of them.

turbo_page_requires_reload

We’ve established that if Turbo doesn’t see the “requestor” frame in the response, it will consider that to be an error. However, if it sees a particular meta tag in the response, it will trigger a full-page navigation. This will look like a redirect to the user. The turbo_page_requires_reload helper inserts this meta tag into your HTML. Let’s trace through an example to see how this would work:

  1. Form, wrapped in a Turbo frame, renders on the “new” page

  2. User fills out and submits form successfully

  3. Turbo intercepts the submit and makes the POST request. It knows which frame triggered the request.

  4. Controller creates the resource and issues a redirect to the “index” page (with redirect_to)

  5. Turbo would usually look for the frame in the response, but in this case sees that the HTML in the response looks something like this:

    <head>
      <meta name="turbo-visit-control" content="reload">
      ...      
    </head>
    <body>
      <div class="notice">
        Thing was created successfully
      </div>
      <h1>
        This is the index page
      </h1>
    </body>
    
  6. Once it sees the turbo-visit-control meta tag, it triggers a full page navigation to the index page and the user is redirected to the index page.

Drawback 1: Two GET requests

Our “break out and redirect” problem is seemingly solved with this approach, but it comes with a cost. This method results in two GET requests being made to the index page. The first GET happens when Turbo receives a 302 Redirect response from the form submit. The response of this GET contains the “index” page. Instead of rendering it however, Turbo sees the turbo-visit-control meta tag and issues another GET for the page.

Drawback 2: What about flash messages?

Because we’re making two GET requests, any flash messages that were set during the rendering of the first GET request get wiped out when the second request is made. This is a bummer, but it can be worked around. To get flash messages to stick around, you can use flash.keep in the action that is being redirected to. For example, if your form submit redirects to the index action, you’d do:

# in the controller
def index
  flash.keep if turbo_frame_request?
end

One one hand, this solution is easy and requires a minimal amount of code. It may very well work for you. But, if the GET for your page happens to be expensive, you may wonder: Is there a way to make Turbo render the contents of the redirected-to page immediately, instead of issuing another GET request?

The turbo:frame-missing event

When Turbo is unable to find the frame it expects in the response, it emits the turbo:frame-missing event. We can listen for this event and potentially add our “break out + redirect” logic here.

Naively, we might do something like this:

document.addEventListener("turbo:frame-missing", event => {
  event.preventDefault()
  event.detail.visit(event.detail.response)
})

For our “break out and redirect” scenario, this solution will work. It does have some issues though. For one, “cancelling” the event with event.preventDefault() has the side-effect of blocking legitimate raises of the “Content missing” error. That is easily fixed by only preventing the event from bubbling up if the response is a redirected response:

document.addEventListener("turbo:frame-missing", event => {
  if (event.detail.response.redirected) {
    event.preventDefault()
    event.detail.visit(event.detail.response)
  }
})

There is a subtle issue lurking here. Using event.detail.visit, unfortunately, will end up triggering another GET request by Turbo Drive. Responses to Turbo frame requests are rendered with a minimal layout - just an empty <head> tag, unless you’ve customized it any. This is done intentionally since Turbo is not expecting to need any content outside of the frame. If you have elements in your head tag (on your current page) that are annotated with data-turbo-track="reload", just the fact that the response event.detail.visit renders does not have these elements in it will cause Turbo Drive to issue a full page reload. Because you’re doing two GETs, you’re going to have to make sure that you invoke flash.keep in your controller. Again, you can work around this issue by including the entirety of your application layout with any Turbo frame response.

class ThingsController < ApplicationController
  layout -> { "application" if turbo_frame_request? }
end

This will solve the double GET issue by ensuring that the turbo frame response’s head content matches the head content on the page . On the flip side, you are now bypassing a framework-level optimization because you’re sending redundant data across the wire.

With this approach, we no longer have the “double GET problem”, and we don’t have to do anything special to make sure that the flash message is rendered. The main drawback of this approach is that it feels like a hack, and makes me wish Turbo supported this natively.

Instead of redirect_to, respond with a “custom action” turbo stream

Turbo lets you define custom actions on top of the seven default actions that it provides (append, replace, delete etc). If we were to respond with a turbo stream that knew how to “redirect”, then that might solve our problem. The first step is to tell Turbo what to do if a stream with the action “redirect” is received:

Turbo.StreamActions.redirect = function () {
  Turbo.visit(this.target);
};

Now, when Turbo sees a stream looks like this: <turbo-stream action="redirect" target="/things.com"></turbo-stream>, it will call the function above.

The next step is to actually render the turbo stream in our controller:

class ThingsController < ApplicationController
  def create
    @thing = Thing.new(thing_params)
    respond_to do |format|
      if @thing.save
        format.turbo_stream do
          flash[:notice] = "Thing was successfully created"
          render turbo_stream: turbo_stream.action(:redirect, things_url)
        end
      else
        format.html { render :new, status: :unprocessable_entity }
      end
    end
  end
end

This approach does not have the “double GET problem” either, and does not need anything special to get flash messages to work. Moreover, it feels like a natural extension of what Turbo currently offers. The disadvantage is that you have now introduced a second way to redirect requests in your app.

Out of the three options I’ve presented, I think this is my current favorite.

Conclusion

In the short-term, I hope this article gives you a way out of your Turbo frame breakout + redirect problem, and that you’ve learned something new about how Turbo works. You may also find my articles on using React with Stimulus and the differences between Turbo and Stimulus useful, if you haven’t read them already.

In the long-term, I suggest keeping an eye out for new Turbo releases. Solving the breakout + redirect problem looks like an active area of development, where the core contributors are aware of the problem and are searching for an elegant solution.

If you got value out of this article and want to hear more from me, consider subscribing to my email newsletter to get notified whenever I publish a new article.

Want to be notified when I publish a new article?