dommy 0.10.0 → 0.12.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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/lib/dommy/attr.rb +4 -5
  3. data/lib/dommy/backend/makiri_adapter.rb +24 -14
  4. data/lib/dommy/backend.rb +18 -5
  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 +62 -16
  9. data/lib/dommy/document.rb +1096 -223
  10. data/lib/dommy/dom_parser.rb +12 -1
  11. data/lib/dommy/element.rb +513 -293
  12. data/lib/dommy/event.rb +462 -111
  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 +151 -41
  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 +45 -0
  27. data/lib/dommy/internal/mutation_coordinator.rb +180 -46
  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 +76 -13
  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 +284 -45
  34. data/lib/dommy/internal/range_text_serializer.rb +7 -5
  35. data/lib/dommy/internal/selector_ast.rb +17 -0
  36. data/lib/dommy/internal/selector_index.rb +24 -8
  37. data/lib/dommy/internal/selector_matcher.rb +139 -24
  38. data/lib/dommy/internal/selector_parser.rb +104 -32
  39. data/lib/dommy/internal/shadow_root_registry.rb +12 -1
  40. data/lib/dommy/internal/template_content_registry.rb +23 -7
  41. data/lib/dommy/internal/utf16.rb +45 -0
  42. data/lib/dommy/internal/web_idl.rb +57 -0
  43. data/lib/dommy/internal/xml_serialization.rb +18 -5
  44. data/lib/dommy/js/custom_element_bridge.rb +10 -0
  45. data/lib/dommy/js/dom_interfaces.rb +82 -9
  46. data/lib/dommy/js/handle_table.rb +15 -0
  47. data/lib/dommy/js/host_bridge.rb +79 -15
  48. data/lib/dommy/js/host_runtime.js +607 -57
  49. data/lib/dommy/js/marshaller.rb +13 -7
  50. data/lib/dommy/js/script_boot.rb +30 -29
  51. data/lib/dommy/mutation_observer.rb +109 -19
  52. data/lib/dommy/node.rb +46 -3
  53. data/lib/dommy/parser.rb +14 -0
  54. data/lib/dommy/promise.rb +4 -4
  55. data/lib/dommy/range.rb +448 -219
  56. data/lib/dommy/selection.rb +422 -0
  57. data/lib/dommy/shadow_root.rb +40 -20
  58. data/lib/dommy/static_range.rb +65 -0
  59. data/lib/dommy/svg_elements.rb +45 -0
  60. data/lib/dommy/tree_walker.rb +16 -20
  61. data/lib/dommy/url_pattern/canonicalizer.rb +183 -0
  62. data/lib/dommy/url_pattern/component.rb +64 -0
  63. data/lib/dommy/url_pattern/constructor_string_parser.rb +281 -0
  64. data/lib/dommy/url_pattern/init_processor.rb +163 -0
  65. data/lib/dommy/url_pattern/pattern_parser.rb +343 -0
  66. data/lib/dommy/url_pattern/regexp_translator.rb +694 -0
  67. data/lib/dommy/url_pattern/tokenizer.rb +199 -0
  68. data/lib/dommy/url_pattern.rb +310 -103
  69. data/lib/dommy/version.rb +1 -1
  70. data/lib/dommy/window.rb +76 -5
  71. data/lib/dommy.rb +7 -1
  72. metadata +18 -4
@@ -7,6 +7,7 @@ require_relative "internal/mutation_coordinator"
7
7
  require_relative "internal/shadow_root_registry"
8
8
  require_relative "internal/cookie_jar"
9
9
  require_relative "internal/node_traversal"
10
+ require_relative "internal/node_adopter"
10
11
  require_relative "internal/observer_manager"
11
12
  require_relative "internal/template_content_registry"
12
13
 
@@ -25,6 +26,12 @@ module Dommy
25
26
  # so it stays tree-DISCONNECTED per its detached nature.
26
27
  class DocumentType
27
28
  include Node
29
+ include Internal::LeafNode
30
+ # A node-backed doctype is an ordinary ChildNode of the document, so
31
+ # `before` / `after` / `replaceWith` go through the shared implementation —
32
+ # which runs the parent's ensure-pre-insertion-validity and the live-range
33
+ # insert steps, neither of which the old doctype-specific path did.
34
+ include Internal::ChildNode
28
35
 
29
36
  # Mixed into a node-backed doctype only, so a synthetic one does NOT respond
30
37
  # to `__dommy_backend_node__` — leaving the Node mixin's guards (which key off
@@ -59,6 +66,14 @@ module Dommy
59
66
  @document || @owner_document
60
67
  end
61
68
 
69
+ # A doctype answers `document` from either of two ivars (a synthetic one has
70
+ # only `@owner_document`), so an adopt has to move both.
71
+ def __internal_reseat__(backend_node, document)
72
+ super
73
+ @owner_document = document
74
+ nil
75
+ end
76
+
62
77
  def name
63
78
  @__node__ ? @__node__.name : @name
64
79
  end
@@ -93,26 +108,44 @@ module Dommy
93
108
  end
94
109
 
95
110
  def before(*nodes)
96
- return nil unless @owner_document
111
+ return synthetic_insert(nodes, after: false) unless @__node__
97
112
 
98
- @owner_document.__internal_insert_at_doctype__(nodes, after: false)
99
- nil
113
+ child_node_before(nodes)
100
114
  end
101
115
 
102
116
  def after(*nodes)
103
- return nil unless @owner_document
117
+ return synthetic_insert(nodes, after: true) unless @__node__
104
118
 
105
- @owner_document.__internal_insert_at_doctype__(nodes, after: true)
106
- nil
119
+ child_node_after(nodes)
107
120
  end
108
121
 
109
122
  def replace_with(*nodes)
123
+ unless @__node__
124
+ synthetic_insert(nodes, after: false)
125
+ remove
126
+ return nil
127
+ end
128
+
129
+ child_node_replace_with(nodes)
130
+ end
131
+
132
+ # A synthetic doctype — the fallback for a backend that cannot create a
133
+ # doctype node — is not in the tree at all, so WHATWG's ChildNode methods
134
+ # would return at step 2. Dommy has always inserted around the document
135
+ # element instead; that stays until the fallback goes away.
136
+ def synthetic_insert(nodes, after:)
110
137
  return nil unless @owner_document
111
138
 
112
- @owner_document.__internal_insert_at_doctype__(nodes, after: false)
113
- remove
139
+ @owner_document.__internal_insert_at_doctype__(nodes, after: after)
114
140
  nil
115
141
  end
142
+ private :synthetic_insert
143
+
144
+ # A doctype is connected when its tree is a document's; a synthetic one is
145
+ # never in a tree at all.
146
+ def is_connected?
147
+ get_root_node.is_a?(Dommy::Document)
148
+ end
116
149
 
117
150
  def __js_get__(key)
118
151
  case key
@@ -141,6 +174,8 @@ module Dommy
141
174
  NodeList.new
142
175
  when "firstChild", "lastChild"
143
176
  nil
177
+ when "isConnected"
178
+ is_connected?
144
179
  end
145
180
  end
146
181
 
@@ -161,12 +196,25 @@ module Dommy
161
196
  rescue StandardError
162
197
  nil
163
198
  end
164
- return DocumentType.new(backend_node: node, document: @document) if node
199
+ if node
200
+ clone = DocumentType.new(backend_node: node, document: @document)
201
+ # Register the wrapper against its backend node, so inserting the clone
202
+ # into a tree and reading it back returns THIS object rather than a
203
+ # freshly built one (`doc.replaceChildren(dt); doc.firstChild === dt`).
204
+ @document.__internal_register_wrapper__(node, clone)
205
+ return clone
206
+ end
165
207
  end
166
208
  DocumentType.new(name, public_id, system_id, owner_document: @owner_document)
167
209
  end
168
210
 
169
211
  include Bridge::Methods
