dommy 0.10.0 → 0.11.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/lib/dommy/attr.rb +4 -5
  3. data/lib/dommy/backend/makiri_adapter.rb +22 -0
  4. data/lib/dommy/backend.rb +18 -0
  5. data/lib/dommy/browser.rb +8 -1
  6. data/lib/dommy/callable_invoker.rb +13 -5
  7. data/lib/dommy/css.rb +33 -10
  8. data/lib/dommy/custom_elements.rb +42 -2
  9. data/lib/dommy/document.rb +868 -189
  10. data/lib/dommy/dom_parser.rb +12 -1
  11. data/lib/dommy/element.rb +444 -273
  12. data/lib/dommy/event.rb +457 -109
  13. data/lib/dommy/html_collection.rb +7 -4
  14. data/lib/dommy/html_elements.rb +763 -182
  15. data/lib/dommy/interaction/driver.rb +23 -1
  16. data/lib/dommy/interaction/event_synthesis.rb +4 -7
  17. data/lib/dommy/interaction/field_interactor.rb +13 -7
  18. data/lib/dommy/internal/accessible_name.rb +45 -8
  19. data/lib/dommy/internal/aria_role.rb +5 -3
  20. data/lib/dommy/internal/child_node.rb +150 -36
  21. data/lib/dommy/internal/css/cascade.rb +3 -1
  22. data/lib/dommy/internal/css/parser.rb +97 -14
  23. data/lib/dommy/internal/css/rule_index.rb +288 -18
  24. data/lib/dommy/internal/css_priority.rb +33 -0
  25. data/lib/dommy/internal/dom_matching.rb +1 -1
  26. data/lib/dommy/internal/leaf_node.rb +51 -0
  27. data/lib/dommy/internal/mutation_coordinator.rb +134 -27
  28. data/lib/dommy/internal/node_adopter.rb +190 -0
  29. data/lib/dommy/internal/node_traversal.rb +10 -0
  30. data/lib/dommy/internal/node_wrapper_cache.rb +53 -12
  31. data/lib/dommy/internal/observer_manager.rb +18 -0
  32. data/lib/dommy/internal/observer_matcher.rb +37 -4
  33. data/lib/dommy/internal/parent_node.rb +321 -22
  34. data/lib/dommy/internal/range_text_serializer.rb +5 -4
  35. data/lib/dommy/internal/selector_index.rb +24 -8
  36. data/lib/dommy/internal/selector_matcher.rb +132 -16
  37. data/lib/dommy/internal/template_content_registry.rb +23 -7
  38. data/lib/dommy/internal/utf16.rb +45 -0
  39. data/lib/dommy/internal/xml_serialization.rb +18 -5
  40. data/lib/dommy/js/dom_interfaces.rb +72 -9
  41. data/lib/dommy/js/host_bridge.rb +65 -15
  42. data/lib/dommy/js/host_runtime.js +529 -45
  43. data/lib/dommy/js/marshaller.rb +12 -7
  44. data/lib/dommy/js/script_boot.rb +30 -29
  45. data/lib/dommy/mutation_observer.rb +109 -19
  46. data/lib/dommy/node.rb +27 -0
  47. data/lib/dommy/parser.rb +14 -0
  48. data/lib/dommy/promise.rb +4 -4
  49. data/lib/dommy/range.rb +358 -62
  50. data/lib/dommy/shadow_root.rb +32 -17
  51. data/lib/dommy/tree_walker.rb +7 -3
  52. data/lib/dommy/version.rb +1 -1
  53. data/lib/dommy/window.rb +74 -4
  54. data/lib/dommy.rb +4 -1
  55. metadata +8 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 52933caebd00a8d1b7dcfa14421116be45617e04d53ee6f8d8771c5fc5ccefdc
