dommy 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/lib/dommy/animation.rb +9 -1
  3. data/lib/dommy/attr.rb +192 -39
  4. data/lib/dommy/backend/nokogiri_adapter.rb +76 -0
  5. data/lib/dommy/backend/nokolexbor_adapter.rb +37 -0
  6. data/lib/dommy/backend.rb +46 -0
  7. data/lib/dommy/blob.rb +28 -9
  8. data/lib/dommy/bridge/constructor_registry.rb +28 -0
  9. data/lib/dommy/bridge/methods.rb +57 -0
  10. data/lib/dommy/bridge.rb +97 -0
  11. data/lib/dommy/callable_invoker.rb +36 -0
  12. data/lib/dommy/cookie_store.rb +3 -1
  13. data/lib/dommy/crypto.rb +7 -1
  14. data/lib/dommy/css.rb +46 -0
  15. data/lib/dommy/custom_elements.rb +27 -3
  16. data/lib/dommy/data_transfer.rb +4 -0
  17. data/lib/dommy/document.rb +615 -48
  18. data/lib/dommy/dom_parser.rb +28 -15
  19. data/lib/dommy/element.rb +999 -471
  20. data/lib/dommy/event.rb +260 -96
  21. data/lib/dommy/event_source.rb +6 -2
  22. data/lib/dommy/fetch.rb +505 -43
  23. data/lib/dommy/file_reader.rb +11 -3
  24. data/lib/dommy/form_data.rb +2 -0
  25. data/lib/dommy/history.rb +43 -8
  26. data/lib/dommy/html_collection.rb +55 -2
  27. data/lib/dommy/html_elements.rb +102 -1519
  28. data/lib/dommy/internal/css_pseudo_handlers.rb +109 -0
  29. data/lib/dommy/internal/global_functions.rb +26 -0
  30. data/lib/dommy/internal/idna.rb +16 -7
  31. data/lib/dommy/internal/ipv4_parser.rb +22 -7
  32. data/lib/dommy/internal/mutation_coordinator.rb +11 -2
  33. data/lib/dommy/internal/namespaces.rb +70 -0
  34. data/lib/dommy/internal/node_equality.rb +86 -0
  35. data/lib/dommy/internal/node_wrapper_cache.rb +62 -27
  36. data/lib/dommy/internal/observable_callback.rb +1 -5
  37. data/lib/dommy/internal/parent_node.rb +126 -0
  38. data/lib/dommy/internal/reflected_attributes.rb +103 -13
  39. data/lib/dommy/internal/selector_parser.rb +664 -0
  40. data/lib/dommy/internal/url_parser.rb +677 -0
  41. data/lib/dommy/intersection_observer.rb +2 -0
  42. data/lib/dommy/location.rb +2 -0
  43. data/lib/dommy/media_query_list.rb +7 -1
  44. data/lib/dommy/message_channel.rb +32 -2
  45. data/lib/dommy/mutation_observer.rb +55 -12
  46. data/lib/dommy/navigator.rb +26 -12
  47. data/lib/dommy/node.rb +158 -28
  48. data/lib/dommy/notification.rb +3 -1
  49. data/lib/dommy/performance.rb +4 -0
  50. data/lib/dommy/performance_observer.rb +2 -0
  51. data/lib/dommy/promise.rb +14 -14
  52. data/lib/dommy/range.rb +74 -5
  53. data/lib/dommy/resize_observer.rb +2 -0
  54. data/lib/dommy/scheduler.rb +34 -13
  55. data/lib/dommy/shadow_root.rb +23 -54
  56. data/lib/dommy/storage.rb +2 -0
  57. data/lib/dommy/streams.rb +18 -27
  58. data/lib/dommy/svg_elements.rb +204 -3606
  59. data/lib/dommy/text_codec.rb +174 -21
  60. data/lib/dommy/tree_walker.rb +255 -66
  61. data/lib/dommy/url.rb +287 -449
  62. data/lib/dommy/url_pattern.rb +2 -0
  63. data/lib/dommy/version.rb +1 -1
  64. data/lib/dommy/web_socket.rb +37 -7
  65. data/lib/dommy/window.rb +202 -213
  66. data/lib/dommy/worker.rb +7 -7
  67. data/lib/dommy/xml_http_request.rb +15 -5
  68. data/lib/dommy.rb +7 -0
  69. metadata +12 -3
data/lib/dommy/event.rb CHANGED
@@ -11,11 +11,13 @@ module Dommy
11
11
  return nil if type.nil? || cb.nil?
12
12
 
13
13
  list = listeners_for(type.to_s)
14
- # Per spec, the same listener (by identity) registered on the
15
- # same type is silently deduplicated.
16
- return nil if list.any? { |entry| entry.listener.equal?(cb) }
14
+ entry = Listener.new(cb, options)
15
+ # Per spec, a listener is deduplicated by (type, callback, capture) — so
16
+ # the same function may be registered once as a capture and once as a
17
+ # bubble listener.
18
+ return nil if list.any? { |e| e.listener.equal?(cb) && e.capture? == entry.capture? }
17
19
 
18
- list << Listener.new(cb, options)
20
+ list << entry
19
21
 
20
22
  # `{ signal: AbortSignal }` — when the signal aborts, auto-
21
23
  # remove the listener. Per spec, if the signal is already aborted
@@ -23,7 +25,7 @@ module Dommy
23
25
  signal = options.is_a?(Hash) ? (options["signal"] || options[:signal]) : nil
24
26
  if signal.respond_to?(:__js_get__)
25
27
  if signal.__js_get__("aborted")
26
- remove_event_listener(type, cb)
28
+ remove_event_listener(type, cb, options)
27
29
  else
28
30
  target = self
29
31
  signal.__js_call__(
@@ -31,7 +33,7 @@ module Dommy
31
33
  [
32
34
  "abort",
33
35
  proc {
34
- target.remove_event_listener(type, cb)
36
+ target.remove_event_listener(type, cb, options)
35
37
  }
36
38
  ]
37
39
  )
@@ -41,10 +43,16 @@ module Dommy
41
43
  nil
42
44
  end
43
45
 
44
- def remove_event_listener(type, listener)
46
+ def remove_event_listener(type, listener, options = nil)
45
47
  return nil if type.nil? || listener.nil?
46
48
 
47
- listeners_for(type.to_s).reject! { |entry| entry.listener.equal?(listener) }
49
+ # Per spec, a listener is identified by (type, callback, capture) — so
50
+ # removing must match the capture flag, not just the callback (a function
51
+ # registered as both a capture and a bubble listener is two listeners).
52
+ capture = EventTarget.capture_flag(options)
53
+ listeners_for(type.to_s).reject! do |entry|
54
+ entry.listener.equal?(listener) && entry.capture? == capture
55
+ end
48
56
  nil
49
57
  end
50
58
 
@@ -55,42 +63,88 @@ module Dommy
55
63
  raise TypeError, "dispatchEvent requires an Event, got #{event.class}" unless event.is_a?(Event)
56
64
 
57
65
  event.__internal_prepare_for_dispatch__(self)
58
- path = if event.bubbles?
59
- event.__js_get__("composed") ? composed_bubble_path(event) : event_bubble_path
60
- else
61
- [self]
62
- end
66
+ event.__internal_set_dispatch_flag__(true)
63
67
 
68
+ # The full propagation path: the target plus its ancestors (root last).
69
+ # Capturing always traverses the ancestors regardless of `bubbles`.
70
+ path = event.__js_get__("composed") ? composed_bubble_path(event) : event_bubble_path
64
71
  event.__internal_record_path__(path) if event.respond_to?(:__internal_record_path__)
65
- path.each do |target|
66
- event.__internal_set_current_target__(target)
67
- target.__internal_deliver_event__(event)
68
- break if event.propagation_stopped?
72
+ ancestors = path[1..] || []
73
+
74
+ catch(:stop_propagation) do
75
+ # Capturing phase: root → … → parent, capture listeners only.
76
+ event.__internal_set_event_phase__(Event::CAPTURING_PHASE)
77
+ ancestors.reverse_each do |node|
78
+ deliver_at(node, event, :capture)
79
+ end
80
+
81
+ # At the target: both capture and bubble listeners.
82
+ event.__internal_set_event_phase__(Event::AT_TARGET)
83
+ deliver_at(self, event, :both)
84
+
85
+ # Bubbling phase: parent → … → root, bubble listeners only (only when
86
+ # the event bubbles).
87
+ if event.bubbles?
88
+ event.__internal_set_event_phase__(Event::BUBBLING_PHASE)
89
+ ancestors.each do |node|
90
+ deliver_at(node, event, :bubble)
91
+ end
92
+ end
69
93
  end
70
94
 
71
- # WHATWG: after dispatch completes, currentTarget reverts to
72
- # null and eventPhase reverts to NONE. Dommy derives
73
- # eventPhase from currentTarget, so clearing it here covers
74
- # both spec requirements.
95
+ # After dispatch, currentTarget reverts to null and eventPhase to NONE, and
96
+ # the propagation flags are unset so the event can be dispatched again.
75
97
  event.__internal_set_current_target__(nil)
98
+ event.__internal_set_event_phase__(Event::NONE)
99
+ event.__internal_clear_propagation_flags__
100
+ event.__internal_set_dispatch_flag__(false)
76
101
 
77
102
  !event.default_prevented?
78
103
  end
79
104
 
80
- def __internal_deliver_event__(event)
105
+ # Deliver `event` to one node's listeners for the current phase, then honor
106
+ # stopPropagation (throws to end the whole walk after this node finishes).
107
+ def deliver_at(node, event, phase)
108
+ # Honor a stop-propagation flag set before reaching this node (including
109
+ # one set before dispatch began) — the spec checks it before invoking a
110
+ # node's listeners, not only after.
111
+ throw :stop_propagation if event.propagation_stopped?
112
+
113
+ event.__internal_set_current_target__(node)
114
+ node.__internal_deliver_event__(event, phase)
115
+ throw :stop_propagation if event.propagation_stopped?
116
+ end
117
+
118
+ # `phase` is :capture (capture listeners), :bubble (non-capture), or :both
119
+ # (at the target). stopImmediatePropagation ends delivery within this node.
120
+ def __internal_deliver_event__(event, phase = :both)
81
121
  listeners = listeners_for(event.type).dup
82
122
  listeners.each do |entry|
83
- invoke_listener(entry.listener, event)
123
+ next unless phase == :both || (phase == :capture ? entry.capture? : !entry.capture?)
124
+
125
+ # Spec: a `once` listener is removed BEFORE its callback runs, so a nested
126
+ # dispatch from within the callback can't invoke it a second time.
84
127
  if entry.once?
85
- listeners_for(event.type).reject! { |candidate| candidate.listener.equal?(entry.listener) }
128
+ listeners_for(event.type).reject! do |candidate|
129
+ candidate.listener.equal?(entry.listener) && candidate.capture? == entry.capture?
130
+ end
86
131
  end
87
132
 
133
+ CallableInvoker.invoke_listener(entry.listener, event)
134
+
88
135
  break if event.immediate_propagation_stopped?
89
136
  end
90
137
 
91
138
  nil
92
139
  end
93
140
 
141
+ # The next target up the propagation path. The default (no parent) suits
142
+ # EventTargets that aren't tree nodes (AbortSignal, XHR, …); Element /
143
+ # Document / ShadowRoot override it to walk the node tree.
144
+ def __internal_event_parent__
145
+ nil
146
+ end
147
+
94
148
  private
95
149
 
96
150
  Listener = Struct.new(:listener, :options) do
@@ -102,6 +156,37 @@ module Dommy
102
156
  false
103
157
  end
104
158
  end
159
+
160
+ # useCapture: a boolean third argument, or `{capture: …}` in the options
161
+ # dictionary. A capture listener fires in the capturing phase; a non-capture
162
+ # listener in the bubbling phase (both at the target).
163
+ def capture?
164
+ EventTarget.capture_flag(options)
165
+ end
166
+ end
167
+
168
+ # The capture flag for an addEventListener/removeEventListener options
169
+ # argument, using JS — not Ruby — truthiness: a boolean useCapture, or the
170
+ # `capture` member of an options dictionary, where 0 / "" / NaN / null /
171
+ # undefined are falsy (in Ruby 0 and "" are truthy, so a naive `!!` is wrong).
172
+ def self.capture_flag(options)
173
+ raw =
174
+ if options.is_a?(Hash)
175
+ options.key?("capture") ? options["capture"] : options[:capture]
176
+ else
177
+ options
178
+ end
179
+ js_truthy?(raw)
180
+ end
181
+
182
+ # JS ToBoolean: false for false/null/undefined, +0/-0, NaN, and "".
183
+ def self.js_truthy?(value)
184
+ return false if value.nil? || value == false
185
+ return false if defined?(Bridge::UNDEFINED) && value.equal?(Bridge::UNDEFINED)
186
+ return false if value.is_a?(Numeric) && (value.zero? || (value.respond_to?(:nan?) && value.nan?))
187
+ return false if value == ""
188
+
189
+ true
105
190
  end
106
191
 
107
192
  def listeners_for(type)
@@ -112,7 +197,7 @@ module Dommy
112
197
  def event_bubble_path
113
198
  path = [self]
114
199
  current = self
115
- while (current = current.send(:__internal_event_parent__))
200
+ while (current = current.__send__(:__internal_event_parent__))
116
201
  path << current
117
202
  end
118
203
 
@@ -127,7 +212,7 @@ module Dommy
127
212
  path = [self]
128
213
  current = self
129
214
  loop do
130
- nxt = current.send(:__internal_event_parent__)
215
+ nxt = current.__send__(:__internal_event_parent__)
131
216
  if nxt.nil? && event.respond_to?(:__js_get__) && event.__js_get__("composed")
132
217
  # Try to cross a shadow boundary
133
218
  if current.is_a?(ShadowRoot)
@@ -162,40 +247,19 @@ module Dommy
162
247
  doc.__internal_shadow_root_containing__(target.__dommy_backend_node__)
163
248
  end
164
249
 
165
- public
166
-
167
- def invoke_listener(listener, event)
168
- # DOM spec: a listener can be (a) a function, or (b) an object
169
- # with a `handleEvent` method. Both Ruby and JS-bridged callables
170
- # are supported.
171
- if listener.respond_to?(:handle_event)
172
- listener.handle_event(event)
173
- elsif listener.respond_to?(:call) && !listener.is_a?(Module)
174
- listener.call(event)
175
- elsif listener.respond_to?(:__js_call__)
176
- # Prefer handleEvent if the bridge object advertises it; fall
177
- # back to call. We can't introspect on the JS side, so we just
178
- # try call (the common case for JS.callback {}).
179
- listener.__js_call__("call", [event])
180
- end
181
- end
182
250
  end
183
251
 
184
252
  class StandaloneEventTarget
185
253
  include EventTarget
186
254
 
187
- # Methods routed through __js_call__ (keep in sync with its when-arms).
188
- JS_METHOD_NAMES = %w[addEventListener removeEventListener dispatchEvent].freeze
189
- def __js_method_names__
190
- JS_METHOD_NAMES
191
- end
192
-
255
+ include Bridge::Methods
256
+ js_methods %w[addEventListener removeEventListener dispatchEvent]
193
257
  def __js_call__(method, args)
194
258
  case method
195
259
  when "addEventListener"
196
260
  add_event_listener(args[0], args[1], args[2])
197
261
  when "removeEventListener"
198
- remove_event_listener(args[0], args[1])
262
+ remove_event_listener(args[0], args[1], args[2])
199
263
  when "dispatchEvent"
200
264
  dispatch_event(args[0])
201
265
  else
@@ -209,6 +273,11 @@ module Dommy
209
273
  end
210
274
 
211
275
  class Event
276
+ NONE = 0
277
+ CAPTURING_PHASE = 1
278
+ AT_TARGET = 2
279
+ BUBBLING_PHASE = 3
280
+
212
281
  def initialize(type, init = nil)
213
282
  @type = type.to_s
214
283
  @bubbles = !!read_init(init, "bubbles")
@@ -219,11 +288,20 @@ module Dommy
219
288
  @immediate_propagation_stopped = false
220
289
  @target = nil
221
290
  @current_target = nil
291
+ @event_phase = NONE
222
292
  @composed_path = []
223
293
  # `timeStamp` is the high-resolution timestamp at construction
224
294
  # in ms (browser uses performance.now). We use monotonic time
225
295
  # for determinism across spec runs.
226
296
  @time_stamp = (Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000.0)
297
+ @trusted = false
298
+ end
299
+
300
+ # Mark this event as UA-generated (`isTrusted === true`). Used by the host
301
+ # for events it fires itself (e.g. AbortSignal's "abort").
302
+ def __internal_mark_trusted__
303
+ @trusted = true
304
+ self
227
305
  end
228
306
 
229
307
  attr_reader :type
@@ -248,10 +326,25 @@ module Dommy
248
326
  @target ||= target
249
327
  end
250
328
 
329
+ # End-of-dispatch cleanup: the dispatch algorithm unsets the stop-propagation
330
+ # and stop-immediate-propagation flags (but NOT the canceled flag), so the
331
+ # same event object can be dispatched again. A stopPropagation() issued
332
+ # before the next dispatch is still honored — only the post-dispatch state is
333
+ # cleared here.
334
+ def __internal_clear_propagation_flags__
335
+ @propagation_stopped = false
336
+ @immediate_propagation_stopped = false
337
+ nil
338
+ end
339
+
251
340
  def __internal_set_current_target__(target)
252
341
  @current_target = target
253
342
  end
254
343
 
344
+ def __internal_set_event_phase__(phase)
345
+ @event_phase = phase
346
+ end
347
+
255
348
  def __js_get__(key)
256
349
  case key
257
350
  when "type"
@@ -264,7 +357,15 @@ module Dommy
264
357
  @composed
265
358
  when "defaultPrevented"
266
359
  @default_prevented
267
- when "target"
360
+ when "returnValue"
361
+ # Legacy alias: false once the default has been prevented, else true.
362
+ !@default_prevented
363
+ when "isTrusted"
364
+ # Script-created events are untrusted; a UA-fired event (e.g. an
365
+ # AbortSignal's "abort") is marked trusted via __internal_mark_trusted__.
366
+ @trusted == true
367
+ when "target", "srcElement"
368
+ # srcElement is a legacy alias of target — null (not undefined) when unset.
268
369
  @target
269
370
  when "currentTarget"
270
371
  @current_target
@@ -274,6 +375,12 @@ module Dommy
274
375
  @propagation_stopped
275
376
  when "eventPhase"
276
377
  event_phase
378
+ else
379
+ # An unknown property reads back as JS `undefined`, not `null` — e.g. a
380
+ # non-dictionary member passed to the constructor (`new Event("x", {sweet:
381
+ # 1}).sweet`) is not reflected on the event. Genuinely-null DOM attributes
382
+ # (target/currentTarget/…) are explicit cases above and still return nil.
383
+ Bridge::UNDEFINED
277
384
  end
278
385
  end
279
386
 
@@ -283,17 +390,19 @@ module Dommy
283
390
  # Setting to truthy stops propagation; spec quirk that
284
391
  # `cancelBubble = false` does NOT un-stop (browser observation).
285
392
  @propagation_stopped = true if value
393
+ when "returnValue"
394
+ # Legacy alias: returnValue = false cancels the event (like
395
+ # preventDefault); a truthy value does not un-cancel.
396
+ @default_prevented = true if !value && @cancelable
397
+ else
398
+ return Bridge::UNHANDLED
286
399
  end
287
400
 
288
401
  nil
289
402
  end
290
403
 
291
- # Methods routed through __js_call__ (keep in sync with its when-arms).
292
- JS_METHOD_NAMES = %w[preventDefault stopPropagation stopImmediatePropagation composedPath initEvent].freeze
293
- def __js_method_names__
294
- JS_METHOD_NAMES
295
- end
296
-
404
+ include Bridge::Methods
405
+ js_methods %w[preventDefault stopPropagation stopImmediatePropagation composedPath initEvent]
297
406
  def __js_call__(method, args)
298
407
  case method
299
408
  when "preventDefault"
@@ -309,14 +418,26 @@ module Dommy
309
418
  when "composedPath"
310
419
  @composed_path.dup
311
420
  when "initEvent"
421
+ # WebIDL: the `type` argument is mandatory.
422
+ raise Bridge::TypeError, "initEvent requires a type argument" if args.empty?
423
+
312
424
  init_event(args[0], args[1], args[2])
313
425
  end
314
426
  end
315
427
 
428
+ # Set while the event is being dispatched, so initEvent() can short-circuit.
429
+ def __internal_set_dispatch_flag__(flag)
430
+ @dispatch_flag = flag
431
+ nil
432
+ end
433
+
316
434
  # Deprecated `Event#initEvent(type, bubbles, cancelable)` — older
317
435
  # browsers used `document.createEvent("Event").initEvent(...)`.
318
436
  # Resets internal flags as a side effect.
319
437
  def init_event(type, bubbles = false, cancelable = false)
438
+ # Spec: initEvent is a no-op while the event is being dispatched.
439
+ return nil if @dispatch_flag
440
+
320
441
  @type = type.to_s
321
442
  @bubbles = !!bubbles
322
443
  @cancelable = !!cancelable
@@ -343,18 +464,9 @@ module Dommy
343
464
  private
344
465
 
345
466
  def event_phase
346
- # 0 = NONE (default), 2 = AT_TARGET, 3 = BUBBLING_PHASE. We don't
347
- # implement capturing (phase 1) by design.
348
- return 0 if @current_target.nil?
349
- return 2 if @current_target.equal?(@target)
350
-
351
- 3
467
+ @event_phase
352
468
  end
353
469
 
354
- public
355
-
356
- private
357
-
358
470
  def read_init(init, key)
359
471
  case init
360
472
  when Hash
@@ -378,6 +490,45 @@ module Dommy
378
490
 
379
491
  super
380
492
  end
493
+
494
+ js_methods %w[initCustomEvent]
495
+ def __js_call__(method, args)
496
+ case method
497
+ when "initCustomEvent"
498
+ # Deprecated initCustomEvent(type, bubbles=false, cancelable=false,
499
+ # detail=null). Like initEvent, the type is mandatory and the whole call
500
+ # is a no-op while the event is being dispatched.
501
+ raise Bridge::TypeError, "initCustomEvent requires a type argument" if args.empty?
502
+
503
+ unless @dispatch_flag
504
+ init_event(args[0], args[1], args[2])
505
+ @detail = args[3]
506
+ end
507
+ nil
508
+ else
509
+ super
510
+ end
511
+ end
512
+ end
513
+
514
+ # PopStateEvent — fired on history back/forward. Exposes the history entry's
515
+ # serialized state via `event.state` (the spec property; framework routers like
516
+ # Turbo read `event.state.turbo` to recognise their own navigations and restore
517
+ # the cached page). A plain `Event` subclass per spec — NOT a CustomEvent, so it
518
+ # has no `detail` and `instanceof CustomEvent` is false.
519
+ class PopStateEvent < Event
520
+ attr_reader :state
521
+
522
+ def initialize(type, init = nil)
523
+ super
524
+ @state = read_init(init, "state")
525
+ end
526
+
527
+ def __js_get__(key)
528
+ return @state if key == "state"
529
+
530
+ super
531
+ end
381
532
  end
382
533
 
383
534
  class MouseEvent < Event
@@ -658,12 +809,8 @@ module Dommy
658
809
  end
659
810
  end
660
811
 
661
- # Methods routed through __js_call__ (keep in sync with its when-arms).
662
- JS_METHOD_NAMES = %w[item].freeze
663
- def __js_method_names__
664
- JS_METHOD_NAMES
665
- end
666
-
812
+ include Bridge::Methods
813
+ js_methods %w[item]
667
814
  def __js_call__(method, args)
668
815
  case method
669
816
  when "item"
@@ -845,9 +992,9 @@ module Dommy
845
992
 
846
993
  # Spec: `AbortSignal.abort(reason?)` returns a fresh, pre-aborted
847
994
  # signal. Convenient for APIs that need an already-cancelled token.
848
- def self.abort(reason = nil)
995
+ def self.abort(*reason)
849
996
  signal = new
850
- signal.__internal_mark_aborted__(reason)
997
+ signal.__internal_mark_aborted__(*reason)
851
998
  signal
852
999
  end
853
1000
 
@@ -917,8 +1064,12 @@ module Dommy
917
1064
  # consumer code that polls before doing async work.
918
1065
  def throw_if_aborted
919
1066
  return unless @aborted
1067
+ # An Exception reason (the default AbortError, or an explicit DOMException)
1068
+ # is raised so the bridge tags it as a real JS error; any other reason — a
1069
+ # string, number, or opaque JSValue — is thrown verbatim, identity kept.
1070
+ raise @reason if @reason.is_a?(Exception)
920
1071
 
921
- raise @reason.is_a?(Exception) ? @reason : RuntimeError.new(@reason.to_s)
1072
+ raise Bridge::ThrowValue.new(@reason)
922
1073
  end
923
1074
 
924
1075
  alias throwIfAborted throw_if_aborted
@@ -928,26 +1079,35 @@ module Dommy
928
1079
  when "aborted"
929
1080
  @aborted
930
1081
  when "reason"
931
- @reason
1082
+ # A non-aborted signal's reason is `undefined` (not null); once aborted
1083
+ # it is the abort reason (an explicit value or the default AbortError).
1084
+ @aborted ? @reason : Bridge::UNDEFINED
1085
+ when "onabort"
1086
+ @onabort_handler
932
1087
  end
933
1088
  end
934
1089
 
935
- def __js_set__(_key, _value)
936
- nil
937
- end
1090
+ # `signal.onabort = fn` is an event-handler IDL attribute: it registers a
1091
+ # single "abort" listener (replacing any previous one); null/undefined clears
1092
+ # it. (Setting it after the signal is already aborted never fires.)
1093
+ def __js_set__(key, value)
1094
+ return Bridge::UNHANDLED unless key == "onabort"
938
1095
 
939
- # Methods routed through __js_call__ (keep in sync with its when-arms).
940
- JS_METHOD_NAMES = %w[addEventListener removeEventListener dispatchEvent throwIfAborted].freeze
941
- def __js_method_names__
942
- JS_METHOD_NAMES
1096
+ remove_event_listener("abort", @onabort_handler) if @onabort_handler
1097
+ cleared = value.nil? || (defined?(Bridge::UNDEFINED) && value.equal?(Bridge::UNDEFINED))
1098
+ @onabort_handler = cleared ? nil : value
1099
+ add_event_listener("abort", @onabort_handler) if @onabort_handler
1100
+ nil
943
1101
  end
944
1102
 
1103
+ include Bridge::Methods
1104
+ js_methods %w[addEventListener removeEventListener dispatchEvent throwIfAborted]
945
1105
  def __js_call__(method, args)
946
1106
  case method
947
1107
  when "addEventListener"
948
1108
  add_event_listener(args[0], args[1], args[2])
949
1109
  when "removeEventListener"
950
- remove_event_listener(args[0], args[1])
1110
+ remove_event_listener(args[0], args[1], args[2])
951
1111
  when "dispatchEvent"
952
1112
  dispatch_event(args[0])
953
1113
  when "throwIfAborted"
@@ -955,12 +1115,18 @@ module Dommy
955
1115
  end
956
1116
  end
957
1117
 
958
- def __internal_mark_aborted__(reason = nil)
1118
+ # Sentinel meaning "no reason argument was supplied" — distinct from an
1119
+ # explicit `null` reason (`abort(null)` keeps reason null, but `abort()` /
1120
+ # `abort(undefined)` default to a fresh AbortError).
1121
+ NO_REASON = Object.new
1122
+
1123
+ def __internal_mark_aborted__(reason = NO_REASON)
959
1124
  return if @aborted
960
1125
 
961
1126
  @aborted = true
962
- @reason = reason
963
- dispatch_event(Event.new("abort", "bubbles" => false, "cancelable" => false))
1127
+ no_reason = reason.equal?(NO_REASON) || (defined?(Bridge::UNDEFINED) && reason.equal?(Bridge::UNDEFINED))
1128
+ @reason = no_reason ? DOMException::AbortError.new("signal is aborted without reason") : reason
1129
+ dispatch_event(Event.new("abort", "bubbles" => false, "cancelable" => false).__internal_mark_trusted__)
964
1130
  end
965
1131
  end
966
1132
 
@@ -976,19 +1142,17 @@ module Dommy
976
1142
  end
977
1143
 
978
1144
  def __js_set__(_key, _value)
979
- nil
980
- end
981
-
982
- # Methods routed through __js_call__ (keep in sync with its when-arms).
983
- JS_METHOD_NAMES = %w[abort].freeze
984
- def __js_method_names__
985
- JS_METHOD_NAMES
1145
+ Bridge::UNHANDLED
986
1146
  end
987
1147
 
1148
+ include Bridge::Methods
1149
+ js_methods %w[abort]
988
1150
  def __js_call__(method, args)
989
1151
  case method
990
1152
  when "abort"
991
- @signal.__internal_mark_aborted__(args[0])
1153
+ # `abort()` (no arg) defaults the reason; `abort(reason)` — even
1154
+ # `abort(null)` — keeps the explicit reason.
1155
+ args.empty? ? @signal.__internal_mark_aborted__ : @signal.__internal_mark_aborted__(args[0])
992
1156
  end
993
1157
  end
994
1158
  end
@@ -84,10 +84,14 @@ module Dommy
84
84
 
85
85
  def __js_set__(key, value)
86
86
  event = inline_event_for(key)
87
- set_inline_handler(event, value) if event
87
+ return Bridge::UNHANDLED unless event
88
+
89
+ set_inline_handler(event, value)
88
90
  nil
89
91
  end
90
92
 
93
+ include Bridge::Methods
94
+ js_methods %w[close addEventListener removeEventListener dispatchEvent]
91
95
  def __js_call__(method, args)
92
96
  case method
93
97
  when "close"
@@ -95,7 +99,7 @@ module Dommy
95
99
  when "addEventListener"
96
100
  add_event_listener(args[0], args[1], args[2])
97
101
  when "removeEventListener"
98
- remove_event_listener(args[0], args[1])
102
+ remove_event_listener(args[0], args[1], args[2])
99
103
  when "dispatchEvent"
100
104
  dispatch_event(args[0])
101
105
  end