Module: Dommy::EventTarget

Overview

Note: Callback and Constructor live in Dommy::Bridge::* — they're bridge-adapter classes, not part of the public DOM surface.

Defined Under Namespace

Classes: Listener

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.capture_flag(options) ⇒ Object

The capture flag for an addEventListener/removeEventListener options argument, using JS — not Ruby — truthiness: a boolean useCapture, or the capture member of an options dictionary, where 0 / "" / NaN / null / undefined are falsy (in Ruby 0 and "" are truthy, so a naive !! is wrong).



263
264
265
266
267
268
269
270
271
# File 'lib/dommy/event.rb', line 263

def self.capture_flag(options)
  raw =
    if options.is_a?(Hash)
      options.key?("capture") ? options["capture"] : options[:capture]
    else
      options
    end
  js_truthy?(raw)
end

.js_truthy?(value) ⇒ Boolean

JS ToBoolean: false for false/null/undefined, +0/-0, NaN, and "".

Returns:

  • (Boolean)


274
275
276
277
278
279
280
281
282
283
284
# File 'lib/dommy/event.rb', line 274

def self.js_truthy?(value)
  return false if value.nil? || value == false
  return false if defined?(Bridge::UNDEFINED) && value.equal?(Bridge::UNDEFINED)
  return false if value.is_a?(Numeric) && (value.zero? || (value.respond_to?(:nan?) && value.nan?))
  # The bridge marshals JS NaN as the symbol :NaN (it has no Ruby Float
  # equivalent that survives the round-trip), which ToBoolean treats as false.
  return false if value == :NaN
  return false if value == ""

  true
end

Instance Method Details

#__dommy_dump_event_failure__(event, listener, error) ⇒ Object

Diagnostic only (DOMMY_EVENT_DEBUG=): when a listener throws, append the event type + error to a log file so an otherwise-unreproducible page failure can be traced to the handler that choked.



206
207
208
209
210
211
212
213
214
# File 'lib/dommy/event.rb', line 206

def __dommy_dump_event_failure__(event, listener, error)
  path = ENV["DOMMY_EVENT_DEBUG"]
  line = "=== event listener raised on #{event.type}: #{error.class}: #{error.message.to_s[0, 200]} " \
         "(listener=#{listener.class})\n"
  # `::File` — inside `module Dommy`, bare `File` is Dommy's DOM File.
  ::File.write(path, line, mode: "a")
rescue StandardError
  nil
end

#__internal_deliver_event__(event, phase = :both) ⇒ Object

phase is :capture (capture listeners), :bubble (non-capture), or :both (at the target). stopImmediatePropagation ends delivery within this node.



143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
# File 'lib/dommy/event.rb', line 143

def __internal_deliver_event__(event, phase = :both)
  listeners = listeners_for(event.type).dup
  listeners.each do |entry|
    next unless phase == :both || (phase == :capture ? entry.capture? : !entry.capture?)

    # Spec: a `once` listener is removed BEFORE its callback runs, so a nested
    # dispatch from within the callback can't invoke it a second time.
    if entry.once?
      listeners_for(event.type).reject! do |candidate|
        candidate.listener.equal?(entry.listener) && candidate.capture? == entry.capture?
      end
    end

    result =
      if entry.passive?
        event.__internal_run_passive__ { invoke_listener_isolated(entry.listener, event, self) }
      else
        invoke_listener_isolated(entry.listener, event, self)
      end
    # Event handler processing algorithm: a handler registered via onX has its
    # return value processed — onerror on a global cancels on `true`, every
    # other handler cancels on `false` (`onsubmit="return false"`).
    __internal_process_event_handler_return__(event, result) if entry.event_handler?

    break if event.immediate_propagation_stopped?
  end

  nil
end

#__internal_event_parent__Object

The next target up the propagation path. The default (no parent) suits EventTargets that aren't tree nodes (AbortSignal, XHR, …); Element / Document / ShadowRoot override it to walk the node tree.



219
220
221
# File 'lib/dommy/event.rb', line 219

def __internal_event_parent__
  nil
end

#__internal_process_event_handler_return__(event, result) ⇒ Object

WHATWG event handler processing algorithm, step "process return value": the special error handler (onerror on a Window) cancels the event when the handler returns true; onbeforeunload has its own returnValue handling (left alone); any other handler cancels when it returns exactly false (return false from onclick/onsubmit). A non-cancelable event ignores it.



178
179
180
181
182
183
184
# File 'lib/dommy/event.rb', line 178

def __internal_process_event_handler_return__(event, result)
  if event.type == "error" && defined?(Dommy::Window) && is_a?(Dommy::Window)
    event.__js_call__("preventDefault", []) if result == true
  elsif event.type != "beforeunload" && result == false
    event.__js_call__("preventDefault", [])
  end
end

#add_event_listener(type, listener = nil, options = nil, event_handler: false, &block) ⇒ Object



9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
# File 'lib/dommy/event.rb', line 9

def add_event_listener(type, listener = nil, options = nil, event_handler: false, &block)
  cb = listener || block
  return nil if type.nil? || cb.nil?

  list = listeners_for(type.to_s)
  entry = Listener.new(cb, options, event_handler)
  # Per spec, a listener is deduplicated by (type, callback, capture) — so
  # the same function may be registered once as a capture and once as a
  # bubble listener.
  return nil if list.any? { |e| e.listener.equal?(cb) && e.capture? == entry.capture? }

  list << entry

  # `{ signal: AbortSignal }` — when the signal aborts, auto-
  # remove the listener. Per spec, if the signal is already aborted
  # the listener must not be registered at all.
  signal = options.is_a?(Hash) ? (options["signal"] || options[:signal]) : nil
  if signal.respond_to?(:__js_get__)
    if signal.__js_get__("aborted")
      remove_event_listener(type, cb, options)
    else
      target = self
      signal.__js_call__(
        "addEventListener",
        [
          "abort",
          proc {
            target.remove_event_listener(type, cb, options)
          }
        ]
      )
    end
  end

  nil
end

#deliver_at(node, event, phase) ⇒ Object

Deliver event to one node's listeners for the current phase, then honor stopPropagation (throws to end the whole walk after this node finishes).



130
131
132
133
134
135
136
137
138
139
# File 'lib/dommy/event.rb', line 130

def deliver_at(node, event, phase)
  # Honor a stop-propagation flag set before reaching this node (including
  # one set before dispatch began) — the spec checks it before invoking a
  # node's listeners, not only after.
  throw :stop_propagation if event.propagation_stopped?

  event.__internal_set_current_target__(node)
  node.__internal_deliver_event__(event, phase)
  throw :stop_propagation if event.propagation_stopped?
end

#dispatch_event(event) ⇒ Object

Raises:

  • (TypeError)


82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
# File 'lib/dommy/event.rb', line 82

def dispatch_event(event)
  return true if event.nil?

  # Per spec, dispatchEvent must receive an Event instance.
  raise TypeError, "dispatchEvent requires an Event, got #{event.class}" unless event.is_a?(Event)

  event.__internal_prepare_for_dispatch__(self)
  event.__internal_set_dispatch_flag__(true)

  # The full propagation path: the target plus its ancestors (root last).
  # Capturing always traverses the ancestors regardless of `bubbles`.
  path = event.__js_get__("composed") ? composed_bubble_path(event) : event_bubble_path
  event.__internal_record_path__(path) if event.respond_to?(:__internal_record_path__)
  ancestors = path[1..] || []

  catch(:stop_propagation) do
    # Capturing phase: root → … → parent, capture listeners only.
    event.__internal_set_event_phase__(Event::CAPTURING_PHASE)
    ancestors.reverse_each do |node|
      deliver_at(node, event, :capture)
    end

    # At the target: both capture and bubble listeners.
    event.__internal_set_event_phase__(Event::AT_TARGET)
    deliver_at(self, event, :both)

    # Bubbling phase: parent → … → root, bubble listeners only (only when
    # the event bubbles).
    if event.bubbles?
      event.__internal_set_event_phase__(Event::BUBBLING_PHASE)
      ancestors.each do |node|
        deliver_at(node, event, :bubble)
      end
    end
  end

  # After dispatch, currentTarget reverts to null and eventPhase to NONE, and
  # the propagation flags are unset so the event can be dispatched again.
  event.__internal_set_current_target__(nil)
  event.__internal_set_event_phase__(Event::NONE)
  event.__internal_clear_propagation_flags__
  event.__internal_set_dispatch_flag__(false)

  !event.default_prevented?
end

#event_name_from_on(key) ⇒ Object

Event handler IDL attributes (el.onclick = fn, window.onload = fn): one named handler registered as a listener, where assignment replaces any previous handler and a nil value removes it. Shared by Element and Window.



62
63
64
# File 'lib/dommy/event.rb', line 62

def event_name_from_on(key)
  key.to_s.sub(/\Aon/, "").downcase
end

#invoke_listener_isolated(listener, event, current_target = nil) ⇒ Object

Run one listener, isolating a throw so it can't escape the dispatch. WHATWG "inner invoke": if a listener's callback throws, the exception is reported (to the global error handler) and event dispatch continues with the remaining listeners — a broken handler must not derail the others or abort whatever Ruby drove the dispatch (a synthetic click from the host). The engine already swallows this for a JS-initiated dispatchEvent (HostBridge#invoke_callback, raising:false), but that has proven engine/Ruby-version-dependent — on some QuickJS/Ruby combinations a JS listener's throw surfaces as a Ruby exception here — so guard the host-initiated path too, mirroring MutationObserver#flush.



196
197
198
199
200
201
# File 'lib/dommy/event.rb', line 196

def invoke_listener_isolated(listener, event, current_target = nil)
  CallableInvoker.invoke_listener(listener, event, current_target)
rescue StandardError => e
  __dommy_dump_event_failure__(event, listener, e) if ENV["DOMMY_EVENT_DEBUG"]
  nil
end

#on_handler(event_name) ⇒ Object



66
67
68
# File 'lib/dommy/event.rb', line 66

def on_handler(event_name)
  @on_handlers&.[](event_name)
end

#remove_event_listener(type, listener, options = nil) ⇒ Object



46
47
48
49
50
51
52
53
54
55
56
57
# File 'lib/dommy/event.rb', line 46

def remove_event_listener(type, listener, options = nil)
  return nil if type.nil? || listener.nil?

  # Per spec, a listener is identified by (type, callback, capture) — so
  # removing must match the capture flag, not just the callback (a function
  # registered as both a capture and a bubble listener is two listeners).
  capture = EventTarget.capture_flag(options)
  listeners_for(type.to_s).reject! do |entry|
    entry.listener.equal?(listener) && entry.capture? == capture
  end
  nil
end

#set_on_handler(event_name, value) ⇒ Object



70
71
72
73
74
75
76
77
78
79
80
# File 'lib/dommy/event.rb', line 70

def set_on_handler(event_name, value)
  @on_handlers ||= {}
  previous = @on_handlers[event_name]
  remove_event_listener(event_name, previous) if previous
  if value
    add_event_listener(event_name, value, event_handler: true)
    @on_handlers[event_name] = value
  else
    @on_handlers.delete(event_name)
  end
end