4
- data.tar.gz: da2372f52dac931a6e7ad4316ec4cb15efb0effa799229eae7bde1435b8aa3c8
3
+ metadata.gz: 47c59ce209a6e63313db2b6685126910463abfc26020ec5f24a7b7494b67b10f
4
+ data.tar.gz: 43980f4f5eb7b2c5bfe76e41bd2786e9c8cad571df1eb882e0e6d5107fbe04c6
5
5
  SHA512:
6
- metadata.gz: e05358f558f5e84fe6da7e7f705dbdc5bdd178646ef71a3ee9311a9d05893320cda1c0e6ca3596aa05e7e389e9112e96c8d44b640b7dea55480066af40e65eb7
7
- data.tar.gz: ac0dea6fecd2fe7624f71cdef6f9eab786be60a584a4307d0fcac66540002de7cc1cc52812a638bfc8bb35afb53dd836ed7ebb2b007fee8fabfd172e62e8850a
6
+ metadata.gz: b2627fa8969554f7ecb7a04ed16c331a77e604b44a9ca9e95412a89e48412744a3dd606892255e93eb56988cfda5214000a15821bbc5eb6ed7d18701740b3262
7
+ data.tar.gz: 632bddbea02093111566d46ba9fc44af35ed17879bf9921c4ad477a138d44b83e2c2761337d34f5bdfa31cc150353a4b7afe47b0cca8d817e79eda95d7b556ad
data/lib/dommy/attr.rb CHANGED
@@ -71,11 +71,10 @@ module Dommy
71
71
 
72
72
  def value
73
73
  if @owner
74
- if @namespace_uri
75
- Backend.get_attribute_ns(@owner.__dommy_backend_node__, @namespace_uri, @local_name).to_s
76
- else
77
- @owner.__dommy_backend_node__[@name].to_s
78
- end
74
+ # Both branches go through the namespace-aware read: the backend's
75
+ # `node[name]` indexes by local name, so a null-namespace `b` would read
76
+ # back the value of a prefixed `xml:b` sitting on the same element.
77
+ Backend.get_attribute_ns(@owner.__dommy_backend_node__, @namespace_uri, @local_name).to_s
79
78
  else
80
79
  @detached_value
81
80
  end
@@ -276,6 +276,12 @@ module Dommy
276
276
 
277
277
  node["xmlns:#{prefix}"] = href.to_s
278
278
  nil
279
+ rescue ArgumentError
280
+ # DOM validates a qualified name against the Name production, which
281
+ # admits prefixes an XML backend cannot spell as an `xmlns:` attribute
282
+ # ("0:a", ";:a"). The element is still valid — its prefix and namespace
283
+ # live on the wrapper — so the declaration is simply not written.
284
+ nil
279
285
  end
280
286
 
281
287
  def namespace_definitions(_node)
@@ -330,6 +336,22 @@ module Dommy
330
336
  node.attribute_nodes
331
337
  end
332
338
 
339
+ # Attribute node matching the qualified name exactly. `node[name]` would
340
+ # also answer for a prefixed attribute with that local name.
341
+ #
342
+ # This sits under `getAttribute` / `hasAttribute` / `setAttribute` /
343
+ # `removeAttribute`, so it runs on the hottest path in the library — every
344
+ # CSS match, every reflected IDL attribute — and Makiri does the scan
345
+ # natively (0.9.0). The value-returning spelling is what the two readers
346
+ # that only want the value ask for: it wraps no Attr at all.
347
+ def attr_by_qualified_name(node, qualified_name)
348
+ node.attribute_by_qualified_name(qualified_name.to_s)
349
+ end
350
+
351
+ def attr_value_by_qualified_name(node, qualified_name)
352
+ node.attribute_value_by_qualified_name(qualified_name.to_s)
353
+ end
354
+
333
355
  # Attribute node matching (namespace, local name) case-sensitively; a
334
356
  # null/empty namespace matches a null-namespace attribute.
335
357
  def attr_by_ns(node, namespace, local_name)
