Skip to content

Commit

Permalink
Support for page refreshes and broadcasting
Browse files Browse the repository at this point in the history
This PR is the Rails companion for the Turbo changes to add page
refreshes.

```ruby
turbo_refreshes_with scroll method: :morph, scroll: :preserve
```

This adds new Active Record helpers to broadcast page refreshes from
models:

```ruby
class Board
  broadcast_refreshes
end
```

This works great in hierarchical structures, where child record touch
parent records automatically to invalidate cache:

```ruby
class Column
  belongs_to :board, touch: true # +Board+ will trigger a page refresh
  on column changes
end
```

You can also specify the streamable declaratively:

```ruby
class Column
  belongs_to :board
  broadcast_refreshes_to :board
end
```

There are also instance-level companion methods to broadcast page
refreshes:

- `broadcast_refresh_later`
- `broadcast_refresh_later_to(*streamables)`

This PR introduces a new mechanism to suppress broadcasting of turbo
treams for arbitrary blocks of code:

```ruby
Recording.suppressing_turbo_broadcasts do
...
end
```

When broadcasting page refreshes, the system will automatically debounce
multiple calls in a row to only broadcast the last one. This is meant
for scenarios where you process records in mass. Because of the nature
of such signals, it makes no sense to broadcast them repeatedly and
individually.
  • Loading branch information
jorgemanrubia committed Oct 27, 2023
1 parent e44b6a9 commit a9e011d
Show file tree
Hide file tree
Showing 18 changed files with 449 additions and 19 deletions.
8 changes: 8 additions & 0 deletions app/channels/turbo/streams/broadcasts.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ def broadcast_prepend_to(*streamables, **opts)
broadcast_action_to(*streamables, action: :prepend, **opts)
end

def broadcast_refresh_to(*streamables, **opts)
broadcast_stream_to(*streamables, content: turbo_stream_refresh_tag)
end

