dommy 0.8.1 → 0.9.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 (115) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +3 -3
  3. data/lib/dommy/animation.rb +4 -0
  4. data/lib/dommy/attr.rb +11 -5
  5. data/lib/dommy/backend/makiri_adapter.rb +330 -0
  6. data/lib/dommy/backend.rb +114 -33
  7. data/lib/dommy/blob.rb +2 -0
  8. data/lib/dommy/bridge.rb +11 -0
  9. data/lib/dommy/browser.rb +217 -0
  10. data/lib/dommy/compression_streams.rb +4 -0
  11. data/lib/dommy/crypto.rb +4 -0
  12. data/lib/dommy/css.rb +487 -50
  13. data/lib/dommy/custom_elements.rb +2 -2
  14. data/lib/dommy/data_transfer.rb +2 -0
  15. data/lib/dommy/data_uri.rb +35 -0
  16. data/lib/dommy/deferred_response.rb +59 -0
  17. data/lib/dommy/document.rb +386 -228
  18. data/lib/dommy/dom_exception.rb +2 -0
  19. data/lib/dommy/dom_parser.rb +7 -17
  20. data/lib/dommy/element.rb +502 -155
  21. data/lib/dommy/event.rb +240 -9
  22. data/lib/dommy/fetch.rb +152 -34
  23. data/lib/dommy/form_data.rb +2 -0
  24. data/lib/dommy/history.rb +2 -0
  25. data/lib/dommy/html_canvas_element.rb +230 -0
  26. data/lib/dommy/html_collection.rb +5 -6
  27. data/lib/dommy/html_elements.rb +304 -27
  28. data/lib/dommy/interaction/debug.rb +35 -0
  29. data/lib/dommy/interaction/dom_summary.rb +131 -0
  30. data/lib/dommy/interaction/driver.rb +244 -0
  31. data/lib/dommy/interaction/event_synthesis.rb +56 -0
  32. data/lib/dommy/interaction/field_interactor.rb +117 -0
  33. data/lib/dommy/interaction/form_submission.rb +268 -0
  34. data/lib/dommy/interaction/locator.rb +158 -0
  35. data/lib/dommy/interaction/role_query.rb +58 -0
  36. data/lib/dommy/interaction.rb +32 -0
  37. data/lib/dommy/internal/accessibility_tree.rb +215 -0
  38. data/lib/dommy/internal/accessible_description.rb +38 -0
  39. data/lib/dommy/internal/accessible_name.rb +301 -0
  40. data/lib/dommy/internal/aria_role.rb +252 -0
  41. data/lib/dommy/internal/aria_snapshot.rb +64 -0
  42. data/lib/dommy/internal/aria_state.rb +151 -0
  43. data/lib/dommy/internal/css/calc.rb +242 -0
  44. data/lib/dommy/internal/css/cascade.rb +430 -0
  45. data/lib/dommy/internal/css/color.rb +381 -0
  46. data/lib/dommy/internal/css/computed_style_declaration.rb +130 -0
  47. data/lib/dommy/internal/css/counters.rb +227 -0
  48. data/lib/dommy/internal/css/custom_properties.rb +183 -0
  49. data/lib/dommy/internal/css/media_query.rb +302 -0
  50. data/lib/dommy/internal/css/parser.rb +265 -0
  51. data/lib/dommy/internal/css/property_registry.rb +512 -0
  52. data/lib/dommy/internal/css/rule_index.rb +494 -0
  53. data/lib/dommy/internal/css/supports.rb +158 -0
  54. data/lib/dommy/internal/css/ua_stylesheet.rb +53 -0
  55. data/lib/dommy/internal/css_pseudo_handlers.rb +283 -42
  56. data/lib/dommy/internal/css_rule_text.rb +160 -0
  57. data/lib/dommy/internal/dom_matching.rb +80 -9
  58. data/lib/dommy/internal/element_matching.rb +109 -0
  59. data/lib/dommy/internal/global_functions.rb +33 -0
  60. data/lib/dommy/internal/mutation_coordinator.rb +95 -4
  61. data/lib/dommy/internal/namespaces.rb +49 -5
  62. data/lib/dommy/internal/node_wrapper_cache.rb +163 -26
  63. data/lib/dommy/internal/parent_node.rb +82 -5
  64. data/lib/dommy/internal/selector_ast.rb +124 -0
  65. data/lib/dommy/internal/selector_index.rb +146 -0
  66. data/lib/dommy/internal/selector_matcher.rb +756 -0
  67. data/lib/dommy/internal/selector_parser.rb +283 -131
  68. data/lib/dommy/internal/shadow_root_registry.rb +9 -2
  69. data/lib/dommy/internal/template_content_registry.rb +26 -18
  70. data/lib/dommy/internal/xml_serialization.rb +344 -0
  71. data/lib/dommy/intersection_observer.rb +2 -0
  72. data/lib/dommy/js/bridge_conformance.rb +80 -0
  73. data/lib/dommy/js/constructor_resolver.rb +44 -0
  74. data/lib/dommy/js/custom_element_bridge.rb +90 -0
  75. data/lib/dommy/js/dom_interfaces.rb +162 -0
  76. data/lib/dommy/js/handle_table.rb +60 -0
  77. data/lib/dommy/js/host_bridge.rb +517 -0
  78. data/lib/dommy/js/host_runtime.js +1495 -0
  79. data/lib/dommy/js/import_map.rb +58 -0
  80. data/lib/dommy/js/marshaller.rb +240 -0
  81. data/lib/dommy/js/module_loader.rb +99 -0
  82. data/lib/dommy/js/observable_runtime.js +742 -0
  83. data/lib/dommy/js/runtime.rb +115 -0
  84. data/lib/dommy/js/script_boot.rb +221 -0
  85. data/lib/dommy/js/wire_tags.rb +62 -0
  86. data/lib/dommy/location.rb +2 -0
  87. data/lib/dommy/media_query_list.rb +50 -14
  88. data/lib/dommy/message_channel.rb +22 -6
  89. data/lib/dommy/minitest/assertions.rb +27 -0
  90. data/lib/dommy/mutation_observer.rb +89 -4
  91. data/lib/dommy/navigator.rb +34 -2
  92. data/lib/dommy/node.rb +24 -14
  93. data/lib/dommy/notification.rb +2 -0
  94. data/lib/dommy/parser.rb +1 -1
  95. data/lib/dommy/performance.rb +21 -1
  96. data/lib/dommy/promise.rb +94 -10
  97. data/lib/dommy/range.rb +173 -31
  98. data/lib/dommy/resources.rb +178 -0
  99. data/lib/dommy/rspec/capy_style_matchers.rb +126 -0
  100. data/lib/dommy/scheduler.rb +149 -13
  101. data/lib/dommy/screen.rb +91 -0
  102. data/lib/dommy/shadow_root.rb +76 -13
  103. data/lib/dommy/storage.rb +2 -1
  104. data/lib/dommy/streams.rb +6 -0
  105. data/lib/dommy/text_codec.rb +7 -1
  106. data/lib/dommy/tree_walker.rb +33 -10
  107. data/lib/dommy/url.rb +13 -1
  108. data/lib/dommy/version.rb +1 -1
  109. data/lib/dommy/window.rb +199 -11
  110. data/lib/dommy/worker.rb +8 -4
  111. data/lib/dommy/xml_http_request.rb +47 -6
  112. data/lib/dommy.rb +36 -1
  113. metadata +86 -14
  114. data/lib/dommy/backend/nokogiri_adapter.rb +0 -127
  115. data/lib/dommy/backend/nokolexbor_adapter.rb +0 -117
