Turbo Frames: Updating Persistent Islands
When playing with Turbo Frames with Rails, I wanted to use a persistent frame which would survive interaction with the back and forward browser buttons but leave me in control about when it was refreshed.
(I am not a frontend dev and don't enjoy wielding JavaScript, but Hotwire has moved me from toleration to appreciation of the language).
The problem
I playing at building a toy podcast player. I want to:
- be able to navigate around as if this were a SPA
- cause audio controls to pop up for the user to play the stream
- be able to use the back and forward button without interrupting playback
Number 1 is readily achievable by decomposing the view into turbo frames and rendering each chunk of the site in a frame. At the moment, I have two frames, one for the main content, another for the audio controls which begin hidden.


When the play button is hit, a turbo targets the hidden player frame and swaps it in.

But if you hit the back button, the player will not be preserved and the poor user's playback will be interrupted.
Simplified code
To help understand what I'm doing, here is some simplified view code (Haml).
!!!
%html
%head
%meta{:content => "text/html; charset=UTF-8", "http-equiv" => "Content-Type"}/
%title GloriousRadio
%meta{:content => "width=device-width,initial-scale=1", :name => "viewport"}/
= csrf_meta_tags
= csp_meta_tag
= stylesheet_link_tag "application", "data-turbo-track": "reload"
= javascript_importmap_tags
%body
%main
= yield
%footer
= turbo_frame_tag 'audio-frame'
Simplified layout
The layout does next to nothing. There is a main
element into which the views will slot and a declared but empty turbo frame in the footer.
= turbo_frame_tag 'content', data: { turbo_action: 'advance' } do
%h1= @episode.title
%h2= @episode.published_at.to_formatted_s(:long_ordinal)
= link_to feed_episode_play_path(@episode.feed, @episode), data: { turbo_frame: 'audio-frame' } do
play
#episode-description= simple_format @episode.description
Simplified show episode view
This is the view of we see when an episode is clicked. There is a link to 'play' which targets the turbo frame 'audio-frame' declared in the layout.
= turbo_frame_tag 'audio-frame'
-# audio controls
Skeleton for the audio controls view
I've stripped this right back for simplicity (there's a bit of markup to handle the audio controls and stimulus wiring). The relevant part is the turbo frame declaration.
Trying to fix it
The Hotwire Handbook is fine, but it ain't no Rails Guides. I can see there is an attribute data-turbo-permanent
I can set on a div with an id. There is a description of how persistence works in the handbook which is better than I can voice.
Setting permanent divs
The I created a div inside my turbo frame and set it to be permanent
!!!
%html
%head
%meta{:content => "text/html; charset=UTF-8", "http-equiv" => "Content-Type"}/
%title GloriousRadio
%meta{:content => "width=device-width,initial-scale=1", :name => "viewport"}/
= csrf_meta_tags
= csp_meta_tag
= stylesheet_link_tag "application", "data-turbo-track": "reload"
= javascript_importmap_tags
%body
%main
= yield
%footer
= turbo_frame_tag 'audio-frame' do
#audio-container{ data: { turbo_permanent: true } }
added permanent div to the layout
and matched it with the view
= turbo_frame_tag 'audio-frame'
#audio-container{ data: { turbo_permanent: true } }
-# audio controls
added permanent div to the view
Result: FAIL. The original, empty, div is never replaced when the play button is pressed.
Tweaking the cache control
Maybe if I ask turbo never to cache the fragment, it won't be present in the restore cache. I tried this with an without the permanent attribute.
Result: FAIL. Made zero difference to this behaviour
Talking to the JS API
So maybe I need to set the permanence attributes as above and use the JavaScript API. Turbo lets you programmatically cause a frame to load or reload.
Result: FAIL. Turbo is making the request and the server is responding but the client is never replacing view. Situation as before.
The solution
The solution to this was so blindingly obvious I couldn't see it. It's not in the docs though (it really should be). I found the answer on Ben Nadel's blog.
It is the turbo frame which should have the permanence attribute set.
!!!
%html
%head
%meta{:content => "text/html; charset=UTF-8", "http-equiv" => "Content-Type"}/
%title GloriousRadio
%meta{:content => "width=device-width,initial-scale=1", :name => "viewport"}/
= csrf_meta_tags
= csp_meta_tag
= stylesheet_link_tag "application", "data-turbo-track": "reload"
= javascript_importmap_tags
%body
%main
= yield
%footer
= turbo_frame_tag 'audio-frame', data: { turbo_permanent: true }
working layout
and
= turbo_frame_tag 'audio-frame', data: { turbo_permanent: true }
-# audio controls
working view
Result: YAY.
Video of a persistent island - no audio
(I grabbed a bunch of popular feeds from Podcast Index – not all of these are my taste!)
Yes, it's full of jank which is the next thing to tackle.
Member discussion