Breaking out of turbo-frame

January 23, 2024 • By Vladimir Elchinov

When using turbo-frames all page updates by default are limited to the frame which initiated the request. On frontend you can change target frame by setting data-turbo-frame attribute on links (and use _top to update the whole page). But what if you want to make that decision on backend?


Until Feb 2023, when a PR  was merged, this problem did not exist. If expected turbo-frame was not present on the page, Turbo was simply showing received page in full.  With this change things broke. Now missing frame causes "Missing content" message to be shown.

When this problem occurs? Well, typical case is a form. When user submits a form with some errors you want to show some error message and stay on same page. But successful submission should redirect to some different page. And decision is made on the server side, so we need someway to tell Turbo to show different page.

Turbo supports various events, and one of them will help us 

document.addEventListener("turbo:frame-missing", function(event) {
    if (event.detail.response.redirected) {
        event.preventDefault()
        event.detail.visit(event.detail.response);
    }
})
lang-javascript

This piece of code (added to application.js ) will do the trick (or most of it). Event is triggered when Turbo reads response and detects, that expected frame is missing. We check if there was a redirect in the middle (Turbo requests from browser to follow redirects automatically) and instruct Turbo to show received page. 

This works in general, but you may notice some little problems. Depending on your setup you may notice, that Turbo is doing actually 2 requests. One light turbo visit, followed by full-page reload. As additional side-effect you may loose flash messages. 

To understand reasons of this we must dive a little bit in layouts and how turbo-rails affects em..

If you don't specify layout in your controller - turbo-rails does it for you with this piece of code:
  layout -> { "turbo_rails/frame" if turbo_frame_request? }
This is a useful optimisation. When we do turbo-frame request we know, that Turbo will ignore anything outside of requested frame. Knowing that we skip rendering full layout. This saves us a little bit of rendering time, and quite a lot of page weight.

<html>
  <head>
    <%= yield :head %>
  </head>
  <body>
    <%= yield %>
  </body>
</html>
lang-html
But if for some reason you need custom layout and specify it as
  layout "admin"
you break this feature. Now you always have a full layout event if you don't' need it. How to do it correctly? Pretty simple, just follow same logic:
  layout -> { turbo_frame_request? ? "turbo_rails/frame" : "admin" }
Now we have custom full layout by default, and tiny layout for turbo-frame requests. Nice. This is described in turbo-rails README, but I personnally missed this part, and it took time to figure out.

So what happens with our javascript snippet above? If you have set custom layout in an incorrect way (always rendering full layout), you actually get break-out feature working without visible problems. But if you have default layout setup, or if you have correctly overridden it, you'll get double-load problem. This is caused by this tiny layout. Remember, if during page navigation Turbo detects, that javascript or css links were changed - it is doing full page reload. That's exactly what happening - we have a response with empty <head/> , which triggers full-page reload.

How can we avoid this? We should render full layout when doing breakout. The easiest way I have found is to set additional flash message.

Here is the final solution:
class ApplicationController < ActionController::Base
  add_flash_types :turbo_breakout

  layout -> {
    turbo_frame_request? && ! turbo_frame_breakout? ? "turbo_rails/frame" : "application"
  }

  def turbo_frame_breakout?
    flash[:turbo_breakout].present?.tap { flash.delete(:turbo_breakout) }
  end

  # ...
  def some_action
     redirect_to target_path, success: "Congratulations!", turbo_breakout: true
  end
end
So, what we are doing here?
  1. add_flash_types registers new flash type, so redirect_to can recognise it
  2. we set proper layout. Tiny one for turbo-frame requests, but not when we are trying to break out.
  3. turbo_frame_breakout? checks the value in flash and removes it (otherwise it could be shown with other messages to the user).
Finally,  redirect_to target_path, success: "Congratulations!", turbo_breakout: true is doing a redirect setting 2 flash messages. One for the user and the other for choosing correct layout.
Last question: what happens if some redirect occurs without our flash message? Well, it still will be working, with that double load and full-page visit, but still working, so it's a good fallback for unexpected cases.

This solution (a little bit more elaborated) is included in Basic version of Rails Blueprint

Start creating your next app now