data/lib/dommy/element.rb CHANGED
@@ -59,22 +59,23 @@ module Dommy
59
59
 
60
60
  def query_selector(selector)
61
61
  return nil if selector.nil?
62
- Internal.validate_selector!(selector)
63
-
64
- @document.wrap_node(@__node__.at_css(Internal.backend_safe_selector(selector.to_s)))
62
+ ast = Internal::SelectorParser.parse!(selector)
63
+ Internal::SelectorMatcher.query_first(self, ast, scope: self)
65
64
  end
66
65
 
67
66
  def query_selector_all(selector)
68
67
  return NodeList.new if selector.nil?
69
- Internal.validate_selector!(selector)
70
-
71
- NodeList.new(@__node__.css(Internal.backend_safe_selector(selector.to_s)).map { |n| @document.wrap_node(n) }.compact)
68
+ ast = Internal::SelectorParser.parse!(selector)
69
+ NodeList.new(Internal::SelectorMatcher.query(self, ast, scope: self))
72
70
  end
73
71
 
74
72
  def get_element_by_id(id)
75
- return nil if id.nil?
73
+ return nil if id.nil? || id.to_s.empty?
76
74
 
77
- @document.wrap_node(@__node__.at_css("##{id}"))
75
+ # getElementById matches the `id` attribute literally, not as a CSS
76
+ # selector, so escape special characters (e.g. React `useId` `:rjm:`) to a
77
+ # valid id-selector ident — a raw "##{id}" would be an invalid selector.
78
+ @document.wrap_node(@__node__.at_css("##{Dommy::CSSNamespace.escape(id)}"))
78
79
  end
79
80
 
80
81
  def __js_get__(key)
@@ -101,6 +102,8 @@ module Dommy
101
102
  @__node__.text
102
103
  when "ownerDocument"
103
104
  @document
105
+ else
106
+ Bridge::ABSENT
104
107
  end
105
108
  end
106
109
 
@@ -124,8 +127,8 @@ module Dommy
124
127
  is_default_namespace(args[0])
125
128
  when "cloneNode"
126
129
  deep = args.empty? ? false : !!args[0]
127
- deep ? @document.wrap_node(Parser.fragment(@__node__.to_html, owner_doc: @document.nokogiri_doc)) : @document
128
- .wrap_node(Parser.fragment("", owner_doc: @document.nokogiri_doc))
130
+ deep ? @document.wrap_node(Parser.fragment(@__node__.to_html, owner_doc: @document.backend_doc)) : @document
131
+ .wrap_node(Parser.fragment("", owner_doc: @document.backend_doc))
129
132
  when "querySelector"
130
133
  query_selector(Internal.css_query_arg!(args))
131
134
  when "querySelectorAll"
@@ -169,7 +172,12 @@ module Dommy
169
172
 
170
173
  def extract_children
171
174
  nodes = @__node__.children.to_a
175
+ return nodes if nodes.empty?
176
+
172
177
  nodes.each(&:unlink)
178
+ # Inserting a DocumentFragment removes all its children first; the spec
179
+ # queues a single childList record on the fragment for that removal.
180
+ @document.notify_child_list_mutation(target_node: @__node__, added_nodes: [], removed_nodes: nodes)
173
181
  nodes
174
182
  end
175
183
 
@@ -207,7 +215,10 @@ module Dommy
207
215
  return false unless other.respond_to?(:__dommy_backend_node__)
208
216
 
209
217
  on = other.__dommy_backend_node__
210
- on == @__node__ || on.ancestors.include?(@__node__)
218
+ # Walk parents rather than the backend's `ancestors`: Makiri omits a
219
+ # DocumentFragment parent from `ancestors`, so a fragment never appears
220
+ # to contain its own children. `parent` is consistent across backends.
221
+ on == @__node__ || Internal::NodeTraversal.ancestor_of?(@__node__, on)
211
222
  end
212
223
 
213
224
  def normalize
@@ -237,6 +248,10 @@ module Dommy
237
248
  include Node
238
249
  include EventTarget
239
250
 
251
+ # The owning Dommy document (as Element exposes), so cross-document adoption
252
+ # checks work for text/comment nodes too.
253
+ attr_reader :document
254
+
240
255
  def __dommy_backend_node__ = @__node__
241
256
 
242
257
  # EventTarget needs a parent for event propagation; a character-data node
@@ -255,7 +270,12 @@ module Dommy
255
270
  rest = full[off..] || ""
256
271
  write_data(full[0, off])
257
272
  new_node = @document.create_text_node(rest)
258
- @__node__.add_next_sibling(new_node.__dommy_backend_node__) if @__node__.parent
273
+ if @__node__.parent
274
+ new_bn = new_node.__dommy_backend_node__
275
+ @__node__.add_next_sibling(new_bn)
276
+ # The new node is inserted right after self — a childList addition record.
277
+ @document.notify_child_list_mutation(target_node: @__node__.parent, added_nodes: [new_bn], removed_nodes: [])
278
+ end
259
279
  new_node
260
280
  end
261
281
 
@@ -392,6 +412,8 @@ module Dommy
392
412
  NodeList.new
393
413
  when "firstChild", "lastChild"
394
414
  nil
415
+ else
416
+ Bridge::ABSENT # unknown property: JS undefined, `in` absent
395
417
  end
396
418
  end
397
419
 
@@ -420,9 +442,14 @@ module Dommy
420
442
  args[0].respond_to?(:__dommy_backend_node__) &&
421
443
  args[0].__dommy_backend_node__ == @__node__
422
444
  when "appendChild", "insertBefore"
423
- # CharacterData is a leaf it cannot be a parent.
445
+ # WebIDL coerces the Node argument first (null/non-Node TypeError);
446
+ # only then does a leaf node reject any child with HierarchyRequestError.
447
+ raise Bridge::TypeError, "Argument is not a Node." unless args[0].is_a?(Dommy::Node)
448
+
424
449
  raise DOMException::HierarchyRequestError, "this node type does not support children"
425
450
  when "removeChild", "replaceChild"
451
+ raise Bridge::TypeError, "Argument is not a Node." unless args[0].is_a?(Dommy::Node)
452
+
426
453
  raise DOMException::NotFoundError, "the node to be removed is not a child of this node"
427
454
  when "compareDocumentPosition"
428
455
  compare_document_position(args[0])
@@ -598,6 +625,46 @@ module Dommy
598
625
  end
599
626
  end
600
627
 