212
+ # WHATWG's own wording for a doctype parent.
213
+ def leaf_insertion_message
214
+ "a DocumentType may not have children"
215
+ end
216
+ private :leaf_insertion_message
217
+
170
218
  js_methods %w[isEqualNode isSameNode getRootNode hasChildNodes normalize compareDocumentPosition contains
171
219
  cloneNode appendChild insertBefore removeChild replaceChild before after replaceWith remove
172
220
  lookupNamespaceURI lookupPrefix isDefaultNamespace
@@ -194,16 +242,14 @@ module Dommy
194
242
  get_root_node(args[0])
195
243
  when "compareDocumentPosition"
196
244
  compare_document_position(args[0])
197
- when "appendChild", "insertBefore", "replaceChild"
198
- # Pre-insert / replace step 1 rejects a non-parent context node with
199
- # HierarchyRequestError before the reference-child check.
200
- raise Bridge::TypeError, "Argument is not a Node." unless args[0].is_a?(Dommy::Node)
201
-
202
- raise DOMException::HierarchyRequestError, "a DocumentType may not have children"
245
+ when "appendChild"
246
+ append_child(args[0])
247
+ when "insertBefore"
248
+ insert_before(args[0], args[1])
249
+ when "replaceChild"
250
+ replace_child(args[0], args[1])
203
251
  when "removeChild"
204
- raise Bridge::TypeError, "Argument is not a Node." unless args[0].is_a?(Dommy::Node)
205
-
206
- raise DOMException::NotFoundError, "the node to be removed is not a child of this node"
252
+ remove_child(args[0])
207
253
  when "before"
208
254
  before(*args)
209
255
  when "after"
@@ -231,15 +277,23 @@ module Dommy
231
277
  @document = document
232
278
  end
233
279
 
280
+ # A name createDocumentType refuses. It is otherwise extremely permissive —
281
+ # "1foo", "@foo", "edi:%" and the empty string are all accepted — but a name
282
+ # carrying whitespace or ">" could not be serialized back as a doctype, and
283
+ # is an InvalidCharacterError.
284
+ UNSERIALIZABLE_DOCTYPE_NAME = /[\s>]/
285
+
234
286
  # A created DocumentType's node document is the implementation's document. When
235
287
  # the backend ships a doctype factory (the HTML backend) and accepts the name,
236
288
  # the result is a real, node-backed (but detached) DocumentType that can join
237
- # the tree; otherwise it falls back to a synthetic one. (Qualified-name QName
238
- # validation isn't enforced — createDocumentType is permissive, so the factory's
239
- # stricter name check is bypassed via the synthetic fallback rather than
240
- # raising; a couple of invalid-name WPT cases stay documented gaps.)
289
+ # the tree; otherwise it falls back to a synthetic one — the factory's own
290
+ # (stricter, XML-flavoured) name check is not the DOM rule.
241
291
  def create_document_type(qualified_name, public_id, system_id)
242
292
  qn = qualified_name.to_s
293
+ if qn.match?(UNSERIALIZABLE_DOCTYPE_NAME)
294
+ raise DOMException::InvalidCharacterError, "invalid doctype name: #{qn.inspect}"
295
+ end
296
+
243
297
  pub = public_id.to_s
244
298
  sys = system_id.to_s
245
299
  node =
@@ -248,7 +302,16 @@ module Dommy
248
302
  rescue ArgumentError
249
303
  nil
250
304
  end
251
- node ? DocumentType.new(backend_node: node, document: @document) : DocumentType.new(qn, pub, sys, owner_document: @document)
305
+ return DocumentType.new(qn, pub, sys, owner_document: @document) unless node
306
+
307
+ # Seed the wrapper cache, or the doctype reached later through the tree
308
+ # (document.childNodes, document.doctype, a NodeIterator) would be a
309
+ # DIFFERENT DocumentType object than the one createDocumentType returned,
310
+ # and `===` / `isSameNode` on the two would be false. Every other
311
+ # create* goes through the cache; this one built its wrapper directly.
312
+ wrapper = DocumentType.new(backend_node: node, document: @document)
313
+ @document.__internal_register_wrapper__(node, wrapper)
314
+ wrapper
252
315
  end
253
316
 
254
317
  # `hasFeature()` is a no-op that always returns true (DOM Standard).
@@ -302,6 +365,7 @@ module Dommy
302
365
  return unless node
303
366
 
304
367
  root = doc.backend_doc.root
368
+ doc.__internal_ranges_will_insert__(doc.backend_doc, root, 1)
305
369
  root ? root.add_previous_sibling(node) : doc.backend_doc.add_child(node)
306
370
  end
307
371
 
@@ -349,24 +413,158 @@ module Dommy
349
413
  attr_reader :backend_doc
350
414
  attr_accessor :default_view
351
415
  # --- CSS cascade support (Internal::CSS) ---
352
- # Monotonic counter bumped on every DOM mutation; the CSS layer
353
- # invalidates its per-document style cache wholesale when it moves.
354
- # The cache slot itself is owned by Internal::CSS::Cascade.
416
+ # Two invalidation epochs, split so a style-neutral mutation doesn't pay
417
+ # for a cascade rebuild (RuleIndex.build is a full rules x document query):
418
+ #
419
+ # `dom_generation` keys the selector-result caches (query caches, selector
420
+ # index). It moves on anything that can change what a selector matches:
421
+ # tree shape, attributes, focus/hover, checkedness, and text edits that
422
+ # flip a node between empty and non-empty (`:empty` is the matcher's only
423
+ # text-sensitive pseudo-class).
424
+ #
425
+ # `style_generation` keys the cascade caches (RuleIndex, computed styles,
426
+ # counters — the slot itself is owned by Internal::CSS::Cascade). It moves
427
+ # on the same events, EXCEPT that an attribute change only moves it when
428
+ # the built RuleIndex's selectors depend on that attribute, and a text
429
+ # edit only when it's inside a <style> or flips emptiness while a sheet
430
+ # uses `:empty`. The coordinator reports mutations through the
431
+ # `__internal_note_*` seams below, which decide what to bump.
355
432
  attr_accessor :__css_style_cache__
356
433
 
357
434
  def style_generation
358
435
  @style_generation || 0
359
436
  end
360
437
 
438
+ def dom_generation
439
+ @dom_generation || 0
440
+ end
441
+
442
+ # Moves only on childList mutations — the coarsest epoch. Keys memos
443
+ # whose value depends on the element population alone (which elements
444
+ # exist, in what order), like the document's <style>/<link> list: an
445
+ # attribute-triggered cascade rebuild can then skip re-walking for them.
446
+ def tree_generation
447
+ @tree_generation || 0
448
+ end
449
+
361
450
  def __internal_bump_style_generation__
362
451
  @style_generation = style_generation + 1
363
452
  nil
364
453
  end
365
454
 
455
+ def __internal_bump_dom_generation__
456
+ @dom_generation = dom_generation + 1
457
+ nil
458
+ end
459
+
460
+ # A childList mutation: tree shape feeds both selector matching and the
461
+ # rule -> element index, so everything is suspect.
462
+ def __internal_note_tree_mutation__
463
+ @tree_generation = tree_generation + 1
464
+ __internal_bump_dom_generation__
465
+ __internal_bump_style_generation__
466
+ end
467
+
468
+ # The document's <style> and <link> elements in document order (their
469
+ # relative order breaks cascade ties), memoized per tree_generation:
470
+ # only a childList mutation can change the list, so the cascade's
471
+ # attribute-triggered rebuilds reuse it without a document walk.
472
+ def __internal_style_sheet_elements__
473
+ if @__sheet_elements_gen != tree_generation
474
+ @__sheet_elements_gen = tree_generation
475
+ @__sheet_elements = query_selector_all("style, link").to_a
476
+ end
477
+ @__sheet_elements
478
+ end
479
+
480
+ # An attribute mutation: selector results are always suspect (any cached
481
+ # query could carry an attribute selector), but the cascade only when the
482
+ # indexed rules read this attribute — or when the attribute belongs to a
483
+ # <style>/<link>, whose media/disabled/rel gate whole sheets.
484
+ def __internal_note_attribute_mutation__(name, target_node)
485
+ __internal_bump_dom_generation__
486
+ __internal_bump_style_generation__ if __internal_style_affected_by_attribute__(name, target_node)
487
+ end
488
+
489
+ # A characterData mutation. Text participates in matching only through
490
+ # `:empty` (a text node counts iff its data is non-empty), so nothing is
491
+ # suspect unless the edit flips that emptiness — except text inside a
492
+ # <style>, which IS the stylesheet source.
493
+ def __internal_note_character_data_mutation__(target_node, old_value)
494
+ # Only a Text node's data participates in :empty; a comment/PI edit
495
+ # can't change any match. `target_node` is a backend node, so its data
496
+ # reads through the Nokogiri-compatible #content.
497
+ text = target_node.respond_to?(:node_type) && target_node.node_type == 3
498
+ flipped = text && (old_value.to_s.empty? != target_node.content.to_s.empty?)
499
+ __internal_bump_dom_generation__ if flipped
500
+ if __internal_inside_style_element__(target_node) ||
501
+ (flipped && __internal_style_text_sensitive__)
502
+ __internal_bump_style_generation__
503
+ end
504
+ nil
505
+ end
506
+
507
+ # Selector-observable state that lives outside the attribute space
508
+ # (focus, hover, checkedness…): both cache families are suspect.
509
+ def __internal_note_selector_state_change__
510
+ __internal_bump_dom_generation__
511
+ __internal_bump_style_generation__
512
+ end
513
+
514
+ # A form control's IDL value changed (typing, `input.value = …`, a form
515
+ # reset). No attribute mutates, yet the value is selector-observable
516
+ # through the validity / range / placeholder pseudo-classes, so the
517
+ # selector epoch always moves — a cached `querySelectorAll(":invalid")`
518
+ # would otherwise survive the very change that flipped it. The cascade
519
+ # follows only when a sheet actually uses one of those pseudo-classes.
520
+ def __internal_note_value_change__
521
+ __internal_bump_dom_generation__
522
+ __internal_bump_style_generation__ if __internal_style_value_sensitive__
523
+ nil
524
+ end
525
+
526
+ def __internal_style_value_sensitive__
527
+ index = @__css_style_cache__ && @__css_style_cache__[:index]
528
+ index ? index.value_sensitive? : true
529
+ end
530
+
531
+ def __internal_style_affected_by_attribute__(name, target_node)
532
+ owner = target_node.respond_to?(:name) ? target_node.name.to_s.downcase : nil
533
+ return true if owner == "style" || owner == "link"
534
+
535
+ index = @__css_style_cache__ && @__css_style_cache__[:index]
536
+ # No RuleIndex yet: the bump is nearly free (at most it drops the
537
+ # author_css?/counters memos), so stay conservative.
538
+ return true unless index
539
+
540
+ index.attribute_dependency?(name)
541
+ end
542
+
543
+ def __internal_style_text_sensitive__
544
+ index = @__css_style_cache__ && @__css_style_cache__[:index]
545
+ index ? index.text_sensitive? : true
546
+ end
547
+
548
+ def __internal_inside_style_element__(node)
549
+ # No <style> in the document -> a text edit can't be sheet source, so
550
+ # skip the ancestor walk. The sheet-element list is memoized per
551
+ # tree_generation (only childList changes it), so a text-editing loop
552
+ # between childList mutations answers this without re-walking.
553
+ return false unless __internal_style_sheet_elements__.any? { |el| el.local_name.to_s.casecmp?("style") }
554
+
555
+ current = node.respond_to?(:parent) ? node.parent : nil
556
+ while current
557
+ return true if current.respond_to?(:name) && current.name.to_s.downcase == "style"
558
+
559
+ current = current.respond_to?(:parent) ? current.parent : nil
560
+ end
561
+ false
562
+ end
563
+
366
564
  # A by-id/class/tag index of the backend element tree, memoized per DOM
367
565
  # generation, for SelectorMatcher's document-scoped fast path (or nil to tell
368
566
  # the caller to walk). Rebuilt lazily only after a mutation bumps
369
- # style_generation, so it costs one tree walk per generation and pays off when
567
+ # dom_generation, so it costs one tree walk per generation and pays off when
370
568
  # several queries run before the next mutation.
371
569
  #
372
570
  # Adaptive bypass: if the index keeps getting invalidated after serving only a
@@ -379,7 +577,7 @@ module Dommy
379
577
  SELECTOR_INDEX_RETEST_GAP = 64 # generations to wait before re-testing a bypass
380
578
 
381
579
  def __internal_selector_index__
382
- gen = style_generation
580
+ gen = dom_generation
383
581
  if @__sel_idx_gen != gen
384
582
  if @__sel_idx
385
583
  if @__sel_idx_served.to_i < SELECTOR_INDEX_MIN_REUSE
@@ -410,18 +608,18 @@ module Dommy
410
608
  # (element, selector) constantly between mutations; this memoizes the match
411
609
  # set, keyed by [scope object_id, kind, selector] and tagged with the DOM
412
610
  # generation, so a hit skips the whole combinator match. Capped, and a
413
- # mutation (style_generation bump) makes every entry stale at once.
611
+ # mutation (dom_generation bump) makes every entry stale at once.
414
612
  SCOPED_QUERY_CACHE_CAP = 4096
415
613
 
416
614
  def __internal_scoped_query_get(key)
417
615
  entry = (@__scoped_query_cache ||= {})[key]
418
- entry && entry[0] == style_generation ? entry[1] : nil
616
+ entry && entry[0] == dom_generation ? entry[1] : nil
419
617
  end
420
618
 
421
619
  def __internal_scoped_query_set(key, value)
422
620
  cache = (@__scoped_query_cache ||= {})
423
621
  cache.clear if cache.size >= SCOPED_QUERY_CACHE_CAP
424
- cache[key] = [style_generation, value]
622
+ cache[key] = [dom_generation, value]
425
623
  value
426
624
  end
427
625
 
@@ -447,6 +645,17 @@ module Dommy
447
645
  # owns firing the element's load / error event. nil = such scripts are inert.
448
646
  attr_accessor :external_script_runner
449
647
 
648
+ # Installed by the browser when a JS runtime is present: re-runs the
649
+ # inline-handler scan so an `on*` attribute that arrived after boot (a cloned
650
+ # template, an innerHTML fragment) is compiled. nil without a runtime, which
651
+ # is also the fast path that keeps a JS-free document out of this entirely.
652
+ attr_accessor :inline_handler_wirer
653
+
654
+ def __internal_wire_inline_handlers__
655
+ @inline_handler_wirer&.call
656
+ nil
657
+ end
658
+
450
659
  def initialize(host = nil, backend_doc: nil, default_view: nil)
451
660
  @host = host
452
661
  @default_view = default_view
@@ -456,7 +665,12 @@ module Dommy
456
665
  @cookie_jar = Internal::CookieJar.new
457
666
  @template_content_registry = Internal::TemplateContentRegistry.new(self)
458
667
  @mutation_coordinator = Internal::MutationCoordinator.new(self, @observer_manager)
459
- @node_iterators = []
668
+ # Weak, like @live_ranges: a NodeIterator is consulted by every removal for
669
+ # as long as its document holds it, and detach() is a no-op by definition,
670
+ # so a strong list would mean every iterator ever created keeps costing
671
+ # work — and keeps its referenceNode's whole detached subtree alive —
672
+ # forever. A browser collects an unreachable one and stops consulting it.
673
+ @node_iterators = ObjectSpace::WeakMap.new
460
674
  @backend_doc = backend_doc || Backend.parse("<!doctype html><html><head></head><body></body></html>")
461
675
  @content_type = "text/html"
462
676
  # The document is fully parsed before scripts run (no incremental network
@@ -504,9 +718,24 @@ module Dommy
504
718
  write_title(value.to_s)
505
719
  end
506
720
 
721
+ # A document is its own shadow-including root, so it is always connected
722
+ # (Element#is_connected? walks up to a document; the document itself is the
723
+ # base case).
724
+ def is_connected?
725
+ true
726
+ end
727
+
507
728
  def document_element
508
729
  # The document's root element — `<html>` for HTML, the actual root for XML.
509
- wrap_node(@backend_doc.root)
730
+ # It is the document's first ELEMENT child, so a document that has none
731
+ # answers null: the backend's `root` falls back to the doctype once the
732
+ # element is gone (`document.removeChild(documentElement)`, or a
733
+ # replaceChild that swaps it for a comment), and head / body / title
734
+ # resolve through this.
735
+ root = @backend_doc.root
736
+ return nil unless root&.element?
737
+
738
+ wrap_node(root)
510
739
  end
511
740
 
512
741
  def head
@@ -691,8 +920,8 @@ module Dommy
691
920
 
692
921
  def __internal_set_active_element__(el)
693
922
  # Focus is selector-observable state (:focus / :focus-within rules), so
694
- # a change invalidates computed styles.
695
- __internal_bump_style_generation__ unless @active_element.equal?(el)
923
+ # a change invalidates cached query results and computed styles.
924
+ __internal_note_selector_state_change__ unless @active_element.equal?(el)
696
925
  @active_element = el
697
926
  end
698
927
 
@@ -713,7 +942,7 @@ module Dommy
713
942
  return if @hovered_element.equal?(el)
714
943
 
715
944
  @hovered_element = el
716
- __internal_bump_style_generation__
945
+ __internal_note_selector_state_change__
717
946
  nil
718
947
  end
719
948
 
@@ -733,15 +962,7 @@ module Dommy
733
962
  # Ruby Proc, a JS-bridge callable, or an object with
734
963
  # `accept_node` / `acceptNode`.
735
964
  def create_tree_walker(root, what_to_show = NodeFilter::SHOW_ALL, filter = nil)
736
- TreeWalker.new(require_node_root(root), what_to_show, filter)
737
- end
738
-
739
- # The `root` of a TreeWalker / NodeIterator is a non-nullable WebIDL `Node`:
740
- # a null or non-Node argument is a TypeError before construction.
741
- def require_node_root(root)
742
- return root if root.is_a?(Dommy::Node)
743
-
744
- raise Bridge::TypeError, "createTreeWalker/createNodeIterator root must be a Node"
965
+ TreeWalker.new(Internal::WebIDL.node!(root), what_to_show, filter)
745
966
  end
746
967
 
747
968
  # WebIDL `unsigned long whatToShow = 0xFFFFFFFF`: an omitted or `undefined`
@@ -767,97 +988,66 @@ module Dommy
767
988
  # wrapper is owned by `this`. Per spec, the source node is left
768
989
  # in place. `deep: true` copies the entire subtree.
769
990
  def import_node(node, deep = false)
991
+ # Step 1: "If node is a document or shadow root, throw a
992
+ # NotSupportedError." Neither has anywhere to go in another document.
993
+ if node.is_a?(Document) || node.is_a?(ShadowRoot)
994
+ raise DOMException::NotSupportedError, "a #{node.is_a?(Document) ? "document" : "shadow root"} cannot be imported"
995
+ end
996
+
997
+ # An Attr is a Node but not a backend-tree node: it is copied by rebuilding
998
+ # it here with the same qualified name, namespace, prefix and value, owned
999
+ # by no element (importNode never attaches the copy to anything).
1000
+ return import_attribute(node) if node.is_a?(Attr)
770
1001
  return nil unless node.respond_to?(:__dommy_backend_node__)
771
1002
 
772
1003
  # WebIDL `optional boolean deep = false`: a missing / undefined argument
773
1004
  # is the default (false / shallow), not a truthy sentinel.
774
1005
  deep = false if deep.nil? || deep.equal?(Bridge::UNDEFINED)
775
- copy = clone_into_doc(node.__dommy_backend_node__, deep)
1006
+ source_document = node.respond_to?(:document) ? node.document : self
1007
+ copy = clone_into_doc(node.__dommy_backend_node__, deep, source_document)
776
1008
  wrap_node(copy)
777
1009
  end
778
1010
 
779
- # Move a node from another document into this one. The source
780
- # node is detached from its previous owner and its ownerDocument
781
- # becomes this. Returns the (possibly re-wrapped) node.
782
- def adopt_node(node)
783
- # WHATWG adopt: a Document can't be adopted into another document.
784
- if node.is_a?(Dommy::Document)
785
- raise DOMException::NotSupportedError, "A Document node cannot be adopted."
786
- end
787
- return nil unless node.respond_to?(:__dommy_backend_node__)
788
-
789
- src = node.__dommy_backend_node__
790
- src.unlink if src.parent
791
-
792
- # Same document: just return the wrapper after the detach above.
793
- return wrap_node(src) if src.document == @backend_doc
794
-
795
- # Cross-document DocumentType: Makiri can't import a doctype node between
796
- # arenas, so re-create it in this document's backend from its public
797
- # name / publicId / systemId (as createDocument does), then reseat the
798
- # caller's wrapper onto the new node so JS identity survives the move.
799
- if node.is_a?(DocumentType)
800
- adopted = begin
801
- Backend.create_document_type(node.name, node.public_id, node.system_id, @backend_doc)
802
- rescue StandardError
803
- nil
804
- end
805
- return node unless adopted
806
-
807
- src_doc_wrapper = node.instance_variable_get(:@document)
808
- src_doc_wrapper.__internal_reset_wrapper__(src) if src_doc_wrapper.respond_to?(:__internal_reset_wrapper__)
809
- node.instance_variable_set(:@document, self)
810
- node.instance_variable_set(:@owner_document, self)
811
- node.instance_variable_set(:@__node__, adopted)
812
- @node_wrapper_cache.register(adopted, node)
813
- return node
814
- end
815
-
816
- # Cross-document: hand the detached source to the backend, which
817
- # returns the node now owned by this document — an imported copy for
818
- # Makiri (a node can't move between arenas). Drop the stale source
819
- # wrapper, then reseat the caller's Dommy wrapper onto the adopted
820
- # node so `adopt_node(x).equal?(x)` stays true across documents.
821
- src_doc_wrapper = node.instance_variable_get(:@document)
822
- adopted = Backend.adopt(src, @backend_doc)
823
-
824
- if src_doc_wrapper.respond_to?(:__internal_reset_wrapper__)
825
- src_doc_wrapper.__internal_reset_wrapper__(src)
826
- end
827
- node.instance_variable_set(:@document, self)
828
- node.instance_variable_set(:@__node__, adopted)
829
- @node_wrapper_cache.register(adopted, node)
830
-
831
- # A deep adopt imports a fresh copy of the whole subtree, so any live
832
- # descendant wrapper (held by page script, e.g. an aria element reference)
833
- # must be reseated onto its corresponding copy — otherwise it stays bound to
834
- # the old document. Import preserves document order, so walk both subtrees in
835
- # lockstep.
836
- reseat_descendant_wrappers(src, adopted, src_doc_wrapper)
837
- node
1011
+ def import_attribute(attr)
1012
+ Attr.new(
1013
+ attr.name,
1014
+ value: attr.value,
1015
+ namespace_uri: attr.namespace_uri,
1016
+ prefix: attr.prefix,
1017
+ local_name: attr.local_name,
1018
+ document: self
1019
+ )
838
1020
  end
839
1021
 
840
- # Move each live wrapper for a descendant of `src_root` onto the matching node
841
- # in `dst_root` (the imported copy), pruning it from the source document.
842
- def reseat_descendant_wrappers(src_root, dst_root, src_doc)
843
- return unless src_doc.respond_to?(:__internal_peek_wrapper__)
844
-
845
- src_nodes = collect_subtree_nodes(src_root)
846
- dst_nodes = collect_subtree_nodes(dst_root)
847
- return unless src_nodes.length == dst_nodes.length
1022
+ # The registry holding this document's `<template>` content fragments. A
1023
+ # cross-document clone or adopt has to read the SOURCE document's registry:
1024
+ # template contents are not in the template's child list, so they are
1025
+ # reachable only through the registry that owns them.
1026
+ def __internal_template_registry__
1027
+ @template_content_registry
1028
+ end
848
1029
 
849
- src_nodes.zip(dst_nodes).each do |orig, copy|
850
- next if orig.equal?(src_root) # the root wrapper is reseated by the caller
1030
+ # Move a node from another document into this one. The source node is
1031
+ # detached from its previous owner and its ownerDocument becomes this.
1032
+ # Returns the (possibly re-bound) node.
1033
+ #
1034
+ # What "moving" costs depends on the backend — an import plus a walk to
1035
+ # carry the live wrappers and template contents over, where a browser swaps
1036
+ # a pointer — so the whole of it lives in Internal::NodeAdopter.
1037
+ def adopt_node(node)
1038
+ node_adopter.adopt(node)
1039
+ end
851
1040
 
852
- wrapper = src_doc.__internal_peek_wrapper__(orig)
853
- next unless wrapper
1041
+ # Adopt a raw backend node with no wrapper of its own: a DocumentFragment's
1042
+ # children, on a cross-document insert.
1043
+ def __internal_adopt_backend_node__(node, source_document)
1044
+ node_adopter.adopt_backend_node(node, source_document)
1045
+ end
854
1046
 
855
- src_doc.__internal_reset_wrapper__(orig)
856
- wrapper.instance_variable_set(:@document, self)
857
- wrapper.instance_variable_set(:@__node__, copy)
858
- @node_wrapper_cache.register(copy, wrapper)
859
- end
1047
+ def node_adopter
1048
+ @node_adopter ||= Internal::NodeAdopter.new(self)
860
1049
  end
1050
+ private :node_adopter
861
1051
 
862
1052
  # HTML "cloning steps": a cloned node copies interface-specific live state
863
1053
  # that the content attributes don't capture — an input's dirty value and
@@ -867,11 +1057,17 @@ module Dommy
867
1057
  # lockstep and copy each live wrapper's cloning state onto its copy. `deep`
868
1058
  # false processes only the root (a shallow clone has no children).
869
1059
  def __internal_apply_cloning_steps__(src_root_bn, clone_root_bn, deep)
870
- src_nodes = deep ? collect_subtree_nodes(src_root_bn) : [src_root_bn]
871
- clone_nodes = deep ? collect_subtree_nodes(clone_root_bn) : [clone_root_bn]
1060
+ src_nodes = deep ? Internal::NodeTraversal.subtree_nodes(src_root_bn) : [src_root_bn]
1061
+ clone_nodes = deep ? Internal::NodeTraversal.subtree_nodes(clone_root_bn) : [clone_root_bn]
872
1062
  return unless src_nodes.length == clone_nodes.length
873
1063
 
874
1064
  src_nodes.zip(clone_nodes).each do |orig, copy|
1065
+ # HTML cloning steps for <template>: its content lives in an off-tree
1066
+ # fragment the backend's subtree clone never reaches, so a deep clone has
1067
+ # to copy it across explicitly (a shallow clone gets an empty template,
1068
+ # per spec).
1069
+ clone_template_content(orig, copy) if deep && @template_content_registry.has_content?(orig)
1070
+
875
1071
  wrapper = @node_wrapper_cache.peek(orig)
876
1072
  next unless wrapper.respond_to?(:__cloning_state__)
877
1073
 
@@ -882,32 +1078,27 @@ module Dommy
882
1078
  end
883
1079
  end
884
1080
 
885
- # A subtree's nodes in document (depth-first) order — the order Backend.adopt
886
- # preserves — so a source node and its imported copy line up by index.
887
- def collect_subtree_nodes(root)
888
- nodes = [root]
889
- root.children.each { |child| nodes.concat(collect_subtree_nodes(child)) } if root.respond_to?(:children)
890
- nodes
891
- end
892
-
893
1081
  # Legacy `document.createEvent("EventName")` factory. Returns an
894
1082
  # Event subclass instance whose init still has to be called
895
1083
  # (`event.initEvent(type, bubbles, cancelable)`). Matches the
896
1084
  # mapping happy-dom and linkedom use.
897
1085
  def create_event(type_name)
898
1086
  name = type_name.to_s
899
- case name
900
- when "Event", "Events", "HTMLEvents"
901
- Event.new("")
902
- when "CustomEvent"
903
- CustomEvent.new("")
904
- when "MouseEvent", "MouseEvents"
905
- MouseEvent.new("")
906
- when "KeyboardEvent", "KeyboardEvents"
907
- KeyboardEvent.new("")
908
- else
909
- Event.new("")
910
- end
1087
+ event =
1088
+ case name
1089
+ when "CustomEvent"
1090
+ CustomEvent.new("")
1091
+ when "MouseEvent", "MouseEvents"
1092
+ MouseEvent.new("")
1093
+ when "KeyboardEvent", "KeyboardEvents"
1094
+ KeyboardEvent.new("")
1095
+ else
1096
+ Event.new("")
1097
+ end
1098
+ # createEvent hands back an *uninitialized* event: it has no type yet and
1099
+ # dispatching it before initEvent() is an InvalidStateError.
1100
+ event.__internal_mark_uninitialized__
1101
+ event
911
1102
  end
912
1103
 
913
1104
  # Stubs for layout / focus / selection / execCommand APIs that
@@ -920,7 +1111,11 @@ module Dommy
920
1111
 
921
1112
  alias has_focus has_focus?
922
1113
 
1114
+ # A document without a browsing context (one built by DOMParser or
1115
+ # createHTMLDocument) has no selection.
923
1116
  def get_selection
1117
+ return nil unless @default_view
1118
+
924
1119
  @__selection ||= Selection.new(self)
925
1120
  end
926
1121
 
@@ -962,7 +1157,7 @@ module Dommy
962
1157
  # `document.createNodeIterator(root, whatToShow?, filter?)` —
963
1158
  # flat depth-first iteration.
964
1159
  def create_node_iterator(root, what_to_show = NodeFilter::SHOW_ALL, filter = nil)
965
- root = require_node_root(root)
1160
+ root = Internal::WebIDL.node!(root)
966
1161
  iterator = NodeIterator.new(root, what_to_show, filter)
967
1162
  # The "NodeIterator pre-removing steps" run for iterators whose root's node
968
1163
  # document is the removed node's document. Track the iterator on the root's
@@ -979,12 +1174,16 @@ module Dommy
979
1174
  def node_iterator_document(root)
980
1175
  return root if root.is_a?(Dommy::Document)
981
1176
 
982
- doc = root.instance_variable_get(:@document)
1177
+ doc = root.document if root.respond_to?(:document)
983
1178
  doc.is_a?(Dommy::Document) ? doc : self
984
1179
  end
985
1180
 
986
1181
  def __internal_track_node_iterator__(iterator)
987
- @node_iterators << iterator
1182
+ @node_iterators[iterator] = true
1183
+ end
1184
+
1185
+ def node_iterators?
1186
+ @node_iterators.size.positive?
988
1187
  end
989
1188
 
990
1189
  # `document.doctype` — the node-backed DocumentType wrapping the parsed
@@ -1012,6 +1211,7 @@ module Dommy
1012
1211
  # from the counts, since replace-all removes them first. `exclude` (replace)
1013
1212
  # is a current child to disregard. Raises HierarchyRequestError on violation.
1014
1213
  def ensure_document_insertion_validity!(args, child_bn, ignore_existing: false, exclude: nil)
1214
+ ensure_not_self_insertion!(args)
1015
1215
  elements = 0
1016
1216
  doctypes = 0
1017
1217
  has_text = false
@@ -1066,6 +1266,31 @@ module Dommy
1066
1266
  end
1067
1267
  end
1068
1268
 
1269
+ # Document's answer to the ParentNode hook a ChildNode mutation
1270
+ # (`before` / `after` / `replaceWith`) calls on its parent. A Document
1271
+ # parent skips the ancestor rule (it is never a descendant of anything) and
1272
+ # instead carries step 6: at most one element child, no Text child, and a
1273
+ # doctype only ahead of the document element. `replacing` is the child a
1274
+ # `replaceWith` stands in for, which WHATWG "replace" disregards when
1275
+ # counting the existing children.
1276
+ def __internal_ensure_insertion_validity__(args, ref_bn, replacing: nil)
1277
+ ensure_document_insertion_validity!(args, ref_bn, exclude: replacing)
1278
+ end
1279
+
1280
+ # WHATWG "ensure pre-insertion validity" step 2 for a Document parent: node
1281
+ # must not be a host-including inclusive ancestor of the parent. A document
1282
+ # is an inclusive ancestor of itself and nothing else can be an ancestor of
1283
+ # one, so this reduces to "you cannot insert the document into itself".
1284
+ #
1285
+ # It is step 2, so it precedes step 3's NotFoundError on the reference
1286
+ # child: `document.replaceChild(document, x)` is a HierarchyRequestError
1287
+ # even when x is not a child of the document.
1288
+ def ensure_not_self_insertion!(args)
1289
+ return unless args.any? { |a| a.equal?(self) }
1290
+
1291
+ raise DOMException::HierarchyRequestError, "Cannot insert a node as a descendant of itself"
1292
+ end
1293
+
1069
1294
  # Whether any element child precedes `child_bn` in the document's child list.
1070
1295
  def element_before_child?(existing, child_bn)
1071
1296
  idx = child_bn && existing.index { |c| c == child_bn }
@@ -1088,26 +1313,147 @@ module Dommy
1088
1313
  ensure_document_insertion_validity!([node], nil)
1089
1314
  return node unless node.respond_to?(:__dommy_backend_node__)
1090
1315
 
1091
- # appendChild adopts a node from another document (per spec). Only needed on
1092
- # a backend that can't move a node across documents (Makiri).
1093
- if !Backend.moves_nodes_across_documents? && node.respond_to?(:document) && !node.document.equal?(self)
1094
- node = adopt_node(node)
1095
- end
1096
- @backend_doc.add_child(node.__dommy_backend_node__)
1316
+ # An append has a null reference child, so insert step 5 shifts nothing.
1317
+ nodes = document_insertion_nodes([node])
1318
+ return node if nodes.empty?
1319
+
1320
+ nodes.each { |bn| @backend_doc.add_child(bn) }
1321
+ notify_document_child_list(added: nodes)
1097
1322
  node
1098
1323
  end
1099
1324
 
1325
+ # The Node / ParentNode mutation methods under their WHATWG names. The
1326
+ # document's own child list has its own rules (at most one element, no Text,
1327
+ # a doctype only ahead of the document element), so each forwards to the
1328
+ # `document_*` implementation that carries them.
1329
+ def insert_before(node, reference)
1330
+ document_insert_before(node, reference)
1331
+ end
1332
+
1333
+ def replace_child(new_child, old_child)
1334
+ document_replace_child(new_child, old_child)
1335
+ end
1336
+
1337
+ def remove_child(node)
1338
+ document_remove_child(node)
1339
+ end
1340
+
1341
+ def replace_children(*args)
1342
+ document_replace_children(args)
1343
+ end
1344
+
1345
+ def append(*args)
1346
+ document_insert(args, prepend: false)
1347
+ end
1348
+
1349
+ def prepend(*args)
1350
+ document_insert(args, prepend: true)
1351
+ end
1352
+
1353
+ # WHATWG ParentNode.moveBefore(node, child) with the DOCUMENT as the new
1354
+ # parent. The move primitive's steps 5 and 6 bind only here: a Text node may
1355
+ # not become a child of a document, and an Element may only be moved in when
1356
+ # the document has no element child, the reference is not the doctype, and
1357
+ # no doctype follows the reference.
1358
+ #
1359
+ # Spec: https://dom.spec.whatwg.org/#dom-parentnode-movebefore
1360
+ def move_before(node, child = nil)
1361
+ Internal::WebIDL.node!(node)
1362
+ Internal::WebIDL.nullable_node!(child)
1363
+ bn = move_backend_node(node)
1364
+ ref_bn = move_backend_node(child)
1365
+ ref_bn = ref_bn.next_sibling if ref_bn && bn && ref_bn == bn
1366
+ ensure_document_move_validity!(node, bn, ref_bn)
1367
+
1368
+ old_parent = bn.parent
1369
+ old_previous = bn.previous_sibling
1370
+ old_next = bn.next_sibling
1371
+ detach_node(bn, moving: true) # steps 10-11, 14
1372
+ ref_bn = nil if ref_bn && ref_bn.parent != @backend_doc
1373
+ __internal_ranges_will_insert__(@backend_doc, ref_bn, 1) # step 16
1374
+ new_previous = ref_bn ? ref_bn.previous_sibling : @backend_doc.children.to_a.last
1375
+ ref_bn ? ref_bn.add_previous_sibling(bn) : @backend_doc.add_child(bn) # step 18
1376
+ if old_parent
1377
+ notify_child_list_mutation(
1378
+ target_node: old_parent, added_nodes: [], removed_nodes: [bn],
1379
+ previous_sibling: old_previous && wrap_node(old_previous),
1380
+ next_sibling: old_next && wrap_node(old_next), moving: true
1381
+ )
1382
+ end
1383
+ notify_document_child_list(added: [bn], previous_sibling: new_previous && wrap_node(new_previous),
1384
+ next_sibling: ref_bn && wrap_node(ref_bn), moving: true)
1385
+ __internal_notify_moved_subtree__(bn) # step 19.3, into a document
1386
+ nil
1387
+ end
1388
+
1389
+ # The backend node an argument to `moveBefore` stands for. A Dommy::Document
1390
+ # has no `__dommy_backend_node__` of its own, but it is a node the algorithm
1391
+ # has to see (step 2 rejects it, step 3 measures its parentage).
1392
+ def move_backend_node(value)
1393
+ return value.backend_doc if value.is_a?(Dommy::Document)
1394
+ return nil unless value.respond_to?(:__dommy_backend_node__)
1395
+
1396
+ value.__dommy_backend_node__
1397
+ end
1398
+
1399
+ # "Move" steps 1-6 with a document new parent.
1400
+ def ensure_document_move_validity!(node, bn, ref_bn)
1401
+ # Step 1 — the same root. A document is its own root, so this says the
1402
+ # node must already be somewhere in this document.
1403
+ root = bn
1404
+ root = root.parent while root.respond_to?(:parent) && root.parent
1405
+ # `==` and not `equal?`: a backend may hand back a fresh Ruby object for
1406
+ # the same underlying node on every `parent` call.
1407
+ unless bn && root == @backend_doc
1408
+ raise DOMException::HierarchyRequestError,
1409
+ "moveBefore requires the node and the new parent to share a root"
1410
+ end
1411
+
1412
+ # Step 2 — a document is its own inclusive ancestor; nothing else can be
1413
+ # an ancestor of one, so this reduces to "not the document itself".
1414
+ ensure_not_self_insertion!([node])
1415
+
1416
+ # Step 3.
1417
+ if ref_bn && ref_bn.parent != @backend_doc
1418
+ raise DOMException::NotFoundError, "The reference child is not a child of this document."
1419
+ end
1420
+
1421
+ # Step 4.
1422
+ unless node.is_a?(Dommy::Element) || node.is_a?(Dommy::CharacterDataNode)
1423
+ raise DOMException::HierarchyRequestError, "this node type cannot be moved"
1424
+ end
1425
+
1426
+ # Step 5.
1427
+ if node.is_a?(Dommy::CharacterDataNode) && node.node_type == 3
1428
+ raise DOMException::HierarchyRequestError, "A Text node cannot be a child of a document."
1429
+ end
1430
+
1431
+ return unless node.is_a?(Dommy::Element)
1432
+
1433
+ # Step 6. The counts are of the CURRENT children, so a document that
1434
+ # already has a document element cannot take another element — not even
1435
+ # by moving that same element to a different slot.
1436
+ existing = @backend_doc.children.to_a
1437
+ return unless existing.any? { |c| c.node_type == 1 } ||
1438
+ (ref_bn && ref_bn.node_type == 10) ||
1439
+ doctype_after_child?(existing, ref_bn)
1440
+
1441
+ raise DOMException::HierarchyRequestError, "An element cannot be moved here."
1442
+ end
1443
+
1100
1444
  # ParentNode / Node mutation on the document's direct children (the doctype
1101
1445
  # and the document element).
1102
1446
  def document_insert(args, prepend:)
1103
1447
  ref_bn = prepend ? @backend_doc.children.first : nil
1104
1448
  ensure_document_insertion_validity!(args, ref_bn)
1105
- nodes = args.filter_map { |a| adopted_backend_node(a) }
1449
+ __internal_ranges_will_insert__(@backend_doc, ref_bn, document_insertion_count(args))
1450
+ nodes = document_insertion_nodes(args)
1106
1451
  if prepend && (first = @backend_doc.children.first)
1107
1452
  nodes.reverse_each { |n| first.add_previous_sibling(n) }
1108
1453
  else
1109
1454
  nodes.each { |n| @backend_doc.add_child(n) }
1110
1455
  end
1456
+ notify_document_child_list(added: nodes)
1111
1457
  nil
1112
1458
  end
1113
1459
 
@@ -1115,8 +1461,11 @@ module Dommy
1115
1461
  # replaceChildren removes the current children first, so the validity
1116
1462
  # checks ignore them (whatwg/dom#1045).
1117
1463
  ensure_document_insertion_validity!(args, nil, ignore_existing: true)
1118
- @backend_doc.children.each(&:unlink)
1119
- args.filter_map { |a| adopted_backend_node(a) }.each { |n| @backend_doc.add_child(n) }
1464
+ removed = @backend_doc.children.to_a
1465
+ removed.each { |child| detach_node(child) }
1466
+ added = document_insertion_nodes(args)
1467
+ added.each { |n| @backend_doc.add_child(n) }
1468
+ notify_document_child_list(added: added, removed: removed)
1120
1469
  nil
1121
1470
  end
1122
1471
 
@@ -1126,15 +1475,15 @@ module Dommy
1126
1475
  bn = backend_node(node)
1127
1476
  raise DOMException::NotFoundError, "node is not a child of this document" unless bn && bn.parent == @backend_doc
1128
1477
 
1129
- run_node_iterator_pre_remove(bn)
1130
- bn.unlink
1478
+ remove_node_with_notify(bn)
1131
1479
  node
1132
1480
  end
1133
1481
 
1134
1482
  def document_insert_before(node, ref)
1135
- # WHATWG pre-insert order: the reference child must be a child of the
1136
- # parent (step 3, NotFoundError) BEFORE the node-type / document-hierarchy
1137
- # checks (steps 4-6).
1483
+ # WHATWG pre-insert order: step 2 (a cycle) first, THEN step 3 (the
1484
+ # reference child must be a child of the parent, NotFoundError), and only
1485
+ # then the node-type / document-hierarchy checks (steps 4-6).
1486
+ ensure_not_self_insertion!([node])
1138
1487
  ref_present = !(ref.nil? || (defined?(Bridge::UNDEFINED) && ref.equal?(Bridge::UNDEFINED)))
1139
1488
  ref_bn = ref_present ? backend_node(ref) : nil
1140
1489
  if ref_present && !(ref_bn && ref_bn.parent == @backend_doc)
@@ -1142,19 +1491,31 @@ module Dommy
1142
1491
  end
1143
1492
 
1144
1493
  ensure_document_insertion_validity!([node], ref_bn)
1145
- bn = adopted_backend_node(node)
1146
- return node unless bn
1494
+ # Insert step 6's insertion point, read BEFORE step 7's adopt moves
1495
+ # anything: the reference child's previous sibling, or the document's last
1496
+ # child when appending.
1497
+ record_previous = document_insertion_previous_sibling(ref_bn)
1498
+ record_next = ref_bn && wrap_node(ref_bn)
1499
+ # Insert step 5 runs before step 7's adopt, so the count is taken while the
1500
+ # node (or fragment) still sits wherever it is now.
1501
+ __internal_ranges_will_insert__(@backend_doc, ref_bn, document_insertion_count([node]))
1502
+ nodes = document_insertion_nodes([node])
1503
+ return node if nodes.empty?
1147
1504
 
1148
1505
  ref_node = ref && backend_node(ref)
1149
1506
  if ref_node && ref_node.parent == @backend_doc
1150
- ref_node.add_previous_sibling(bn)
1507
+ nodes.each { |bn| ref_node.add_previous_sibling(bn) }
1151
1508
  else
1152
- @backend_doc.add_child(bn)
1509
+ nodes.each { |bn| @backend_doc.add_child(bn) }
1153
1510
  end
1511
+ notify_document_child_list(added: nodes, previous_sibling: record_previous,
1512
+ next_sibling: record_next)
1154
1513
  node
1155
1514
  end
1156
1515
 
1157
1516
  def document_replace_child(new_child, old_child)
1517
+ # Step 2 (a cycle) precedes step 3 (the reference child's parentage).
1518
+ ensure_not_self_insertion!([new_child])
1158
1519
  old_bn = backend_node(old_child)
1159
1520
  raise DOMException::NotFoundError, "node is not a child of this document" unless old_bn && old_bn.parent == @backend_doc
1160
1521
 
@@ -1162,18 +1523,51 @@ module Dommy
1162
1523
  # the document's existing element / doctype children.
1163
1524
  ensure_document_insertion_validity!([new_child], old_bn, exclude: old_bn)
1164
1525
 
1165
- # Remove old FIRST, then adopt the incoming node. A cross-document doctype
1166
- # is re-created in this backend, and Makiri's fail-closed guard refuses a
1167
- # second doctype — so the old one must be gone before the new is made.
1168
1526
  ref = old_bn.next
1169
- old_bn.unlink
1170
- if !Backend.moves_nodes_across_documents? && new_child.respond_to?(:document) && !new_child.document.equal?(self)
1171
- new_child = adopt_node(new_child)
1172
- end
1173
- new_bn = backend_node(new_child)
1174
- if new_bn
1175
- ref && ref.parent == @backend_doc ? ref.add_previous_sibling(new_bn) : @backend_doc.add_child(new_bn)
1527
+ # Replace step 4's previousSibling: the old child's previous sibling,
1528
+ # read before the adopt below removes anything.
1529
+ record_previous = old_bn.previous && wrap_node(old_bn.previous)
1530
+ record_next = ref && wrap_node(ref)
1531
+ cross_document = !Backend.moves_nodes_across_documents? &&
1532
+ new_child.respond_to?(:document) && !new_child.document.equal?(self)
1533
+ fragment = new_child.is_a?(Dommy::Fragment)
1534
+
1535
+ # Same document: WHATWG replace adopts the incoming node — which removes
1536
+ # it from whatever parent it has — BEFORE removing the child it replaces,
1537
+ # so that old parent gets its removing steps and its childList record.
1538
+ #
1539
+ # Cross-document is deferred instead: a doctype has to be re-created in
1540
+ # this backend, and Makiri's fail-closed guard refuses a second doctype,
1541
+ # so the old one must be gone before the new one is made.
1542
+ #
1543
+ # A DocumentFragment has nothing to adopt at this point: what gets
1544
+ # inserted is its children, and insert step 4 takes them out later, after
1545
+ # step 7 has removed the child being replaced.
1546
+ new_bn = adopted_backend_node(new_child) if !cross_document && !fragment
1547
+ # Insert step 2's count, taken while the fragment still holds its children.
1548
+ count = document_insertion_count([new_child])
1549
+ detach_node(old_bn)
1550
+ # Insert step 4: a fragment's children are REMOVED from it, which runs the
1551
+ # live range and NodeIterator pre-remove steps and queues a childList
1552
+ # record on the fragment. Then step 5 shifts the ranges on this document
1553
+ # by the count. WHATWG "replace" removes the old child (step 7) before the
1554
+ # insert (step 9), so step 5 measures `ref` in the tree the removal leaves.
1555
+ nodes =
1556
+ if fragment
1557
+ document_insertion_nodes([new_child])
1558
+ elsif cross_document
1559
+ new_child = adopt_node(new_child)
1560
+ bn = backend_node(new_child)
1561
+ bn ? [bn] : []
1562
+ else
1563
+ new_bn ? [new_bn] : []
1564
+ end
1565
+ __internal_ranges_will_insert__(@backend_doc, ref && ref.parent == @backend_doc ? ref : nil, count)
1566
+ nodes.each do |bn|
1567
+ ref && ref.parent == @backend_doc ? ref.add_previous_sibling(bn) : @backend_doc.add_child(bn)
1176
1568
  end
1569
+ notify_document_child_list(added: nodes, removed: [old_bn],
1570
+ previous_sibling: record_previous, next_sibling: record_next)
1177
1571
  old_child
1178
1572
  end
1179
1573
 
@@ -1183,8 +1577,7 @@ module Dommy
1183
1577
  node = backend_node(doctype) || Backend.internal_subset(@backend_doc)
1184
1578
  return nil unless node
1185
1579
 
1186
- run_node_iterator_pre_remove(node)
1187
- node.unlink
1580
+ remove_node_with_notify(node)
1188
1581
  nil
1189
1582
  end
1190
1583
 
@@ -1192,21 +1585,28 @@ module Dommy
1192
1585
  # (at the document start) or after it (just before the document element).
1193
1586
  def __internal_insert_at_doctype__(nodes, after:)
1194
1587
  bns = nodes.filter_map { |n| backend_node(n) }
1588
+ anchor = after ? @backend_doc.root : @backend_doc.children.first
1589
+ __internal_ranges_will_insert__(@backend_doc, anchor, bns.size)
1195
1590
  if after
1196
- root = @backend_doc.root
1197
- root ? bns.each { |n| root.add_previous_sibling(n) } : bns.each { |n| @backend_doc.add_child(n) }
1591
+ anchor ? bns.each { |n| anchor.add_previous_sibling(n) } : bns.each { |n| @backend_doc.add_child(n) }
1198
1592
  else
1199
- first = @backend_doc.children.first
1200
- first ? bns.reverse_each { |n| first.add_previous_sibling(n) } : bns.each { |n| @backend_doc.add_child(n) }
1593
+ anchor ? bns.reverse_each { |n| anchor.add_previous_sibling(n) } : bns.each { |n| @backend_doc.add_child(n) }
1201
1594
  end
1202
1595
  nil
1203
1596
  end
1204
1597
 
1205
- # `document.cloneNode(deep)` → a fresh Document over a (deep) copy of the
1206
- # Makiri tree, preserving the content type.
1598
+ # `document.cloneNode(deep)` → a fresh Document of the same kind, keeping
1599
+ # the content type. "Clone a single node" creates a document EMPTY, and a
1600
+ # deep clone then copies the original's children into it — so nothing
1601
+ # sprouts an html/head/body of its own, and a document with no children
1602
+ # clones to one with no children.
1207
1603
  def clone_node(deep)
1208
- copy = deep ? Backend.clone_document(@backend_doc) : Backend.empty_document_like(@backend_doc)
1209
- Document.new(nil, backend_doc: copy).tap { |d| d.content_type = @content_type }
1604
+ copy = Document.new(nil, backend_doc: Backend.empty_document_like(@backend_doc))
1605
+ copy.content_type = @content_type
1606
+ return copy unless deep
1607
+
1608
+ child_nodes.each { |child| copy.append_child(copy.import_node(child, true)) }
1609
+ copy
1210
1610
  end
1211
1611
 
1212
1612
  def backend_node(node)
@@ -1219,13 +1619,55 @@ module Dommy
1219
1619
  # `append_child` does, otherwise a cross-document node's backend node comes
1220
1620
  # from a foreign arena and the insertion silently drops it on a backend that
1221
1621
  # can't move nodes across documents (Makiri).
1622
+ # WHATWG pre-insert: adopt the node into this document, which removes it
1623
+ # from whatever parent it has now. The backend would detach it implicitly on
1624
+ # the next add_child, but silently — that is a storage operation, not a DOM
1625
+ # removal, so route it through the shared remove primitive instead and let
1626
+ # the old parent see its removing steps and its childList record.
1627
+ # WHATWG "insert" steps 1 and 4 for the DOCUMENT's own child list. A
1628
+ # DocumentFragment argument contributes its children, and they are removed
1629
+ # from it first — with their removing steps, so a live range or NodeIterator
1630
+ # inside them follows — before anything is linked here. Every other node is
1631
+ # adopted, which removes it from its old parent.
1632
+ #
1633
+ # Document's insertion paths hand the backend the node they were given, and
1634
+ # the backend splices a fragment's children in silently; without this the
1635
+ # fragment's children would move with no removing steps at all.
1636
+ def document_insertion_nodes(args)
1637
+ args.flat_map do |arg|
1638
+ if arg.is_a?(Dommy::Fragment)
1639
+ source = arg.document
1640
+ arg.extract_children.map do |n|
1641
+ n.document == @backend_doc ? n : __internal_adopt_backend_node__(n, source)
1642
+ end
1643
+ else
1644
+ bn = adopted_backend_node(arg)
1645
+ bn ? [bn] : []
1646
+ end
1647
+ end
1648
+ end
1649
+
1650
+ # How many nodes `args` will contribute, counted BEFORE any of them moves —
1651
+ # insert step 5 needs the count while the fragment still holds its children.
1652
+ def document_insertion_count(args)
1653
+ args.sum do |arg|
1654
+ if arg.is_a?(Dommy::Fragment) then arg.child_nodes.to_a.size
1655
+ elsif arg.respond_to?(:__dommy_backend_node__) then 1
1656
+ else 0
1657
+ end
1658
+ end
1659
+ end
1660
+
1222
1661
  def adopted_backend_node(node)
1223
1662
  return nil unless node.respond_to?(:__dommy_backend_node__)
1224
1663
 
1225
1664
  if !Backend.moves_nodes_across_documents? && node.respond_to?(:document) && !node.document.equal?(self)
1226
- node = adopt_node(node)
1665
+ return adopt_node(node)&.__dommy_backend_node__
1227
1666
  end
1228
- node.__dommy_backend_node__
1667
+
1668
+ bn = node.__dommy_backend_node__
1669
+ remove_node_with_notify(bn) if bn.parent
1670
+ bn
1229
1671
  end
1230
1672
 
1231
1673
  # Delegate to CookieJar
@@ -1333,14 +1775,16 @@ module Dommy
1333
1775
  when "scrollingElement"
1334
1776
  wrap_node(@backend_doc.at_css("html"))
1335
1777
  when "documentElement"
1336
- # The document's root element — `<html>` for HTML, the actual root for XML.
1337
- wrap_node(@backend_doc.root)
1778
+ document_element
1338
1779
  when "title"
1339
1780
  read_title
1340
1781
  when "cookie"
1341
1782
  cookie
1342
1783
  when "nodeType"
1343
1784
  9
1785
+ when "isConnected"
1786
+ # A document is its own shadow-including root, so it is always connected.
1787
+ true
1344
1788
  when "nodeValue", "textContent"
1345
1789
  # A Document's nodeValue and textContent are null (not the concatenated
1346
1790
  # descendant text) per the DOM.
@@ -1524,7 +1968,7 @@ module Dommy
1524
1968
  adoptNode hasFocus getSelection elementFromPoint queryCommandSupported addEventListener
1525
1969
  removeEventListener dispatchEvent write writeln open close isEqualNode isSameNode appendChild
1526
1970
  hasChildNodes contains append prepend replaceChildren removeChild insertBefore replaceChild
1527
- cloneNode normalize compareDocumentPosition getRootNode
1971
+ cloneNode normalize compareDocumentPosition getRootNode moveBefore
1528
1972
  lookupNamespaceURI lookupPrefix isDefaultNamespace
1529
1973
  ]