data/lib/dommy/backend.rb CHANGED
@@ -233,6 +233,24 @@ module Dommy
233
233
  current.attribute_ns_info(attr_node)
234
234
  end
235
235
 
236
+ # The attribute node whose QUALIFIED name is `qualified_name`, or nil.
237
+ #
238
+ # WHATWG's by-name family ("get an attribute by name", `setAttribute`,
239
+ # `removeAttribute`) matches on the qualified name. A lookup by local name
240
+ # confuses `b` with a prefixed `xml:b`, so those paths must come through
241
+ # here rather than through `node[name]`.
242
+ def attr_by_qualified_name(node, qualified_name)
243
+ current.attr_by_qualified_name(node, qualified_name)
244
+ end
245
+
246
+ # That attribute's VALUE, or nil when there is no such attribute. The same
247
+ # match without an attribute node in hand, for the two readers that only
248
+ # ever wanted the value — `getAttribute` and `hasAttribute` — which run on
249
+ # every CSS match and every reflected IDL attribute.
250
+ def attr_value_by_qualified_name(node, qualified_name)
251
+ current.attr_value_by_qualified_name(node, qualified_name)
252
+ end
253
+
236
254
  # The element's attribute nodes (each readable via attribute_ns_info).
237
255
  # The single choke point so DOM code doesn't touch parser internals.
238
256
  def attribute_nodes(node)
data/lib/dommy/browser.rb CHANGED
@@ -279,9 +279,16 @@ module Dommy
279
279
  runtime.install_wasm_memory_shim if @wasm_memory_shim && runtime.respond_to?(:install_wasm_memory_shim)
280
280
  window.globals["__fetch_handler__"] = Resources::FetchHandler.new(@resources) if @resources
281
281
  @runtime = runtime
282
+ doc = window.document
283
+ # An `on*` attribute that arrived after boot (a cloned template, an
284
+ # innerHTML fragment) is compiled on first dispatch, which replays the scan.
285
+ # Installed whenever a runtime is attached — an embedder that drives script
286
+ # boot itself (`execute_scripts: false`) still needs inline handlers wired.
287
+ doc.inline_handler_wirer = lambda do
288
+ Js::ScriptBoot.wire_inline_handlers(runtime, on_error: ->(e) { @js_errors << e })
289
+ end
282
290
  return unless @execute_scripts
283
291
 
284
- doc = window.document
285
292
  # Dynamically-inserted `<script src>` (webpack/Vite on-demand chunks)
286
293
  # fetch + run through the same resources adapter, after boot.
287
294
  doc.external_script_runner = lambda do |element, src|
@@ -25,15 +25,23 @@ module Dommy
25
25
  # that order). A JS function listener's `this` must be the event's
26
26
  # currentTarget (the node the listener is attached to), so pass it through
27
27
  # when the bridge supports an explicit receiver.
28
- def invoke_listener(listener, event, current_target = nil)
28
+ # `args:` overrides the single-event argument list — the special error
29
+ # event handler (`window.onerror`) is called with (message, filename,
30
+ # lineno, colno, error) instead of the event. An EventListener object's
31
+ # handleEvent always receives the event.
32
+ def invoke_listener(listener, event, current_target = nil, args: nil)
33
+ args ||= [event]
29
34
  if listener.respond_to?(:handle_event)
30
35
  listener.handle_event(event)
31
36
  elsif listener.respond_to?(:call) && !listener.is_a?(Module)
32
- listener.call(event)
33
- elsif listener.respond_to?(:__js_call_with_this__)
34
- listener.__js_call_with_this__([event], current_target)
37
+ listener.call(*args)
38
+ elsif listener.respond_to?(:__js_invoke__)
39
+ # A JS function listener: `this` is the currentTarget, and a thrown value
40
+ # surfaces (as a ThrowValue) so the dispatch reports it as a window
41
+ # `error` event instead of swallowing it.
42
+ listener.__js_invoke__(args, this: current_target, raising: true)
35
43
  elsif listener.respond_to?(:__js_call__)
