The future of full-stack Rails: Turbo Morph Drive—Martian Chronicles, Evil Martians’ team blog

By admin
If you’re interested in translating or adapting this post, please contact us

first
ORDINAL

.

The “getting-back-into-full-stack” trend in web development communities is gaining more traction. Frontend frameworks are trying to embrace server components,

htmx
PERSON

is the new black, and

LiveView
ORG

and LiveWire are conquering

Elixir
ORG

and

Laravel
ORG

applications, respectively. And, of course,

Ruby on Rails
ORG

has its newer offspring, Hotwire. Let’s explore how far you can go with the full-stack approach in

Rails
ORG

, and what the future might hold!

Other parts: The future of full-stack Rails: Turbo Morph Drive The future of full-stack Rails II: Turbo View Transitions

You can watch the

DHH
ORG

’s keynote here.

Oh, and

Ruby on Rails
ORG

should have probably been

first
ORDINAL

in that list of full-stack technologies. Why? Because it is always trusted by small and productive teams.

As

David Heinemeier Hansson
PERSON

, creator of the framework (

Rails
ORG

), said at the very recent

Rails World
ORG

conference, “

Ruby on Rails
ORG

is a

one
CARDINAL

-person framework”. This “person”, by design, could only be a full-stack engineer (not a “part-stack” one).

Speaking of that

Rails World
ORG

conference, in terms of news and announcements, it was a very fruitful event.

One
CARDINAL

thing of particular interest related to this post is

Turbo 8
PRODUCT

, the new version of

Turbo
PRODUCT

, a library for building modern HTML-driven web applications, which has been a default frontend component for

Rails
ORG

applications since

Rails 7
ORG

.

We still don’t know the complete list of changes and new features in

Turbo 8
PRODUCT

, but a couple have already been revealed: DOM morphing and page transitions. Both features are still being properly formulated, and we’ve yet to see their final look, but that doesn’t mean we shouldn’t attempt to explore those ideas and try to apply them to

today
DATE

’s Hotwire applications.

In this

two
CARDINAL

-part series, I’d like to explore these aforementioned frontend technologies and demonstrate how we can use them

today
DATE

with

Turbo 7
PRODUCT

(so you can still enjoy

TypeScript 😁
ORG

).

Our demo application:

Turbo Music Drive
ORG

For web frameworks, music apps are the new TODO MVC for demonstrating their features. See, for example, this

Spotify
ORG

clone built with

Astro
ORG

to demonstrate their View Transitions support.

I’ve created a demo application to showcase how

Turbo
PRODUCT

can be used to drive interactive web applications. I call it

Turbo Music Drive
ORG

, and it’s a music library and a player with some basic browsing capabilities. It’s a Rails 7.1 application with a few models and controllers to serve the data, with sprinkles of Stimulus on the client side.

Go to turbo-music-drive.fly.dev to see it in action 🎧.

Final version of the demo app

I

first
ORDINAL

built all the functionality with plain

Turbo
PRODUCT

(v7), and even that version looked pretty neat, given I didn’t have to write a single line of

JavaScript
PRODUCT

. Just take a look at the baseline version of the application:

The plain,

Turbo
PRODUCT

version of the demo app

Isn’t it cool? I think it is. However, if you give it a more thorough look, you can spot some imperfections. No worries, we’re going to fix them all.

To morph or not to morph?

Before doing a deep dive into the topic of morphing, let’s recall how Turbo performs page updates related to navigation.


Turbo Drive
LOC

intercepts navigation events (clicks on links, form submissions, and so on), performs an AJAX request in the background, then it updates both the page contents with the new HTML and the browser history. We’re going to focus on the “updates the page contents” part.

Currently,

Turbo Drive
ORG

updates the page contents by replacing the whole <body> element with the new HTML. So, it could be simplified to the following code:

render ( newHTML ) { document . body .

innerHTML
GPE

=

newHTML
ORG

; }

The problem with this approach is that we lose the local state of the browser page. We can look at our demo application to see a couple of examples of this problem:

Scroll and CSS transition glitches


First
ORDINAL