628
+ # ProcessingInstruction (`<?target data?>`, nodeType 7) — CharacterData with a
629
+ # `target`. Backed by a real backend node (created via
630
+ # document.createProcessingInstruction), so it participates in the tree like
631
+ # Text/Comment: insertion, ChildNode methods, identity caching and
632
+ # serialization all come from the shared CharacterDataNode machinery.
633
+ class ProcessingInstructionNode < CharacterDataNode
634
+ def node_type
635
+ 7
636
+ end
637
+
638
+ # WHATWG: a ProcessingInstruction's nodeName is its target.
639
+ def node_name
640
+ @__node__.target
641
+ end
642
+
643
+ def target
644
+ @__node__.target
645
+ end
646
+
647
+ def __js_get__(key)
648
+ case key
649
+ when "target"
650
+ @__node__.target
651
+ else
652
+ super
653
+ end
654
+ end
655
+
656
+ # Own __js_call__ methods, on top of CharacterDataNode's.
657
+ js_methods %w[cloneNode]
658
+ def __js_call__(method, args)
659
+ case method
660
+ when "cloneNode"
661
+ @document.create_processing_instruction(@__node__.target, @__node__.content)
662
+ else
663
+ super
664
+ end
665
+ end
666
+ end
667
+
601
668
  # (`LiveChildren` removed — `el.children` now returns a
602
669
  # `Dommy::HTMLCollection` initialized with a re-evaluating block.)
603
670
 
@@ -637,6 +704,14 @@ module Dommy
637
704
  class_tokens.include?(token.to_s)
638
705
  end
639
706
 
707
+ # DOMTokenList membership. Defined explicitly (rather than inheriting
708
+ # Enumerable#include?, which re-iterates via #each) so a class-selector match
709
+ # is a single Array#include? over the cached tokens — the hot path under a
710
+ # querySelector-heavy SPA.
711
+ def include?(token)
712
+ class_tokens.include?(token.to_s)
713
+ end
714
+
640
715
  def add(*tokens)
641
716
  update_tokens { |existing| existing | normalize_tokens(tokens) }
642
717
  nil
@@ -662,8 +737,12 @@ module Dommy
662
737
  idx = tokens.index(old_s)
663
738
  return false unless idx
664
739
 
665
- tokens[idx] = new_s
666
- @element.set_attribute(@attribute, tokens.uniq.join(" "))
740
+ # class_tokens returns the cached token array; dup before mutating so the
741
+ # in-place assignment can't corrupt the cache (whose key is still the old
742
+ # raw attribute string, which would then hand stale tokens to later reads).
743
+ updated = tokens.dup
744
+ updated[idx] = new_s
745
+ @element.set_attribute(@attribute, updated.uniq.join(" "))
667
746
  true
668
747
  end
669
748
 
@@ -698,6 +777,8 @@ module Dommy
698
777
  i = key.to_i
699
778
  token = i.negative? ? nil : class_tokens[i]
700
779
  token.nil? ? Bridge::UNDEFINED : token
780
+ else
781
+ Bridge::ABSENT # unknown non-index property
701
782
  end
702
783
  end
703
784
  end
@@ -790,7 +871,17 @@ module Dommy
790
871
  # return the raw attribute. ASCII whitespace per the spec is space/tab/LF/FF/CR.
791
872
  def class_tokens
792
873
  raw = @element.__dommy_backend_node__[@attribute].to_s
793
- raw.split(/[ \t\n\f\r]+/).reject(&:empty?).uniq
874
+ # Cache the parsed token list keyed by the raw attribute string: a class
875
+ # selector match re-reads this for every element on every querySelector,
876
+ # and the split/reject/uniq dominated heavy-SPA load profiles. The key is
877
+ # the raw value itself, so any change (add/remove/className=, or a direct
878
+ # backend mutation) yields a different key and transparently recomputes.
879
+ cached = @token_cache
880
+ return cached[1] if cached && cached[0] == raw
881
+
882
+ tokens = raw.split(/[ \t\n\f\r]+/).reject(&:empty?).uniq
883
+ @token_cache = [raw, tokens]
884
+ tokens
794
885
  end
795
886
 
796
887
  # DOMTokenList "update steps": serialize the (deduplicated) token set back to
@@ -814,7 +905,10 @@ module Dommy
814
905
  end
815
906
 
816
907
  def __js_get__(key)
817
- @element.__dommy_backend_node__[attr_name(key)]
908
+ # A missing data-* attribute reads as JS `undefined` (and `"foo" in dataset`
909
+ # is false), per DOMStringMap semantics.
910
+ value = @element.__dommy_backend_node__[attr_name(key)]
911
+ value.nil? ? Bridge::ABSENT : value
818
912
  end
819
913
 
820
914
  def __js_set__(key, value)
@@ -895,6 +989,8 @@ module Dommy
895
989
  @x + @width
896
990
  when "bottom"
897
991
  @y + @height
992
+ else
993
+ Bridge::ABSENT
898
994
  end
899
995
  end
900
996
 
@@ -910,22 +1006,15 @@ module Dommy
910
1006
  @element = element
911
1007
  end
912
1008
 
913
- # CSSStyleDeclaration interface: cssText round-trips the full
914
- # `style` attribute. Setter parses semicolon-separated entries.
1009
+ # CSSStyleDeclaration interface: cssText serializes the parsed declaration
1010
+ # block (`prop: value;` joined by spaces), dropping invalid declarations.
1011
+ # The setter reparses and rewrites the `style` attribute in that form.
915
1012
  def css_text
916
- properties.map { |k, v| "#{k}:#{v}" }.join(";")
1013
+ serialize_properties(properties)
917
1014
  end
918
1015
 
919
1016
  def css_text=(value)
920
- props = {}
921
- value.to_s.split(";").each do |entry|
922
- key, val = entry.split(":", 2)
923
- next unless key && val
924
-
925
- props[key.strip] = val.strip
926
- end
927
-
928
- write_properties(props)
1017
+ write_properties(parse_declarations(value))
929
1018
  end
930
1019
 
931
1020
  def length
@@ -978,7 +1067,10 @@ module Dommy
978
1067
  if key.is_a?(Integer) || key.to_s.match?(/\A-?\d+\z/)
979
1068
  self[key.to_i]
980
1069
  else
981
- properties[method_to_css_name(key)]
1070
+ # An unset CSS property reads as "" (per CSSStyleDeclaration), not nil —
1071
+ # `el.style.display` is "" until assigned, which `v-show` and other
1072
+ # display-toggling code compares against.
1073
+ properties[method_to_css_name(key)] || ""
982
1074
  end
983
1075
  end
984
1076
  end
@@ -1041,20 +1133,48 @@ module Dommy
1041
1133
  end
1042
1134
 
1043
1135
  def properties
1044
- raw = @element.__dommy_backend_node__["style"].to_s
1045
- raw.split(";").each_with_object({}) do |entry, out|
1136
+ parse_declarations(@element.__dommy_backend_node__["style"].to_s)
1137
+ end
1138
+
1139
+ # Parse a declaration block into an ordered { property => value } hash,
1140
+ # dropping declarations whose value is invalid (empty, or — like the second
1141
+ # colon in "color:: invalid" — containing a bare colon outside parentheses).
1142
+ def parse_declarations(str)
1143
+ str.to_s.split(";").each_with_object({}) do |entry, out|
1046
1144
  key, value = entry.split(":", 2)