36
- listener.__js_call__("call", [event])
44
+ listener.__js_call__("call", args)
37
45
  end
38
46
  end
39
47
  end
data/lib/dommy/css.rb CHANGED
@@ -18,7 +18,9 @@ module Dommy
18
18
  class CSSStyleSheet
19
19
  attr_reader :owner_node, :css_rules
20
20
 
21
- def initialize(owner_node:, href: nil, media: nil, title: nil, type: "text/css", source_text: nil)
21
+ # A stylesheet constructed by `new CSSStyleSheet()` has no owner node.
22
+ # Element-owned sheets still pass their <style> / <link> node explicitly.
23
+ def initialize(owner_node: nil, href: nil, media: nil, title: nil, type: "text/css", source_text: nil)
22
24
  @owner_node = owner_node
23
25
  @href = href
24
26
  @media = media
@@ -291,21 +293,27 @@ module Dommy
291
293
  end
292
294
 
293
295
  def get_property_value(name)
294
- entry = @props[name.to_s]
296
+ entry = @props[property_key(name)]
295
297
  entry ? entry[:value] : ""
296
298
  end
297
299
 
298
300
  def get_property_priority(name)
299
- entry = @props[name.to_s]
301
+ entry = @props[property_key(name)]
300
302
  entry ? entry[:priority] : ""
301
303
  end
302
304
 
303
305
  def set_property(name, value, priority = nil)
304
- key = name.to_s
306
+ key = property_key(name)
305
307
  if value.nil? || value.to_s.empty?
308
+ # CSSOM step 3 — an empty value removes the declaration, and it runs
309
+ # before the priority check, so the priority is irrelevant here.
306
310
  @props.delete(key)
307
311
  else
308
- important = priority.to_s.downcase == "important" ? "important" : ""
312
+ # CSSOM step 4 — an invalid priority abandons the call, leaving the
313
+ # declaration block exactly as it was.
314
+ important = Internal::CssPriority.normalize(priority)
315
+ return nil if important.nil?
316
+
309
317
  @props[key] = {value: value.to_s, priority: important}
310
318
  end
311
319
  flush!
@@ -313,7 +321,7 @@ module Dommy
313
321
  end
314
322
 
315
323
  def remove_property(name)
316
- removed = @props.delete(name.to_s)
324
+ removed = @props.delete(property_key(name))
317
325
  flush!
318
326
  removed ? removed[:value] : ""
319
327
  end
@@ -403,6 +411,13 @@ module Dommy
403
411
 
404
412
  private
405
413
 
414
+ # CSSOM normalizes every property name it is handed (see
415
+ # Internal::CSS::Parser.property_name); `css_name` below is the separate
416
+ # camelCase / snake_case → kebab conversion for the IDL accessors.
417
+ def property_key(name)
418
+ Internal::CSS::Parser.property_name(name)
419
+ end
420
+
406
421
  def css_name(name)
407
422
  str = name.to_s
408
423
  return str if str.start_with?("--")
@@ -414,8 +429,8 @@ module Dommy
414
429
  # reusing the cascade's declaration parser (same normalization the cascade
415
430
  # sees) so reads agree with computed style.
416
431
  def parse(body_text)
417
- Internal::CSS::Parser.parse_declarations(body_text.to_s).each_with_object({}) do |decl, out|
418
- out[decl.name] = {value: decl.value, priority: decl.important ? "important" : ""}
432
+ Internal::CSS::Parser.parse_block(body_text).transform_values do |decl|
433
+ {value: decl.value, priority: decl.important ? "important" : ""}
419
434
  end
420
435
  end
421
436
 
@@ -458,8 +473,16 @@ module Dommy
458
473
  @parent_style_sheet = parent_style_sheet
459
474
  end
