Form submissions, fetch requests, visits, renders and redirects. Every interaction a user has with a web application is one of these. While Turbo can make these feel seamless to the user, it can be annoying to get them right as a developer. Redirects within a Turbo Frame sometimes work, and other times you get a “Content Missing” message. Sometimes everything looks like it ought to work - no errors in the browser, no errors in the server logs - but nothing happens, and you’re left wondering why.
Wouldn’t it be nice to know exactly what is going on under the hood when you notice something isn’t working the way you want?
In the pre-Turbo (naturally aspirated?) world, no matter where on the page the user request originated, you could call render or redirect_to in your controller and expect that the browser would replace the entire viewport with fresh HTML, <html> tag on downwards. To the user, whether you were rendering validation errors on the same page or successfully redirecting to another page, it felt like a “reload” happened before the new content was seen. JS responses to Rails UJS requests, rendered either inline with render js: ... or by with a .js.erb view, were the only way to have the contents of the viewport change without forcing a reload.
With Turbo, the behaviour of redirect_to and render now depends on if Turbo is going to enforce the presence of a given frame id in the response. If Turbo is expecting to see a turbo frame and the response does not contain the expected <turbo-frame> element, it’ll show you the “Content Missing” message.
There are two types of navigation in Turbo. Turbo Drive navigation, and Turbo Frame navigation. Turbo Drive navigation is the “full-page” navigation layer, where the entire HTML <body> is replaced. With Turbo Frame navigation, only the content of a given <turbo-frame> element is replaced. When a user takes an action on a page, like clicking a link or submitting a form, Turbo intercepts it and determines what type of navigation to apply. If Turbo Frame navigation applies, then it will enforce that the response contains the expected <turbo-frame> element.
How does Turbo decide that Turbo Frame navigation applies to a user interaction? And when applying Turbo Frame navigation, how does it know which <turbo-frame> element to look for in the response?
It all depends on the element (e.g. link or form) the user is interacting with. When Turbo intercepts a click or submit event, it looks for one of the following cases:
data-turbo-frame attribute. If it does, and that frame exists in the DOM, then Turbo will enforce that any response to the click or submit contains that frame element. It doesn’t matter where the element is located. It could be outside or inside a Turbo Frame.<turbo-frame> element and does not have a data-turbo-frame attribute defined. In this case Turbo enforces that any response to the click or submit contains the enclosing <turbo-frame> element.<turbo-frame> element, does not have a data-turbo-frame attribute defined, and the <turbo-frame> element has a target attribute set. In this case Turbo enforces that any response to the click or submit contains the target frame element.There are a few ways to bypass frame navigation:
data-turbo-frame on the link or form to _top. _top is a “special” frame id which tells Turbo to bypass frame navigation and do full page visits with Turbo Drive.target on the enclosing <turbo-frame> to _top. This only works if the link or form does not itself have data-turbo-frame set.turbo-visit-control meta tag in your response and setting its content attribute to reload. This will force a “full page” visit to the URL that serves the response. This approach comes with a drawback - it causes two requests to be made for the response.text/html. Responses that are not text/html are ignored by Turbo navigation. Responding with a Turbo Stream (with content type text/vnd.turbo-stream.html) simultaneously skips Turbo navigation and harnesses listeners that add <turbo-stream> elements to the DOM. That gives you a lot of flexibility.If your frame navigation needs are “static”, that is if a given link or form always targets the same frame (either by virtue of being enclosed in that frame or explicitly with the data-turbo-frame attribute), then all you need to do to avoid seeing “Content Missing” is ensure that render or redirect_to always include that frame in the response. If the target is _top, then you don’t even need to do that.
If your frame navigation needs are “dynamic”, that is if a given link or form sometimes targets one frame, and other times needs to target another frame or even perform full-page navigation, then you have to render <turbo-stream> elements. The only navigation related action that comes with Turbo Streams by default is refresh, so you will have to write a custom action that can take in a URL and an optional frame ID.
Fortunately, that is a straightforward affair. In any file that gets imported by your application’s entry point (e.g. application.js), you can do the following:
import { StreamActions } from "@hotwired/turbo";
StreamActions.redirect = function () {
const url = this.getAttribute("url");
const frame = this.getAttribute("frame")
Turbo.visit(url, { frame })
};
And then, whenever you want to use this action in a controller, you can do:
render turbo_stream: helpers.turbo_stream_action_tag(
"redirect",
url: some_url,
frame: "some_frame" # omit if you're going to do a full-page navigation
)
If your navigation paths are restricted to either a full-page navigation or rendering something in the enclosing frame (perhaps a more typical situation), then you can do render turbo_stream: ... for the full-page navigation and a plain render to update the enclosing frame.
If you wanted to navigate frames purely with JavaScript (e.g. in a Stimulus controller), that’s pretty easy too and you have a couple of options.
If all you want to do is “refresh” the frame, you can do:
const myFrame = document.getElementById("myFrame")
myFrame.reload()
If you want to load a specific url into your frame, you can do:
const myFrame = document.getElementById("myFrame")
myFrame.src = "/some_url"
Turbo will take care of the rest for you. Isn’t that convenient?