diff --git a/lib/hotwire-spark.rb b/lib/hotwire-spark.rb index 79e53a8..fb37f7d 100644 --- a/lib/hotwire-spark.rb +++ b/lib/hotwire-spark.rb @@ -17,6 +17,7 @@ module Hotwire::Spark mattr_accessor :html_reload_method, default: :morph mattr_accessor :enabled, default: Rails.env.development? mattr_accessor :cable_server_path, default: "/hotwire-spark" + mattr_accessor :debounce_window, default: 0.1 class << self def install_into(application) @@ -30,5 +31,9 @@ def enabled? def cable_server @server ||= Hotwire::Spark::ActionCable::Server.new end + + def debouncer + @debouncer ||= Hotwire::Spark::Debouncer.new + end end end diff --git a/lib/hotwire/spark/change.rb b/lib/hotwire/spark/change.rb index a584597..36aaa9f 100644 --- a/lib/hotwire/spark/change.rb +++ b/lib/hotwire/spark/change.rb @@ -9,7 +9,9 @@ def initialize(monitored_paths, extensions, changed_path, action) end def broadcast - broadcast_reload_action if should_broadcast? + return unless should_broadcast? + + Hotwire::Spark.debouncer.enqueue(dedupe_key) { broadcast_reload_action } end private @@ -17,6 +19,10 @@ def broadcast_reload_action Hotwire::Spark.cable_server.broadcast "hotwire_spark", reload_message end + def dedupe_key + [ action, canonical_changed_path ] + end + def reload_message { action: action, path: canonical_changed_path } end diff --git a/lib/hotwire/spark/debouncer.rb b/lib/hotwire/spark/debouncer.rb new file mode 100644 index 0000000..c17513c --- /dev/null +++ b/lib/hotwire/spark/debouncer.rb @@ -0,0 +1,48 @@ +class Hotwire::Spark::Debouncer + def initialize + @mutex = Mutex.new + @pending = {} + @flush_at = nil + @worker = nil + end + + def enqueue(key, &block) + @mutex.synchronize do + @pending[key] = block + @flush_at = monotonic_now + Hotwire::Spark.debounce_window + return if @worker&.alive? + @worker = Thread.new { run } + end + end + + private + def run + loop do + remaining = @mutex.synchronize { @flush_at - monotonic_now } + if remaining > 0 + sleep remaining + next + end + + to_run = @mutex.synchronize { @pending.values.tap { @pending.clear } } + to_run.each do |block| + begin + block.call + rescue => error + Rails.logger&.error("Hotwire::Spark debouncer block raised: #{error.class}: #{error.message}") + end + end + + @mutex.synchronize do + if @pending.empty? + @worker = nil + return + end + end + end + end + + def monotonic_now + Process.clock_gettime(Process::CLOCK_MONOTONIC) + end +end diff --git a/test/debouncer_test.rb b/test/debouncer_test.rb new file mode 100644 index 0000000..3a0bc7d --- /dev/null +++ b/test/debouncer_test.rb @@ -0,0 +1,60 @@ +require "test_helper" +require "concurrent/array" + +class Hotwire::Spark::DebouncerTest < ActiveSupport::TestCase + setup do + @debouncer = Hotwire::Spark::Debouncer.new + @original_window = Hotwire::Spark.debounce_window + Hotwire::Spark.debounce_window = 0.05 + end + + teardown do + Hotwire::Spark.debounce_window = @original_window + end + + test "coalesces calls sharing a key within the quiet window" do + calls = Concurrent::Array.new + + 10.times { @debouncer.enqueue(:same) { calls << :ran } } + + wait_for_flush + assert_equal 1, calls.size + end + + test "keeps the latest block for a key" do + captured = Concurrent::Array.new + + @debouncer.enqueue(:k) { captured << :first } + @debouncer.enqueue(:k) { captured << :second } + + wait_for_flush + assert_equal [ :second ], captured.to_a + end + + test "flushes one entry per distinct key" do + calls = Concurrent::Array.new + + @debouncer.enqueue(:a) { calls << :a } + @debouncer.enqueue(:b) { calls << :b } + @debouncer.enqueue(:a) { calls << :a } + + wait_for_flush + assert_equal [ :a, :b ].sort, calls.to_a.sort + end + + test "handles a second burst after the first flush completes" do + calls = Concurrent::Array.new + + @debouncer.enqueue(:k) { calls << 1 } + wait_for_flush + @debouncer.enqueue(:k) { calls << 2 } + wait_for_flush + + assert_equal [ 1, 2 ], calls.to_a + end + + private + def wait_for_flush + sleep Hotwire::Spark.debounce_window + 0.1 + end +end