Class: Dommy::Document

Inherits:
Object
  • Object
show all
Includes:
EventTarget, Node
Defined in:
lib/dommy/document.rb

Overview

‘document` — the entry point for DOM construction and querying. Wrapper caching keeps DOM identity stable across repeated traversals (`body.children.parentElement`).

Constant Summary collapse

NAME_RE =

Spec-permitted name pattern (XML “Name” production restricted to ASCII for practicality). Used by ‘createElement` and `createAttribute` to validate the argument.

/\A[A-Za-z_][\w\-.:]*\z/.freeze

Constants included from Node

Node::ATTRIBUTE_NODE, Node::CDATA_SECTION_NODE, Node::COMMENT_NODE, Node::DOCUMENT_FRAGMENT_NODE, Node::DOCUMENT_NODE, Node::DOCUMENT_POSITION_CONTAINED_BY, Node::DOCUMENT_POSITION_CONTAINS, Node::DOCUMENT_POSITION_DISCONNECTED, Node::DOCUMENT_POSITION_FOLLOWING, Node::DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC, Node::DOCUMENT_POSITION_PRECEDING, Node::DOCUMENT_TYPE_NODE, Node::ELEMENT_NODE, Node::PROCESSING_INSTRUCTION_NODE, Node::TEXT_NODE

Instance Attribute Summary collapse

Instance Method Summary collapse

Methods included from EventTarget

#__deliver_event__, #add_event_listener, #dispatch_event, #invoke_listener, #remove_event_listener

Constructor Details

#initialize(host = nil, nokogiri_doc: nil, default_view: nil) ⇒ Document

Returns a new instance of Document.



50
51
52
53
54
55
56
57
58
59
60
61
62
# File 'lib/dommy/document.rb', line 50

def initialize(host = nil, nokogiri_doc: nil, default_view: nil)
  @host = host
  @default_view = default_view
  @node_wrapper_cache = Internal::NodeWrapperCache.new(self)
  @observer_manager = Internal::ObserverManager.new
  @shadow_registry = Internal::ShadowRootRegistry.new
  @cookie_jar = Internal::CookieJar.new
  @template_content_registry = Internal::TemplateContentRegistry.new(self)
  @mutation_coordinator = Internal::MutationCoordinator.new(self, @observer_manager)
  @nokogiri_doc = nokogiri_doc || Nokogiri::HTML5("<!doctype html><html><head></head><body></body></html>")
  body_node = @nokogiri_doc.at_css("body")
  @body = wrap_node(body_node) if body_node
end

Instance Attribute Details

#bodyObject (readonly)

Returns the value of attribute body.



47
48
49
# File 'lib/dommy/document.rb', line 47

def body
  @body
end

#default_viewObject

Returns the value of attribute default_view.



48
49
50
# File 'lib/dommy/document.rb', line 48

def default_view
  @default_view
end

#nokogiri_docObject (readonly)

Returns the value of attribute nokogiri_doc.



47
48
49
# File 'lib/dommy/document.rb', line 47

def nokogiri_doc
  @nokogiri_doc
end

Instance Method Details

#[](key) ⇒ Object



330
331
332
# File 'lib/dommy/document.rb', line 330

def [](key)
  __js_get__(key.to_s)
end

#[]=(key, value) ⇒ Object



334
335
336
# File 'lib/dommy/document.rb', line 334

def []=(key, value)
  __js_set__(key.to_s, value)
end

#__event_parent__Object



477
478
479
# File 'lib/dommy/document.rb', line 477

def __event_parent__
  @default_view
end

#__js_call__(method, args) ⇒ Object



414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
# File 'lib/dommy/document.rb', line 414

def __js_call__(method, args)
  case method
  when "createElement"
    create_element(args[0])
  when "createElementNS"
    create_element_ns(args[0], args[1])
  when "createTextNode"
    create_text_node(args[0])
  when "createComment"
    create_comment(args[0])
  when "createDocumentFragment"
    create_document_fragment
  when "querySelector"
    query_selector(args[0])
  when "querySelectorAll"
    query_selector_all(args[0])
  when "getElementById"
    get_element_by_id(args[0])
  when "getElementsByClassName"
    get_elements_by_class_name(args[0])
  when "getElementsByTagName"
    get_elements_by_tag_name(args[0])
  when "getElementsByName"
    get_elements_by_name(args[0])
  when "createAttribute"
    create_attribute(args[0])
  when "createAttributeNS"
    create_attribute_ns(args[0], args[1])
  when "createTreeWalker"
    create_tree_walker(args[0], args[1] || NodeFilter::SHOW_ALL, args[2])
  when "createNodeIterator"
    create_node_iterator(args[0], args[1] || NodeFilter::SHOW_ALL, args[2])
  when "createEvent"
    create_event(args[0])
  when "importNode"
    import_node(args[0], args[1])
  when "adoptNode"
    adopt_node(args[0])
  when "hasFocus"
    has_focus?
  when "getSelection"
    get_selection
  when "elementFromPoint"
    element_from_point(args[0], args[1])
  when "queryCommandSupported"
    query_command_supported(args[0])
  when "addEventListener"
    add_event_listener(args[0], args[1], args[2])
  when "removeEventListener"
    remove_event_listener(args[0], args[1])
  when "dispatchEvent"
    dispatch_event(args[0])
  when "write"
    write(*args)
  when "open"
    open
  when "close"
    close
  else
    nil
  end
end

#__js_get__(key) ⇒ Object



352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
# File 'lib/dommy/document.rb', line 352

def __js_get__(key)
  case key
  when "body"
    @body
  when "head"
    head
  when "doctype"
    doctype
  when "defaultView"
    @default_view
  when "documentElement"
    wrap_node(@nokogiri_doc.at_css("html"))
  when "title"
    read_title
  when "cookie"
    cookie
  when "nodeType"
    9
  when "activeElement"
    active_element
  when "URL", "documentURI"
    url
  when "baseURI"
    base_uri
  when "domain"
    domain
  when "referrer"
    referrer
  when "links"
    links
  when "forms"
    forms
  when "scripts"
    scripts
  when "images"
    images
  when "children"
    children
  when "childElementCount"
    child_element_count
  when "firstElementChild"
    first_element_child
  when "lastElementChild"
    last_element_child
  when "nodeName"
    "#document"
  else
    nil
  end
end

#__js_set__(key, value) ⇒ Object



403
404
405
406
407
408
409
410
411
412
# File 'lib/dommy/document.rb', line 403

def __js_set__(key, value)
  case key
  when "title"
    write_title(value.to_s)
  when "cookie"
    self.cookie = value.to_s
  end

  nil
end

#__notify_attribute_changed__(element, name, old_value, new_value) ⇒ Object



532
533
534
# File 'lib/dommy/document.rb', line 532

def __notify_attribute_changed__(element, name, old_value, new_value)
  @mutation_coordinator.notify_attribute_changed(element, name, old_value, new_value)
end

#__notify_connected__(element) ⇒ Object

Lifecycle callback dispatchers. Errors raised inside user callbacks are swallowed so a single buggy custom element can’t break the whole mutation pipeline. Delegate to MutationCoordinator



516
517
518
# File 'lib/dommy/document.rb', line 516

def __notify_connected__(element)
  @mutation_coordinator.notify_connected(element)
end

#__notify_connected_subtree__(nk) ⇒ Object



524
525
526
# File 'lib/dommy/document.rb', line 524

def __notify_connected_subtree__(nk)
  @mutation_coordinator.notify_connected_subtree(nk)
end

#__notify_disconnected__(element) ⇒ Object



520
521
522
# File 'lib/dommy/document.rb', line 520

def __notify_disconnected__(element)
  @mutation_coordinator.notify_disconnected(element)
end

#__notify_disconnected_subtree__(nk) ⇒ Object



528
529
530
# File 'lib/dommy/document.rb', line 528

def __notify_disconnected_subtree__(nk)
  @mutation_coordinator.notify_disconnected_subtree(nk)
end

#__register_shadow_fragment__(fragment_node, shadow_root) ⇒ Object

ShadowRoot identity registry: map a Nokogiri DocumentFragment (the shadow tree’s backing node) to the wrapping ShadowRoot so slot assignment and event composition can walk from any inner node back to its shadow boundary. Delegate to ShadowRootRegistry



499
500
501
# File 'lib/dommy/document.rb', line 499

def __register_shadow_fragment__(fragment_node, shadow_root)
  @shadow_registry.register(fragment_node, shadow_root)
end

#__reset_wrapper__(nokogiri_node) ⇒ Object

Clear the cached wrapper so the next ‘wrap_node` creates a new one. Used by `customElements.define` to upgrade nodes that were constructed before the registration landed.



489
490
491
# File 'lib/dommy/document.rb', line 489

def __reset_wrapper__(nokogiri_node)
  @node_wrapper_cache.reset_wrapper(nokogiri_node)
end

#__set_active_element__(el) ⇒ Object



178
179
180
# File 'lib/dommy/document.rb', line 178

def __set_active_element__(el)
  @active_element = el
end

#__shadow_root_containing__(node) ⇒ Object



507
508
509
# File 'lib/dommy/document.rb', line 507

def __shadow_root_containing__(node)
  @shadow_registry.find_enclosing(node)
end

#__shadow_root_for_fragment__(fragment_node) ⇒ Object



503
504
505
# File 'lib/dommy/document.rb', line 503

def __shadow_root_for_fragment__(fragment_node)
  @shadow_registry.find_for_fragment(fragment_node)
end

#active_elementObject

Currently-focused element (or body if none). Updated via ‘el.focus()` / `el.blur()`.



174
175
176
# File 'lib/dommy/document.rb', line 174

def active_element
  @active_element || @body
end

#adopt_node(node) ⇒ Object

Move a node from another document into this one. The source node is detached from its previous owner and its ownerDocument becomes this. Returns the (possibly re-wrapped) node.



214
215
216
217
218
219
220
221
222
223
224
225
226
# File 'lib/dommy/document.rb', line 214

def adopt_node(node)
  return nil unless node.respond_to?(:__node__)

  src = node.__node__
  src.unlink if src.parent
  moved = if src.document == @nokogiri_doc
    src
  else
    clone_into_doc(src, true)
  end

  wrap_node(moved)
end

#attach_template_content(template_element, html) ⇒ Object

—– template content helpers (called from Element) —–



604
605
606
# File 'lib/dommy/document.rb', line 604

def attach_template_content(template_element, html)
  @template_content_registry.attach(template_element, html)
end

#base_uriObject

‘document.baseURI` — resolves the first `<base href>` (if any) relative to the document URL; otherwise just the document URL. When `<base href>` is itself absolute, that wins. Browsers also ignore subsequent <base> elements; we mirror that.



95
96
97
98
99
100
101
102
103
104
105
106
107
108
# File 'lib/dommy/document.rb', line 95

def base_uri
  doc_url = url
  base_el = @nokogiri_doc.at_css("base[href]")
  return doc_url unless base_el

  href = base_el["href"].to_s
  return doc_url if href.empty?

  begin
    URI.join(doc_url.to_s.empty? ? "about:blank" : doc_url, href).to_s
  rescue URI::InvalidURIError
    doc_url
  end
end

#child_element_countObject



160
161
162
# File 'lib/dommy/document.rb', line 160

def child_element_count
  children.size
end

#childrenObject

ParentNode mixin (operates on the document’s element children —in practice the ‘<html>` root).



153
154
155
156
157
158
# File 'lib/dommy/document.rb', line 153

def children
  HTMLCollection.new do
    root = @nokogiri_doc.root
    root ? [wrap_node(root)].compact : []
  end
end

#closeObject



326
327
328
# File 'lib/dommy/document.rb', line 326

def close
  nil
end

Delegate to CookieJar



286
287
288
# File 'lib/dommy/document.rb', line 286

def cookie
  @cookie_jar.to_cookie_string
end

#cookie=(value) ⇒ Object



290
291
292
293
# File 'lib/dommy/document.rb', line 290

def cookie=(value)
  @cookie_jar.set_cookie(value)
  nil
end

#create_attribute(name) ⇒ Object

Create a detached Attr. ‘setAttributeNode` attaches it to an element. Per spec, name must match the XML Name production —invalid names throw InvalidCharacterError.



185
186
187
# File 'lib/dommy/document.rb', line 185

def create_attribute(name)
  @node_wrapper_cache.create_attribute(name)
end

#create_attribute_ns(namespace_uri, qualified_name) ⇒ Object



189
190
191
# File 'lib/dommy/document.rb', line 189

def create_attribute_ns(namespace_uri, qualified_name)
  @node_wrapper_cache.create_attribute_ns(namespace_uri, qualified_name)
end

#create_comment(text) ⇒ Object

Create a Comment node. Wraps the Nokogiri comment so it flows through the same wrap_node identity machinery as Element / TextNode.



340
341
342
# File 'lib/dommy/document.rb', line 340

def create_comment(text)
  @node_wrapper_cache.create_comment(text)
end

#create_document_fragmentObject



344
345
346
# File 'lib/dommy/document.rb', line 344

def create_document_fragment
  @node_wrapper_cache.create_document_fragment
end

#create_element(name) ⇒ Object

Delegate factory methods to NodeWrapperCache



582
583
584
# File 'lib/dommy/document.rb', line 582

def create_element(name)
  @node_wrapper_cache.create_element(name)
end

#create_element_ns(namespace_uri, qualified_name) ⇒ Object



295
296
297
# File 'lib/dommy/document.rb', line 295

def create_element_ns(namespace_uri, qualified_name)
  @node_wrapper_cache.create_element_ns(namespace_uri, qualified_name)
end

#create_event(type_name) ⇒ Object

Legacy ‘document.createEvent(“EventName”)` factory. Returns an Event subclass instance whose init still has to be called (`event.initEvent(type, bubbles, cancelable)`). Matches the mapping happy-dom and linkedom use.



232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
# File 'lib/dommy/document.rb', line 232

def create_event(type_name)
  name = type_name.to_s
  case name
  when "Event", "Events", "HTMLEvents"
    Event.new("")
  when "CustomEvent"
    CustomEvent.new("")
  when "MouseEvent", "MouseEvents"
    MouseEvent.new("")
  when "KeyboardEvent", "KeyboardEvents"
    KeyboardEvent.new("")
  else
    Event.new("")
  end
end

#create_node_iterator(root, what_to_show = NodeFilter::SHOW_ALL, filter = nil) ⇒ Object

‘document.createNodeIterator(root, whatToShow?, filter?)` —flat depth-first iteration.



272
273
274
# File 'lib/dommy/document.rb', line 272

def create_node_iterator(root, what_to_show = NodeFilter::SHOW_ALL, filter = nil)
  NodeIterator.new(root, what_to_show, filter)
end

#create_text_node(text) ⇒ Object



586
587
588
# File 'lib/dommy/document.rb', line 586

def create_text_node(text)
  @node_wrapper_cache.create_text_node(text)
end

#create_tree_walker(root, what_to_show = NodeFilter::SHOW_ALL, filter = nil) ⇒ Object

‘document.createTreeWalker(root, whatToShow?, filter?)` — stateful tree traversal with sibling/parent navigation. `filter` may be a Ruby Proc, a JS-bridge callable, or an object with `accept_node` / `acceptNode`.



197
198
199
# File 'lib/dommy/document.rb', line 197

def create_tree_walker(root, what_to_show = NodeFilter::SHOW_ALL, filter = nil)
  TreeWalker.new(root, what_to_show, filter)
end

#doctypeObject

Minimal DocumentType — represents the ‘<!doctype html>` line. Always present in HTML5 documents we parse, so we synthesize a stub object whose only useful field is `name`. Tests just need `nodeType == 10`.



280
281
282
# File 'lib/dommy/document.rb', line 280

def doctype
  @doctype ||= DocumentType.new("html")
end

#document_elementObject



74
75
76
# File 'lib/dommy/document.rb', line 74

def document_element
  wrap_node(@nokogiri_doc.at_css("html"))
end

#domainObject

‘document.domain` — host portion of the URL. Real browsers restrict cross-origin reads of this; we just return the bare host.



112
113
114
115
116
117
# File 'lib/dommy/document.rb', line 112

def domain
  view = @default_view
  return "" unless view&.location

  view.location.__js_get__("hostname").to_s
end

#element_from_point(_x, _y) ⇒ Object



262
263
264
# File 'lib/dommy/document.rb', line 262

def element_from_point(_x, _y)
  nil
end

#first_element_childObject



164
165
166
# File 'lib/dommy/document.rb', line 164

def first_element_child
  wrap_node(@nokogiri_doc.root)
end

#formsObject



133
134
135
136
137
# File 'lib/dommy/document.rb', line 133

def forms
  HTMLCollection.new do
    @nokogiri_doc.css("form").map { |n| wrap_node(n) }.compact
  end
end

#get_element_by_id(id) ⇒ Object



598
599
600
# File 'lib/dommy/document.rb', line 598

def get_element_by_id(id)
  @node_wrapper_cache.get_element_by_id(id)
end

#get_elements_by_class_name(name) ⇒ Object



348
349
350
# File 'lib/dommy/document.rb', line 348

def get_elements_by_class_name(name)
  @node_wrapper_cache.get_elements_by_class_name(name)
end

#get_elements_by_name(name) ⇒ Object



303
304
305
# File 'lib/dommy/document.rb', line 303

def get_elements_by_name(name)
  @node_wrapper_cache.get_elements_by_name(name)
end

#get_elements_by_tag_name(name) ⇒ Object



299
300
301
# File 'lib/dommy/document.rb', line 299

def get_elements_by_tag_name(name)
  @node_wrapper_cache.get_elements_by_tag_name(name)
end

#get_selectionObject



258
259
260
# File 'lib/dommy/document.rb', line 258

def get_selection
  nil
end

#has_focus?Boolean Also known as: has_focus

Stubs for layout / focus / selection / execCommand APIs that don’t apply to a layout-less DOM. They exist so callers don’t hit NoMethodError; semantics are documented as no-op.

Returns:

  • (Boolean)


252
253
254
# File 'lib/dommy/document.rb', line 252

def has_focus?
  true
end

#has_template_content?(nokogiri_node) ⇒ Boolean

Returns:

  • (Boolean)


620
621
622
# File 'lib/dommy/document.rb', line 620

def has_template_content?(nokogiri_node)
  @template_content_registry.has_content?(nokogiri_node)
end

#headObject



78
79
80
# File 'lib/dommy/document.rb', line 78

def head
  wrap_node(@nokogiri_doc.at_css("head"))
end

#imagesObject



145
146
147
148
149
# File 'lib/dommy/document.rb', line 145

def images
  HTMLCollection.new do
    @nokogiri_doc.css("img").map { |n| wrap_node(n) }.compact
  end
end

#import_node(node, deep = false) ⇒ Object

Copy a node from another document into this one. The returned wrapper is owned by ‘this`. Per spec, the source node is left in place. `deep: true` copies the entire subtree.



204
205
206
207
208
209
# File 'lib/dommy/document.rb', line 204

def import_node(node, deep = false)
  return nil unless node.respond_to?(:__node__)

  copy = clone_into_doc(node.__node__, deep)
  wrap_node(copy)
end

#last_element_childObject



168
169
170
# File 'lib/dommy/document.rb', line 168

def last_element_child
  wrap_node(@nokogiri_doc.root)
end

Live HTMLCollection helpers — each call re-queries the document so post-mutation reads reflect the current state.



127
128
129
130
131
# File 'lib/dommy/document.rb', line 127

def links
  HTMLCollection.new do
    @nokogiri_doc.css("a[href], area[href]").map { |n| wrap_node(n) }.compact
  end
end

#migrate_template_descendants(root) ⇒ Object



616
617
618
# File 'lib/dommy/document.rb', line 616

def migrate_template_descendants(root)
  @template_content_registry.migrate_descendants(root)
end

#notify_attribute_mutation(target_node:, attribute_name:, old_value:) ⇒ Object



560
561
562
563
564
565
566
# File 'lib/dommy/document.rb', line 560

def notify_attribute_mutation(target_node:, attribute_name:, old_value:)
  @mutation_coordinator.notify_attribute_mutation(
    target_node: target_node,
    attribute_name: attribute_name,
    old_value: old_value
  )
end

#notify_character_data_mutation(target_node:, old_value:) ⇒ Object



568
569
570
571
572
573
# File 'lib/dommy/document.rb', line 568

def notify_character_data_mutation(target_node:, old_value:)
  @mutation_coordinator.notify_character_data_mutation(
    target_node: target_node,
    old_value: old_value
  )
end

#notify_child_list_mutation(target_node:, added_nodes:, removed_nodes:, previous_sibling: nil, next_sibling: nil) ⇒ Object



544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
# File 'lib/dommy/document.rb', line 544

def notify_child_list_mutation(
  target_node:,
  added_nodes:,
  removed_nodes:,
  previous_sibling: nil,
  next_sibling: nil
)
  @mutation_coordinator.notify_child_list_mutation(
    target_node: target_node,
    added_nodes: added_nodes,
    removed_nodes: removed_nodes,
    previous_sibling: previous_sibling,
    next_sibling: next_sibling
  )
end

#openObject

No-ops — real browsers reset the DOM on ‘open()` and flush pending writes on `close()`. We don’t model the parse pipeline.