1047
1145
  next unless key && value
1048
1146
 
1049
- out[key.strip] = value.strip
1147
+ name = key.strip
1148
+ val = value.strip
1149
+ next if name.empty? || !valid_declaration_value?(val)
1150
+
1151
+ out[name] = val
1152
+ end
1153
+ end
1154
+
1155
+ def valid_declaration_value?(value)
1156
+ return false if value.empty?
1157
+
1158
+ depth = 0
1159
+ value.each_char do |c|
1160
+ case c
1161
+ when "(" then depth += 1
1162
+ when ")" then depth -= 1 if depth.positive?
1163
+ when ":" then return false if depth.zero?
1164
+ end
1050
1165
  end
1166
+ true
1167
+ end
1168
+
1169
+ def serialize_properties(props)
1170
+ props.map { |k, v| "#{k}: #{v};" }.join(" ")
1051
1171
  end
1052
1172
 
1053
1173
  def write_properties(props)
1054
1174
  if props.empty?
1055
1175
  @element.remove_attribute("style") if @element.__dommy_backend_node__.key?("style")
1056
1176
  else
1057
- @element.set_attribute("style", props.map { |k, v| "#{k}:#{v}" }.join(";"))
1177
+ @element.set_attribute("style", serialize_properties(props))
1058
1178
  end
1059
1179
  end
1060
1180
  end
@@ -1101,11 +1221,18 @@ module Dommy
1101
1221
  end
1102
1222
 
1103
1223
  def text_content=(value)
1104
- # `node.content =` removes all existing children and (if value is
1105
- # non-empty) appends a single text node. Capture before/after to feed
1106
- # MutationObserver.
1224
+ # Setting textContent removes all existing children and, only for a
1225
+ # non-empty value, appends a single text node. Capture before/after to
1226
+ # feed MutationObserver. The empty case clears children directly: the
1227
+ # backend's `content=` is the parser's, and Makiri leaves an empty text
1228
+ # node behind for "" (Nokogiri and the DOM produce no node at all).
1107
1229
  removed = @__node__.children.to_a
1108
- @__node__.content = value.to_s
1230
+ str = value.to_s
1231
+ if str.empty?
1232
+ removed.each(&:unlink)
1233
+ else
1234
+ @__node__.content = str
1235
+ end
1109
1236
  added = @__node__.children.to_a
1110
1237
  notify_child_list(added: added, removed: removed)
1111
1238
  end
@@ -1127,10 +1254,28 @@ module Dommy
1127
1254
  else
1128
1255
  @__node__.inner_html = value.to_s
1129
1256
  @document.migrate_template_descendants(@__node__)
1257
+ mark_fragment_scripts_started(@__node__.children.to_a)
1130
1258
  end
1131
1259
  notify_child_list(added: @__node__.children.to_a, removed: removed)
1132
1260
  end
1133
1261
 
1262
+ # Per the HTML fragment parsing algorithm, a <script> created while parsing a
1263
+ # fragment (innerHTML / insertAdjacentHTML / outerHTML) has its "already
1264
+ # started" flag set, so it never executes when inserted. Flag every script in
1265
+ # the freshly parsed backend subtree before the connection notification —
1266
+ # which is what would otherwise run them — fires.
1267
+ def mark_fragment_scripts_started(backend_nodes)
1268
+ backend_nodes.each do |nk|
1269
+ next unless nk.respond_to?(:element?) && nk.element?
1270
+
1271
+ if nk.name == "script"
1272
+ wrapped = @document.wrap_node(nk)
1273
+ wrapped&.__internal_mark_script_already_started__ if wrapped.respond_to?(:__internal_mark_script_already_started__)
1274
+ end
1275
+ mark_fragment_scripts_started(nk.children.to_a) if nk.respond_to?(:children)
1276
+ end
1277
+ end
1278
+
1134
1279
  HTML_NAMESPACE = "http://www.w3.org/1999/xhtml"
1135
1280
 
1136
1281
  # Record the namespace/prefix/localName an element was created with via
@@ -1158,6 +1303,16 @@ module Dommy
1158
1303
  @__ns_prefix
1159
1304
  end
1160
1305
 
1306
+ # The [namespace, prefix, local_name] explicitly assigned via createElementNS,
1307
+ # or nil when the element wasn't created with an explicit namespace. Lets the
1308
+ # XML serializer recover element-namespace info the makiri backend (lexbor,
1309
+ # HTML-only) doesn't retain.
1310
+ def __internal_created_namespace__
1311
+ return nil unless @__ns_qname
1312
+
1313
+ [@__ns_uri, @__ns_prefix, @__ns_local]
1314
+ end
1315
+
1161
1316
  def id
1162
1317
  @__node__["id"].to_s
1163
1318
  end
@@ -1315,6 +1470,7 @@ module Dommy
1315
1470
  anchor = @__node__.next_sibling
1316
1471
  removed = @__node__
1317
1472
  new_nodes = fragment.children.to_a
1473
+ mark_fragment_scripts_started(new_nodes)
1318
1474
  @__node__.unlink
1319
1475
  if anchor
1320
1476
  new_nodes.reverse_each { |n| anchor.add_previous_sibling(n) }
@@ -1340,9 +1496,18 @@ module Dommy
1340
1496
  # ShadowRoot, fragment, or self if detached). If the element lives
1341
1497
  # inside a shadow tree, returns that ShadowRoot. Otherwise walks
1342
1498
  # until we hit the Nokogiri Document (then returns the Document).
1343
- def root_node
1499
+ def root_node(options = nil)
1500
+ composed = options.is_a?(Hash) && EventTarget.js_truthy?(options.key?("composed") ? options["composed"] : options[:composed])
1344
1501
  sr = @document.__internal_shadow_root_containing__(@__node__)
1345
- return sr if sr
1502
+ if sr
1503
+ # Default: the shadow root is the root. `composed: true` is
1504
+ # shadow-including — cross the boundary and continue from the host, so
1505
+ # the topmost document is returned.
1506
+ return sr unless composed
1507
+ return sr.host.root_node({"composed" => true}) if sr.host.respond_to?(:root_node)
1508
+
1509
+ return sr
1510
+ end
1346
1511
 
1347
1512
  current = @__node__
1348
1513
  attached = false
@@ -1365,17 +1530,33 @@ module Dommy
1365
1530
  alias get_root_node root_node
1366
1531
 
1367
1532
  # Merge adjacent text node siblings and drop empty text nodes.
1533
+ # WHATWG Node.normalize: drop empty Text nodes and merge each run of
1534
+ # contiguous Text nodes into the first, firing the matching mutation records
1535
+ # (childList for every removed node, characterData for the merged data).
1368
1536
  def normalize
1369
- @__node__.traverse do |node|
1370
- next unless node.text?
1371
- next if node.parent.nil?
1372
-
1373
- if node.content == "" && node.parent
1374
- node.unlink
1375
- elsif node.next && node.next.text?
1376
- node.content = node.content + node.next.content
1377
- node.next.unlink
1537
+ text_nodes = []
1538
+ @__node__.traverse { |node| text_nodes << node if node.respond_to?(:text?) && node.text? }
1539
+
1540
+ text_nodes.each do |node|
1541
+ next unless node.parent # already removed as part of an earlier run
1542
+
1543
+ if node.content.to_s.empty?
1544
+ @document.remove_node_with_notify(node)
1545
+ next
1378
1546
  end