1530
1974
  def __js_call__(method, args)
@@ -1563,17 +2007,20 @@ module Dommy
1563
2007
  document_remove_child(args[0])
1564
2008
  when "insertBefore"
1565
2009
  raise Bridge::TypeError, "insertBefore requires 2 arguments." if args.length < 2
1566
- unless args[1].nil? || args[1].equal?(Bridge::UNDEFINED) || args[1].is_a?(Dommy::Node)
1567
- raise Bridge::TypeError, "The reference child is not a Node."
1568
- end
2010
+ Internal::WebIDL.nullable_node!(args[1])
1569
2011
 
1570
2012
  document_insert_before(args[0], args[1])
1571
2013
  when "replaceChild"
1572
2014
  document_replace_child(args[0], args[1])
2015
+ when "moveBefore"
2016
+ raise Bridge::TypeError, "moveBefore requires 2 arguments." if args.length < 2
2017
+
2018
+ move_before(args[0], args[1])
2019
+ Bridge::UNDEFINED
1573
2020
  when "cloneNode"
1574
2021
  clone_node(args[0])
1575
2022
  when "normalize"
1576
- nil # the document has no text children to merge
2023
+ normalize
1577
2024
  when "writeln"
1578
2025
  write(*(args + ["\n"]))
1579
2026
  when "exitFullscreen"