322
323
324
# File 'lib/dommy/document.rb', line 322

def open
  nil
end

#query_command_supported(_command) ⇒ Object



266
267
268
# File 'lib/dommy/document.rb', line 266

def query_command_supported(_command)
  false
end

#query_selector(selector) ⇒ Object



590
591
592
# File 'lib/dommy/document.rb', line 590

def query_selector(selector)
  @node_wrapper_cache.query_selector(selector)
end

#query_selector_all(selector) ⇒ Object



594
595
596
# File 'lib/dommy/document.rb', line 594

def query_selector_all(selector)
  @node_wrapper_cache.query_selector_all(selector)
end

#referrerObject

‘document.referrer` — Dommy never has a referring page, so this is always empty.



121
122
123
# File 'lib/dommy/document.rb', line 121

def referrer
  ""
end

#register_observer(observer) ⇒ Object



536
537
538
# File 'lib/dommy/document.rb', line 536

def register_observer(observer)
  @mutation_coordinator.register_observer(observer)
end

#scriptsObject



139
140
141
142
143
# File 'lib/dommy/document.rb', line 139

def scripts
  HTMLCollection.new do
    @nokogiri_doc.css("script").map { |n| wrap_node(n) }.compact
  end
end

#template_content_fragment(template_element) ⇒ Object



608
609
610
# File 'lib/dommy/document.rb', line 608

def template_content_fragment(template_element)
  @template_content_registry.fragment_for(template_element)
end

#template_content_inner_html(template_element) ⇒ Object



612
613
614
# File 'lib/dommy/document.rb', line 612

def template_content_inner_html(template_element)
  @template_content_registry.inner_html_of(template_element)
end

#titleObject

—– Public Ruby API (snake_case) —–



66
67
68
# File 'lib/dommy/document.rb', line 66

def title
  read_title
end

#title=(value) ⇒ Object



70
71
72
# File 'lib/dommy/document.rb', line 70

def title=(value)
  write_title(value.to_s)
end

#unregister_observer(observer) ⇒ Object



540
541
542
# File 'lib/dommy/document.rb', line 540

def unregister_observer(observer)
  @mutation_coordinator.unregister_observer(observer)
end

#urlObject Also known as: document_uri

‘document.URL` / `documentURI` — both return location.href in real browsers (legacy aliases of the same field).



84
85
86
87
# File 'lib/dommy/document.rb', line 84

def url
  view = @default_view
  view&.location ? view.location.href : ""
end

#wrap_node(node) ⇒ Object

Delegate node wrapping to NodeWrapperCache



482
483
484
# File 'lib/dommy/document.rb', line 482

def wrap_node(node)
  @node_wrapper_cache.wrap(node)
end

#write(*args) ⇒ Object

‘document.write(html)` — legacy API. Appends parsed nodes to the body. Real browsers only re-stream the DOM during initial parse; this stub is enough for tests that fire write() during teardown.



310
311
312
313
314
315
316
317
318
# File 'lib/dommy/document.rb', line 310

def write(*args)
  html = args.join
  fragment = Parser.fragment(html, owner_doc: @nokogiri_doc)
  removed = []
  added = fragment.children.to_a
  added.each { |node| @body.__node__.add_child(node) }
  notify_child_list_mutation(target_node: @body.__node__, added_nodes: added, removed_nodes: removed)
  nil
end