Skip to content

Stimulus action options emitted on event side, not after #method — silently dropped (e.g. :capture) #34

Description

@stevegeek

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions