dommy 0.9.0 → 0.10.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/lib/dommy/attr.rb +46 -8
  3. data/lib/dommy/backend/makiri_adapter.rb +34 -0
  4. data/lib/dommy/backend.rb +29 -0
  5. data/lib/dommy/blob.rb +48 -6
  6. data/lib/dommy/browser.rb +239 -36
  7. data/lib/dommy/callable_invoker.rb +6 -2
  8. data/lib/dommy/document.rb +525 -57
  9. data/lib/dommy/element.rb +479 -239
  10. data/lib/dommy/event.rb +296 -15
  11. data/lib/dommy/fetch.rb +431 -25
  12. data/lib/dommy/history.rb +24 -3
  13. data/lib/dommy/html_collection.rb +145 -21
  14. data/lib/dommy/html_elements.rb +1675 -266
  15. data/lib/dommy/interaction/driver.rb +155 -14
  16. data/lib/dommy/interaction/event_synthesis.rb +115 -7
  17. data/lib/dommy/interaction/field_interactor.rb +60 -0
  18. data/lib/dommy/internal/child_node.rb +199 -0
  19. data/lib/dommy/internal/css/cascade.rb +44 -2
  20. data/lib/dommy/internal/global_functions.rb +23 -10
  21. data/lib/dommy/internal/mutation_coordinator.rb +27 -7
  22. data/lib/dommy/internal/node_wrapper_cache.rb +56 -14
  23. data/lib/dommy/internal/observer_manager.rb +6 -0
  24. data/lib/dommy/internal/observer_matcher.rb +6 -8
  25. data/lib/dommy/internal/parent_node.rb +37 -73
  26. data/lib/dommy/internal/selector_matcher.rb +89 -0
  27. data/lib/dommy/internal/selector_parser.rb +44 -7
  28. data/lib/dommy/js/custom_element_bridge.rb +13 -2
  29. data/lib/dommy/js/dom_interfaces.rb +19 -4
  30. data/lib/dommy/js/host_bridge.rb +21 -0
  31. data/lib/dommy/js/host_runtime.js +926 -64
  32. data/lib/dommy/js/script_boot.rb +80 -0
  33. data/lib/dommy/location.rb +76 -13
  34. data/lib/dommy/mutation_observer.rb +3 -4
  35. data/lib/dommy/navigation.rb +263 -0
  36. data/lib/dommy/node.rb +180 -27
  37. data/lib/dommy/range.rb +15 -3
  38. data/lib/dommy/scheduler.rb +16 -0
  39. data/lib/dommy/shadow_root.rb +33 -0
  40. data/lib/dommy/storage.rb +61 -10
  41. data/lib/dommy/tree_walker.rb +18 -36
  42. data/lib/dommy/url.rb +4 -2
  43. data/lib/dommy/version.rb +1 -1
  44. data/lib/dommy/web_socket.rb +45 -2
  45. data/lib/dommy/window.rb +126 -7
  46. data/lib/dommy/xml_http_request.rb +30 -4
  47. data/lib/dommy.rb +2 -0
  48. metadata +6 -10
@@ -1,32 +1,45 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "cgi"
4
- require "erb"
5
3
  require "base64"
6
4
 
5
+ require_relative "url_parser"
6
+
7
7
  module Dommy
8
8
  module Internal
9
9
  # Stateless global functions exposed on the JS global (Window) that don't
10
- # depend on any window state. Kept here so Window doesn't carry the cgi/erb
11
- # dependency just for URI component encoding.
10
+ # depend on any window state.
12
11
  module GlobalFunctions
13
12
  module_function
14
13
 
15
- # JS `encodeURIComponent`: percent-encode everything except
16
- # `A-Za-z0-9 - _ . ! ~ * ' ( )`. `ERB::Util.url_encode` matches this,
17
- # unlike `CGI.escape` (which uses `+` for space).
14
+ # WebIDL DOMString conversion of a JS value: null "null", undefined →
15
+ # "undefined", booleans "true"/"false", everything else its string form.
16
+ # (Ruby's `nil.to_s` is "", which would silently drop a `btoa(null)` arg.)
17
+ def js_domstring(value)
18
+ return "null" if value.nil?
19
+ return "undefined" if defined?(Bridge::UNDEFINED) && value.equal?(Bridge::UNDEFINED)
20
+
21
+ value.to_s
22
+ end
23
+
24
+ # JS `encodeURIComponent`: UTF-8 percent-encode everything except the
25
+ # unreserved marks `A-Za-z0-9 - _ . ! ~ * ' ( )`. (Neither
26
+ # `ERB::Util.url_encode` nor `CGI.escape` matches this set — the former
27
+ # encodes `!~*'()`, the latter uses `+` for space.)
18
28
  def encode_uri_component(value)
19
- ERB::Util.url_encode(value.to_s)
29
+ value.to_s.b.gsub(/[^A-Za-z0-9\-_.!~*'()]/n) { |c| format("%%%02X", c.ord) }
20
30
  end
21
31
 
32
+ # JS `decodeURIComponent`: percent-decode only — unlike form-urlencoded
33
+ # decoding, "+" stays a literal "+". Malformed UTF-8 becomes U+FFFD
34
+ # (a real engine throws URIError; nothing downstream relies on that).
22
35
  def decode_uri_component(value)
23
- CGI.unescape(value.to_s)
36
+ UrlParser.percent_decode(value.to_s).force_encoding(Encoding::UTF_8).scrub("\u{FFFD}")
24
37
  end
25
38
 
26
39
  # JS `btoa`: base64-encode a binary (Latin1) string. Each code unit must be
27
40
  # 0..255; anything beyond Latin1 is an InvalidCharacterError (per spec).
28
41
  def btoa(value)
29
- codepoints = value.to_s.codepoints
42
+ codepoints = js_domstring(value).codepoints
30
43
  if codepoints.any? { |c| c > 0xFF }
31
44
  raise DOMException::InvalidCharacterError.new(
32
45
  "Failed to execute 'btoa': characters outside the Latin1 range cannot be base64-encoded."
@@ -144,14 +144,23 @@ module Dommy
144
144
  nk.children.each { |c| notify_disconnected_subtree(c) } if nk.respond_to?(:children)
145
145
  end
146
146
 
147
- def notify_attribute_changed(element, name, old_value, new_value)
147
+ def notify_attribute_changed(element, name, old_value, new_value, namespace = nil)
148
148
  return unless element&.respond_to?(:attribute_changed_callback)
149
149
 
150
150
  klass = element.class
151
151
  return unless klass.respond_to?(:observed_attributes)
152
152
  return unless klass.observed_attributes.include?(name.to_s.downcase)
153
153
 
154
- element.attribute_changed_callback(name, old_value, new_value)
154
+ # attributeChangedCallback's 4th arg is the attribute's namespace (null
155
+ # for a plain HTML attribute). Pass it only to callbacks that accept it
156
+ # (the JS bridge, or a 4-arg Ruby callback) so existing 3-arg Ruby custom
157
+ # elements keep working.
158
+ cb = element.method(:attribute_changed_callback)
159
+ if cb.arity.negative? || cb.arity >= 4
160
+ element.attribute_changed_callback(name, old_value, new_value, namespace)
161
+ else
162
+ element.attribute_changed_callback(name, old_value, new_value)
163
+ end
155
164
  rescue StandardError
156
165
  nil
157
166
  end
@@ -169,13 +178,24 @@ module Dommy
169
178
  return nil unless target
170
179
  return nil if added_nodes.empty? && removed_nodes.empty?
171
180
 
181
+ # Custom Element connected/disconnected callbacks, script execution, and
182
+ # blank-iframe load all require the subtree to be connected to the
183
+ # document (the script/iframe paths already check is_connected?, and
184
+ # connectedCallback fires only when connected). So skip the O(subtree)
185
+ # walk for mutations within a still-detached tree — the common case
186
+ # during bulk DOM construction, where nothing in the walk can fire.
187
+ if !target.respond_to?(:is_connected?) || target.is_connected?
188
+ added_nodes.each { |nk| notify_connected_subtree(nk) }
189
+ removed_nodes.each { |nk| notify_disconnected_subtree(nk) }
190
+ end
191
+
192
+ # MutationRecords are only needed when something is observing; skip the
193
+ # eager wrapping + record entirely when no observer is registered.
194
+ return nil unless @observer_manager.any?
195
+
172
196
  wrapped_added = added_nodes.map { |node| @document.wrap_node(node) }.compact
173
197
  wrapped_removed = removed_nodes.map { |node| @document.wrap_node(node) }.compact
174
198
 
175
- # Fire Custom Element lifecycle callbacks (synchronous, before MutationObserver microtask)
176
- added_nodes.each { |nk| notify_connected_subtree(nk) }
177
- removed_nodes.each { |nk| notify_disconnected_subtree(nk) }
178
-
179
199
  # Capture previousSibling / nextSibling (the position within target)
180
200
  prev_w = previous_sibling
181
201
  next_w = next_sibling
@@ -223,7 +243,7 @@ module Dommy
223
243
  new_value = target_node[attr]
224
244
 
225
245
  # Custom Element attributeChangedCallback (synchronous)
226
- notify_attribute_changed(target, attr, old_value, new_value)
246
+ notify_attribute_changed(target, attr, old_value, new_value, namespace)
227
247
 
228
248
  @observer_manager.observers_matching(target).each do |observer|
229
249
  entry = observer.find_matching_entry(target)
@@ -115,14 +115,14 @@ module Dommy
115
115
  # WHATWG createAttribute: an HTML document lower-cases the name (an XML
116
116
  # document preserves it). Attr.new no longer folds case, so do it here.
117
117
  str = str.downcase if @document.html_document?
118
- Attr.new(str)
118
+ Attr.new(str, document: @document)
119
119
  end
120
120
 
121
121
  def create_attribute_ns(namespace_uri, qualified_name)
122
122
  namespace_uri = nil if namespace_uri.equal?(Bridge::UNDEFINED)
123
123
  qualified_name = domstring(qualified_name)
124
124
  ns, prefix, local = Namespaces.validate_and_extract(namespace_uri, qualified_name)
125
- Attr.new(qualified_name, namespace_uri: ns, prefix: prefix, local_name: local)
125
+ Attr.new(qualified_name, namespace_uri: ns, prefix: prefix, local_name: local, document: @document)
126
126
  end
127
127
 
128
128
  def create_element_ns(namespace_uri, qualified_name)
@@ -135,7 +135,17 @@ module Dommy
135
135
  qualified_name = domstring(qualified_name)
136
136
  ns, prefix, local = Namespaces.validate_and_extract(namespace_uri, qualified_name, context: :element)
137
137
 
138
- el = Backend.create_element(qualified_name, @document.backend_doc)
138
+ # An XML backend rejects some DOM-valid qualified names (an invalid char
139
+ # in the local part, which DOM permits): the loose creator builds them
140
+ # verbatim. A genuinely invalid name it (or the strict path) rejects with
141
+ # an ArgumentError becomes an InvalidCharacterError, per DOM.
142
+ el =
143
+ begin
144
+ Backend.create_element_loose(qualified_name, prefix, local, ns, @document.backend_doc) ||
145
+ Backend.create_element(qualified_name, @document.backend_doc)
146
+ rescue ArgumentError
147
+ raise DOMException::InvalidCharacterError, "'#{qualified_name}' is not a valid element name"
148
+ end
139
149
  Backend.add_namespace_definition(el, prefix, ns) if ns
140
150
 
141
151
  wrapper = build_element_wrapper(el, namespace: ns, local_name: local)
@@ -143,6 +153,18 @@ module Dommy
143
153
  wrapper
144
154
  end
145
155
 
156
+ # Wrap a freshly-cloned backend element whose original was built via
157
+ # createElementNS: route the wrapper class by the known local name (the
158
+ # backend node name may be the full qualified name, e.g. "foo:div", which
159
+ # would otherwise resolve to HTMLUnknownElement) and reapply its namespace
160
+ # metadata (namespaceURI / prefix / localName / tagName).
161
+ def wrap_cloned_element_ns(node, namespace, prefix, local, qualified_name)
162
+ reset_wrapper(node)
163
+ wrapper = build_element_wrapper(node, namespace: namespace, local_name: local)
164
+ wrapper.__internal_set_namespace__(namespace, prefix, local, qualified_name)
165
+ wrapper
166
+ end
167
+
146
168
  # Query methods
147
169
 
148
170
  def query_selector(selector)
@@ -183,14 +205,7 @@ module Dommy
183
205
  end
184
206
 
185
207
  def get_elements_by_tag_name(name)
186
- n = name.to_s.downcase
187
- doc = @document.backend_doc
188
- cache = self
189
- if n == "*"
190
- HTMLCollection.new { doc.css("*").map { |x| cache.wrap(x) }.compact }
191
- else
192
- HTMLCollection.new { doc.css(n).map { |x| cache.wrap(x) }.compact }
193
- end
208
+ HTMLCollection.elements_by_tag_name(@document.backend_doc, @document, name)
194
209
  end
195
210
 
196
211
  def get_elements_by_name(name)
@@ -202,15 +217,27 @@ module Dommy
202
217
  end
203
218
  end
204
219
 
220
+ # DOM "ASCII whitespace" (https://infra.spec.whatwg.org/#ascii-whitespace):
221
+ # TAB, LF, FF, CR, SPACE — NOT Ruby's `\s` (which also matches VT / U+000B)
222
+ # and NOT any Unicode space (U+00A0, U+2000…). Class tokens split on exactly
223
+ # this set, so a class of a single U+000B or U+00A0 is ONE token.
224
+ ASCII_WHITESPACE = /[\t\n\f\r ]+/
225
+
205
226
  def get_elements_by_class_name(name)
206
- tokens = name.to_s.split(/\s+/).reject(&:empty?)
227
+ tokens = name.to_s.split(ASCII_WHITESPACE).reject(&:empty?)
207
228
  doc = @document.backend_doc
208
229
  cache = self
209
230
  HTMLCollection.new do
210
231
  next [] if tokens.empty?
211
232
 
212
- selector = tokens.map { |t| ".#{t}" }.join("")
213
- doc.css(selector).map { |n| cache.wrap(n) }.compact
233
+ # Match class tokens directly rather than composing a `.tok` CSS
234
+ # selector string an exotic class token (control chars, Unicode
235
+ # spaces, quotes) can't be safely embedded in a selector, and the
236
+ # split must be ASCII-whitespace, not the CSS engine's tokenization.
237
+ doc.css("[class]").select do |n|
238
+ classes = n["class"].to_s.split(ASCII_WHITESPACE)
239
+ tokens.all? { |t| classes.include?(t) }
240
+ end.map { |n| cache.wrap(n) }.compact
214
241
  end
215
242
  end
216
243
 
@@ -219,6 +246,13 @@ module Dommy
219
246
  @wrappers.delete(identity_key(nokogiri_node))
220
247
  end
221
248
 
249
+ # The cached wrapper for `node`, or nil — WITHOUT creating one (unlike
250
+ # #wrap). Used by cross-document adoption to find the live descendant
251
+ # wrappers that must be reseated onto the imported copy.
252
+ def peek(node)
253
+ node && @wrappers[identity_key(node)]
254
+ end
255
+
222
256
  # Register an externally-built wrapper. Used by
223
257
  # Document#adopt_node when migrating a wrapper from another
224
258
  # document so the existing Ruby object survives the move
@@ -293,6 +327,12 @@ module Dommy
293
327
 
294
328
  def build_wrapper_for(node)
295
329
  case node
330
+ when Backend.document_class
331
+ # The backend document node has no wrapper of its own — map it to the
332
+ # Dommy Document that owns this cache, so a top-level node's parentNode /
333
+ # getRootNode resolves to the document (documentElement.parentNode ===
334
+ # document), matching the DOM tree.
335
+ @document
296
336
  when Backend.element_class
297
337
  build_element_wrapper(node)
298
338
  when ->(n) { Backend.cdata_class && n.is_a?(Backend.cdata_class) }
@@ -306,6 +346,8 @@ module Dommy
306
346
  ProcessingInstructionNode.new(@document, node)
307
347
  when Backend.document_fragment_class
308
348
  Fragment.new(@document, node)
349
+ when ->(n) { (dt = Backend.document_type_class) && n.is_a?(dt) }
350
+ DocumentType.new(backend_node: node, document: @document)
309
351
  end
310
352
  end
311
353
 
@@ -26,6 +26,12 @@ module Dommy
26
26
  def all
27
27
  @observers.dup
28
28
  end
29
+
30
+ # True when at least one observer is registered — a cheap gate so a
31
+ # mutation with no observers skips building MutationRecords entirely.
32
+ def any?
33
+ !@observers.empty?
34
+ end
29
35
  end
30
36
  end
31
37
  end
@@ -4,9 +4,8 @@ module Dommy
4
4
  module Internal
5
5
  # Matches a mutation target against an observed node based on observer options.
6
6
  # Works exclusively with wrapped DOM nodes (not Nokogiri internals).
7
- class ObserverMatcher
8
- def initialize
9
- end
7
+ module ObserverMatcher
8
+ module_function
10
9
 
11
10
  # Does this observer's target scope match the mutation target?
12
11
  # Returns true if:
@@ -20,11 +19,10 @@ module Dommy
20
19
  observed_wrapped.contains?(target_wrapped)
21
20
  end
22
21
 
23
- # Special case: Document observation
24
- # Matches if subtree=false (never) or target is in document (always)
25
- def matches_document?(target_wrapped, subtree:)
26
- return true if subtree
27
- false
22
+ # Special case: Document observation. Matches iff subtree=true
23
+ # (a plain target==observed match never applies to a Document).
24
+ def matches_document?(_target_wrapped, subtree:)
25
+ subtree
28
26
  end
29
27
  end
30
28
  end
@@ -11,6 +11,11 @@ module Dommy
11
11
  # coordinator already no-ops on empty added/removed sets and on an
12
12
  # unwrappable target, so callers may invoke it unconditionally.
13
13
  module ParentNode
14
+ # Argument coercion (`detach_dom_nodes`), childList notification, and the
15
+ # ChildNode `before`/`after`/`replaceWith` surface all live in ChildNode,
16
+ # shared with the leaf CharacterData nodes.
17
+ include ChildNode
18
+
14
19
  # `appendChild(child)` — detach the node(s) from any current parent
15
20
  # and append to the end of this node's child list.
16
21
  def append_child(child)
@@ -37,7 +42,10 @@ module Dommy
37
42
  nodes = args.flat_map { |arg| detach_dom_nodes(arg) }
38
43
  anchor = @__node__.children.first
39
44
  if anchor
40
- nodes.reverse_each { |n| anchor.add_previous_sibling(n) }
45
+ # Insert each node before the (fixed) original first child in order:
46
+ # forward iteration keeps document order (n1, n2, … then the old first
47
+ # child). Reversing here would emit them backwards.
48
+ nodes.each { |n| anchor.add_previous_sibling(n) }
41
49
  else
42
50
  nodes.each { |n| @__node__.add_child(n) }
43
51
  end
@@ -57,84 +65,40 @@ module Dommy
57
65
  nil
58
66
  end
59
67
 
60
- private
68
+ # Node#normalize — merge each run of adjacent exclusive Text descendants
69
+ # into its first node (preserving that node's identity, so a JS reference
70
+ # to it survives) and drop empty Text nodes. Recurses the whole subtree,
71
+ # so it works for Element, DocumentFragment, and ShadowRoot alike.
72
+ def normalize
73
+ text_nodes = []
74
+ @__node__.traverse { |node| text_nodes << node if node.respond_to?(:text?) && node.text? }
61
75
 
62
- # Centralized MutationObserver childList notification. Defaults the
63
- # target to this node; beforebegin/afterend/replaceWith/outerHTML
64
- # callers pass the parent explicitly. The coordinator filters out
65
- # empty added/removed sets, so this is always safe to call.
66
- def notify_child_list(added: [], removed: [], target: @__node__)
67
- @document.notify_child_list_mutation(
68
- target_node: target,
69
- added_nodes: added,
70
- removed_nodes: removed
71
- )
72
- end
76
+ text_nodes.each do |node|
77
+ next unless node.parent # already removed as part of an earlier run
73
78
 
74
- # Coerce an append/prepend/replaceChildren argument into raw Nokogiri
75
- # node(s), detached from any current parent:
76
- # - Element / TextNode / CommentNode → its backing node (unlinked)
77
- # - Fragment → its extracted children
78
- # - String → a fresh text node
79
- # - anything else with a backing node → that node (unlinked)
80
- #
81
- # The class constants resolve at call time, so the mixin only needs to
82
- # be defined before the including class bodies run.
83
- def detach_dom_nodes(value)
84
- case value
85
- when Fragment
86
- value.extract_children.map { |n| adopt_into_document(n) }
87
- when String
88
- [@document.create_text_node(value).__dommy_backend_node__]
89
- else
90
- node = value.respond_to?(:__dommy_backend_node__) ? value.__dommy_backend_node__ : nil
91
- return [] unless node
92
-
93
- # WHATWG pre-insert adopts the node into this node's document before
94
- # linking it. libxml2 reassigns ownership in place during add_child, so
95
- # the explicit adopt is a no-op move there; Makiri can't move a node
96
- # between document arenas, so a cross-document insert must adopt (an
97
- # imported copy) first. adopt_node reseats the Dommy wrapper onto the
98
- # adopted node, so JS identity (`parent.appendChild(x); x` ===
99
- # `parent.lastChild`) survives. Same-document: the wrapper's backend
100
- # node is unchanged, so this is identical to the previous behavior.
101
- detach_with_notify(node)
102
- [@document.adopt_node(value).__dommy_backend_node__]
79
+ if node.content.to_s.empty?
80
+ @document.remove_node_with_notify(node)
81
+ next
82
+ end
83
+
84
+ merged = []
85
+ sib = node.next
86
+ while sib.respond_to?(:text?) && sib.text?
87
+ merged << sib
88
+ sib = sib.next
89
+ end
90
+ next if merged.empty?
91
+
92
+ old = node.content.to_s
93
+ node.content = old + merged.map { |m| m.content.to_s }.join
94
+ @document.notify_character_data_mutation(target_node: node, old_value: old)
95
+ merged.each { |m| @document.remove_node_with_notify(m) }
103
96
  end
104
- end
105
97
 
106
- # Bring a raw backend node into this node's document (WHATWG adopt). A
107
- # no-op when already same-document; otherwise Backend.adopt — in place for
108
- # Nokogiri, an imported copy for Makiri (which can't move nodes between
109
- # arenas). Used for fragment children, which have no standalone wrapper to
110
- # reseat.
111
- def adopt_into_document(node)
112
- target = @document.backend_doc
113
- node.document == target ? node : Backend.adopt(node, target)
98
+ nil
114
99
  end
115
100
 
116
- # Detach a node from its current parent, queuing a childList removal
117
- # record on that old parent first (WHATWG "remove" runs before the
118
- # subsequent insert, so moving a node yields a removal record + an addition
119
- # record). Returns the raw node, ready to be re-linked.
120
- def detach_with_notify(node)
121
- old_parent = node.parent
122
- return node unless old_parent
123
-
124
- # Capture the position (as wrapped nodes — the coordinator records
125
- # explicit siblings verbatim) before unlinking.
126
- prev_sib = node.previous_sibling && @document.wrap_node(node.previous_sibling)
127
- next_sib = node.next_sibling && @document.wrap_node(node.next_sibling)
128
- node.unlink
129
- @document.notify_child_list_mutation(
130
- target_node: old_parent,
131
- added_nodes: [],
132
- removed_nodes: [node],
133
- previous_sibling: prev_sib,
134
- next_sibling: next_sib
135
- )
136
- node
137
- end
101
+ private
138
102
 
139
103
  # Hierarchy guard hook. Default no-op (Fragment / ShadowRoot stay
140
104
  # permissive, matching current behavior). Element overrides this to
@@ -358,6 +358,12 @@ module Dommy
358
358
  when "hover"
359
359
  hovered = element.owner_document&.__internal_hovered_element__
360
360
  hovered && (element.equal?(hovered) || element.contains?(hovered))
361
+ when "invalid" then constraint_invalid?(element)
362
+ when "valid" then constraint_valid?(element)
363
+ when "required" then form_control_required?(element)
364
+ when "optional" then form_control_optional?(element)
365
+ when "read-only" then read_only_element?(element)
366
+ when "read-write" then read_write_element?(element)
361
367
  when "active", "visited" then false # supported-but-currently-false (no pointer state / history)
362
368
  when "dir" then dir_match?(element, pseudo.argument)
363
369
  when "target" then element.get_attribute("id").to_s == Internal.target_id(element.owner_document).to_s && !Internal.target_id(element.owner_document).nil?
@@ -642,6 +648,89 @@ module Dommy
642
648
  %w[button input select textarea optgroup option fieldset].include?(element.local_name.to_s.downcase)
643
649
  end
644
650
 
651
+ # A candidate for constraint validation: a form-associated control whose
652
+ # `willValidate` is true (not disabled / readonly / barred).
653
+ def validation_candidate?(element)
654
+ element.respond_to?(:will_validate) && element.respond_to?(:validity) && element.will_validate
655
+ end
656
+
657
+ # `:invalid` / `:valid` apply to candidates (by their validity) and to a
658
+ # form / fieldset (by whether any descendant candidate is invalid).
659
+ def constraint_invalid?(element)
660
+ name = element.local_name.to_s.downcase
661
+ if %w[form fieldset].include?(name)
662
+ descendant_candidates(element).any? { |c| !c.validity.valid }
663
+ else
664
+ validation_candidate?(element) && !element.validity.valid
665
+ end
666
+ end
667
+
668
+ def constraint_valid?(element)
669
+ name = element.local_name.to_s.downcase
670
+ if %w[form fieldset].include?(name)
671
+ descendant_candidates(element).all? { |c| c.validity.valid }
672
+ else
673
+ validation_candidate?(element) && element.validity.valid
674
+ end
675
+ end
676
+
677
+ def descendant_candidates(element)
678
+ element.query_selector_all("input, select, textarea, button").select do |c|
679
+ validation_candidate?(c)
680
+ end
681
+ end
682
+
683
+ # `:required` / `:optional` apply to input / select / textarea per the
684
+ # `required` attribute.
685
+ def requirable_element?(element)
686
+ %w[input select textarea].include?(element.local_name.to_s.downcase)
687
+ end
688
+
689
+ def form_control_required?(element)
690
+ requirable_element?(element) && element.has_attribute?("required")
691
+ end
692
+
693
+ def form_control_optional?(element)
694
+ requirable_element?(element) && !element.has_attribute?("required")
695
+ end
696
+
697
+ # `:read-write` matches an editable control (a mutable text input / textarea,
698
+ # or an element with contenteditable); `:read-only` is its complement over
699
+ # the elements the pseudo-classes apply to.
700
+ def read_write_element?(element)
701
+ name = element.local_name.to_s.downcase
702
+ if name == "textarea"
703
+ return !element.has_attribute?("readonly") && !disabled_element?(element)
704
+ end
705
+ if name == "input"
706
+ return false unless mutable_input_type?(element)
707
+
708
+ return !element.has_attribute?("readonly") && !disabled_element?(element)
709
+ end
710
+ editable_via_contenteditable?(element)
711
+ end
712
+
713
+ def read_only_element?(element)
714
+ name = element.local_name.to_s.downcase
715
+ return !read_write_element?(element) if %w[input textarea].include?(name)
716
+
717
+ # For other elements, :read-only matches when not editable.
718
+ !editable_via_contenteditable?(element)
719
+ end
720
+
721
+ # Text-like input types that can be read-write (not button/checkbox/etc.).
722
+ def mutable_input_type?(element)
723
+ %w[text search url tel email password date month week time
724
+ datetime-local number range color].include?(
725
+ (element.get_attribute("type") || "text").to_s.downcase
726
+ )
727
+ end
728
+
729
+ def editable_via_contenteditable?(element)
730
+ v = element.get_attribute("contenteditable")
731
+ !v.nil? && v.to_s.downcase != "false"
732
+ end
733
+
645
734
  def disabled_element?(element)
646
735
  return true if element.has_attribute?("disabled")
647
736
 
@@ -12,7 +12,8 @@ module Dommy
12
12
  # exactly the inputs the spec requires — cases Nokogiri's CSS parser silently
13
13
  # accepts (`[*=test]`, `div % p`, `..x`) or rejects with the wrong error.
14
14
  #
15
- # This only VALIDATES; matching is still delegated to the backend. It is a
15
+ # `parse!` returns the AST that `SelectorMatcher` matches against (it both
16
+ # validates and is the matcher's front end). It is a
16
17
  # hand-written tokenizer + recursive-descent parser covering the productions
17
18
  # the Selectors spec (and the WPT corpus) exercise: selector lists,
18
19
  # combinators, type/universal selectors with namespace prefixes, id/class,
@@ -39,6 +40,17 @@ module Dommy
39
40
  IDENT_FUNCTIONS = %w[lang dir].to_set.freeze
40
41
  NESTED_SELECTOR_FUNCTIONS = %w[host host-context current].to_set.freeze
41
42
 
43
+ # A parsed AST is a pure function of (selector string, namespaces) and never
44
+ # goes stale as the DOM mutates (unlike the query-result caches, which tag
45
+ # entries with style_generation). So memoize globally: `matches?` / `closest`
46
+ # re-parse on every call, and query* re-parse on every cache miss (i.e. after
47
+ # any mutation), so the same handful of selectors are otherwise re-parsed
48
+ # constantly. Bounded via the same "clear at cap" idiom as the query caches.
49
+ # Lock-free plain Hash: `parse!` is pure Ruby (never releases the GVL), so a
50
+ # read/write is atomic under the GVL — a benign duplicate parse is the worst
51
+ # a race can cause, matching the existing per-document caches.
52
+ AST_CACHE_CAP = 2048
53
+
42
54
  module_function
43
55
 
44
56
  # `namespaces` maps a prefix String to its URI (with the symbol key
@@ -46,9 +58,32 @@ module Dommy
46
58
  # stylesheet that declared `@namespace`. nil/empty (the DOM querySelector
47
59
  # path) keeps any named prefix undeclared — a SyntaxError, as before.
48
60
  def parse!(selector, namespaces: nil)
49
- new_parser(selector.to_s, namespaces).parse_selector_list!
50
- rescue InvalidSelector => e
51
- raise ::Dommy::DOMException::SyntaxError, "'#{selector}' is not a valid selector: #{e.message}"
61
+ key = [selector.to_s, namespaces]
62
+ cache = (@ast_cache ||= {})
63
+ if cache.key?(key)
64
+ cached = cache[key]
65
+ # An invalid selector is memoized as its SyntaxError so a repeat still
66
+ # throws (the message embeds the selector, so re-raising is exact).
67
+ raise cached if cached.is_a?(::Dommy::DOMException::SyntaxError)
68
+
69
+ return cached
70
+ end
71
+
72
+ ast =
73
+ begin
74
+ new_parser(selector.to_s, namespaces).parse_selector_list!
75
+ rescue InvalidSelector => e
76
+ error = ::Dommy::DOMException::SyntaxError.new("'#{selector}' is not a valid selector: #{e.message}")
77
+ ast_cache_store(cache, key, error)
78
+ raise error
79
+ end
80
+ ast_cache_store(cache, key, ast)
81
+ ast
82
+ end
83
+
84
+ def ast_cache_store(cache, key, value)
85
+ cache.clear if cache.size >= AST_CACHE_CAP
86
+ cache[key] = value
52
87
  end
53
88
 
54
89
  # Validate `selector`; raise DOMException::SyntaxError if it is not a valid
@@ -451,7 +486,9 @@ module Dommy
451
486
  # Validate `name(...)` per the function's argument grammar.
452
487
  def consume_function_args!(name, pseudo_element:)
453
488
  advance # consume '('
454
- arg = consume_function_argument_source
489
+ # :is()/:where()/:matches() take a forgiving selector list, which may be
490
+ # empty (matches nothing); other functional pseudos require an argument.
491
+ arg = consume_function_argument_source(allow_empty: %w[is where matches].include?(name))
455
492
  arg_parser = Parser.new(arg, in_has: @in_has, namespaces: @namespaces)
456
493
  if pseudo_element
457
494
  # ::slotted(<compound>), ::part(<ident>+), ::cue(<selector>), …
@@ -485,7 +522,7 @@ module Dommy
485
522
  end
486
523
  end
487
524
 
488
- def consume_function_argument_source
525
+ def consume_function_argument_source(allow_empty: false)
489
526
  skip_ws
490
527
  start = @i
491
528
  depth = 0
@@ -504,7 +541,7 @@ module Dommy
504
541
  advance
505
542
  end
506
543
  arg = @s[start...@i].to_s.strip
507
- fail!("empty function arguments") if arg.empty?
544
+ fail!("empty function arguments") if arg.empty? && !allow_empty
508
545
  advance if peek == ")"
509
546
  arg
510
547
  end
@@ -36,6 +36,17 @@ module Dommy
36
36
  nil
37
37
  end
38
38
 
39
+ # Direct `new MyElement()`: create a fresh, unattached backing element for a
40
+ # registered tag (its ownerDocument is the window's document). The JS ctor
41
+ # is already running, so the element must NOT be re-upgraded — the bridge
42
+ # crosses it with upgrade suppressed. Returns nil when the tag is undefined.
43
+ def create(name)
44
+ return unless @window.respond_to?(:custom_elements)
45
+ return unless @window.custom_elements.get(name.to_s)
46
+
47
+ @window.document.create_element(name.to_s)
48
+ end
49
+
39
50
  private
40
51
 
41
52
  # A Dommy custom element class for `name` whose reactions forward to the JS
@@ -82,8 +93,8 @@ module Dommy
82
93
  self.class.js_bridge.invoke_lifecycle(self, "adoptedCallback", [])
83
94
  end
84
95
 
85
- def attribute_changed_callback(attr, old_value, new_value)
86
- self.class.js_bridge.invoke_lifecycle(self, "attributeChangedCallback", [attr, old_value, new_value])
96
+ def attribute_changed_callback(attr, old_value, new_value, namespace = nil)
97
+ self.class.js_bridge.invoke_lifecycle(self, "attributeChangedCallback", [attr, old_value, new_value, namespace])
87
98
  end
88
99
  end
89
100
  end