, we can see that the scroll position of the albums container is reset whenever we perform the page reload while filtering tracks.

Second
ORDINAL

, the sound wave animation of the player is reset whenever we navigate between pages (although the player is a permanent element, its HTML is preserved).

These are just some examples of potential UX problems caused by fully swapping a page’s HTML. Another example is losing focus or input field state (e.g. when implementing an autosave feature with

Turbo
PRODUCT

). But if you’re aiming for a perfect user experience, you should try to avoid problems like this. And if we switch to incremental

DOM
ORG

updates or morphing, we can.


Turbo
PERSON

meets

Idiomorph

PERSON

The current work-in-progress pull request for

Turbo
PRODUCT

only uses morphing for page updates triggered via a specific

Turbo Stream
ORG

action, “refresh”. Regular navigation will still use the full swap approach, which may change in the final release.

Morphing is not a new thing. This technique has been used for

years
DATE

, primarily by other full-stack frameworks, like

Phoenix LiveView
ORG

and

StimulusReflex
ORG

, and it has proven to be a very effective technique. So, it’s not surprising that

Turbo
PRODUCT

plans to adopt it as well. Luckily, we don’t have to wait for

Turbo 8
PRODUCT

to try it out—

Turbo 7
PRODUCT

is flexible enough to allow us to implement a custom rendering strategy ourselves right now.


First
ORDINAL

, we need to pick a morphing library. There are a few, but I’ve chosen

Idiomorph
PERSON

because it’s what we’ll end up with in

Turbo 8
PRODUCT

(it also has some benefits over other libraries, like

morphdom
GPE

, which is what I previously used).

Then, we can integrate it into

Turbo Drive
FAC

using the following snippet from the handbook:

document .

addEventListener
PERSON

( "turbo:before-render" , ( event ) => { event . detail . render = async ( prevEl ,

newEl
ORG

) => { await new

Promise
PRODUCT

( ( resolve ) => setTimeout ( ( ) => resolve ( ) ,

0
CARDINAL

) ) ;

Idiomorph
PERSON

. morph ( prevEl ,

newEl
ORG

) ; } ; } ) ;

Note that we had to add a hack to perform asynchronous morphing; without going into too much detail, this is required to make sure

Turbo
PRODUCT

caching works fine. See this issue for more information.

Let’s see if those few lines of code were enough to fix our previously-encountered problems:

Straightforward morphing fixed only the horizontal scroll issue

Unfortunately, it was not. The horizontal scroll position is preserved, but the vertical one is still being reset. Why is that?

It turns out that

Turbo
PRODUCT

restores the scroll position to

zero
CARDINAL

on each navigation event. This logic can be explained as follows: if a URL has changed, we assume we’re on a new page, so we must have multi-page-navigation-type behavior. In our case, we’re only updating the query string, so it’s safe to assume that this is the same page being refreshed; no need to scroll back. We can work around this by adding the following code to our callback function:

let prevPath = window . location . pathname ; document .

addEventListener
PERSON

( "turbo:before-render" , ( event ) => { Turbo . navigator . currentVisit . scrolled = prevPath === window . location . pathname ; prevPath = window . location . pathname ; event . detail . render = async ( prevEl ,

newEl
ORG

) => { await new

Promise
PRODUCT

( ( resolve ) => setTimeout ( ( ) => resolve ( ) ,

0
CARDINAL

) ) ;

Idiomorph
PERSON

. morph ( prevEl ,

newEl
ORG

) ; } ; } ) ;

Great! Both scrolling problems have been fixed. But what about the player animation? For some reason, it’s still being reset on every navigation event, even though we shouldn’t be touching this element at all. How strange!

Let’s add some puts-like debugging to our rendering process using CSS. We can add a default animation to every node which will highlight the element once it’s been added to the

DOM
ORG

tree:

@keyframes highlight {

0%
PERCENT

{ outline : 1px solid red ; }

100%
PERCENT

{ outline : 0px solid red ; } } * { animation : highlight 0.2s ease ; }

Now, we can see which parts of the page are being re-rendered. You might be surprised, but the player is being highlighted on every navigation event:

Debug

DOM
ORG

modifications via

CSS
PRODUCT

highlighting

Investigating this took some time, but in the end, I discovered the following: data-turbo-permanent elements are removed before every rendering operation and then added back to the

DOM
ORG

tree instead of matching new elements (if any). This means the element is still the same, but it’s being unmounted and re-mounted, thus causing CSS animation to restart.

In

Turbo 8
PRODUCT

, data-turbo-permanent will work interchangeably in both replace and morph modes since rendering functionality is going to be abstracted further into separate classes. See this PR.

To fix this, we can port the data-turbo-permanent functionality to our custom morphing-based rendering. This makes a lot of sense: instead of having

two
CARDINAL

different rendering engines that don’t know about each other, it’s better to keep all the logic in a single place.

Let’s change the player attribute to data-morph-permanent and add the beforeNodeMorphed callback to the

Idiomorph.morph
ORG

call:

event . detail . render = async ( prevEl ,

newEl
ORG

) => { await new

Promise
PRODUCT

( ( resolve ) => setTimeout ( ( ) => resolve ( ) ,

0
CARDINAL

) ) ;

Idiomorph
PERSON

. morph ( prevEl ,

newEl
ORG

, { callbacks : { beforeNodeMorphed : ( fromEl , toEl ) => { if ( typeof fromEl !== "object" || ! fromEl . hasAttribute ) return true ; if ( fromEl . isEqualNode ( toEl ) ) return false ; if ( fromEl . hasAttribute ( "data-morph-permanent" ) && toEl . hasAttribute ( "data-morph-permanent" ) ) { return false ; } return true ; } , } , } ) ; } ) ;

Let’s see what has changed:

The player CSS animation works now

Our player stays in the

DOM
ORG

tree during navigations (we don’t see any highlighting), and the animation is smooth (as well as the overall user experience). Awesome!

If you think that we’re done with adopting morphing as a primary rendering strategy for

Turbo
PRODUCT

, you’re wrong. We still need to take care of some other Hotwire stuff.

Morphing vs. other Hotwire components


Turbo Drive
FAC

is just

one
CARDINAL

part of the Hotwire family; we also have

Turbo Frames
ORG

and Turbo Streams, which also perform page updates. Further, Stimulus controllers are also affected by how we modify HTML since they’re connected to the

DOM
ORG

tree. Let’s see what we need to do to make them play nicely with morphing.

Dealing with frames


Turbo Frames
PRODUCT

can be seen as sub-pages. They’re rendered independently of the page, and hence, we must set up morphing for them separately. Luckily, it’s straightforward to do:

document .

addEventListener
PERSON

