Turbo morphing woes

Matheus Richard

Turbo 8 introduced the concept of page refreshes with morphing. It is a powerful feature, so you might be tempted to enable it globally. However, that can cause some issues with your JavaScript code. Here are some of the common pitfalls and how to work around them.

A common problem with morphing

Let’s see the problem in action. Imagine we have a Rails app with a simple scaffold for a Comment model that has_rich_text :content. We list all comments in the index page, with a form to create a new one:

<!-- app/views/comments/index.html.erb -->
<h1>Comments</h1>

<% if @comments.empty? %>
  <p>No comments yet.</p>
<% else %>
  <ol id="comments" reverse>
    <%= render @comments %>
  </ol>
<% end %>

<hr>

<%= render "form", comment: Comment.new %>

The controller action is very simple:

# app/controllers/comments_controller.rb
def create
  Comment.create(comment_params)

  redirect_to comments_path
end

So, when submitting the form, the server will render the index view again with the new comment. Let’s enable Turbo morphing and see what happens. In your app layout, add the following:

<!-- app/views/layouts/application.html.erb -->
+<%= turbo_refreshes_with method: :morph, scroll: :preserve %>
<%= yield :head %>

If we try adding a comment, the editor toolbar simply disappears and the text area stops being interactive:

That happens because Morphing replaces the trix-editor element with a new one, but the JavaScript state is lost. If you used Turbolinks in the past, you might be familiar with this issue.

Opting out of morphing

Luckily, there’s an easy way to opt-out of morphing for specific elements. Just add the data-turbo-permanent attribute to the element you want to stay untouched:

<!-- app/views/comments/_form.html.erb -->
-<div>
+<div data-turbo-permanent>
  <%= form.label :content %>
  <%= form.rich_text_area :content %>
</div>

Now, the comment is added to the list and the editor is kept intact. Unfortunately, that means that its content isn’t cleared. We can fix that by resetting the form after a successful submission. For example, with a Stimulus controller:

// app/javascript/controllers/form_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  reset() {
    this.element.reset()
  }
}

And we can use it in the form:

<!-- app/views/comments/_form.html.erb -->
<%= form_with(model: comment, data: {controller: "form", action: "turbo:submit-end->form#reset"}) do |form| %>
  <%# ... %>
<% end %>

Problem solved! This is the basic pattern with the problem: listen for a certain Turbo event (e.g. turbo:submit-end or turbo:morph-element) and reset/reconnect the JS library.

When we can’t skip morphing

Let’s say we got two new requirements:

  1. Make the list update in real-time as people add/edit/delete comments.
  2. Make comments collapsible, so they don’t take up too much space.

To implement the first requirement, we’ll use Turbo streams. First, let’s broadcast refreshes from the model:

# app/models/comment.rb
class Comment < ApplicationRecord
  # ...
+  broadcasts_refreshes
end

And then stream from the comments channel in the index view:

<!-- app/views/comments/index.html.erb -->
+<%= turbo_stream_from 'comments' %>
<div id="comments">
  <%= render @comments %>
</div>

And in the comment partial:

<!-- app/views/comments/_comment.html.erb -->
+<%= turbo_stream_from comment %>

On to the second task! To make comments collapsible, we’ll simply wrap them with a <details> element:

<!-- app/views/comments/_comment.html.erb -->
<div id="<%= dom_id comment %>">
  <%= turbo_stream_from comment %>
  <details open>
    <summary>
      Comment #<%= comment.id %> | <%= comment.updated_at %>
    </summary>

    <%= comment.content %>

    <%= button_to "Update", comment, method: :patch %>
    <%= button_to "Delete", comment, method: :delete %>
  </details>
</div>

That all works, but everytime someone edits their comment, the model broadcasts a page refresh, which makes the details element open again for everyone that had it closed. Annoying!

We can’t use data-turbo-permanent here because we want the comment to be updated when users change it. Instead, what we will do is to prevent Turbo from morphing the open attribute of the details element. We’ll create a Stimulus controller to handle that:

// app/javascript/controllers/details_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  preventToggle(event) {
    const { attributeName } = event.detail;
    if (attributeName === "open") event.preventDefault()
  }
}

And use it in the details element with the turbo:before-morph-attributes event:

<!-- app/views/comments/_comment.html.erb -->
  <%= turbo_stream_from comment %>
-  <details open>
+  <details open data-controller="details" data-action="turbo:before-morph-attribute->details#preventToggle">

And that’s it. Now the details element won’t be reopened when the model changes, but its contents are still updated. If you’re using a dialog element, you may need to do something similar too.

Morphing by default?

I hope these examples illustrate that morphing isn’t as simple as it might seem, so enabling it by default can be dangerous. There’s an open issue in the Turbo repository to discuss this and other morphing-related problems. Follow it to stay updated on other possible solutions.

With that all in mind, I think that’s safe to say that at this point Turbo morphs are sharp knives that should be wielded with care in specific scenarios, but not something ready yet to be enabled globally.