def broadcast_action_to(*streamables, action:, target: nil, targets: nil, attributes: {}, **rendering)
broadcast_stream_to(*streamables, content: turbo_stream_action_tag(action, target: target, targets: targets, template:
rendering.delete(:content) || rendering.delete(:html) || (rendering[:render] != false && rendering.any? ? render_format(:html, **rendering) : nil),
Expand Down Expand Up @@ -64,6 +68,10 @@ def broadcast_prepend_later_to(*streamables, **opts)
broadcast_action_later_to(*streamables, action: :prepend, **opts)
end

def broadcast_refresh_later_to(*streamables, **opts)
Turbo::Streams::BroadcastStreamJob.perform_later stream_name_from(streamables), content: turbo_stream_refresh_tag(**opts)
end

def broadcast_action_later_to(*streamables, action:, target: nil, targets: nil, attributes: {}, **rendering)
Turbo::Streams::ActionBroadcastJob.perform_later \
stream_name_from(streamables), action: action, target: target, targets: targets, attributes: attributes, **rendering
Expand Down
12 changes: 12 additions & 0 deletions app/controllers/concerns/turbo/request_id_tracking.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
module Turbo::RequestIdTracking
extend ActiveSupport::Concern

included do
around_action :turbo_tracking_request_id
end

private
def turbo_tracking_request_id(&block)
Turbo.with_request_id(request.headers["X-Turbo-Request-Id"], &block)
end
end
8 changes: 8 additions & 0 deletions app/helpers/turbo/drive_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,12 @@ def turbo_exempts_page_from_preview
def turbo_page_requires_reload
provide :head, tag.meta(name: "turbo-visit-control", content: "reload")
end

def turbo_refreshes_with(method: :replace, scroll: :reset)
raise ArgumentError, "Invalid refresh option '#{method}'" unless method.in?(%i[ replace morph ])
raise ArgumentError, "Invalid scroll option '#{scroll}'" unless scroll.in?(%i[ reset preserve ])

provide :head, tag.meta(name: "turbo-refresh-method", content: method)
provide :head, tag.meta(name: "turbo-refresh-scroll", content: scroll)
end
end
6 changes: 5 additions & 1 deletion app/helpers/turbo/streams/action_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ module Turbo::Streams::ActionHelper
# # => <turbo-stream action="remove" target="special_message_1"></turbo-stream>
#
def turbo_stream_action_tag(action, target: nil, targets: nil, template: nil, **attributes)
template = action.to_sym == :remove ? "" : tag.template(template.to_s.html_safe)
template = action.to_sym.in?(%i[ remove refresh ]) ? "" : tag.template(template.to_s.html_safe)

if target = convert_to_turbo_stream_dom_id(target)
tag.turbo_stream(template, **attributes, action: action, target: target)
Expand All @@ -35,6 +35,10 @@ def turbo_stream_action_tag(action, target: nil, targets: nil, template: nil, **
end
end

def turbo_stream_refresh_tag(**attributes)
turbo_stream_action_tag(:refresh, **{ "request-id": Turbo.current_request_id }.compact, **attributes)
end

private
def convert_to_turbo_stream_dom_id(target, include_selector: false)
if Array(target).any? { |value| value.respond_to?(:to_key) }
Expand Down
7 changes: 7 additions & 0 deletions app/jobs/turbo/streams/broadcast_stream_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
class Turbo::Streams::BroadcastStreamJob < ActiveJob::Base
discard_on ActiveJob::DeserializationError

def perform(stream, content:)
Turbo::StreamsChannel.broadcast_stream_to(stream, content: content)
end
end
91 changes: 77 additions & 14 deletions app/models/concerns/turbo/broadcastable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,32 @@
# In addition to the four basic actions, you can also use <tt>broadcast_render</tt>,
# <tt>broadcast_render_to</tt> <tt>broadcast_render_later</tt>, and <tt>broadcast_render_later_to</tt>
# to render a turbo stream template with multiple actions.
#
# == Suppressing broadcasts
#
# Sometimes, you need to disable broadcasts in certain scenarios. You can use <tt>.suppressing_turbo_broadcasts</tt> to create
# execution contexts where broadcasts are disabled:
#
# class Message < ApplicationRecord
# after_create_commit :update_message
#
# private
# def update_message
# broadcast_replace_to(user, :message, target: "message", renderable: MessageComponent.new)
# end
# end
#
# Message.suppressing_turbo_broadcasts do
# Message.create!(board: board) # This won't broadcast the replace action
# end
module Turbo::Broadcastable
extend ActiveSupport::Concern

included do
thread_mattr_accessor :suppressed_turbo_broadcasts, instance_accessor: false
delegate :suppressed_turbo_broadcasts?, to: "self.class"
end

module ClassMethods
# Configures the model to broadcast creates, updates, and destroys to a stream name derived at runtime by the
# <tt>stream</tt> symbol invocation. By default, the creates are appended to a dom id target name derived from
Expand Down Expand Up @@ -112,10 +135,34 @@ def broadcasts(stream = model_name.plural, inserts_by: :append, target: broadcas
after_destroy_commit -> { broadcast_remove }
end

# Configures the model to broadcast a "page refresh" on creates, updates, and destroys to a stream
# name derived at runtime by the <tt>stream</tt> symbol invocation.
def broadcasts_refreshes_to(stream)
after_commit -> { broadcast_refresh_later_to(stream.try(:call, self) || send(stream)) }
end

# Same as <tt>#broadcasts_refreshes_to</tt>, but the designated stream for page refreshes is automatically set to
# the current model.
def broadcasts_refreshes
after_commit -> { broadcast_refresh_later }
end

# All default targets will use the return of this method. Overwrite if you want something else than <tt>model_name.plural</tt>.
def broadcast_target_default
model_name.plural
end

# Executes +block+ preventing both synchronous and asynchronous broadcasts from this model.
def suppressing_turbo_broadcasts(&block)
original, self.suppressed_turbo_broadcasts = self.suppressed_turbo_broadcasts, true
yield
ensure
self.suppressed_turbo_broadcasts = original
end

def suppressed_turbo_broadcasts?
suppressed_turbo_broadcasts
end
end

# Remove this broadcastable model from the dom for subscribers of the stream name identified by the passed streamables.
Expand All @@ -124,7 +171,7 @@ def broadcast_target_default
# # Sends <turbo-stream action="remove" target="clearance_5"></turbo-stream> to the stream named "identity:2:clearances"
# clearance.broadcast_remove_to examiner.identity, :clearances
def broadcast_remove_to(*streamables, target: self)
Turbo::StreamsChannel.broadcast_remove_to(*streamables, target: target)
Turbo::StreamsChannel.broadcast_remove_to(*streamables, target: target) unless suppressed_turbo_broadcasts?
end

# Same as <tt>#broadcast_remove_to</tt>, but the designated stream is automatically set to the current model.
Expand All @@ -143,7 +190,7 @@ def broadcast_remove
# # to the stream named "identity:2:clearances"
# clearance.broadcast_replace_to examiner.identity, :clearances, partial: "clearances/other_partial", locals: { a: 1 }
def broadcast_replace_to(*streamables, **rendering)
Turbo::StreamsChannel.broadcast_replace_to(*streamables, target: self, **broadcast_rendering_with_defaults(rendering))
Turbo::StreamsChannel.broadcast_replace_to(*streamables, target: self, **broadcast_rendering_with_defaults(rendering)) unless suppressed_turbo_broadcasts?
end

# Same as <tt>#broadcast_replace_to</tt>, but the designated stream is automatically set to the current model.
Expand All @@ -162,7 +209,7 @@ def broadcast_replace(**rendering)
# # to the stream named "identity:2:clearances"
# clearance.broadcast_update_to examiner.identity, :clearances, partial: "clearances/other_partial", locals: { a: 1 }
def broadcast_update_to(*streamables, **rendering)
Turbo::StreamsChannel.broadcast_update_to(*streamables, target: self, **broadcast_rendering_with_defaults(rendering))
Turbo::StreamsChannel.broadcast_update_to(*streamables, target: self, **broadcast_rendering_with_defaults(rendering)) unless suppressed_turbo_broadcasts?
end

# Same as <tt>#broadcast_update_to</tt>, but the designated stream is automatically set to the current model.
Expand Down Expand Up @@ -215,7 +262,7 @@ def broadcast_after_to(*streamables, target:, **rendering)
# clearance.broadcast_append_to examiner.identity, :clearances, target: "clearances",
# partial: "clearances/other_partial", locals: { a: 1 }
def broadcast_append_to(*streamables, target: broadcast_target_default, **rendering)
Turbo::StreamsChannel.broadcast_append_to(*streamables, target: target, **broadcast_rendering_with_defaults(rendering))
Turbo::StreamsChannel.broadcast_append_to(*streamables, target: target, **broadcast_rendering_with_defaults(rendering)) unless suppressed_turbo_broadcasts?
end

# Same as <tt>#broadcast_append_to</tt>, but the designated stream is automatically set to the current model.
Expand All @@ -236,21 +283,29 @@ def broadcast_append(target: broadcast_target_default, **rendering)
# clearance.broadcast_prepend_to examiner.identity, :clearances, target: "clearances",
# partial: "clearances/other_partial", locals: { a: 1 }
def broadcast_prepend_to(*streamables, target: broadcast_target_default, **rendering)
Turbo::StreamsChannel.broadcast_prepend_to(*streamables, target: target, **broadcast_rendering_with_defaults(rendering))
Turbo::StreamsChannel.broadcast_prepend_to(*streamables, target: target, **broadcast_rendering_with_defaults(rendering)) unless suppressed_turbo_broadcasts?
end

# Same as <tt>#broadcast_prepend_to</tt>, but the designated stream is automatically set to the current model.
def broadcast_prepend(target: broadcast_target_default, **rendering)
broadcast_prepend_to self, target: target, **rendering
end

def broadcast_refresh_to(*streamables)
Turbo::StreamsChannel.broadcast_refresh_to *streamables unless suppressed_turbo_broadcasts?
end

def broadcast_refresh
broadcast_refresh_to self
end

# Broadcast a named <tt>action</tt>, allowing for dynamic dispatch, instead of using the concrete action methods. Examples:
#
# # Sends <turbo-stream action="prepend" target="clearances"><template><div id="clearance_5">My Clearance</div></template></turbo-stream>
# # to the stream named "identity:2:clearances"
# clearance.broadcast_action_to examiner.identity, :clearances, action: :prepend, target: "clearances"
def broadcast_action_to(*streamables, action:, target: broadcast_target_default, attributes: {}, **rendering)
Turbo::StreamsChannel.broadcast_action_to(*streamables, action: action, target: target, attributes: attributes, **broadcast_rendering_with_defaults(rendering))
Turbo::StreamsChannel.broadcast_action_to(*streamables, action: action, target: target, attributes: attributes, **broadcast_rendering_with_defaults(rendering)) unless suppressed_turbo_broadcasts?
end

# Same as <tt>#broadcast_action_to</tt>, but the designated stream is automatically set to the current model.
Expand All @@ -261,7 +316,7 @@ def broadcast_action(action, target: broadcast_target_default, attributes: {}, *

# Same as <tt>broadcast_replace_to</tt> but run asynchronously via a <tt>Turbo::Streams::BroadcastJob</tt>.
def broadcast_replace_later_to(*streamables, **rendering)
Turbo::StreamsChannel.broadcast_replace_later_to(*streamables, target: self, **broadcast_rendering_with_defaults(rendering))
Turbo::StreamsChannel.broadcast_replace_later_to(*streamables, target: self, **broadcast_rendering_with_defaults(rendering)) unless suppressed_turbo_broadcasts?
end

# Same as <tt>#broadcast_replace_later_to</tt>, but the designated stream is automatically set to the current model.
Expand All @@ -271,7 +326,7 @@ def broadcast_replace_later(**rendering)

# Same as <tt>broadcast_update_to</tt> but run asynchronously via a <tt>Turbo::Streams::BroadcastJob</tt>.
def broadcast_update_later_to(*streamables, **rendering)
Turbo::StreamsChannel.broadcast_update_later_to(*streamables, target: self, **broadcast_rendering_with_defaults(rendering))
Turbo::StreamsChannel.broadcast_update_later_to(*streamables, target: self, **broadcast_rendering_with_defaults(rendering)) unless suppressed_turbo_broadcasts?
end

# Same as <tt>#broadcast_update_later_to</tt>, but the designated stream is automatically set to the current model.
Expand All @@ -281,7 +336,7 @@ def broadcast_update_later(**rendering)

# Same as <tt>broadcast_append_to</tt> but run asynchronously via a <tt>Turbo::Streams::BroadcastJob</tt>.
def broadcast_append_later_to(*streamables, target: broadcast_target_default, **rendering)
Turbo::StreamsChannel.broadcast_append_later_to(*streamables, target: target, **broadcast_rendering_with_defaults(rendering))
Turbo::StreamsChannel.broadcast_append_later_to(*streamables, target: target, **broadcast_rendering_with_defaults(rendering)) unless suppressed_turbo_broadcasts?
end

# Same as <tt>#broadcast_append_later_to</tt>, but the designated stream is automatically set to the current model.
Expand All @@ -291,17 +346,25 @@ def broadcast_append_later(target: broadcast_target_default, **rendering)

# Same as <tt>broadcast_prepend_to</tt> but run asynchronously via a <tt>Turbo::Streams::BroadcastJob</tt>.
def broadcast_prepend_later_to(*streamables, target: broadcast_target_default, **rendering)
Turbo::StreamsChannel.broadcast_prepend_later_to(*streamables, target: target, **broadcast_rendering_with_defaults(rendering))
Turbo::StreamsChannel.broadcast_prepend_later_to(*streamables, target: target, **broadcast_rendering_with_defaults(rendering)) unless suppressed_turbo_broadcasts?
end

# Same as <tt>#broadcast_prepend_later_to</tt>, but the designated stream is automatically set to the current model.
def broadcast_prepend_later(target: broadcast_target_default, **rendering)
broadcast_prepend_later_to self, target: target, **rendering
end

def broadcast_refresh_later_to(*streamables, target: broadcast_target_default, **rendering)
Turbo::StreamsChannel.broadcast_refresh_later_to(*streamables, target: target, **broadcast_rendering_with_defaults(rendering).merge(request_id: Turbo.current_request_id)) unless suppressed_turbo_broadcasts?
end

def broadcast_refresh_later(target: broadcast_target_default, **rendering)
broadcast_refresh_later_to self, target: target, **rendering
end

# Same as <tt>broadcast_action_to</tt> but run asynchronously via a <tt>Turbo::Streams::BroadcastJob</tt>.
def broadcast_action_later_to(*streamables, action:, target: broadcast_target_default, attributes: {}, **rendering)
Turbo::StreamsChannel.broadcast_action_later_to(*streamables, action: action, target: target, attributes: attributes, **broadcast_rendering_with_defaults(rendering))
Turbo::StreamsChannel.broadcast_action_later_to(*streamables, action: action, target: target, attributes: attributes, **broadcast_rendering_with_defaults(rendering)) unless suppressed_turbo_broadcasts?
end

# Same as <tt>#broadcast_action_later_to</tt>, but the designated stream is automatically set to the current model.
Expand Down Expand Up @@ -337,7 +400,7 @@ def broadcast_render(**rendering)
# desireable for model callbacks, certainly not if those callbacks are inside of a transaction. Most of the time you should
# be using `broadcast_render_later_to`, unless you specifically know why synchronous rendering is needed.
def broadcast_render_to(*streamables, **rendering)
Turbo::StreamsChannel.broadcast_render_to(*streamables, **broadcast_rendering_with_defaults(rendering))
Turbo::StreamsChannel.broadcast_render_to(*streamables, **broadcast_rendering_with_defaults(rendering)) unless suppressed_turbo_broadcasts?
end

# Same as <tt>broadcast_action_to</tt> but run asynchronously via a <tt>Turbo::Streams::BroadcastJob</tt>.
Expand All @@ -348,7 +411,7 @@ def broadcast_render_later(**rendering)
# Same as <tt>broadcast_render_later</tt> but run with the added option of naming the stream using the passed
# <tt>streamables</tt>.
def broadcast_render_later_to(*streamables, **rendering)
Turbo::StreamsChannel.broadcast_render_later_to(*streamables, **broadcast_rendering_with_defaults(rendering))
Turbo::StreamsChannel.broadcast_render_later_to(*streamables, **broadcast_rendering_with_defaults(rendering)) unless suppressed_turbo_broadcasts?
end


Expand All @@ -361,7 +424,7 @@ def broadcast_rendering_with_defaults(options)
options.tap do |o|
# Add the current instance into the locals with the element name (which is the un-namespaced name)
# as the key. This parallels how the ActionView::ObjectRenderer would create a local variable.
o[:locals] = (o[:locals] || {}).reverse_merge!(model_name.element.to_sym => self)
o[:locals] = (o[:locals] || {}).reverse_merge!(model_name.element.to_sym => self, request_id: Turbo.current_request_id).compact

if o[:html] || o[:partial]
return o
Expand Down
9 changes: 9 additions & 0 deletions lib/turbo-rails.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ module Turbo

mattr_accessor :draw_routes, default: true

thread_mattr_accessor :current_request_id

class << self
attr_writer :signed_stream_verifier_key

Expand All @@ -15,5 +17,12 @@ def signed_stream_verifier
def signed_stream_verifier_key
@signed_stream_verifier_key or raise ArgumentError, "Turbo requires a signed_stream_verifier_key"
end

def with_request_id(request_id)
old_request_id, self.current_request_id = self.current_request_id, request_id
yield
ensure
self.current_request_id = old_request_id
end
end
end
6 changes: 6 additions & 0 deletions lib/turbo/engine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ class Engine < Rails::Engine
end
end

initializer "turbo.request_id_tracking" do
ActiveSupport.on_load(:action_controller) do
include Turbo::RequestIdTracking
end
end

initializer "turbo.broadcastable" do
ActiveSupport.on_load(:active_record) do
include Turbo::Broadcastable
Expand Down
28 changes: 28 additions & 0 deletions test/current_request_id_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
require "test_helper"
require "action_cable"

class Turbo::CurrentRequestIdTest < ActiveSupport::TestCase
test "sets the current request id for a block of code" do
assert_nil Turbo.current_request_id

result = Turbo.with_request_id("123") do
assert_equal "123", Turbo.current_request_id
:the_result
end

assert_equal :the_result, result
assert_nil Turbo.current_request_id
end

test "raised errors will raise and clear the current request id" do
assert_nil Turbo.current_request_id

assert_raise "Some error" do
Turbo.with_request_id("123") do
raise "Some error"
end
end

assert_nil Turbo.current_request_id
end
end
20 changes: 20 additions & 0 deletions test/drive/drive_helper_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,24 @@ class Turbo::DriveHelperTest < ActionDispatch::IntegrationTest
get trays_path
assert_match(/<meta name="turbo-visit-control" content="reload">/, @response.body)
end

test "configuring refresh strategy" do
get trays_path
assert_match(/<meta name="turbo-refresh-method" content="morph">/, @response.body)
assert_match(/<meta name="turbo-refresh-scroll" content="preserve">/, @response.body)
end
end

class Turbo::DriverHelperUnitTest < ActionView::TestCase
include Turbo::DriveHelper

test "validate turbo refresh values" do
assert_raises ArgumentError do
turbo_refreshes_with(method: :invalid)
end

assert_raises ArgumentError do
turbo_refreshes_with(scroll: :invalid)
end
end
end
5 changes: 5 additions & 0 deletions test/dummy/app/controllers/request_ids_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class RequestIdsController < ApplicationController
def show
render json: { turbo_frame_request_id: Turbo.current_request_id }
end
end
1 change: 1 addition & 0 deletions test/dummy/app/views/trays/index.html.erb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<% turbo_exempts_page_from_cache %>
<% turbo_page_requires_reload %>
<%= turbo_refreshes_with method: :morph, scroll: :preserve %>

<p>Not in the cache!</p>

0 comments on commit a9e011d

Please sign in to comment.