@@ -1702,6 +2149,58 @@ module Dommy
1702
2149
  @node_wrapper_cache.wrap_cloned_element_ns(node, namespace, prefix, local, qualified_name)
1703
2150
  end
1704
2151
 
2152
+ # The task scheduler this document's own tasks run on: its browsing context's
2153
+ # when it has one, otherwise the one handed to it by whatever built it (a
2154
+ # DOMParser document has no defaultView but still queues tasks on the window
2155
+ # whose script created it).
2156
+ attr_writer :task_scheduler
2157
+
2158
+ def __internal_scheduler__
2159
+ (@default_view&.scheduler if @default_view.respond_to?(:scheduler)) || @task_scheduler
2160
+ end
2161
+
2162
+ # The parser built the tree without any insertion or attribute steps
2163
+ # running: give the elements that depend on them their due, once the
2164
+ # document exists. Every details gets its insertion steps (the toggle event
2165
+ # it owes, its exclusive accordion group settled), and every select has its
2166
+ # list of options settled (a single-select the parser left with no, or
2167
+ # several, selected options).
2168
+ def __internal_run_parsed_insertion_steps__
2169
+ return nil unless @backend_doc.respond_to?(:css)
2170
+
2171
+ elements = @backend_doc.css("details").filter_map { |node| wrap_node(node) }
2172
+ HTMLDetailsElement.run_insertion_steps(elements) unless elements.empty?
2173
+ @backend_doc.css("select").each { |node| wrap_node(node)&.__internal_settle_selectedness_once__ }
2174
+ nil
2175
+ end
2176
+
2177
+ # Bind an externally built wrapper to its backend node, so later traversals
2178
+ # return the same Ruby object (JS identity) instead of building a new one.
2179
+ def __internal_register_wrapper__(node, wrapper)
2180
+ @node_wrapper_cache.register(node, wrapper)
2181
+ wrapper
2182
+ end
2183
+
2184
+ # The wrapper already cached for a backend node, or nil — never builds one.
2185
+ def __internal_cached_wrapper__(node)
2186
+ @node_wrapper_cache.cached_wrapper(node)
2187
+ end
2188
+
2189
+ # Recorded when an element is created outside the HTML namespace or with a
2190
+ # prefix. Deep cloning only has to carry that metadata across for a document
2191
+ # that has some — which the overwhelming majority never do, so the ordinary
2192
+ # `body.cloneNode(true)` keeps walking nothing.
2193
+ def __internal_note_namespaced_element__(namespace, prefix)
2194
+ return if namespace == Element::HTML_NAMESPACE && prefix.nil?
2195
+
2196
+ @namespaced_elements = true
2197
+ nil
2198
+ end
2199
+
2200
+ def __internal_namespaced_elements__?
2201
+ @namespaced_elements == true
2202
+ end
2203
+
1705
2204
  # Clear the cached wrapper so the next `wrap_node` creates a new
1706
2205
  # one. Used by `customElements.define` to upgrade nodes that were
1707
2206
  # constructed before the registration landed.
@@ -1727,6 +2226,33 @@ module Dommy
1727
2226
  @shadow_registry.find_for_fragment(fragment_node)
1728
2227
  end
1729
2228
 
2229
+ def __internal_shadow_root_for_host__(host_node)
2230
+ @shadow_registry.find_for_host(host_node)
2231
+ end
2232
+
2233
+ # Every element among `root`'s shadow-including inclusive descendants, as
2234
+ # backend nodes, in shadow-including tree order: an element, then the shadow
2235
+ # tree it hosts, then its children. The whole list, shadow trees included, is
2236
+ # taken before the first element is yielded. Callbacks run synchronously here,
2237
+ # and one that attaches a shadow root or inserts elements triggers those
2238
+ # changes' own reactions; walking into them as well would run them twice.
2239
+ def __internal_each_shadow_including_element__(root, &block)
2240
+ shadow_including_elements(root).each(&block)
2241
+ nil
2242
+ end
2243
+
2244
+ def shadow_including_elements(root, list = [])
2245
+ elements = root.respond_to?(:element?) && root.element? ? [root] : []
2246
+ elements.concat(root.css("*").to_a) if root.respond_to?(:css)
2247
+ elements.each do |element|
2248
+ list << element
2249
+ shadow = @shadow_registry.find_for_host(element)
2250
+ shadow_including_elements(shadow.__dommy_backend_node__, list) if shadow
2251
+ end
2252
+ list
2253
+ end
2254
+ private :shadow_including_elements
2255
+
1730
2256
  def __internal_shadow_root_containing__(node)
