Summary
Vident::Stimulus::Action#to_s emits action options (modifiers like :capture, :once, :passive) on the event side of the descriptor, but Stimulus's descriptor grammar requires options at the very end, after #method. The result is a descriptor Stimulus mis-parses into a bogus event name, so the listener silently never fires and the options are dropped.
Affects vident 3.1.0.
Repro
a = Vident::Stimulus::Action.from_descriptor(
{ event: "scroll", method: :scrolled, options: [:capture], window: true },
implied: Vident::Stimulus::Controller.new(path: "forms/date_calendar", name: "forms--date-calendar")
)
a.to_s
Actual:
scroll:capture@window->forms--date-calendar#scrolled
Expected (per Stimulus):
scroll@window->forms--date-calendar#scrolled:capture
Why it's broken
Stimulus's descriptor parser (@hotwired/stimulus) is:
const descriptorPattern = /^(?:(?:([^.]+?)\+)?(.+?)(?:\.(.+?))?(?:@(window|document))?->)?(.+?)(?:#([^:]+?))(?::(.+))?$/;
Options are capture group 7 — the trailing (?::(.+))?, i.e. everything after #method:. There is no slot for options between the event name and @window.
Parsing the actual output scroll:capture@window->forms--date-calendar#scrolled:
- eventName (group 2) =
scroll:capture ← bogus event type
- target (group 4) =
window
- method (group 6) =
scrolled
- options (group 7) = (none)
So Stimulus does addEventListener("scroll:capture", ...) with no options — a permanently dead listener. The capture/once/passive option is silently lost.
Root cause
lib/vident/stimulus/action.rb, #to_s builds the modifiers onto the event head, before @window and before ->:
def to_s
head =
if event
ev = event.to_s
ev = "#{ev}.#{keyboard}" if keyboard
ev = "#{ev}#{modifiers.map { |o| ":#{o}" }.join}" if modifiers.any? # <-- options on event side
ev = "#{ev}@window" if window
"#{ev}->"
else
""
end
"#{head}#{controller.name}##{method_name}"
end
The modifiers segment needs to move to the tail, after #{method_name}:
def to_s
head =
if event
ev = event.to_s
ev = "#{ev}.#{keyboard}" if keyboard
ev = "#{ev}@window" if window
"#{ev}->"
else
""
end
tail = modifiers.any? ? modifiers.map { |o| ":#{o}" }.join : ""
"#{head}#{controller.name}##{method_name}#{tail}"
end
That yields scroll@window->forms--date-calendar#scrolled:capture, which parses correctly (options = capture).
Impact
Any use of action options via the DSL is silently a no-op: action(...).modifier(:capture), :once, :passive, :!passive, :stop, :prevent, :self. Found this while trying to make a scroll@window listener capture-phase (needed because scroll doesn't bubble, so a bubble-phase window listener misses nested overflow:auto scroll containers); had to drop to a hand-rolled addEventListener(..., true) in the controller because the DSL couldn't emit a working :capture.
A regression test asserting to_s for each option position (with and without @window/keyboard filter) would lock this down.
Summary
Vident::Stimulus::Action#to_semits action options (modifiers like:capture,:once,:passive) on the event side of the descriptor, but Stimulus's descriptor grammar requires options at the very end, after#method. The result is a descriptor Stimulus mis-parses into a bogus event name, so the listener silently never fires and the options are dropped.Affects vident 3.1.0.
Repro
Actual:
Expected (per Stimulus):
Why it's broken
Stimulus's descriptor parser (
@hotwired/stimulus) is:Options are capture group 7 — the trailing
(?::(.+))?, i.e. everything after#method:. There is no slot for options between the event name and@window.Parsing the actual output
scroll:capture@window->forms--date-calendar#scrolled:scroll:capture← bogus event typewindowscrolledSo Stimulus does
addEventListener("scroll:capture", ...)with no options — a permanently dead listener. Thecapture/once/passiveoption is silently lost.Root cause
lib/vident/stimulus/action.rb,#to_sbuilds the modifiers onto the event head, before@windowand before->:The
modifierssegment needs to move to the tail, after#{method_name}:That yields
scroll@window->forms--date-calendar#scrolled:capture, which parses correctly (options =capture).Impact
Any use of action options via the DSL is silently a no-op:
action(...).modifier(:capture),:once,:passive,:!passive,:stop,:prevent,:self. Found this while trying to make ascroll@windowlistener capture-phase (needed becausescrolldoesn't bubble, so a bubble-phase window listener misses nestedoverflow:autoscroll containers); had to drop to a hand-rolledaddEventListener(..., true)in the controller because the DSL couldn't emit a working:capture.A regression test asserting
to_sfor each option position (with and without@window/keyboard filter) would lock this down.