4 min read

Turbo Frames: Updating Persistent Islands

Turbo frames persistent islands
Turbo Frames: Updating Persistent Islands
Photo by Charlotte Coneybeer / Unsplash

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:

  1. be able to navigate around as if this were a SPA
  2. cause audio controls to pop up for the user to play the stream
  3. 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.

Ugly view of a podcast feed
Ugly view of an individual episode -- a stimulus controller manages a description disclosure

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

Up pops the player view, which at least has a modicum of style

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.

0:00
/0:20

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.