Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions lib/hotwire-spark.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
8 changes: 7 additions & 1 deletion lib/hotwire/spark/change.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,20 @@ 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
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
Expand Down
48 changes: 48 additions & 0 deletions lib/hotwire/spark/debouncer.rb
Original file line number Diff line number Diff line change
@@ -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
60 changes: 60 additions & 0 deletions test/debouncer_test.rb
Original file line number Diff line number Diff line change
@@ -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