Hotwire and That Syncing Feeling

Recently, I had to implement a multi-screen slide deck presentation system that stayed in sync with the presenter’s view.

How did I get there?

Requirements

Render three views: the group view, the individual view, and the presenter view.

  • The group view should show a fullscreen view of a slide, including group content such as a video
  • The individual view should show a fullscreen view of a slide with the option to take actions such as filling out a form, but should hide group content
  • The presenter view should show a “preview” of the previous, current, and next slides
  • The presenter view can navigate forward, backward, or jump to any slide

and most importantly, all views have to stay in sync with the presenter’s current slide.

Also, this isn’t syncing only three instances. In our environment it was around 30, as we had that many people with the individual view running on their laptop!

Routes and views

The foundation of the system is a Presentation table with a #current_slide column.

To simplify things a bit, the system supports three routes:

/presentations/:id
/presentations/:id/group
/presentations/:id/individual

Without getting into too much detail, the group and individual routes have a show.html.erb that uses a @presentation reference to display the content for the current slide.

The presenter view renders three iframes of the group view:

  • one for #current_slide - 1
  • one for #current_slide
  • one for #current_slide + 1

The iframes are wrapped in buttons that update #current_slide and use pointer-events: none in the CSS to prevent the presenter from accidentally interacting with the previews.

But how to keep all the views in sync?

Can Hotwire help us? Yes!

We can leverage broadcasting page refreshes by adding broadcasts_refreshes to our Presentation model and turbo_stream_from to our views.

Getting it working in Development

To tie everything together, we have to make a handful of one- or two-line changes to:

  • the model
  • the view
  • the layout
# app/models/presentation.rb

class Presentation < ApplicationRecord
  broadcasts_refreshes
<!-- app/views/presentations/group/show.html.erb -->

<%= turbo_stream_from @presentation %>
  <div id="<%= dom_id(@presentation) %>">
    <div>Group View: Slide <%= @presentation.current_slide %></div>
<!-- app/views/presentations/individual/show.html.erb -->

<%= turbo_stream_from @presentation %>
  <div id="<%= dom_id(@presentation) %>">
    <div>Individual View: Slide <%= @presentation.current_slide %></div>

We also need to update our Application layout to refresh with morphing:

<!-- app/views/layouts/application.html.erb -->

<!DOCTYPE html>
<html lang="en">
  <head>
    ...
    <%= csp_meta_tag %>

    <%= turbo_refreshes_with method: :morph, scroll: :preserve %>

Using content_for instead of an application-wide layout

Some sites won’t want Turbo refreshes on all pages. To enable morphing on a specific view only, use content_for in a view’s html.erb file.

<%= content_for :head do %>
  <%= turbo_refreshes_with method: :morph, scroll: :preserve %>
<% end %>

Support the content_for in the Application layout with a yield:

<!-- app/views/layouts/application.html.erb -->

<!DOCTYPE html>
<html lang="en">
  <head>
    ...
    <%= csp_meta_tag %>

    <%= yield :head %>

The broadcasting part

With this setup, when Presentation#current_slide is updated, the change is broadcast to the listening views, and they magically automatically show the content for the current slide!

The overall approach requires updating the Presentation record, but more specifically, it relies on an ActiveRecord callback. At first, we tried the seemingly-convenient increment! and decrement! methods to update the current slide.

The issue with these (and a few other ActiveRecord methods) is that they do not trigger callbacks. And so, they never broadcast a change, and the views never update!

Getting it working in Test

Here’s a simple system test to verify that the broadcasting is working as expected:

# test/system/presentation_test.rb

test "/presentations/:id/group dynamically updates its content" do
  presentation = create :presentation, current_slide: 1

  visit presentation_group_path(presentation)

  assert_text "Slide 1"

  presentation.update!(current_slide: 2)

  assert_text "Slide 2"
end

…but if you run this on a default Rails setup, it will never work. Why? Because behind the scenes, the broadcasts are handled by jobs. And in Test, jobs are configured by default to use the TestAdapter.

So, you have two choices:

  • Use the :async adapter in test, include the ActiveJob::TestHelper in your test file, and use a perform_enqueued_jobs block around the #update! call:
# config/environments/test.rb

config.active_storage.service = :async
# test/system/presentation_test.rb

include ActiveJob::TestHelper

test "/presentations/:id/group dynamically updates its content" do
  presentation = create :presentation, current_slide: 1

  visit presentation_group_path(presentation)

  assert_text "Slide 1"

  perform_enqueued_jobs do
    presentation.update!(current_slide: 2)
  end

  assert_text "Slide 2"
end
  • or, use the :inline adapter but swap it out for the test adapter in your test_helper.rb file so that ActiveJob tests that rely on enqueued jobs continue to work:
# config/environments/test.rb

config.active_storage.service = :inline
# test/test_helper.rb

module ActiveJob
  module TestHelper
    def queue_adapter_for_test
      ActiveJob::QueueAdapters::TestAdapter.new
    end
  end
end

with no changes to the first system test example above.

Getting it working in Production (Heroku)

There are many ways you might be handling jobs in your Production environment, but in Heroku, we had to create a separate dyno running bundle exec bin/jobs:

A list of two dynos in Heroku, one running puma, the other running bin/jobs.

Final thoughts

Thanks to broadcasts_refreshes, turbo_stream_from, and Turbo morphing, we can keep a robust multi-screen system that stays in sync with the presenter’s view with only a few tiny additions to our code.

The possibilities are endless, but the core idea is the same: any view can reflect the state of a model with Hotwire.

There are some gotchas to keep in mind, but other than that, it’s as easy as…syncing!