1547
+
1548
+ merged = []
1549
+ sib = node.next
1550
+ while sib.respond_to?(:text?) && sib.text?
1551
+ merged << sib
1552
+ sib = sib.next
1553
+ end
1554
+ next if merged.empty?
1555
+
1556
+ old = node.content.to_s
1557
+ node.content = old + merged.map { |m| m.content.to_s }.join
1558
+ @document.notify_character_data_mutation(target_node: node, old_value: old)
1559
+ merged.each { |m| @document.remove_node_with_notify(m) }
1379
1560
  end
1380
1561
 
1381
1562
  nil
@@ -1398,11 +1579,8 @@ module Dommy
1398
1579
 
1399
1580
  def matches?(selector)
1400
1581
  return false if selector.nil?
1401
- Internal.validate_selector!(selector)
1402
-
1403
- # `:scope` pseudo — match against this element itself.
1404
- sel = Internal.backend_safe_selector(selector.to_s).gsub(":scope", "*:nth-last-child(n)")
1405
- matches_selector?(@__node__, sel)
1582
+ ast = Internal::SelectorParser.parse!(selector)
1583
+ Internal::SelectorMatcher.matches?(self, ast, scope: self)
1406
1584
  end
1407
1585
 
1408
1586
  def get_elements_by_class_name(name)
@@ -1476,6 +1654,26 @@ module Dommy
1476
1654
  set_attribute("slot", value.to_s)
1477
1655
  end
1478
1656
 
1657
+ # `assignedSlot` — for a slottable (a direct light-DOM child of a shadow
1658
+ # host), the `<slot>` in the host's *open* shadow tree it composes into,
1659
+ # else null. Per the spec's "open flag", a closed shadow tree always
1660
+ # returns null (mirrors `Element#shadowRoot` being null when closed).
1661
+ def assigned_slot
1662
+ parent = @__node__.parent
1663
+ return nil unless parent.respond_to?(:element?) && parent.element?
1664
+
1665
+ host = @document.wrap_node(parent)
1666
+ return nil unless host.respond_to?(:shadow_root)
1667
+
1668
+ sr = host.shadow_root
1669
+ return nil unless sr
1670
+
1671
+ slot_name = @__node__.element? ? @__node__["slot"].to_s : ""
1672
+ sr.query_selector_all("slot").find do |slot|
1673
+ (slot.respond_to?(:name) ? slot.name.to_s : "") == slot_name
1674
+ end
1675
+ end
1676
+
1479
1677
  def role
1480
1678
  @__node__["role"].to_s
1481
1679
  end
@@ -1484,6 +1682,38 @@ module Dommy
1484
1682
  set_attribute("role", value.to_s)
1485
1683
  end
1486
1684
 
1685
+ # The WAI-ARIA computed role (what `getByRole` / WPT's get_computed_role
1686
+ # report): an explicit valid `role` attribute, else the implicit HTML role.
1687
+ def computed_role
1688
+ Internal::AriaRole.compute(self)
1689
+ end
1690
+
1691
+ # The WAI-ARIA accessible name (WPT's get_computed_label): aria-labelledby /
1692
+ # aria-label / native label / name-from-content / title.
1693
+ def computed_label
1694
+ Internal::AccessibleName.compute(self)
1695
+ end
1696
+
1697
+ # The WAI-ARIA accessible description: aria-describedby / aria-description /
1698
+ # title (title only when not already used as the accessible name).
1699
+ def computed_description
1700
+ Internal::AccessibleDescription.compute(self)
1701
+ end
1702
+
1703
+ # The accessibility tree rooted at this element (a synthetic root whose
1704
+ # children are this element's accessible nodes). See
1705
+ # Internal::AccessibilityTree.
1706
+ def accessibility_tree
1707
+ Internal::AccessibilityTree.build(self)
1708
+ end
1709
+ alias_method :aria_tree, :accessibility_tree
1710
+
1711
+ # A Playwright-compatible ARIA snapshot (indented YAML outline) of this
1712
+ # element's accessibility subtree.
1713
+ def aria_snapshot
1714
+ Internal::AriaSnapshot.serialize(accessibility_tree)
1715
+ end
1716
+
1487
1717
  # `Node.baseURI` — resolves against the document's base URL, which
1488
1718
  # in turn honors the first `<base href>` element (see
1489
1719
  # `Document#base_uri`).
@@ -1503,9 +1733,9 @@ module Dommy
1503
1733
  seen = {}
1504
1734
  loop do
1505
1735
  # Guard against unexpected cycles in malformed trees.
1506
- return false if seen[current.object_id]
1736
+ return false if seen[Backend.identity_key(current)]
1507
1737
 
1508
- seen[current.object_id] = true
1738
+ seen[Backend.identity_key(current)] = true
1509
1739
 
1510
1740
  parent = current.respond_to?(:parent) ? current.parent : nil
1511
1741
  return false unless parent
@@ -1652,6 +1882,7 @@ module Dommy
1652
1882
 
1653
1883
  fragment = Parser.fragment(html.to_s, owner_doc: @__node__.document)
1654
1884
  nodes = fragment.children.to_a
1885
+ mark_fragment_scripts_started(nodes)
1655
1886
  # `add_previous_sibling` inserts immediately before the anchor, so a forward
1656
1887
  # walk preserves document order; `add_next_sibling` inserts immediately
1657
1888
  # after, so afterend walks in reverse to keep order.
@@ -1784,21 +2015,75 @@ module Dommy
1784
2015
  inner_html
1785
2016
  end
1786
2017
 
2018
+ # `click()` runs the HTML activation behavior around the dispatched event:
2019
+ # pre-click activation may change state (e.g. toggle a checkbox), the click
2020
+ # is dispatched, and then either the activation behavior runs (not canceled)
2021
+ # or the pre-click state is restored (default prevented). Elements with no
2022
+ # activation behavior (the default) just dispatch the event.
1787
2023
  def click
1788
- dispatch_event(MouseEvent.new("click", "bubbles" => true, "cancelable" => true, "button" => 0))
2024
+ pre = pre_click_activation_state
2025
+ not_canceled = dispatch_event(MouseEvent.new("click", "bubbles" => true, "cancelable" => true, "button" => 0))
2026
+ return not_canceled if pre.nil?
2027
+
2028
+ if not_canceled
2029
+ run_post_click_activation(pre)
2030
+ else
2031
+ restore_pre_click_activation(pre)
2032
+ end
2033
+ not_canceled
2034
+ end
2035
+
2036
+ # Activation-behavior hooks. The default element has none; HTMLInputElement
2037
+ # overrides these for checkbox/radio.
2038
+ def pre_click_activation_state
2039
+ nil
1789
2040
  end
1790
2041
 
