diff --git a/app/channels/turbo/streams/broadcasts.rb b/app/channels/turbo/streams/broadcasts.rb
index 3aecebad..1b13afed 100644
--- a/app/channels/turbo/streams/broadcasts.rb
+++ b/app/channels/turbo/streams/broadcasts.rb
@@ -68,8 +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)
+ def broadcast_refresh_later_to(*streamables, request_id: Turbo.current_request_id, **opts)
+ refresh_debouncer_for(*streamables, request_id: request_id).debounce do
+ Turbo::Streams::BroadcastStreamJob.perform_later stream_name_from(streamables), content: turbo_stream_refresh_tag(request_id: request_id, **opts)
+ end
end
def broadcast_action_later_to(*streamables, action:, target: nil, targets: nil, attributes: {}, **rendering)
@@ -89,6 +91,9 @@ def broadcast_stream_to(*streamables, content:)
ActionCable.server.broadcast stream_name_from(streamables), content
end
+ def refresh_debouncer_for(*streamables, request_id: nil) # :nodoc:
+ Turbo::ThreadDebouncer.for("turbo-refresh-debouncer-#{stream_name_from(streamables.including(request_id))}")
+ end
private
def render_format(format, **rendering)
diff --git a/app/helpers/turbo/streams/action_helper.rb b/app/helpers/turbo/streams/action_helper.rb
index a43255a9..35f52938 100644
--- a/app/helpers/turbo/streams/action_helper.rb
+++ b/app/helpers/turbo/streams/action_helper.rb
@@ -35,8 +35,8 @@ 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)
+ def turbo_stream_refresh_tag(request_id: Turbo.current_request_id, **attributes)
+ turbo_stream_action_tag(:refresh, **{ "request-id": request_id }.compact, **attributes)
end
private
diff --git a/app/models/concerns/turbo/broadcastable.rb b/app/models/concerns/turbo/broadcastable.rb
index 78735d8b..52f3dbc9 100644
--- a/app/models/concerns/turbo/broadcastable.rb
+++ b/app/models/concerns/turbo/broadcastable.rb
@@ -354,12 +354,12 @@ 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?
+ def broadcast_refresh_later_to(*streamables)
+ Turbo::StreamsChannel.broadcast_refresh_later_to(*streamables, 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
+ def broadcast_refresh_later
+ broadcast_refresh_later_to self
end
# Same as broadcast_action_to but run asynchronously via a Turbo::Streams::BroadcastJob.
diff --git a/app/models/turbo/debouncer.rb b/app/models/turbo/debouncer.rb
new file mode 100644
index 00000000..8dc2e805
--- /dev/null
+++ b/app/models/turbo/debouncer.rb
@@ -0,0 +1,24 @@
+class Turbo::Debouncer
+ attr_reader :delay, :scheduled_task
+
+ DEFAULT_DELAY = 0.5
+
+ def initialize(delay: DEFAULT_DELAY)
+ @delay = delay
+ @scheduled_task = nil
+ end
+
+ def debounce(&block)
+ scheduled_task&.cancel unless scheduled_task&.complete?
+ @scheduled_task = Concurrent::ScheduledTask.execute(delay, &block)
+ end
+
+ def wait
+ scheduled_task.wait(wait_timeout)
+ end
+
+ private
+ def wait_timeout
+ delay + 1
+ end
+end
diff --git a/app/models/turbo/thread_debouncer.rb b/app/models/turbo/thread_debouncer.rb
new file mode 100644
index 00000000..429bf0a2
--- /dev/null
+++ b/app/models/turbo/thread_debouncer.rb
@@ -0,0 +1,28 @@
+# A decorated debouncer that will store instances in the current thread clearing them
+# after the debounced logic triggers.
+class Turbo::ThreadDebouncer
+ delegate :wait, to: :debouncer
+
+ def self.for(key, delay: Turbo::Debouncer::DEFAULT_DELAY)
+ Thread.current[key] ||= new(key, Thread.current, delay: delay)
+ end
+
+ private_class_method :new
+
+ def initialize(key, thread, delay: )
+ @key = key
+ @debouncer = Turbo::Debouncer.new(delay: delay)
+ @thread = thread
+ end
+
+ def debounce
+ debouncer.debounce do
+ yield.tap do
+ thread[key] = nil
+ end
+ end
+ end
+
+ private
+ attr_reader :key, :debouncer, :thread
+end
diff --git a/test/streams/broadcastable_test.rb b/test/streams/broadcastable_test.rb
index e13bbc1a..52c736ab 100644
--- a/test/streams/broadcastable_test.rb
+++ b/test/streams/broadcastable_test.rb
@@ -108,6 +108,20 @@ class Turbo::BroadcastableTest < ActionCable::Channel::TestCase
end
end
+ test "broadcasting refresh later is debounced" do
+ assert_broadcast_on @message.to_gid_param, turbo_stream_refresh_tag do
+ assert_broadcasts(@message.to_gid_param, 1) do
+ perform_enqueued_jobs do
+ assert_no_changes -> { Thread.current.keys.size } do
+ # Not leaking thread variables once the debounced code executes
+ 3.times { @message.broadcast_refresh_later }
+ Turbo::StreamsChannel.refresh_debouncer_for(@message).wait
+ end
+ end
+ end
+ end
+ end
+
test "broadcasting action to stream now" do
assert_broadcast_on "stream", turbo_stream_action_tag("prepend", target: "messages", template: render(@message)) do
@message.broadcast_action_to "stream", action: "prepend"
@@ -146,7 +160,7 @@ class Turbo::BroadcastableTest < ActionCable::Channel::TestCase
test "broadcasting action later to with attributes" do
@message.save!
-
+
assert_broadcast_on @message.to_gid_param, turbo_stream_action_tag("prepend", target: "messages", template: render(@message), "data-foo" => "bar") do
perform_enqueued_jobs do
@message.broadcast_action_later_to @message, action: "prepend", target: "messages", attributes: { "data-foo" => "bar" }
@@ -176,7 +190,7 @@ class Turbo::BroadcastableTest < ActionCable::Channel::TestCase
test "broadcasting action later with no rendering" do
@message.save!
-
+
assert_broadcast_on @message.to_gid_param, turbo_stream_action_tag("prepend", target: "messages", template: nil) do
perform_enqueued_jobs do
@message.broadcast_action_later action: "prepend", target: "messages", render: false
diff --git a/test/streams/streams_channel_test.rb b/test/streams/streams_channel_test.rb
index 9ea562f6..5b015edb 100644
--- a/test/streams/streams_channel_test.rb
+++ b/test/streams/streams_channel_test.rb
@@ -8,7 +8,6 @@ class Turbo::StreamsChannelTest < ActionCable::Channel::TestCase
assert_equal "stream", Turbo::StreamsChannel.verified_stream_name(Turbo::StreamsChannel.signed_stream_name("stream"))
end
-
test "broadcasting remove now" do
assert_broadcast_on "stream", turbo_stream_action_tag("remove", target: "message_1") do
Turbo::StreamsChannel.broadcast_remove_to "stream", target: "message_1"
@@ -175,13 +174,46 @@ class Turbo::StreamsChannelTest < ActionCable::Channel::TestCase
assert_broadcast_on "stream", turbo_stream_refresh_tag do
perform_enqueued_jobs do
Turbo::StreamsChannel.broadcast_refresh_later_to "stream"
+ Turbo::StreamsChannel.refresh_debouncer_for("stream").wait
end
end
Turbo.current_request_id = "123"
- assert_broadcast_on "stream", turbo_stream_refresh_tag do
+ assert_broadcast_on "stream", turbo_stream_refresh_tag(request_id: "123") do
perform_enqueued_jobs do
Turbo::StreamsChannel.broadcast_refresh_later_to "stream"
+ Turbo::StreamsChannel.refresh_debouncer_for("stream", request_id: "123").wait
+ end
+ end
+ end
+
+ test "broadcasting refresh later is debounced" do
+ assert_broadcast_on "stream", turbo_stream_refresh_tag do
+ assert_broadcasts("stream", 1) do
+ perform_enqueued_jobs do
+ Turbo::StreamsChannel.broadcast_refresh_later_to "stream"
+
+ Turbo::StreamsChannel.refresh_debouncer_for("stream").wait
+ end
+ end
+ end
+ end
+
+ test "broadcasting refresh later is debounced considering the current request id" do
+ assert_broadcasts("stream", 2) do
+ perform_enqueued_jobs do
+ assert_broadcast_on "stream", turbo_stream_refresh_tag("request-id": "123") do
+ assert_broadcast_on "stream", turbo_stream_refresh_tag("request-id": "456") do
+ Turbo.current_request_id = "123"
+ 3.times { Turbo::StreamsChannel.broadcast_refresh_later_to "stream" }
+
+ Turbo.current_request_id = "456"
+ 3.times { Turbo::StreamsChannel.broadcast_refresh_later_to "stream" }
+
+ Turbo::StreamsChannel.refresh_debouncer_for("stream", request_id: "123").wait
+ Turbo::StreamsChannel.refresh_debouncer_for("stream", request_id: "456").wait
+ end
+ end
end
end
end
@@ -204,7 +236,6 @@ class Turbo::StreamsChannelTest < ActionCable::Channel::TestCase
end
end
-
test "broadcasting render now" do
assert_broadcast_on "stream", turbo_stream_action_tag("replace", target: "message_1", template: "Goodbye!") do
Turbo::StreamsChannel.broadcast_render_to "stream", partial: "messages/message"