An Out of Body Experience with Turbo

Messengers

I needed to add a third-party messenger widget to a project I’m working on, and given their widespread use on many sites, I figured this would be a trivial case of dropping some JavaScript into the page and we’d be done. In fact, it was that simple! Until I navigated off the first page, which is when the chat bubble disappeared, and this story begins! What’s going on?

Rails Turbo Drive

For a bit of context, this is of course a modern Rails web site, with all the amazing out-of-the-box benefits we all know and love. On the front end, Turbo is one of these incredible features, and the default Turbo Drive is so transparent it can be easy to forget it’s even there.

With Turbo Drive, unlike a traditional page load that would replace the entire HTML, turbo intercepts link clicks and form submissions, fetches the full HTML with AJAX, and then replaces just the <body> element with the new content. This results in much faster rendering when navigating since all the head content like JavaScript, CSS etc doesn’t need to be re-parsed. Genius!

The Problem

This approach works beautifully for most content, but it creates a problem for third-party widgets that inject themselves into the DOM. When the messenger widget’s JavaScript runs on the initial page load, it inserts the code necessary to render and manage the chat window directly in the document body. As soon as we navigate to another page, Turbo Drive throws away that entire body content—including my carefully positioned chat widget—and replaces it with the fresh HTML that has no knowledge of the messenger integration since the initialiser code does not run.

Disappearing widget!

Permanent

Of course, Rails has data-turbo-permanent for this kind of situation right? From the documentation:

Turbo Drive allows you to mark certain elements as permanent. Permanent elements persist across page loads, so that any changes you make to those elements do not need to be reapplied after navigation.

This sounds like the solution right? It’s a little tricky because the dixa-messenger markup is dynamically loaded after the page is rendered so we can’t just add data-turbo-permanent easily. To get around this I added a new <div id="dixa-permanent-container" data-turbo-permanent></div> to our layout and then adapted the JavaScript adding a MutationObserver that watched the page and as soon as the dixa widget had been added asynchronously, moves it to our data-turbo-permanent div. Clever huh? Well.. no

Ghostly widget!

iFrames

Since the widget is implemented with an iFrame, as far as I can ascertain, iFrames lose all their state and crucially the connection to the third party remote widget platform. So despite our efforts the widget still does not survive across Turbo Drive navigations. We can see the chat bubble circle and ghostly image of where the chat window previously was, but the content rendered from the remote service is lost.

Out of Body Experience

Now I understood the issue was Turbo replacing the body, and that due to being an iFrame that turb-permanent would not resolve the issue, I had an idea to move the widget completely outside of the body tag. This seemed wrong but worth a try. Bingo! Using the same trick to observe the page and then move the div, but this time move it outside the body of the page worked perfectly. Our widget icon persists across navigations, and even with the chat window open, state is maintained!

Out of body widget. It works!

Rails World 2025

It just so happens that Rails World conference was last week, so I took this opportunity to ask some of the best and brightest in the business. Great and helpful conversations with many, but DHH himself pointed me to this issue which describes the same problem and suggests intercepting the body replacement with turbo:before-render and then using an inner div that Turbo Drive replaces on each page render. That means our widget is not replaced and functions as we expect. It’s a bunch more code though, and despite not being technically ‘legal’ HTML markup, the out of body fix described previously is still my preference.

Wrapping Up

If you have any ideas, opinions, or better suggestions, don’t hesitate to let me know. I spent way longer than I should have trying to work this out so sharing this post in case anyone else out there finds it of any use. As always, find me on Blue Sky or via any channels listed here