1731
2257
  @shadow_registry.find_enclosing(node)
1732
2258
  end
@@ -1754,6 +2280,12 @@ module Dommy
1754
2280
  @mutation_coordinator.notify_connected_subtree(nk)
1755
2281
  end
1756
2282
 
2283
+ # The custom element reactions of a move (moveBefore), for a node that ended
2284
+ # up connected.
2285
+ def __internal_notify_moved_subtree__(nk)
2286
+ @mutation_coordinator.notify_moved_subtree(nk)
2287
+ end
2288
+
1757
2289
  def __internal_notify_disconnected_subtree__(nk)
1758
2290
  @mutation_coordinator.notify_disconnected_subtree(nk)
1759
2291
  end
@@ -1770,22 +2302,114 @@ module Dommy
1770
2302
  @mutation_coordinator.unregister_observer(observer)
1771
2303
  end
1772
2304
 
2305
+ # Queue the childList record for a mutation. The live-range insertion steps
2306
+ # are NOT run here: WHATWG puts them at insert step 5, before the nodes are
2307
+ # converted and linked, so every insertion site calls
2308
+ # __internal_ranges_will_insert__ itself, at that point.
1773
2309
  def notify_child_list_mutation(
1774
2310
  target_node:,
1775
2311
  added_nodes:,
1776
2312
  removed_nodes:,
1777
2313
  previous_sibling: nil,
1778
- next_sibling: nil
2314
+ next_sibling: nil,
2315
+ moving: false
1779
2316
  )
