From 4ea663183107774c92259b2d1fd7944f4bec55b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joel=20Junstr=C3=B6m?= Date: Tue, 26 May 2026 11:14:29 +0200 Subject: [PATCH] Coalesce reload broadcasts within a quiet window Listen + macOS fsevents commonly fires several writes for a single logical change: esbuild rewriting a bundle plus its sourcemap, editors performing atomic-rename saves, asset pipelines touching siblings. Each write currently flows through `Hotwire::Spark::Change#broadcast` and reaches the browser as a separate reload frame, producing visible flicker and redundant work. This behaviour / issue becomes apperant when running Falcon in development due to the increased concurrency. Routes broadcasts through `Hotwire::Spark::Debouncer`, a trailing-edge debouncer keyed on `[action, canonical_changed_path]`. Each event extends a quiet-window deadline; once no event has arrived for `debounce_window` seconds (default 0.1), pending entries flush as one broadcast per key. The shared state is mutex-guarded; the actual broadcast runs outside the lock so ActionCable latency does not serialize incoming watcher events. Per-block rescue keeps one failing broadcast from dropping siblings in the same flush. --- lib/hotwire-spark.rb | 5 +++ lib/hotwire/spark/change.rb | 8 ++++- lib/hotwire/spark/debouncer.rb | 48 +++++++++++++++++++++++++++ test/debouncer_test.rb | 60 ++++++++++++++++++++++++++++++++++ 4 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 lib/hotwire/spark/debouncer.rb create mode 100644 test/debouncer_test.rb 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