From 1a05571441b4b24d09181df2663998fdbabf31be Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Thu, 1 Oct 2026 12:43:40 +0200 Subject: [PATCH 1/2] Show a second init adds a second "Set range" link Nothing marks an element as already wired, so a second call appends a second link and a second delegated handler. A click then runs both, and the one that fires last tears down the popup the first just opened. This was unreachable while the auto-init was dead. Hosts worked around that by calling the plugin themselves in active_admin.js, so once the auto-init works again both run; the specs stand in for that call. Two of the four examples fail here: the link count and the handler count, both reporting 2 where 1 is expected. The other two pin the semantics of the fix rather than the bug -- that re-initialising still fills the inputs, and that options passed to the second call are honoured rather than dropped. Both of those pass on this commit, because with two handlers bound the second one simply overwrites the first one's output. This commit is expected to fail CI. --- spec/init_once_spec.rb | 71 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 spec/init_once_spec.rb diff --git a/spec/init_once_spec.rb b/spec/init_once_spec.rb new file mode 100644 index 0000000..2ffe52c --- /dev/null +++ b/spec/init_once_spec.rb @@ -0,0 +1,71 @@ +require 'spec_helper' + +describe 'initialising the same element twice', type: :feature, js: true do + + let(:today) { Time.now.utc.to_date } + + before { add_post_resource } + before { visit '/admin/posts' } + + def filter + page.find('.filter_date_range') + end + + def init_again(options = '') + page.execute_script( + "$('form.filter_form div.filter_date_range').date_range_ext_preset(#{options});" + ) + end + + # first, not find: with the bug there are two links, and an ambiguous-match + # error would mask whatever the example is actually asserting. + def pick_today + page.first('.filter_date_range a.btn_timerange').click + page.find('.block_timerange .btn_today').click + end + + def chosen_range + inputs = filter.all('input[type="text"]') + [inputs.first.value, inputs.last.value] + end + + # The auto-init was dead from jQuery 3 until it was restored, so the + # workaround -- calling the plugin by hand in active_admin.js -- is what + # host apps are carrying. Both run after an upgrade. + context 'with no options' do + before { init_again } + + it 'leaves one "Set range" link, not one per call' do + expect(filter).to have_css('a.btn_timerange', count: 1) + end + + it 'leaves one click handler, not one per call' do + count = page.evaluate_script( + "$._data($('form.filter_form div.filter_date_range')[0], 'events').click.length" + ) + + expect(count).to eq(1) + end + + it 'still fills the inputs' do + pick_today + + expect(chosen_range).to eq([today.strftime('%Y-%m-%d'), + (today + 1).strftime('%Y-%m-%d')]) + end + end + + # Sprockets puts the plugin's own ready callback ahead of anything the host + # appends to active_admin.js, so a guard that kept the first wiring would + # drop the host's configuration without a word. + context 'when the second call passes options' do + before { init_again('{ date_to_human_readable: true }') } + + it 'honours them instead of discarding them' do + pick_today + + expect(chosen_range).to eq([today.strftime('%Y-%m-%d'), + today.strftime('%Y-%m-%d')]) + end + end +end From 9dcd570ea305ffd453abb883a1babad97e6602b9 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Thu, 1 Oct 2026 12:43:40 +0200 Subject: [PATCH 2/2] Bind the preset link once per element Remove the existing link and unbind the namespaced handler before wiring, so a repeated call replaces the element's wiring instead of adding to it. Replacing rather than skipping is deliberate. Sprockets puts the plugin's own ready callback ahead of anything a host appends to active_admin.js, so a guard that kept the first wiring and bailed out of the second would silently discard the host's options -- worse than the duplicate link it would be fixing, because the duplicate is at least visible. The guard reads the link it guards rather than a jQuery data flag. Data lives in an expando, not the DOM, so clone() and Turbo cache restores copy the link without it: the flag and the thing it marks would drift apart in both directions. The click handler is namespaced to make the unbind targeted; it was the only unnamespaced handler in the file. --- .../active_admin_date_range_preset.js | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/vendor/assets/javascripts/active_admin_date_range_preset.js b/vendor/assets/javascripts/active_admin_date_range_preset.js index 4ed2d9a..cdc33f5 100644 --- a/vendor/assets/javascripts/active_admin_date_range_preset.js +++ b/vendor/assets/javascripts/active_admin_date_range_preset.js @@ -54,6 +54,21 @@ $(function () { return this.each(function(i, el) { let $this = $(el); + // Re-initialising an element replaces its wiring instead of adding to + // it. The auto-init below was dead from jQuery 3 onwards, so hosts + // worked around it by calling the plugin themselves; now that it works + // again both run, and without this the label grows a second "Set range" + // whose handler tears down the first one's popup. + // + // Replacing rather than skipping matters: the auto-init registers its + // ready callback before anything a host adds to active_admin.js, so + // bailing out on the second call would silently drop the host's + // options. Reading the link itself rather than a data flag keeps the + // guard and the thing it guards from drifting apart across clone() and + // Turbo cache restores, which copy the node but not jQuery's data. + $this.find('a.btn_timerange').remove(); + $this.off('click.dateRangeExtPreset'); + // Per element, because data-show-time is read off the element itself. // Writing it back into the shared opts let one pair's setting reach // every other pair in the same call, and formatDate reads the flag at @@ -93,7 +108,7 @@ $(function () { lteq_input.val(formatDate(end, el_opts)); } - $this.on('click', '.btn_timerange', function(e) { + $this.on('click.dateRangeExtPreset', '.btn_timerange', function(e) { unbindClickEventBlockTimerange(); e.stopPropagation(); e.preventDefault();