1780
2317
  @mutation_coordinator.notify_child_list_mutation(
1781
2318
  target_node: target_node,
1782
2319
  added_nodes: added_nodes,
1783
2320
  removed_nodes: removed_nodes,
1784
2321
  previous_sibling: previous_sibling,
1785
- next_sibling: next_sibling
2322
+ next_sibling: next_sibling,
2323
+ moving: moving
2324
+ )
2325
+ end
2326
+
2327
+ # WHATWG "removing steps", run while `node` is STILL attached (they are all
2328
+ # expressed in terms of the position it is about to vacate). Every path that
2329
+ # takes a node out of its parent — an explicit removeChild, the implicit
2330
+ # removal a move performs, replaceChildren, textContent=, fragment
2331
+ # extraction — must go through here, or a live Range / NodeIterator anchored
2332
+ # in the vacated position is left pointing at a detached node.
2333
+ #
2334
+ # A document with no live range and no NodeIterator has nothing to observe
2335
+ # the vacated position, so the whole thing collapses to two predicate calls
2336
+ # — this runs once per removed node, and a bulk replaceChildren /
2337
+ # textContent= must not pay for machinery nobody is watching.
2338
+ def pre_remove_node(node)
2339
+ return nil if !node_iterators? && !live_ranges?
2340
+ return nil unless node.parent
2341
+
2342
+ run_node_iterator_pre_remove(node)
2343
+ __internal_ranges_will_remove__(node)
2344
+ nil
2345
+ end
2346
+
2347
+ # A childList mutation on the DOCUMENT's own child list (its doctype, the
2348
+ # document element, a stray comment). Document-level mutation is observable
2349
+ # like any other — `observe(document, {childList: true})` is legal — so it
2350
+ # goes through the same pipeline rather than only nudging live ranges.
2351
+ # Insert step 6 for a document parent: the reference child's previous
2352
+ # sibling, or the document's last child when appending.
2353
+ def document_insertion_previous_sibling(ref_bn)
2354
+ node = ref_bn ? ref_bn.previous : @backend_doc.children.to_a.last
2355
+ node && wrap_node(node)
2356
+ end
2357
+
2358
+ def notify_document_child_list(added: [], removed: [], previous_sibling: nil, next_sibling: nil, moving: false)
2359
+ notify_child_list_mutation(
2360
+ target_node: @backend_doc,
2361
+ added_nodes: added,
2362
+ removed_nodes: removed,
2363
+ previous_sibling: previous_sibling,
2364
+ next_sibling: next_sibling,
2365
+ moving: moving
1786
2366
  )
1787
2367
  end
1788
2368
 
2369
+ # The single detach primitive: pre-removing steps, then unlink. Callers that
2370
+ # batch several removals into one childList record (replaceChildren,
2371
+ # textContent=, replaceChild) use this and queue the record themselves;
2372
+ # `remove_node_with_notify` is this plus a per-node record.
2373
+ #
2374
+ # `moving:` marks the first half of the "move" primitive (moveBefore). A
2375
+ # move takes the node out of its old parent's children only to put it
2376
+ # straight back into the same shadow-including tree: it never leaves the
2377
+ # document, so nothing that reacts to a removal from the document may run.
2378
+ def detach_node(node, moving: false)
2379
+ pre_remove_node(node)
2380
+ add_transient_observers_for(node)
2381
+ node.unlink
2382
+ # A removal can take a shadow tree, and a selection range in it, out of
2383
+ # the document without moving the range; the selection lets go of it.
2384
+ @__selection&.__internal_node_removed__ unless moving
2385
+ node
2386
+ end
2387
+
2388
+ # WHATWG remove step 20: every subtree registration reachable from the old
2389
+ # parent's inclusive ancestors gains a transient registered observer on the
2390
+ # node being removed, so a subtree observer keeps seeing mutations inside
2391
+ # the just-removed subtree until the next microtask checkpoint.
2392
+ #
2393
+ # The step is NOT guarded by suppressObservers (only step 21's record is),
2394
+ # so it has to run here, in the removal primitive, rather than alongside the
2395
+ # record. Replace all step 3, insert step 4 and replace step 7 all remove
2396
+ # with observers suppressed.
2397
+ def add_transient_observers_for(node)
2398
+ return unless @observer_manager.any?
2399
+
2400
+ parent = node.parent
2401
+ return unless parent
2402
+
2403
+ target = wrap_node(parent)
2404
+ removed = wrap_node(node)
2405
+ return unless target && removed
2406
+
2407
+ @observer_manager.observers_matching(target).each do |observer|
2408
+ entry = observer.find_matching_entry(target)
2409
+ observer.add_transient(removed, entry) if entry && entry[:subtree]
2410
+ end
2411
+ end
2412
+
1789
2413
  # Unlink a backend node from its parent and queue a childList removal record
1790
2414
  # capturing the node's position (previous/next sibling) BEFORE the unlink, so
1791
2415
  # the record's previousSibling/nextSibling are correct (the coordinator can't
@@ -1796,8 +2420,7 @@ module Dommy
1796
2420
 
1797
2421
  prev_w = node.previous_sibling && wrap_node(node.previous_sibling)
1798
2422
  next_w = node.next_sibling && wrap_node(node.next_sibling)
1799
- run_node_iterator_pre_remove(node)
1800
- node.unlink
2423
+ detach_node(node)
1801
2424
  notify_child_list_mutation(
1802
2425
  target_node: parent,
1803
2426
  added_nodes: [],
@@ -1807,14 +2430,198 @@ module Dommy
1807
2430
  )
1808
2431
  end
1809
2432
 
2433
+ # --- Live ranges -------------------------------------------------
2434
+ # Ranges are live: a DOM mutation moves their boundary points so they keep
2435
+ # designating the same content. They are held weakly, so a range the caller
2436
+ # drops is collected rather than pinned for the document's lifetime.
2437
+
2438
+ def __internal_register_range__(range)
2439
+ @live_ranges ||= ObjectSpace::WeakMap.new
2440
+ @live_ranges[range] = true
2441
+ nil
2442
+ end
2443
+
2444
+ def __internal_each_live_range__
2445
+ return if @live_ranges.nil? || @live_ranges.size.zero?
2446
+
2447
+ @live_ranges.each_key { |range| yield range }
2448
+ end
2449
+
2450
+ def live_ranges?
2451
+ !@live_ranges.nil? && @live_ranges.size.positive?
2452
+ end
2453
+
2454
+ # WHATWG "removing steps" for live ranges. Must run while `backend_node` is
2455
+ # still attached, since the rules are expressed in terms of the position it
2456
+ # is about to vacate.
2457
+ def __internal_ranges_will_remove__(backend_node)
2458
+ return unless live_ranges?
2459
+
2460
+ parent = backend_node.parent
2461
+ return unless parent
2462
+
2463
+ removed = wrap_node(backend_node)
2464
+ parent_wrapper = wrap_node(parent)
2465
+ affected = live_ranges_where { |r| r.__internal_affected_by_removal__(removed, parent_wrapper) }
2466
+ return if affected.empty?
2467
+
2468
+ index = child_index_of_wrapper(parent_wrapper, removed)
2469
+ return unless index
2470
+
2471
+ affected.each { |r| r.__internal_apply_remove__(removed, parent_wrapper, index) }
2472
+ end
2473
+
2474
+ # WHATWG "insert a node into a parent before a child", step 5 — the
2475
+ # live-range offset shift.
2476
+ #
2477
+ # It runs BEFORE step 7 adopts each node (which removes it from wherever it
2478
+ # is now), so `child`'s index, and every boundary it shifts, are measured
2479
+ # against the tree as it stands before the insertion begins. Running it
2480
+ # afterwards double-counts a boundary that one of those removals has just
2481
+ # moved onto `parent`: `parent.insertBefore(second, first)` with a range
2482
+ # inside `second` leaves that boundary at `(parent, 1)` per spec, but at
2483
+ # `(parent, 2)` if the shift is applied after the move.
2484
+ #
2485
+ # Appending (a null `child`) shifts nothing: a boundary at the parent's end
2486
+ # stays before the new nodes.
2487
+ def __internal_ranges_will_insert__(parent_backend_node, ref_backend_node, count)
2488
+ return if ref_backend_node.nil? || count.zero?
2489
+ return unless live_ranges?
2490
+
2491
+ parent_wrapper = wrap_node(parent_backend_node)
2492
+ return unless parent_wrapper.respond_to?(:child_nodes)
2493
+
2494
+ affected = live_ranges_where { |r| r.__internal_anchored_at__(parent_wrapper) }
2495
+ return if affected.empty?
2496
+
2497
+ index = child_index_of_wrapper(parent_wrapper, wrap_node(ref_backend_node))
2498
+ return unless index
2499
+
2500
+ affected.each { |r| r.__internal_apply_insert__(parent_wrapper, index, count) }
2501
+ end
2502
+
2503
+ # WHATWG normalize() steps 6.1-6.4. `current` is a contiguous exclusive Text
2504
+ # sibling whose data has just been appended to `node` at `length`; its own
2505
+ # boundaries — and a parent-anchored boundary pointing AT it — follow the
2506
+ # data into the merged node. Run for every merged sibling before any of them
2507
+ # is removed, so the indices still describe the pre-removal tree.
2508
+ def __internal_ranges_normalize_merge__(node, current, length)
2509
+ return unless live_ranges?
2510
+
2511
+ merged_into = wrap_node(node)
2512
+ current_wrapper = wrap_node(current)
2513
+ parent = current.parent && wrap_node(current.parent)
2514
+ index = parent && child_index_of_wrapper(parent, current_wrapper)
2515
+ __internal_each_live_range__ do |range|
2516
+ range.__internal_apply_normalize_merge__(merged_into, current_wrapper, length, parent, index)
2517
+ end
2518
+ end
2519
+
2520
+ # Node.normalize() on the document: every Text run in the tree.
2521
+ def normalize
2522
+ __internal_normalize__(@backend_doc)
2523
+ end
2524
+
2525
+ # Node#normalize — merge each run of adjacent exclusive Text descendants
2526
+ # into its first node (preserving that node's identity, so a JS reference
2527
+ # to it survives) and drop empty Text nodes. Recurses the whole subtree,
2528
+ # so it serves Element, DocumentFragment, ShadowRoot and the Document alike.
2529
+ #
2530
+ # A run is merged one sibling at a time: append the sibling's data to the
2531
+ # survivor (a characterData record), hand its live range boundaries over,
2532
+ # remove it (a childList record), then the next. Read literally, the spec
2533
+ # concatenates every sibling's data first (steps 3-4, one "replace data")
2534
+ # and removes them afterwards (step 7), which would queue ONE
2535
+ # characterData record per run. Every shipping engine merges pairwise
2536
+ # instead — Blink, WebCore and Gecko all answer [characterData, childList,
2537
+ # characterData, childList, …] for a run of four text nodes, confirmed by
2538
+ # running the same script in Chromium 141, WebKitGTK 2.52.6 and Firefox —
2539
+ # and the WPT suite fixes only the childList side, so the records follow
2540
+ # the engines. The tree and every live range boundary end up exactly where
2541
+ # the spec's steps put them: a boundary in a later sibling (or on the
2542
+ # parent, pointing at one) is shifted down by each earlier removal and
2543
+ # then handed over at the survivor's length of that moment, which is the
2544
+ # same offset the batch steps compute up front.
2545
+ # https://github.com/takahashim/dommy/issues/24
2546
+ def __internal_normalize__(root)
2547
+ text_nodes = []
2548
+ root.traverse { |node| text_nodes << node if node.respond_to?(:text?) && node.text? }
2549
+
2550
+ text_nodes.each do |node|
2551
+ next unless node.parent # already removed as part of an earlier run
2552
+
2553
+ if node.content.to_s.empty?
2554
+ remove_node_with_notify(node)
2555
+ next
2556
+ end
2557
+
2558
+ sib = node.next
2559
+ while sib.respond_to?(:text?) && sib.text?
2560
+ following = sib.next
2561
+ data = sib.content.to_s
2562
+ # The offset the sibling's data lands at inside the survivor — the
2563
+ # length of what it already holds, measured before the append.
2564
+ length = wrap_node(node).length
2565
+ # An empty sibling has nothing to append: the engines skip the data
2566
+ # step for it (Gecko checks the length, Blink and WebCore behave the
2567
+ # same), so it is removed without a characterData record. Its range
2568
+ # boundaries still move to the survivor's join.
2569
+ unless data.empty?
2570
+ old = node.content.to_s
2571
+ node.content = old + data
2572
+ notify_character_data_mutation(target_node: node, old_value: old)
2573
+ end
2574
+ # WHATWG normalize() step 6: the merged-away sibling hands its live
2575
+ # range boundaries to the survivor at that offset BEFORE it is
2576
+ # removed, or the plain removing steps would strand them on the
2577
+ # parent.
2578
+ __internal_ranges_normalize_merge__(node, sib, length)
2579
+ remove_node_with_notify(sib)
2580
+ sib = following
2581
+ end
2582
+ end
2583
+
2584
+ nil
2585
+ end
2586
+
2587
+ def __internal_ranges_replaced_data__(node, offset, count, new_length)
2588
+ return unless live_ranges?
2589
+
2590
+ __internal_each_live_range__ { |r| r.__internal_apply_replace_data__(node, offset, count, new_length) }
2591
+ end
2592
+
2593
+ def __internal_ranges_split_text__(node, offset, new_node)
2594
+ return unless live_ranges?
2595
+
2596
+ parent = node.parent_node
2597
+ # Only the parent-anchored rule needs an index, so resolve one lazily.
2598
+ index =
2599
+ if parent && live_ranges_where { |r| r.__internal_anchored_at__(parent) }.any?
2600
+ child_index_of_wrapper(parent, node)
2601
+ end
2602
+ __internal_each_live_range__ { |r| r.__internal_apply_split__(node, offset, new_node, parent, index) }
2603
+ end
2604
+
2605
+ def live_ranges_where
2606
+ out = []
2607
+ __internal_each_live_range__ { |r| out << r if yield(r) }
2608
+ out
2609
+ end
2610
+
2611
+ def child_index_of_wrapper(parent_wrapper, child_wrapper)
2612
+ return nil unless parent_wrapper.respond_to?(:child_nodes)
2613
+
2614
+ parent_wrapper.child_nodes.to_a.index { |c| c.equal?(child_wrapper) }
2615
+ end
2616
+
1810
2617
  # Run the "NodeIterator pre-removing steps" for every live iterator before