( "turbo:before-frame-render" , ( event ) => { event . detail . render = ( prevEl ,

newEl
ORG

) => {

Idiomorph
PERSON

. morph ( prevEl ,

newEl
ORG

. children , { morphStyle : "

innerHTML
GPE

" } ) ; } ; } ) ;

We define a listener for the before-frame-render event and override the render function. This is similar to what we did for the before-render event, and the only difference is that we use the

innerHTML
GPE

morph style instead of the default

outerHTML
DATE

. This is because when a frame is updated, we only update its children.

The only frame we have in our application is the one showing listener stats on the artist page. Let’s see if there is any difference between morphing and replacing:

The listeners counter frame is broken

Oops, it looks like we broke our listeners counter; it’s not being animated or even updated anymore. Why? Well, a Stimulus controller powers the animation, and it seems it isn’t correctly handling incremental

DOM
ORG

updates. Let’s dig deeper into this issue.

Writing morphing-aware Stimulus controllers

Stimulus controllers are coupled with HTML elements. It’s a common practice to define some setup and teardown logic for controllers in the connect() and disconnect() callbacks respectively. That’s precisely how the animated-number controller works. That’s also why it stopped working after we migrated to morphing: the

DOM
ORG

element the controller attached to stays the same; we only update the data-animated-number-end-value attribute. So, how can we fix this?

In general, there are

two
CARDINAL

ways to make Stimulus controllers morphing-aware: we either restart them after morphing (triggering disconnect() and connect() , thus emulating HTML replacement), or we adjust them to react on attribute updates.

Turbo 8
PRODUCT

(as of now) has decided to go with the

first
ORDINAL

option, and they’ve added a callback function to restart Stimulus controllers after morphing. Sure, we could do the same, but I we’d lose the benefits of morphing for Stimulus; we already have lifecycle callbacks in Stimulus to track attribute changes (values) or updates on dependent elements (targets). Let’s use them!

All we need is to extend the animated-number controller and add a callback for the data-animated-number-end-value attribute change:

import

AnimatedNumber
ORG

from "stimulus-animated-number" ; export default class extends

AnimatedNumber
ORG

{ endValueChanged ( _

newValue
PERSON

,

oldValue
PERSON

) { this . startValue =

oldValue
PERSON

; this . animate ( ) ; } }

Note that we also set the startValue to the previous

endValue
PRODUCT

. This way, we can improve our animation and make it start from the last value instead of

zero
CARDINAL

. That’s how we leverage the benefits of morphing for Stimulus controllers. Let’s see it in action:

The listeners counter is working again, better than ever


P.S. Turbo Morph Drive
PERSON

vs.

Turbo 8 Page Refresh
WORK_OF_ART

I must confess: our Turbo Morph Drive is not the same as the upcoming Page Refresh feature of

Turbo 8
PRODUCT

. Turbo is not going to switch to morphing for all page updates, only for those refreshing the current page (via the corresponding

Turbo Streams
PRODUCT

action). However, I think having

two
CARDINAL

different update modes within the same application may become a source of confusion for developers, and it’s a limiting factor for unveiling the power of morphing (like we saw in the Stimulus example). I recommend going with morphing all the way, but it’s up to you to decide.

With AnyCable, the amount of ceremony required to implement broadcasting to others is minimal due to the recently added feature with the same title. Check out this commit.

What about the refresh

Turbo Stream
ORG

action? You can implement it yourself by following this guide by

Marco Roth
PERSON

. My version is as follows:

import {

StreamActions
FAC

} from "@hotwired/turbo" ; const

sessionID
ORG

= Math . random ( ) . toString (

36
CARDINAL

) .

slice
PERSON

(

4
CARDINAL

) ;

StreamActions
FAC

. refresh = function ( ) { if ( this .

getAttribute
PERSON

( "session-id" ) !==

sessionID
ORG

) { window . Turbo . cache .

exemptPageFromPreview
PERSON

( ) ; window . Turbo . visit ( window . location . href , { action : "replace" } ) ; } } ; document .

addEventListener
PERSON

( "turbo:before-fetch-request" , ( event ) => { event . detail . fetchOptions . headers [ "X-Turbo-Session-ID" ] =

sessionID
ORG

; } ) ;

On the

Rails
ORG

side, we can store the current

Turbo
PRODUCT

session ID in the Current object and use it when broadcasting the refresh stream:

class

ApplicationController
PERSON

< ActionController :: Base around_action :set_turbo_session_id private def set_turbo_session_id ( & block ) Current . set ( turbo_session_id : request . headers [ "X-Turbo-Session-ID" ] , & block ) end end class

ApplicationRecord
ORG

< ActiveRecord :: Base primary_abstract_class class << self def broadcasts_refreshes after_commit do Turbo ::

StreamsChannel
ORG

. broadcast_stream_to ( self , content : Turbo ::

StreamsChannel
ORG

. turbo_stream_action_tag ( :refresh , :"session-id" => Current . turbo_session_id ) ) end end end end class Artist < ApplicationRecord broadcasts_refreshes end

Check out this commit to see the complete implementation.

Our Turbo Morph Drive implementation is almost complete. Adding morphing to

Turbo Streams
LOC

updates also makes sense, but we’ll leave that as homework for you, dear reader.

Next time, we’ll enhance our application with beautiful page transitions. Stay tuned!

The source code for both parts can be found on GitHub.

At Evil

Martians
NORP

, we transform growth-stage startups into

unicorns
ORG

, build developer tools, and create open source products. If you’re ready to engage warp drive, give us a shout!