2042
+ def run_post_click_activation(_state); end
2043
+
2044
+ def restore_pre_click_activation(_state); end
2045
+
1791
2046
  def get_attribute_names
1792
2047
  Backend.attribute_nodes(@__node__).map(&:name)
1793
2048
  end
1794
2049
 
1795
- # No layout engine geometry getters return zeroed rects.
2050
+ # No real layout engine. By default geometry getters return zeroed rects;
2051
+ # when the window opts into approximate geometry (window.approximate_layout)
2052
+ # they return non-zero estimates from a cheap pseudo-layout so a site that
2053
+ # treats an all-zero rect as "the DOM is broken" can proceed.
1796
2054
  def get_bounding_client_rect
1797
- DOMRect.new
2055
+ approximate_layout? ? DOMRect.new(**__internal_approx_box) : DOMRect.new
2056
+ end
2057
+
2058
+ def approximate_layout? = !!@document&.default_view&.approximate_layout
2059
+
2060
+ # Estimate {x, y, width, height} (CSS px) without laying out the page: block
2061
+ # elements fill the viewport width; inline elements are sized to their text;
2062
+ # height is the wrapped line count × a nominal line height. Position is the
2063
+ # origin (we don't position elements). Used only when approximate_layout?.
2064
+ INLINE_TAGS = %w[a span b i em strong small code label abbr cite q sub sup time mark u s
2065
+ tt var samp kbd bdi bdo wbr big font nobr].freeze
2066
+ APPROX_CHAR_PX = 8
2067
+ APPROX_LINE_PX = 20
2068
+
2069
+ def __internal_approx_box
2070
+ viewport = @document&.default_view&.inner_width.to_i
2071
+ viewport = 1280 if viewport <= 0
2072
+ text = text_content.to_s
2073
+ content_px = text.length * APPROX_CHAR_PX
2074
+ if INLINE_TAGS.include?(local_name.to_s.downcase)
2075
+ {x: 0, y: 0, width: [content_px, viewport].min, height: text.empty? ? 0 : APPROX_LINE_PX}
2076
+ else
2077
+ lines = text.empty? ? 0 : [(content_px.to_f / viewport).ceil, 1].max
2078
+ {x: 0, y: 0, width: viewport, height: lines * APPROX_LINE_PX}
2079
+ end
1798
2080
  end
1799
2081
 
1800
2082
  def get_client_rects
1801
- []
2083
+ return [] unless approximate_layout?
2084
+
2085
+ box = __internal_approx_box
2086
+ box[:width].positive? || box[:height].positive? ? [DOMRect.new(**box)] : []
1802
2087
  end
1803
2088
 
1804
2089
  def request_fullscreen
@@ -1849,22 +2134,19 @@ module Dommy
1849
2134
  1
1850
2135
  when "isConnected"
1851
2136
  is_connected?
1852
- when
1853
- "scrollTop",
1854
- "scrollLeft",
1855
- "scrollWidth",
1856
- "scrollHeight",
1857
- "clientWidth",
1858
- "clientHeight",
1859
- "clientTop",
1860
- "clientLeft",
1861
- "offsetWidth",
1862
- "offsetHeight",
1863
- "offsetTop",
1864
- "offsetLeft"
1865
- # No layout engine — zeroed values match what real browsers
1866
- # report for hidden / pre-paint elements.
2137
+ when "scrollTop", "scrollLeft", "clientTop", "clientLeft", "offsetTop", "offsetLeft"
2138
+ # Position-ish metrics: 0 (we never lay elements out in the page), as a
2139
+ # real browser reports for hidden / pre-paint elements.
1867
2140
  0
2141
+ when "clientWidth", "clientHeight", "scrollWidth", "scrollHeight", "offsetWidth", "offsetHeight"
2142
+ # Size metrics: 0 by default; a best-effort estimate when the window opts
2143
+ # into approximate geometry (see #get_bounding_client_rect).
2144
+ if approximate_layout?
2145
+ box = __internal_approx_box
2146
+ key.end_with?("Width") ? box[:width] : box[:height]
2147
+ else
2148
+ 0
2149
+ end
1868
2150
  when "offsetParent"
1869
2151
  nil
1870
2152
  when "popover"
@@ -1960,6 +2242,8 @@ module Dommy
1960
2242
  base_uri
1961
2243
  when "shadowRoot"
1962
2244
  shadow_root
2245
+ when "assignedSlot"
2246
+ assigned_slot
1963
2247
  when "ownerDocument"
1964
2248
  @document
1965
2249
  else
@@ -1978,6 +2262,18 @@ module Dommy
1978
2262
  elsif key.start_with?("on") && key.length > 2
1979
2263
  # `el.onXxx` event handler property — the registered callback or nil.
1980
2264
  @on_handlers&.[](event_name_from_on(key))
2265
+ elsif key.start_with?("_") || key.include?("$")
2266
+ # A framework-private expando key (React stores per-node state under
2267
+ # keys like `__reactListeners$<id>` and feature-detects it with
2268
+ # `node[key] === undefined`). Real DOM property names never use `_`/`$`,
2269
+ # so reporting these *absent* (undefined value, `in` false) is correct
2270
+ # JS and doesn't touch real DOM reflection (which WPT pins to null).
2271
+ Bridge::ABSENT
2272
+ else
2273
+ # A genuinely-unknown element property: JS `undefined`, `in` false.
2274
+ # (Reflected / ARIA / on* IDL attributes are handled above and keep
2275
+ # their nullable-DOMString null semantics.)
2276
+ Bridge::ABSENT
1981
2277
  end
1982
2278
  end
1983
2279
  end
@@ -2159,6 +2455,12 @@ module Dommy
2159
2455
  remove_attribute(name)
2160
2456
  end
2161
2457
 
2458
+ when "style"
2459
+ # WHATWG [PutForwards=cssText]: `el.style = "..."` forwards to
2460
+ # `el.style.cssText`, reparsing and rewriting the `style` attribute.
2461
+ # Handling it here stops the bridge from stashing a string expando that
2462
+ # would shadow the CSSStyleDeclaration getter.
2463
+ @style.css_text = value.nil? ? "" : value.to_s
2162
2464
  when "className"
2163
2465
  set_attribute("class", value.to_s)
2164
2466
  when "classList"
@@ -2210,9 +2512,16 @@ module Dommy
2210
2512
  scrollTo scrollBy requestFullscreen showPopover hidePopover togglePopover isEqualNode
2211
2513
  hasChildNodes hasAttributes getRootNode normalize contains
2212
2514
  compareDocumentPosition isSameNode lookupNamespaceURI lookupPrefix isDefaultNamespace
2515
+ __internal_computed_role__ __internal_computed_label__ __internal_computed_description__
2213
2516
  ]
2214
2517
  def __js_call__(method, args)
2215
2518
  case method
2519
+ when "__internal_computed_role__"
2520
+ computed_role
2521
+ when "__internal_computed_label__"
2522
+ computed_label
2523
+ when "__internal_computed_description__"
2524
+ computed_description
2216
2525
  when "hasChildNodes"
2217
2526
  has_child_nodes?
2218
2527
  when "hasAttributes"
@@ -2254,7 +2563,7 @@ module Dommy
2254
2563
  when "getElementsByTagName"
