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:
- Make the list update in real-time as people add/edit/delete comments.
- 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.