At Rails World I presented the Hotwire Cookbook - a collection of UI behaviours that you can achieve today with Turbo Drive, Frames, Streams, and Stimulus.

In the meantime Jorge Manrubia talked about new/future Hotwire/Turbo features. Mainly, about “Turbo Morphing”. It was introduced in this pull request.

What is “morphing”? #

Morphing = refresh current page with preserving the scroll position;

Full page refresh animation is skipped, because before the refresh happens, there is a “diff” of old VS new page, and only the diff gets updated.

Try morphing (Turbo Drive) #

Morphing will be released in Turbo 8, so currently the best way to try it together with all the other new features is to use the main git branch:

# Gemfile
gem "turbo-rails", github: "hotwired/turbo-rails", branch: "main"

Enable turbo 8 morphing:

Old, default page refresh behaviour:

# app/views/layouts/application.html.erb
<head>
  # <meta name="turbo-refresh-method" content="replace">
  # <meta name="turbo-refresh-scroll" content="reset">
  <%= turbo_refreshes_with method: :replace, scroll: :reset %>
  <%= yield :head %>
</head>

New, add morphing to your app by adding this:

# app/views/layouts/application.html.erb
<head>
  # <meta name="turbo-refresh-method" content="morph">
  # <meta name="turbo-refresh-scroll" content="preserve">
  <%= turbo_refreshes_with method: :morph, scroll: :preserve %>
  <%= yield :head %>
</head>

Now, whenever you redirect_to current page, the scroll position will be preserved.

The easiest way to reproduce this behaviour is deleting a record from a list:

  • without morphing, you would be redirected to the top of the page
  • with morphing, you will keep scroll position, and the element will just disappear

This means you can use fewer TurboStreams in your app!

Do not morph-refresh some elements #

If an element should not be refreshed, add data-turbo-permanent attribute.

Previously, data-turbo-permanent required the presence of an [id] attribute. Now it does not!

For example: if you add data-turbo-permanent to this <details> dropdown, it will not close after a morph-refresh:

-<details>
+<details data-turbo-permanent>
  <summary>Details</summary>
  Something small enough to escape casual notice.
</details>

New turbo stream action - refresh #

  • Morph-refresh for current user, only current tab? => redirect_to;
  • Morph-refresh for all users on a page? => TurboStreams;

For this we have a new turbo stream redirect action has been introduced:

<turbo-stream action="refresh"></turbo-stream>

Now you can trigger refreshes in a model, and send updates to all clients/browser tabs!

# app/models/project.rb

# For this to work out of the box, consider using very RESTful (scaffold-default) conventions.
  broadcasts_refreshes

  # same as:
  after_create_commit  -> { broadcast_refresh_later_to(model_name.plural) }
  after_update_commit  -> { broadcast_refresh_later }
  after_destroy_commit -> { broadcast_refresh }

This would require having an open ActionCable/Websockets channel for this specific record:

# app/views/projects/index.html.erb

<% @projects.each do |project| %>
  <%= turbo_stream_from project %>
  <%= render project %>
<% end %>

or directly within project partial

# app/views/projects/_project.html.erb
<%= turbo_stream_from @project %>

I don’t like invoking view-related logic from a model.

Instead, we can trigger refreshes from from the console:

Turbo::StreamsChannel.broadcast_refresh_to @project

will refresh all pages that have the listener

<%= turbo_stream_from @project %>

Global refresh #

Refresh current_page(s) for all users in the app 🤪

# app/views/application.html.erb
Turbo::StreamsChannel.broadcast_refresh_to :global
<%= turbo_stream_from :global %>

Refresh all current page(s) for current_user:

# app/views/application.html.erb
Turbo::StreamsChannel.broadcast_refresh_to current_user
<%= turbo_stream_from current_user %>

Debugging common problems #

After a page morph stimulus controllers can lose the default state. You can manually “re-connect” a stimulus controller:

<div data-action="turbo:morph@window->dropdown#connect">

You can also trigger a callback on a value change.

In the below scenario, a page morph would not affect elements inside a div, but we trigger some turbo/form behaviours manually:

<div data-turbo-permanent data-action="
  turbo:submit-end->dropdown#close
  turbo:submit-end->form-reset#connect">
  <form ....>

Open questions: #

  • Can morphing replace turbo frames search?
  • Would it correctly refresh based on each users’ current query params?
  • How would it work on a page with infinite pagination?

Resources: