dommy 0.6.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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +30 -38
  3. data/lib/dommy/animation.rb +10 -2
  4. data/lib/dommy/attr.rb +197 -32
  5. data/lib/dommy/backend/nokogiri_adapter.rb +127 -0
  6. data/lib/dommy/backend/nokolexbor_adapter.rb +117 -0
  7. data/lib/dommy/backend.rb +175 -0
  8. data/lib/dommy/blob.rb +30 -11
  9. data/lib/dommy/bridge/constructor_registry.rb +28 -0
  10. data/lib/dommy/bridge/methods.rb +57 -0
  11. data/lib/dommy/bridge.rb +97 -0
  12. data/lib/dommy/callable_invoker.rb +36 -0
  13. data/lib/dommy/compression_streams.rb +4 -4
  14. data/lib/dommy/cookie_store.rb +4 -2
  15. data/lib/dommy/crypto.rb +16 -9
  16. data/lib/dommy/css.rb +53 -7
  17. data/lib/dommy/custom_elements.rb +33 -9
  18. data/lib/dommy/data_transfer.rb +4 -0
  19. data/lib/dommy/document.rb +693 -60
  20. data/lib/dommy/dom_parser.rb +29 -15
  21. data/lib/dommy/element.rb +1147 -438
  22. data/lib/dommy/event.rb +279 -79
  23. data/lib/dommy/event_source.rb +14 -10
  24. data/lib/dommy/fetch.rb +509 -39
  25. data/lib/dommy/file_reader.rb +14 -6
  26. data/lib/dommy/form_data.rb +3 -3
  27. data/lib/dommy/history.rb +46 -8
  28. data/lib/dommy/html_collection.rb +59 -6
  29. data/lib/dommy/html_elements.rb +153 -1502
  30. data/lib/dommy/internal/css_pseudo_handlers.rb +137 -0
  31. data/lib/dommy/internal/dom_matching.rb +3 -3
  32. data/lib/dommy/internal/global_functions.rb +26 -0
  33. data/lib/dommy/internal/idna.rb +16 -7
  34. data/lib/dommy/internal/ipv4_parser.rb +22 -7
  35. data/lib/dommy/internal/mutation_coordinator.rb +11 -2
  36. data/lib/dommy/internal/namespaces.rb +70 -0
  37. data/lib/dommy/internal/node_equality.rb +86 -0
  38. data/lib/dommy/internal/node_traversal.rb +1 -1
  39. data/lib/dommy/internal/node_wrapper_cache.rb +77 -31
  40. data/lib/dommy/internal/observable_callback.rb +1 -5
  41. data/lib/dommy/internal/parent_node.rb +126 -0
  42. data/lib/dommy/internal/reflected_attributes.rb +103 -13
  43. data/lib/dommy/internal/selector_parser.rb +664 -0
  44. data/lib/dommy/internal/template_content_registry.rb +6 -6
  45. data/lib/dommy/internal/url_parser.rb +677 -0
  46. data/lib/dommy/intersection_observer.rb +4 -2
  47. data/lib/dommy/location.rb +10 -4
  48. data/lib/dommy/media_query_list.rb +10 -4
  49. data/lib/dommy/message_channel.rb +41 -11
  50. data/lib/dommy/mutation_observer.rb +76 -23
  51. data/lib/dommy/navigator.rb +38 -24
  52. data/lib/dommy/node.rb +158 -16
  53. data/lib/dommy/notification.rb +6 -4
  54. data/lib/dommy/parser.rb +13 -13
  55. data/lib/dommy/performance.rb +4 -0
  56. data/lib/dommy/performance_observer.rb +4 -2
  57. data/lib/dommy/promise.rb +14 -14
  58. data/lib/dommy/range.rb +74 -5
  59. data/lib/dommy/resize_observer.rb +4 -2
  60. data/lib/dommy/scheduler.rb +34 -13
  61. data/lib/dommy/shadow_root.rb +31 -60
  62. data/lib/dommy/storage.rb +2 -0
  63. data/lib/dommy/streams.rb +40 -49
  64. data/lib/dommy/svg_elements.rb +204 -3606
  65. data/lib/dommy/text_codec.rb +178 -25
  66. data/lib/dommy/tree_walker.rb +270 -81
  67. data/lib/dommy/url.rb +305 -450
  68. data/lib/dommy/url_pattern.rb +2 -0
  69. data/lib/dommy/version.rb +1 -1
  70. data/lib/dommy/web_socket.rb +49 -19
  71. data/lib/dommy/window.rb +205 -203
  72. data/lib/dommy/worker.rb +12 -12
  73. data/lib/dommy/xml_http_request.rb +32 -7
  74. data/lib/dommy.rb +19 -2
  75. metadata +22 -27
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
 
@@ -54,37 +62,89 @@ module Dommy
54
62
  # Per spec, dispatchEvent must receive an Event instance.