2255
2564
  get_elements_by_tag_name(args[0])
2256
2565
  when "getRootNode"
2257
- get_root_node
2566
+ get_root_node(args[0])
2258
2567
  when "normalize"
2259
2568
  normalize
2260
2569
  when "insertAdjacentElement"
@@ -2443,24 +2752,8 @@ module Dommy
2443
2752
 
2444
2753
  def closest(selector)
2445
2754
  return nil if selector.nil?
2446
- Internal.validate_selector!(selector)
2447
-
2448
- # Elements matching the selector (scoped to this element, so `:scope`
2449
- # resolves here), then return the nearest inclusive ancestor among them.
2450
- handler = Internal.scoped_pseudo_handlers(@__node__)
2451
- safe = Internal.backend_safe_selector(selector.to_s)
2452
- matched = with_selector_errors(selector) do
2453
- @document.nokogiri_doc.css(safe, handler).map(&:pointer_id)
2454
- end
2455
-
2456
- node = @__node__
2457
- while node&.element?
2458
- return @document.wrap_node(node) if matched.include?(node.pointer_id)
2459
-
2460
- node = node.parent
2461
- end
2462
-
2463
- nil
2755
+ ast = Internal::SelectorParser.parse!(selector)
2756
+ Internal::SelectorMatcher.closest(self, ast)
2464
2757
  end
2465
2758
 
2466
2759
  # Map Nokogiri's selector errors to spec behavior:
@@ -2468,16 +2761,8 @@ module Dommy
2468
2761
  # syntactically invalid → SyntaxError (querySelector/closest must throw);
2469
2762
  # - an "Unregistered function" means a valid pseudo Nokogiri compiled but
2470
2763
  # can't evaluate (`:hover`, `:invalid`, …) → degrade to matching nothing.
2471
- def with_selector_errors(selector)
2472
- yield
2473
- rescue ::StandardError => e
2474
- return [] if e.message.include?("Unregistered function")
2475
-
2476
- if (defined?(::Nokogiri::CSS::SyntaxError) && e.is_a?(::Nokogiri::CSS::SyntaxError)) || e.message.include?("unexpected")
2477
- raise DOMException::SyntaxError, "'#{selector}' is not a valid selector."
2478
- end
2479
-
2480
- raise
2764
+ def with_selector_errors(selector, &block)
2765
+ Internal.with_selector_errors(selector, &block)
2481
2766
  end
2482
2767
 
2483
2768
  # Web Animations: start an animation on this element.
@@ -2504,36 +2789,32 @@ module Dommy
2504
2789
  return nil if selector.nil?
2505
2790
  # The empty string is not a valid selector (an explicit DOMString "" is a
2506
2791
  # SyntaxError; `null` coerces to "null" and is handled above as nil).
2507
- Internal.validate_selector!(selector)
2792
+ sel = selector.to_s
2793
+ doc = owner_document
2794
+ key = [object_id, :first, sel]
2795
+ if doc && (hit = doc.__internal_scoped_query_get(key))
2796
+ return hit.first # [result] tuple — distinguishes a cached nil match from a miss
2797
+ end
2508
2798
 
2509
- @document.wrap_node(scoped_query(selector.to_s).first)
2799
+ ast = Internal::SelectorParser.parse!(selector)
2800
+ result = Internal::SelectorMatcher.query_first(self, ast, scope: self)
2801
+ doc&.__internal_scoped_query_set(key, [result])
2802
+ result
2510
2803
  end
2511
2804
 
2512
2805
  def query_selector_all(selector)
2513
2806
  return NodeList.new if selector.nil?
2514
- Internal.validate_selector!(selector)
2515
-
2516
- NodeList.new(scoped_query(selector.to_s).map { |node| @document.wrap_node(node) }.compact)
2517
- end
2518
-
2519
- # Run a CSS query rooted at this element. A `:scope` selector must resolve to
2520
- # this element, but Nokogiri scopes `el.css` to descendants (`.//`), which
2521
- # excludes the element itself — so for `:scope` queries we evaluate against
2522
- # the whole document (where this element IS reachable) and restrict the
2523
- # results to this element's own subtree.
2524
- def scoped_query(sel)
2525
- sel = Internal.backend_safe_selector(sel)
2526
- handler = Internal.scoped_pseudo_handlers(@__node__)
2527
- with_selector_errors(sel) do
2528
- if sel.include?(":scope")
2529
- self_id = @__node__.pointer_id
2530
- @document.nokogiri_doc.css(sel, handler).select do |n|
2531
- n.ancestors.any? { |a| a.pointer_id == self_id }
2532
- end
2533
- else
2534
- @__node__.css(sel, handler)
2535
- end
2807
+ sel = selector.to_s
2808
+ doc = owner_document
2809
+ key = [object_id, :all, sel]
2810
+ if doc && (hit = doc.__internal_scoped_query_get(key))
2811
+ return NodeList.new(hit) # NodeList.new copies, so the cached array is never aliased
2536
2812
  end
2813
+
2814
+ ast = Internal::SelectorParser.parse!(selector)
2815
+ matches = Internal::SelectorMatcher.query(self, ast, scope: self)
2816
+ doc&.__internal_scoped_query_set(key, matches)
2817
+ NodeList.new(matches)
2537
2818
  end
2538
2819
 
2539
2820
  # XPath queries scoped to this element, returning wrapped nodes.
@@ -2552,20 +2833,21 @@ module Dommy
2552
2833
  end
2553
2834
 
2554
2835
  def insert_before(child, reference)
2555
- check_hierarchy!(child)
2836
+ coerce_node_argument!(child)
2837
+ # WHATWG: if the reference child is the node being inserted, the reference
2838
+ # becomes that node's next sibling, so "insert x before x" doesn't move x.
2839
+ reference = wrapped_next_sibling(reference) if same_wrapped_node?(reference, child)
2840
+ ensure_pre_insertion_validity!(child, reference)
2556
2841
  nodes = detach_dom_nodes(child)
2557
- if reference.nil?
2842
+ if reference.nil? || (defined?(Bridge::UNDEFINED) && reference.equal?(Bridge::UNDEFINED))
2558
2843
  append_dom_nodes(nodes)
2559
2844
  else
2845
+ # The reference is guaranteed (by validity) to be a child here. Insert in
2846
+ # order before it: each new node becomes its immediate previous sibling,
2847
+ # so forward iteration yields the original order (reverse would flip a
2848
+ # multi-node fragment).
2560
2849
  ref_node = unwrap_dom_node(reference)
2561
- if ref_node&.parent != @__node__
2562
- # Per spec this should be a NotFoundError, but the legacy
2563
- # behaviour of `appendChild` when reference is foreign is a
2564
- # silent append. Preserve that for compatibility.
2565
- append_dom_nodes(nodes)
2566
- else
2567
- nodes.reverse_each { |node| ref_node.add_previous_sibling(node) }
2568
- end
2850
+ nodes.each { |node| ref_node.add_previous_sibling(node) }
2569
2851
  end
2570
2852
 
2571
2853
  notify_child_list(added: nodes)
