Skip to content

Commit

Permalink
Polish Turbo morphing article
Browse files Browse the repository at this point in the history
  • Loading branch information
radanskoric committed Dec 11, 2023
1 parent e1fcb65 commit 673ae88
Showing 1 changed file with 12 additions and 8 deletions.
20 changes: 12 additions & 8 deletions _posts/2023-12-12-turbo-morphing-deep-dive.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ mermaid: true

Turbo 8 and the morphing functionality that was [presented at the first Rails World](https://www.youtube.com/watch?v=m97UsXa6HFg){:target="_blank"} is looking like a strong contender for the most magical Rails feature yet! And as with every *Rails magic* feature I'm part excited for all of the development time savings I'm about to reap and anxious for all of the time I will waste figuring out why it stopped working. In my 15+ year long professional career dominated by Rails projects I've experienced both. Thankfully it was mostly the former but the latter was quite painful and I would really like to eliminate it. My favourite way to battle it is to pull the curtain on the magic. I'm not scared once I've seen the wizard behind the curtain pulling the ropes.

This is not an introduction on how to use Turbo Morph as the user but a teardown of how it works under the hood. For the introduction to how to use it I would recommend [Turbo 8 in 8 minutes](https://fly.io/ruby-dispatch/turbo-8-in-8-minutes/){:target="_blank"}.
This is not an introduction on how to use Turbo Morph in your app, but a teardown of how it works under the hood. For the introduction to how to use it I would recommend [Turbo 8 in 8 minutes](https://fly.io/ruby-dispatch/turbo-8-in-8-minutes/){:target="_blank"}.

## Magia ex machina

Expand Down Expand Up @@ -45,7 +45,8 @@ Bob submits the form which is handled by the Rails controller in a regular way s

If you're using morphing the model will use [broadcasts_refreshes](https://github.com/hotwired/turbo-rails/blob/4eb4e928e30be8cd537af8073f98b80ddea4a578/app/models/concerns/turbo/broadcastable.rb#L146-L150){:target="_blank"} which unrolls into:
```ruby
after_create_commit -> { broadcast_refresh_later_to(model_name.plural) }
stream = model_name.plural
after_create_commit -> { broadcast_refresh_later_to(stream) }
after_update_commit -> { broadcast_refresh_later }
after_destroy_commit -> { broadcast_refresh }
```
Expand All @@ -71,15 +72,18 @@ The stream name is then signed using `Turbo.signed_stream_verifier#generate`to p

Since all we're broadcasting is a message that the page needs to be refreshed, the content is very simple, it's just a turbo stream refresh tag rendered using [turbo_stream_refresh_tag helper](app/helpers/turbo/streams/action_helper.rb:38){:target="_blank"} and looks like this:
```html
<turbo-stream request-id=\"ca519ab9-1138-4625-abc2-6049317321a9\" action=\"refresh\"></turbo-stream>
<turbo-stream
request-id=\"ca519ab9-1138-4625-abc2-6049317321a9\"
action=\"refresh\">
</turbo-stream>
```
The request id is a new mechanism added specifically for refresh actions. It is a unique id [generated on the frontend](https://github.com/hotwired/turbo/blob/ac0035982e2f8a6a72055acc954d813330afa771/src/http/fetch.js#L12){:target="_blank"}, and passed to the server via `X-Turbo-Request-Id` header. The backend simply passes it on to the refresh tag. The frontend stores it in an array and if a refresh action comes with an already stored request id it is ignored. As far as I could make it, the purpose is to **avoid a refresh being caused by your own action** since you should get the content with the regular HTTP response.

#### Debouncing the brodcasts

Before the broadcasting job is actually scheduled, there's a little optimisation happening which is important to understand: **The creation of the background job goes through a debouncer object to avoid brodcasting multiple unnecessary refresh actions when we execute multiple updates during the same HTTP request.**

The debouncer is an instance of [Turbo::Debouncer](https://github.com/hotwired/turbo-rails/blob/main/app/models/turbo/debouncer.rb){:target="_blank"} scoped to the thread . Under the hood it relies on [Concurrent::ScheduledTask](https://ruby-concurrency.github.io/concurrent-ruby/master/Concurrent/ScheduledTask.html){:target="_blank"} from concurrent-ruby gem. In short, it's an object that *ensures that an action will run only once in a given period of time*. Debouncer works by cancelling the current broadcast and scheduling a new one with a delay. The default delay is 0.5 seconds. Unlike with throttling which runs immediately and then rejects subsequent requests for a certain period, debouncer runs once at the end of the delay. This means that usually[^2] the actual broadcast will happen half a second after the last update you make.
The debouncer is an instance of [Turbo::Debouncer](https://github.com/hotwired/turbo-rails/blob/main/app/models/turbo/debouncer.rb){:target="_blank"} scoped to the thread . Under the hood it relies on [Concurrent::ScheduledTask](https://ruby-concurrency.github.io/concurrent-ruby/master/Concurrent/ScheduledTask.html){:target="_blank"} from concurrent-ruby gem. In short, it's an object that *ensures that an action will run only once in a given period of time*. Debouncer works by cancelling the current broadcast and scheduling a new one with a delay. The default delay is 0.5 seconds. Unlike with throttling which runs immediately and then rejects subsequent requests for a certain period, debouncer runs once at the end of the delay. This means that usually[^2] the actual broadcast will happen half a second after the last database update you make.

### Turbo::StreamChannel -> Browsers

Expand Down Expand Up @@ -107,13 +111,13 @@ This is a 2 part article. In this first part I'm looking at the backend side and
## Conclusions

For me the main takeaways are:
- I don't need to worry about spawning to many broadcast messages on the same stream, the framework handles that. However, I should think for a moment if a specific model really needs to broadcast at all.
- The user initiating an action will not do a refresh but will instead morph with what I send it back and I just need to make sure that is the same as what the other users refreshing will fetch.
- I don't need to worry about spawning too many broadcast messages on the same stream, the framework handles that. However, I should think for a moment if a specific model really needs to broadcast at all as refreshes from different models are not aggregated.
- The user initiating an action will not do a refresh but will instead morph with what I send it back and I just need to make sure that is the same as what the other users will fetch when refreshing.
- I can broadcast to a collection without a parent by picking a string name and constructing the refresh callbacks myself.
- I can exclude sections of the page from morphing by using `data-turbo-permanent` attribute.
- The approach clearly has more nuance to it and more corner cases that need to be handled will arise but it has a solid and straight forward logic so I'm optimistic about its future.
- The approach clearly has nuance to it and more corner cases that need to be handled will arise but it has a solid and straight forward logic so I'm optimistic about its future.

The real meat of the feature is the idiomorph[^3] library and I am preparing a deep dive into how it works. If you are interested in it, subscribing is the easiest way to not miss it.
The real meat of the feature is the idiomorph[^3] library and I am preparing a deep dive into how it works.

## Footnotes

Expand Down

0 comments on commit 673ae88

Please sign in to comment.