55
63
  raise TypeError, "dispatchEvent requires an Event, got #{event.class}" unless event.is_a?(Event)
56
64
 
57
- event.__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
65
+ event.__internal_prepare_for_dispatch__(self)
66
+ event.__internal_set_dispatch_flag__(true)
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
71
+ event.__internal_record_path__(path) if event.respond_to?(:__internal_record_path__)
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
63
80
 
64
- event.__record_path__(path) if event.respond_to?(:__record_path__)
65
- path.each do |target|
66
- event.__set_current_target__(target)
67
- target.__deliver_event__(event)
68
- break if event.propagation_stopped?
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
 
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.
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)
101
+
71
102
  !event.default_prevented?
72
103
  end
73
104
 
74
- def __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)
75
121
  listeners = listeners_for(event.type).dup
76
122
  listeners.each do |entry|
77
- 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.
78
127
  if entry.once?
79
- 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
80
131
  end
81
132
 
133
+ CallableInvoker.invoke_listener(entry.listener, event)
134
+
82
135
  break if event.immediate_propagation_stopped?
83
136
  end
84
137
 
85
138
  nil
86
139
  end
87
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
+
88
148
  private
89
149
 
90
150
  Listener = Struct.new(:listener, :options) do
@@ -96,6 +156,37 @@ module Dommy
96
156
  false
97
157
  end
98
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
99
190
  end
100
191
 
101
192
  def listeners_for(type)
@@ -106,7 +197,7 @@ module Dommy
106
197
  def event_bubble_path
107
198
  path = [self]
108
199
  current = self
109
- while (current = current.send(:__event_parent__))
200
+ while (current = current.__send__(:__internal_event_parent__))
110
201
  path << current
111
202
  end
112
203
 
@@ -116,12 +207,12 @@ module Dommy
116
207
  # Build the propagation path with optional shadow-boundary
117
208
  # crossing. When the in-flight event has `composed: true`, the
118
209
  # walk continues from a ShadowRoot to its host; otherwise it
119
- # stops at the shadow boundary (nil from `__event_parent__`).
210
+ # stops at the shadow boundary (nil from `__internal_event_parent__`).
120
211
  def composed_bubble_path(event)
121
212
  path = [self]
122
213
  current = self
123
214
  loop do
124
- nxt = current.send(:__event_parent__)
215
+ nxt = current.__send__(:__internal_event_parent__)
125
216
  if nxt.nil? && event.respond_to?(:__js_get__) && event.__js_get__("composed")
126
217
  # Try to cross a shadow boundary
127
218
  if current.is_a?(ShadowRoot)
@@ -148,42 +239,27 @@ module Dommy
148
239
  private
149
240
 
150
241
  def enclosing_shadow_root_of(target)
151
- return nil unless target.respond_to?(:__node__)
242
+ return nil unless target.respond_to?(:__dommy_backend_node__)
152
243
 
153
244
  doc = target.instance_variable_get(:@document)
154
- return nil unless doc && doc.respond_to?(:__shadow_root_containing__)
245
+ return nil unless doc && doc.respond_to?(:__internal_shadow_root_containing__)
155
246
 
156
- doc.__shadow_root_containing__(target.__node__)
247
+ doc.__internal_shadow_root_containing__(target.__dommy_backend_node__)
157
248
  end
158
249
 
159
- public
160
-
161
- def invoke_listener(listener, event)
162
- # DOM spec: a listener can be (a) a function, or (b) an object
163
- # with a `handleEvent` method. Both Ruby and JS-bridged callables
164
- # are supported.
165
- if listener.respond_to?(:handle_event)
166
- listener.handle_event(event)
167
- elsif listener.respond_to?(:call) && !listener.is_a?(Module)
168
- listener.call(event)
169
- elsif listener.respond_to?(:__js_call__)
170
- # Prefer handleEvent if the bridge object advertises it; fall
171
- # back to call. We can't introspect on the JS side, so we just
172
- # try call (the common case for JS.callback {}).
173
- listener.__js_call__("call", [event])
174
- end
175
- end
176
250
  end
177
251
 
178
252
  class StandaloneEventTarget
179
253
  include EventTarget
180
254
 
255
+ include Bridge::Methods
256
+ js_methods %w[addEventListener removeEventListener dispatchEvent]
181
257
  def __js_call__(method, args)
182
258
  case method
183
259
  when "addEventListener"
184
260
  add_event_listener(args[0], args[1], args[2])
185
261
  when "removeEventListener"
186
- remove_event_listener(args[0], args[1])
262
+ remove_event_listener(args[0], args[1], args[2])
187
263
  when "dispatchEvent"
188
264
  dispatch_event(args[0])
189
265
  else
@@ -191,12 +267,17 @@ module Dommy
191
267
  end
192
268
  end
193
269
 
194
- def __event_parent__
270
+ def __internal_event_parent__
195
271
  nil
196
272
  end
197
273
  end
198
274
 
199
275
  class Event
276
+ NONE = 0
277
+ CAPTURING_PHASE = 1
278
+ AT_TARGET = 2
279
+ BUBBLING_PHASE = 3
280
+
200
281
  def initialize(type, init = nil)
201
282
  @type = type.to_s
202
283
  @bubbles = !!read_init(init, "bubbles")
@@ -207,11 +288,20 @@ module Dommy
207
288
  @immediate_propagation_stopped = false
208
289
  @target = nil
209
290
  @current_target = nil
291
+ @event_phase = NONE
210
292
  @composed_path = []
211
293
  # `timeStamp` is the high-resolution timestamp at construction
212
294
  # in ms (browser uses performance.now). We use monotonic time
213
295
  # for determinism across spec runs.
214
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
215
305
  end
216
306
 
217
307
  attr_reader :type
@@ -232,14 +322,29 @@ module Dommy
232
322
  @immediate_propagation_stopped
233
323
  end
234
324
 
235
- def __prepare_for_dispatch__(target)
325
+ def __internal_prepare_for_dispatch__(target)
236
326
  @target ||= target
237
327
  end
238
328
 
239
- def __set_current_target__(target)
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
+
340
+ def __internal_set_current_target__(target)
240
341
  @current_target = target
241
342
  end
242
343
 
344
+ def __internal_set_event_phase__(phase)
345
+ @event_phase = phase
346
+ end
347
+
243
348
  def __js_get__(key)
244
349
  case key
245
350
  when "type"
@@ -252,7 +357,15 @@ module Dommy
252
357
  @composed
253
358
  when "defaultPrevented"
254
359
  @default_prevented
255
- 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.
256
369
  @target
257
370
  when "currentTarget"
258
371
  @current_target
@@ -262,6 +375,12 @@ module Dommy
262
375
  @propagation_stopped
263
376
  when "eventPhase"
264
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
265
384
  end
266
385
  end
267
386
 
@@ -271,11 +390,19 @@ module Dommy
271
390
  # Setting to truthy stops propagation; spec quirk that
272
391
  # `cancelBubble = false` does NOT un-stop (browser observation).
273
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
274
399
  end
275
400
 
276
401
  nil
277
402
  end
278
403
 
404
+ include Bridge::Methods
405
+ js_methods %w[preventDefault stopPropagation stopImmediatePropagation composedPath initEvent]
279
406
  def __js_call__(method, args)
280
407
  case method
281
408
  when "preventDefault"
@@ -291,14 +418,26 @@ module Dommy
291
418
  when "composedPath"
292
419
  @composed_path.dup
293
420
  when "initEvent"
421
+ # WebIDL: the `type` argument is mandatory.
422
+ raise Bridge::TypeError, "initEvent requires a type argument" if args.empty?
423
+
294
424
  init_event(args[0], args[1], args[2])
295
425
  end
296
426
  end
297
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
+
298
434
  # Deprecated `Event#initEvent(type, bubbles, cancelable)` — older
299
435
  # browsers used `document.createEvent("Event").initEvent(...)`.
300
436
  # Resets internal flags as a side effect.
301
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
+
302
441
  @type = type.to_s
303
442
  @bubbles = !!bubbles
304
443
  @cancelable = !!cancelable
@@ -314,7 +453,7 @@ module Dommy
314
453
  # Per spec, `load` events do not propagate to the Window when
315
454
  # composed paths are computed (resource-finished signal stays at
316
455
  # the target).
317
- def __record_path__(targets)
456
+ def __internal_record_path__(targets)
318
457
  @composed_path = if @type == "load"
319
458
  targets.reject { |t| t.is_a?(Window) }
320
459
  else
@@ -325,18 +464,9 @@ module Dommy
325
464
  private
326
465
 
327
466
  def event_phase
328
- # 0 = NONE (default), 2 = AT_TARGET, 3 = BUBBLING_PHASE. We don't
329
- # implement capturing (phase 1) by design.
330
- return 0 if @current_target.nil?
331
- return 2 if @current_target.equal?(@target)
332
-
333
- 3
467
+ @event_phase
334
468
  end
335
469
 
336
- public
337
-
338
- private
339
-
340
470
  def read_init(init, key)
341
471
  case init
342
472
  when Hash
@@ -360,6 +490,45 @@ module Dommy
360
490
 
361
491
  super
362
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
363
532
  end
364
533
 
365
534
  class MouseEvent < Event
@@ -640,6 +809,8 @@ module Dommy
640
809
  end
641
810
  end
642
811
 
812
+ include Bridge::Methods
813
+ js_methods %w[item]
643
814
  def __js_call__(method, args)
644
815
  case method
645
816
  when "item"