1811
2618
  # `backend_node` is detached, so referenceNode/pointerBeforeReferenceNode
1812
2619
  # stay valid. `backend_node` must still be attached (tree intact) here.
1813
2620
  def run_node_iterator_pre_remove(backend_node)
1814
- return if @node_iterators.empty?
2621
+ return unless node_iterators?
1815
2622
 
1816
2623
  removed = wrap_node(backend_node)
1817
- @node_iterators.each { |iter| iter.pre_remove(removed) }
2624
+ @node_iterators.each_key { |iter| iter.pre_remove(removed) }
1818
2625
  end
1819
2626
 
1820
2627
  def notify_attribute_mutation(target_node:, attribute_name:, old_value:, namespace: nil)
@@ -1895,41 +2702,106 @@ module Dommy
1895
2702
  # Build a Nokogiri copy of the given node inside our @backend_doc.
1896
2703
  # `deep: true` recurses into children. Used by importNode and
1897
2704
  # adoptNode for cross-document transfer.
1898
- def clone_into_doc(source, deep)
1899
- copy = if source.element?
1900
- new_el = Backend.create_element(source.name, @backend_doc)
1901
- Backend.attribute_nodes(source).each { |a| new_el[a.name] = a.value }
1902
- new_el
2705
+ def clone_into_doc(source, deep, source_document = self)
2706
+ copy = clone_single_node_into_doc(source, source_document)
2707
+
2708
+ if source.element? && source.name == "template"
2709
+ # A <template>'s contents live in a separate content fragment, not its
2710
+ # child list, so the generic deep pass over `children` misses them.
2711
+ clone_template_content(source, copy, source_document) if deep
2712
+ elsif deep && source.respond_to?(:children)
2713
+ source.children.each do |child|
2714
+ copy.add_child(clone_into_doc(child, true, source_document))
2715
+ end
2716
+ end
2717
+
2718
+ copy
2719
+ end
2720
+
2721
+ # "Clone a single node" (§4.4): the copy implements the SAME interface as
2722
+ # the original — a ProcessingInstruction clones to one, not to the comment
2723
+ # its serialization looks like — and carries the same data. Elements are
2724
+ # cloned by #clone_element_into_doc; children are the caller's business.
2725
+ def clone_single_node_into_doc(source, source_document)
2726
+ if source.element?
2727
+ clone_element_into_doc(source, source_document)
2728
+ elsif (cdata = Backend.cdata_class) && source.is_a?(cdata)
2729
+ # CDATA is a Text subtype in the backend, so ask about it first.
2730
+ Backend.create_cdata(source.content, @backend_doc)
1903
2731
  elsif source.text?
1904
2732
  Backend.create_text(source.content, @backend_doc)
1905
2733
  elsif source.is_a?(Backend.comment_class)
1906
2734
  Backend.create_comment(source.content, @backend_doc)
2735
+ elsif source.is_a?(Backend.processing_instruction_class)
2736
+ Backend.create_processing_instruction(source.target, source.content, @backend_doc)
1907
2737
  elsif source.is_a?(Backend.document_fragment_class)
1908
2738
  # A DocumentFragment clones to a fragment (its children are appended by
1909
- # the deep pass below), NOT to its first child — `importNode(<template>
2739
+ # the deep pass), NOT to its first child — `importNode(<template>
1910
2740
  # .content, true)` must return a fragment so `.firstElementChild` works
1911
2741
  # (Vue/Alpine x-for clone template content this way). Built via the
1912
2742
  # document's own `fragment` (as TemplateContentRegistry does) rather than
1913
2743
  # `document_fragment_class.new`, so it works on backends whose fragment
1914
2744
  # class isn't directly instantiable (Makiri).
1915
- @backend_doc.fragment("")
2745
+ Parser.fragment("", owner_doc: @backend_doc)
2746
+ elsif (doctype = Backend.document_type_class) && source.is_a?(doctype)
2747
+ wrapper = source_document.wrap_node(source)
2748
+ Backend.create_document_type(wrapper.name, wrapper.public_id, wrapper.system_id, @backend_doc)
1916
2749
  else
1917
- # Fallback: serialize + reparse via fragment for unusual types.
1918
- fragment = Parser.fragment(source.to_html, owner_doc: @backend_doc)
1919
- fragment.children.first || Backend.create_text("", @backend_doc)
2750
+ Backend.create_text("", @backend_doc)
1920
2751
  end
2752
+ end
1921
2753
 
1922
- if source.element? && source.name == "template"
1923
- # A <template>'s contents live in a separate content fragment, not its
1924
- # child list, so the generic deep pass over `children` misses them.
1925
- clone_template_content(source, copy) if deep
1926
- elsif deep && source.respond_to?(:children)
1927
- source.children.each do |child|
1928
- copy.add_child(clone_into_doc(child, true))
2754
+ # An element's copy keeps its namespace, its prefix and the exact spelling
2755
+ # of its local name. None of that is on the backend node — the HTML backend
2756
+ # tracks no namespace for an element script created, and createElementNS's
2757
+ # case lives on the wrapper — so the original is read through its wrapper
2758
+ # and the copy is given the same metadata.
2759
+ def clone_element_into_doc(source, source_document)
2760
+ wrapper = source_document.wrap_node(source)
2761
+ namespace, prefix, local, qualified = clone_name_parts(wrapper, source)
2762
+ copy = Backend.create_element_loose(qualified, prefix, local, namespace, @backend_doc) ||
2763
+ Backend.create_element(source.name, @backend_doc)
2764
+ copy_attributes_into(source, copy)
2765
+ note_cloned_element_namespace(copy, namespace, prefix, local, qualified)
2766
+ copy
2767
+ end
2768
+
2769
+ # The four parts of the original's name — namespace, prefix, local name and
2770
+ # qualified name. The wrapper's own metadata has them whenever there is
2771
+ # anything to carry (createElementNS's case and prefix, an XML document's,
2772
+ # the HTML parser's SVG names); otherwise the element is an unprefixed one
2773
+ # whose namespace the backend knows.
2774
+ def clone_name_parts(wrapper, source)
2775
+ meta = wrapper.__internal_namespace_metadata__ if wrapper.respond_to?(:__internal_namespace_metadata__)
2776
+ return meta if meta
2777
+
2778
+ local = wrapper ? wrapper.local_name.to_s : source.name
2779
+ [wrapper&.namespace_uri, nil, local, local]
2780
+ end
2781
+
2782
+ # "Clone a single node" step 2.1 clones the attributes one by one, so each
2783
+ # keeps its own namespace and prefix — an `xml:b` must not flatten into an
2784
+ # attribute whose local name is the qualified string "xml:b".
2785
+ def copy_attributes_into(source, copy)
2786
+ Backend.attribute_nodes(source).each do |attr|
2787
+ info = Backend.attribute_ns_info(attr)
2788
+ if info[:namespace_uri]
2789
+ Backend.set_attribute_ns(copy, info[:namespace_uri], info[:prefix], info[:local_name],
2790
+ info[:qualified_name], info[:value])
2791
+ else
2792
+ copy[info[:qualified_name]] = info[:value]
1929
2793
  end
1930
2794
  end
2795
+ nil
2796
+ end
1931
2797
 
1932
- copy
2798
+ # Give the copy's wrapper the namespace metadata whenever the backend node
2799
+ # alone would report something else (see #clone_element_into_doc).
2800
+ def note_cloned_element_namespace(copy, namespace, prefix, local, qualified)
2801
+ derived = Backend.namespace_of(copy)&.href || (html_document? ? Element::HTML_NAMESPACE : nil)
2802
+ return if derived == namespace && prefix.nil? && local == copy.name
2803
+
2804
+ wrap_cloned_element_ns(copy, namespace, prefix, local, qualified)
1933
2805
  end
1934
2806
 
1935
2807
  # Clone a <template>'s content into a fragment registered as `copy`'s
@@ -1937,13 +2809,14 @@ module Dommy
1937
2809
  # keeps it in a native content fragment, Nokogiri keeps it as direct children
1938
2810
  # before migration and in the registry after — so source it from the registry
1939
2811
  # fragment when migrated, else from Backend.template_content_nodes.
1940
- def clone_template_content(source, copy)
1941
- src_frag = @template_content_registry.raw_fragment_for(source)
2812
+ def clone_template_content(source, copy, source_document = self)
2813
+ registry = source_document.__internal_template_registry__
2814
+ src_frag = registry.raw_fragment_for(source)
1942
2815
  content_nodes = src_frag ? src_frag.children.to_a : Backend.template_content_nodes(source)
1943
2816
  return if content_nodes.empty?
1944
2817
 
1945
- frag = @backend_doc.fragment("")
1946
- content_nodes.each { |n| frag.add_child(clone_into_doc(n, true)) }
2818
+ frag = Parser.fragment("", owner_doc: @backend_doc)
2819
+ content_nodes.each { |n| frag.add_child(clone_into_doc(n, true, source_document)) }
1947
2820
  @template_content_registry.store(copy, frag)
1948
2821
  end
1949
2822
 
@@ -1968,7 +2841,7 @@ module Dommy
1968
2841
  head.add_child(title)
1969
2842
  end
1970
2843
 
1971
- title.children.each(&:unlink)
2844
+ title.children.to_a.each { |child| detach_node(child) }
1972
2845
  title.add_child(Backend.create_text(value, @backend_doc))
1973
2846
  end
1974
2847