460
475
 
476
+ # CSSOM serializes a style rule as its selector plus its declaration block,
477
+ # so the text is rebuilt from the parsed declarations rather than echoed
478
+ # back: `#foo { color: red }` reads as `#foo { color: red; }`, and a block
479
+ # whose contents are not declarations at all serializes empty. An at-rule
480
+ # keeps its text as given.
461
481
  def css_text
462
- @css_text
482
+ return @css_text unless style_rule?
483
+
484
+ declarations = style&.css_text.to_s
485
+ declarations.empty? ? "#{selector_text} { }" : "#{selector_text} { #{declarations} }"
463
486
  end
464
487
 
465
488
  def css_text=(v)
@@ -553,7 +576,7 @@ module Dommy
553
576
 
554
577
  def __js_get__(key)
555
578
  case key
556
- when "cssText" then @css_text
579
+ when "cssText" then css_text
557
580
  when "type" then type
558
581
  when "selectorText" then selector_text
559
582
  when "style" then style
@@ -34,6 +34,15 @@ module Dommy
34
34
  @pending_promises = {}
35
35
  end
36
36
 
37
+ # Whether `name` is a valid custom element name. Also consulted by
38
+ # `element_class_for`: an unrecognized HTML-namespace name that is valid here
39
+ # is an *undefined custom element* (interface HTMLElement), not an unknown
40
+ # element (HTMLUnknownElement).
41
+ def self.valid_name?(name)
42
+ key = name.to_s
43
+ key.match?(NAME_RE) && !RESERVED_NAMES.include?(key)
44
+ end
45
+
37
46
  def define(name, klass, _options = nil)
38
47
  key = name.to_s
39
48
  unless key.match?(NAME_RE)
@@ -90,7 +99,13 @@ module Dommy
90
99
  # Force re-wrap by clearing the document's cached wrapper.
91
100
  @window.document.__internal_reset_wrapper__(nk)
92
101
  wrapped = @window.document.wrap_node(nk)
93
- @window.document.__internal_notify_connected__(wrapped) if wrapped
102
+ next unless wrapped
103
+
104
+ replay_observed_attributes(wrapped)
105
+ # connectedCallback is enqueued only for an element that is actually in
106
+ # a document tree — `customElements.upgrade()` on a detached subtree
107
+ # upgrades it without connecting it.
108
+ @window.document.__internal_notify_connected__(wrapped) if wrapped.is_connected?
94
109
  end
95
110
 
96
111
  nil
@@ -135,7 +150,32 @@ module Dommy
135
150
 
136
151
  doc.__internal_reset_wrapper__(nk)
137
152
  wrapped = doc.wrap_node(nk)
138
- doc.__internal_notify_connected__(wrapped) if wrapped
153
+ next unless wrapped
154
+
155
+ replay_observed_attributes(wrapped)
156
+ doc.__internal_notify_connected__(wrapped)
157
+ end
158
+ end
159
+
160
+ # "Upgrade an element" step 6: the element's *existing* attributes are
161
+ # replayed through attributeChangedCallback (oldValue null) before
162
+ # connectedCallback, so a definition registered after the markup was parsed
163
+ # still sees the attributes that were already there.
164
+ def replay_observed_attributes(element)
165
+ klass = element.class
166
+ return unless klass.respond_to?(:observed_attributes)
167
+
168
+ observed = Array(klass.observed_attributes).map { |a| a.to_s.downcase }
169
+ return if observed.empty?
170
+
171
+ element.get_attribute_names.each do |name|
172
+ next unless observed.include?(name.to_s.downcase)
173
+
174
+ # Routed through the coordinator so the callback's arity handling (the
175
+ # optional 4th namespace argument) stays in one place.
176
+ @window.document.__internal_notify_attribute_changed__(
177
+ element, name, nil, element.get_attribute(name)
178
+ )
139
179
  end
140
180
  end
141
181