@@ -821,9 +992,9 @@ module Dommy
821
992
 
822
993
  # Spec: `AbortSignal.abort(reason?)` returns a fresh, pre-aborted
823
994
  # signal. Convenient for APIs that need an already-cancelled token.
824
- def self.abort(reason = nil)
995
+ def self.abort(*reason)
825
996
  signal = new
826
- signal.__mark_aborted__(reason)
997
+ signal.__internal_mark_aborted__(*reason)
827
998
  signal
828
999
  end
829
1000
 
@@ -836,9 +1007,9 @@ module Dommy
836
1007
  signal = new
837
1008
  reason = DOMException::TimeoutError.new("operation timed out")
838
1009
  if scheduler
839
- scheduler.set_timeout(proc { signal.__mark_aborted__(reason) }, ms.to_i)
1010
+ scheduler.set_timeout(proc { signal.__internal_mark_aborted__(reason) }, ms.to_i)
840
1011
  else
841
- signal.__schedule_thread_timeout__(ms.to_i, reason)
1012
+ signal.__internal_schedule_thread_timeout__(ms.to_i, reason)
842
1013
  end
843
1014
 
844
1015
  signal
@@ -853,12 +1024,12 @@ module Dommy
853
1024
  list = Array(signals).select { |s| s.is_a?(AbortSignal) }
854
1025
  already = list.find(&:aborted?)
855
1026
  if already
856
- composite.__mark_aborted__(already.reason)
1027
+ composite.__internal_mark_aborted__(already.reason)
857
1028
  return composite
858
1029
  end
859
1030
 
860
1031
  list.each do |sig|
861
- sig.add_event_listener("abort", proc { composite.__mark_aborted__(sig.reason) })
1032
+ sig.add_event_listener("abort", proc { composite.__internal_mark_aborted__(sig.reason) })
862
1033
  end
863
1034
 
864
1035
  composite
@@ -871,11 +1042,11 @@ module Dommy
871
1042
 
872
1043
  # Background-thread timeout used by `AbortSignal.timeout` when no
873
1044
  # scheduler is provided. Kept package-private; tests can also
874
- # drive the abort manually via `__mark_aborted__`.
875
- def __schedule_thread_timeout__(ms, reason)
1045
+ # drive the abort manually via `__internal_mark_aborted__`.
1046
+ def __internal_schedule_thread_timeout__(ms, reason)
876
1047
  Thread.new do
877
1048
  sleep(ms.to_f / 1000.0)
878
- __mark_aborted__(reason)
1049
+ __internal_mark_aborted__(reason)
879
1050
  end
880
1051
 
881
1052
  nil
@@ -893,8 +1064,12 @@ module Dommy
893
1064
  # consumer code that polls before doing async work.
894
1065
  def throw_if_aborted
895
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)
896
1071
 
897
- raise @reason.is_a?(Exception) ? @reason : RuntimeError.new(@reason.to_s)
1072
+ raise Bridge::ThrowValue.new(@reason)
898
1073
  end
899
1074
 
900
1075
  alias throwIfAborted throw_if_aborted
@@ -904,20 +1079,35 @@ module Dommy
904
1079
  when "aborted"
905
1080
  @aborted
906
1081
  when "reason"
907
- @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
908
1087
  end
909
1088
  end
910
1089
 
911
- def __js_set__(_key, _value)
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"
1095
+
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
912
1100
  nil
913
1101
  end
914
1102
 
1103
+ include Bridge::Methods
1104
+ js_methods %w[addEventListener removeEventListener dispatchEvent throwIfAborted]
915
1105
  def __js_call__(method, args)
916
1106
  case method
917
1107
  when "addEventListener"
918
1108
  add_event_listener(args[0], args[1], args[2])
919
1109
  when "removeEventListener"
920
- remove_event_listener(args[0], args[1])
1110
+ remove_event_listener(args[0], args[1], args[2])
921
1111
  when "dispatchEvent"
922
1112
  dispatch_event(args[0])
923
1113
  when "throwIfAborted"
@@ -925,12 +1115,18 @@ module Dommy
925
1115
  end
926
1116
  end
927
1117
 
928
- def __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)
929
1124
  return if @aborted
930
1125
 
931
1126
  @aborted = true
932
- @reason = reason
933
- 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__)
934
1130
  end
935
1131
  end
936
1132
 
@@ -946,13 +1142,17 @@ module Dommy
946
1142
  end
947
1143
 
948
1144
  def __js_set__(_key, _value)
949
- nil
1145
+ Bridge::UNHANDLED
950
1146
  end
951
1147
 
1148
+ include Bridge::Methods
1149
+ js_methods %w[abort]
952
1150
  def __js_call__(method, args)
953
1151
  case method
954
1152
  when "abort"
955
- @signal.__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])
956
1156
  end
957
1157
  end
958
1158
  end