@@ -2573,6 +2855,7 @@ module Dommy
2573
2855
  end
2574
2856
 
2575
2857
  def remove_child(child)
2858
+ coerce_node_argument!(child)
2576
2859
  node = unwrap_dom_node(child)
2577
2860
  unless node&.parent == @__node__
2578
2861
  raise DOMException::NotFoundError, "node is not a child of this element"
@@ -2588,26 +2871,46 @@ module Dommy
2588
2871
  # MutationObserver of both changes in one record so observers
2589
2872
  # see the swap atomically.
2590
2873
  def replace_child(new_child, old_child)
2874
+ coerce_node_argument!(new_child)
2875
+ coerce_node_argument!(old_child)
2876
+ # replaceChild shares the pre-insertion checks (ancestor, node type,
2877
+ # doctype placement); the reference child here is old_child, so step 3
2878
+ # also enforces that it is actually a child (NotFoundError otherwise).
2879
+ ensure_pre_insertion_validity!(new_child, old_child)
2591
2880
  old_node = unwrap_dom_node(old_child)
2592
- return nil unless old_node&.parent == @__node__
2593
2881
 
2882
+ # Capture the insertion point (old's next sibling) before detaching the new
2883
+ # child, which may itself be old (replaceChild(x, x)) or old's sibling.
2884
+ anchor = old_node.next_sibling
2594
2885
  new_nodes = detach_dom_nodes(new_child)
2595
- new_nodes.reverse_each { |node| old_node.add_previous_sibling(node) }
2596
- old_node.unlink
2597
- notify_child_list(added: new_nodes, removed: [old_node])
2886
+ anchor = nil if anchor && anchor.parent != @__node__
2887
+
2888
+ # detach_dom_nodes already removed old when new_child === old_child; only
2889
+ # unlink (and record the removal) when old is still attached.
2890
+ removed = []
2891
+ if old_node.parent == @__node__
2892
+ old_node.unlink
2893
+ removed = [old_node]
2894
+ end
2895
+
2896
+ if anchor
2897
+ new_nodes.each { |node| anchor.add_previous_sibling(node) }
2898
+ else
2899
+ new_nodes.each { |node| @__node__.add_child(node) }
2900
+ end
2901
+ notify_child_list(added: new_nodes, removed: removed)
2598
2902
  old_child
2599
2903
  end
2600
2904
 
2601
2905
  def clone_node(deep_arg)
2602
- # Copy the node in place via libxml's deep dup, NOT by re-parsing to_html as
2603
- # a fragment: the HTML fragment parser unwraps `<body>` / `<head>` /
2604
- # `<html>`, so cloning a body produced its children, not a body element
2605
- # (which broke Turbo's snapshot cache — it clones the body and restores it
2606
- # via documentElement.replaceChild on back/forward). dup(1) preserves the
2607
- # element's namespace and attributes (createElement would lose the
2608
- # namespace); for a shallow clone we keep that node but drop its subtree.
2609
- copy = @__node__.dup(1)
2610
- copy.children.each(&:unlink) unless deep_arg
2906
+ # Copy the node in place via the backend's deep clone, NOT by re-parsing
2907
+ # to_html as a fragment: the HTML fragment parser unwraps `<body>` /
2908
+ # `<head>` / `<html>`, so cloning a body would produce its children, not a
2909
+ # body element (which broke Turbo's snapshot cache — it clones the body and
2910
+ # restores it via documentElement.replaceChild on back/forward). The clone
2911
+ # preserves the element's namespace and attributes (createElement would
2912
+ # lose the namespace).
2913
+ copy = Backend.clone_node(@__node__, deep: deep_arg)
2611
2914
  @document.wrap_node(copy)
2612
2915
  end
2613
2916
 
@@ -2760,6 +3063,20 @@ module Dommy
2760
3063
  detach_dom_nodes(value).first
2761
3064
  end
2762
3065
 
3066
+ # Whether two wrapped values back the same backend node (used to detect
3067
+ # `insertBefore(x, x)`).
3068
+ def same_wrapped_node?(a, b)
3069
+ an = a.respond_to?(:__dommy_backend_node__) ? a.__dommy_backend_node__ : nil
3070
+ bn = b.respond_to?(:__dommy_backend_node__) ? b.__dommy_backend_node__ : nil
3071
+ !an.nil? && an == bn
3072
+ end
3073
+
3074
+ # The wrapped next sibling of a wrapped reference node (nil at end of list).
3075
+ def wrapped_next_sibling(reference)
3076
+ nk = reference.respond_to?(:__dommy_backend_node__) ? reference.__dommy_backend_node__&.next : nil
3077
+ nk && @document.wrap_node(nk)
3078
+ end
3079
+
2763
3080
  def unwrap_dom_node(value)
2764
3081
  return value.__dommy_backend_node__ if value.respond_to?(:__dommy_backend_node__)
2765
3082
 
@@ -2767,11 +3084,41 @@ module Dommy
2767
3084
  end
2768
3085
 
2769
3086
  def matches_selector?(node, selector)
2770
- if node.respond_to?(:matches?)
2771
- node.matches?(selector)
2772
- else
2773
- node.document.css(selector).any? { |candidate| candidate == node }
3087
+ return false if node.nil?
3088
+
3089
+ # A valid pseudo the backend can't evaluate (`:active`, `:invalid`, …)
3090
+ # degrades to not-matching ([] from the rescue) the same policy as
3091
+ # the query methods.
3092
+ result = with_selector_errors(selector) { matches_selector_uncaught?(node, selector) }
3093
+ result == [] ? false : result
3094
+ end
3095
+
3096
+ def matches_selector_uncaught?(node, selector)
3097
+ return node.document.css(selector).any? { |candidate| candidate == node } unless node.respond_to?(:matches?)
3098
+
3099
+ # A detached node (no parent) breaks Nokogiri's `matches?`, which evaluates
3100
+ # `ancestors.last.search(selector)` — `ancestors.last` is nil with no
3101
+ # ancestors. matches() ignores connectivity (a disconnected element still
3102
+ # matches a selector it satisfies — e.g. Stimulus checks a just-removed
3103
+ # outlet element), so give a parentless node a transient fragment root,
3104
+ # then restore its detached state.
3105
+ if node.respond_to?(:parent) && node.parent.nil? &&
3106
+ node.respond_to?(:document) && node.document.respond_to?(:fragment)
3107
+ return matches_detached_node?(node, selector)
2774
3108
  end
3109
+
3110
+ node.matches?(selector)
3111
+ end
3112
+
3113
+ # Match a parentless node by wrapping it in a throwaway fragment so the
3114
+ # backend's `matches?` has an ancestor root, then unlinking to leave the
3115
+ # node detached (and its parentNode unchanged) as it was. `fragment("")`
3116
+ # (not the no-arg form) is backend-agnostic — Makiri's takes a source string.
3117
+ def matches_detached_node?(node, selector)
3118
+ node.document.fragment("").add_child(node)
3119
+ node.matches?(selector)
3120
+ ensure
3121
+ node.unlink
2775
3122
  end
2776
3123
 
2777
3124
  # No real layout — record the scroll request so tests can assert it.