dommy 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +3 -3
  3. data/lib/dommy/animation.rb +4 -0
  4. data/lib/dommy/attr.rb +11 -5
  5. data/lib/dommy/backend/makiri_adapter.rb +330 -0
  6. data/lib/dommy/backend.rb +114 -33
  7. data/lib/dommy/blob.rb +2 -0
  8. data/lib/dommy/bridge.rb +11 -0
  9. data/lib/dommy/browser.rb +217 -0
  10. data/lib/dommy/compression_streams.rb +4 -0
  11. data/lib/dommy/crypto.rb +4 -0
  12. data/lib/dommy/css.rb +487 -50
  13. data/lib/dommy/custom_elements.rb +2 -2
  14. data/lib/dommy/data_transfer.rb +2 -0
  15. data/lib/dommy/data_uri.rb +35 -0
  16. data/lib/dommy/deferred_response.rb +59 -0
  17. data/lib/dommy/document.rb +386 -228
  18. data/lib/dommy/dom_exception.rb +2 -0
  19. data/lib/dommy/dom_parser.rb +7 -17
  20. data/lib/dommy/element.rb +502 -155
  21. data/lib/dommy/event.rb +240 -9
  22. data/lib/dommy/fetch.rb +152 -34
  23. data/lib/dommy/form_data.rb +2 -0
  24. data/lib/dommy/history.rb +2 -0
  25. data/lib/dommy/html_canvas_element.rb +230 -0
  26. data/lib/dommy/html_collection.rb +5 -6
  27. data/lib/dommy/html_elements.rb +304 -27
  28. data/lib/dommy/interaction/debug.rb +35 -0
  29. data/lib/dommy/interaction/dom_summary.rb +131 -0
  30. data/lib/dommy/interaction/driver.rb +244 -0
  31. data/lib/dommy/interaction/event_synthesis.rb +56 -0
  32. data/lib/dommy/interaction/field_interactor.rb +117 -0
  33. data/lib/dommy/interaction/form_submission.rb +268 -0
  34. data/lib/dommy/interaction/locator.rb +158 -0
  35. data/lib/dommy/interaction/role_query.rb +58 -0
  36. data/lib/dommy/interaction.rb +32 -0
  37. data/lib/dommy/internal/accessibility_tree.rb +215 -0
  38. data/lib/dommy/internal/accessible_description.rb +38 -0
  39. data/lib/dommy/internal/accessible_name.rb +301 -0
  40. data/lib/dommy/internal/aria_role.rb +252 -0
  41. data/lib/dommy/internal/aria_snapshot.rb +64 -0
  42. data/lib/dommy/internal/aria_state.rb +151 -0
  43. data/lib/dommy/internal/css/calc.rb +242 -0
  44. data/lib/dommy/internal/css/cascade.rb +430 -0
  45. data/lib/dommy/internal/css/color.rb +381 -0
  46. data/lib/dommy/internal/css/computed_style_declaration.rb +130 -0
  47. data/lib/dommy/internal/css/counters.rb +227 -0
  48. data/lib/dommy/internal/css/custom_properties.rb +183 -0
  49. data/lib/dommy/internal/css/media_query.rb +302 -0
  50. data/lib/dommy/internal/css/parser.rb +265 -0
  51. data/lib/dommy/internal/css/property_registry.rb +512 -0
  52. data/lib/dommy/internal/css/rule_index.rb +494 -0
  53. data/lib/dommy/internal/css/supports.rb +158 -0
  54. data/lib/dommy/internal/css/ua_stylesheet.rb +53 -0
  55. data/lib/dommy/internal/css_pseudo_handlers.rb +283 -42
  56. data/lib/dommy/internal/css_rule_text.rb +160 -0
  57. data/lib/dommy/internal/dom_matching.rb +80 -9
  58. data/lib/dommy/internal/element_matching.rb +109 -0
  59. data/lib/dommy/internal/global_functions.rb +33 -0
  60. data/lib/dommy/internal/mutation_coordinator.rb +95 -4
  61. data/lib/dommy/internal/namespaces.rb +49 -5
  62. data/lib/dommy/internal/node_wrapper_cache.rb +163 -26
  63. data/lib/dommy/internal/parent_node.rb +82 -5
  64. data/lib/dommy/internal/selector_ast.rb +124 -0
  65. data/lib/dommy/internal/selector_index.rb +146 -0
  66. data/lib/dommy/internal/selector_matcher.rb +756 -0
  67. data/lib/dommy/internal/selector_parser.rb +283 -131
  68. data/lib/dommy/internal/shadow_root_registry.rb +9 -2
  69. data/lib/dommy/internal/template_content_registry.rb +26 -18
  70. data/lib/dommy/internal/xml_serialization.rb +344 -0
  71. data/lib/dommy/intersection_observer.rb +2 -0
  72. data/lib/dommy/js/bridge_conformance.rb +80 -0
  73. data/lib/dommy/js/constructor_resolver.rb +44 -0
  74. data/lib/dommy/js/custom_element_bridge.rb +90 -0
  75. data/lib/dommy/js/dom_interfaces.rb +162 -0
  76. data/lib/dommy/js/handle_table.rb +60 -0
  77. data/lib/dommy/js/host_bridge.rb +517 -0
  78. data/lib/dommy/js/host_runtime.js +1495 -0
  79. data/lib/dommy/js/import_map.rb +58 -0
  80. data/lib/dommy/js/marshaller.rb +240 -0
  81. data/lib/dommy/js/module_loader.rb +99 -0
  82. data/lib/dommy/js/observable_runtime.js +742 -0
  83. data/lib/dommy/js/runtime.rb +115 -0
  84. data/lib/dommy/js/script_boot.rb +221 -0
  85. data/lib/dommy/js/wire_tags.rb +62 -0
  86. data/lib/dommy/location.rb +2 -0
  87. data/lib/dommy/media_query_list.rb +50 -14
  88. data/lib/dommy/message_channel.rb +22 -6
  89. data/lib/dommy/minitest/assertions.rb +27 -0
  90. data/lib/dommy/mutation_observer.rb +89 -4
  91. data/lib/dommy/navigator.rb +34 -2
  92. data/lib/dommy/node.rb +24 -14
  93. data/lib/dommy/notification.rb +2 -0
  94. data/lib/dommy/parser.rb +1 -1
  95. data/lib/dommy/performance.rb +21 -1
  96. data/lib/dommy/promise.rb +94 -10
  97. data/lib/dommy/range.rb +173 -31
  98. data/lib/dommy/resources.rb +178 -0
  99. data/lib/dommy/rspec/capy_style_matchers.rb +126 -0
  100. data/lib/dommy/scheduler.rb +149 -13
  101. data/lib/dommy/screen.rb +91 -0
  102. data/lib/dommy/shadow_root.rb +76 -13
  103. data/lib/dommy/storage.rb +2 -1
  104. data/lib/dommy/streams.rb +6 -0
  105. data/lib/dommy/text_codec.rb +7 -1
  106. data/lib/dommy/tree_walker.rb +33 -10
  107. data/lib/dommy/url.rb +13 -1
  108. data/lib/dommy/version.rb +1 -1
  109. data/lib/dommy/window.rb +199 -11
  110. data/lib/dommy/worker.rb +8 -4
  111. data/lib/dommy/xml_http_request.rb +47 -6
  112. data/lib/dommy.rb +36 -1
  113. metadata +96 -10
  114. data/lib/dommy/backend/nokogiri_adapter.rb +0 -127
  115. data/lib/dommy/backend/nokolexbor_adapter.rb +0 -117
@@ -71,6 +71,8 @@ module Dommy
71
71
  @public_id
72
72
  when "systemId"
73
73
  @system_id
74
+ when "ownerDocument"
75
+ @owner_document
74
76
  end
75
77
  end
76
78
 
@@ -81,13 +83,16 @@ module Dommy
81
83
  end
82
84
 
83
85
  include Bridge::Methods
84
- js_methods %w[isEqualNode isSameNode getRootNode hasChildNodes normalize compareDocumentPosition
86
+ js_methods %w[isEqualNode isSameNode getRootNode hasChildNodes normalize compareDocumentPosition contains
85
87
  appendChild insertBefore removeChild replaceChild before after replaceWith remove
86
88
  addEventListener removeEventListener dispatchEvent]
87
89
  def __js_call__(method, args)
88
90
  case method
89
91
  when "hasChildNodes"
90
92
  false
93
+ when "contains"
94
+ # A DocumentType is a leaf node: it contains only itself.
95
+ !args[0].nil? && is_same_node(args[0])
91
96
  when "isEqualNode"
92
97
  is_equal_node(args[0])
93
98
  when "isSameNode"
@@ -97,8 +102,12 @@ module Dommy
97
102
  when "compareDocumentPosition"
98
103
  compare_document_position(args[0])
99
104
  when "appendChild", "insertBefore"
105
+ raise Bridge::TypeError, "Argument is not a Node." unless args[0].is_a?(Dommy::Node)
106
+
100
107
  raise DOMException::HierarchyRequestError, "a DocumentType may not have children"
101
108
  when "removeChild", "replaceChild"
109
+ raise Bridge::TypeError, "Argument is not a Node." unless args[0].is_a?(Dommy::Node)
110
+
102
111
  raise DOMException::NotFoundError, "the node to be removed is not a child of this node"
103
112
  when "before"
104
113
  before(*args)
@@ -120,143 +129,22 @@ module Dommy
120
129
  end
121
130
  end
122
131
 
123
- # ProcessingInstruction (`<?target data?>`) a CharacterData-like node with a
124
- # `target`; created via `document.createProcessingInstruction`.
125
- class ProcessingInstruction
126
- include Node
127
-
128
- attr_reader :target
129
-
130
- def initialize(target, data)
131
- @target = target.to_s
132
- @data = data.to_s
133
- end
134
-
135
- include EventTarget
136
-
137
- def data = @data
138
-
139
- def __js_get__(key)
140
- case key
141
- when "target"
142
- @target
143
- when "data", "nodeValue", "textContent"
144
- @data
145
- when "nodeName"
146
- @target
147
- when "nodeType"
148
- 7
149
- when "length"
150
- @data.length
151
- end
152
- end
153
-
154
- def __js_set__(key, value)
155
- case key
156
- when "data", "nodeValue", "textContent"
157
- @data = value.to_s
158
- else
159
- return Bridge::UNHANDLED
160
- end
161
- nil
162
- end
163
-
164
- # A PI is CharacterData: its data methods are string operations on @data.
165
- def substring_data(offset, count)
166
- o = offset.to_i
167
- raise DOMException::IndexSizeError, "offset out of bounds" if o.negative? || o > @data.length
168
-
169
- @data[o, count.to_i] || ""
170
- end
171
-
172
- def append_data(value)
173
- @data += value.to_s
174
- nil
175
- end
176
-
177
- def insert_data(offset, value)
178
- o = offset.to_i
179
- raise DOMException::IndexSizeError, "offset out of bounds" if o.negative? || o > @data.length
180
-
181
- @data = @data[0, o].to_s + value.to_s + (@data[o..] || "")
182
- nil
183
- end
184
-
185
- def delete_data(offset, count)
186
- o = offset.to_i
187
- raise DOMException::IndexSizeError, "offset out of bounds" if o.negative? || o > @data.length
188
-
189
- @data = @data[0, o].to_s + (@data[(o + count.to_i)..] || "")
190
- nil
191
- end
192
-
193
- def replace_data(offset, count, value)
194
- delete_data(offset, count)
195
- insert_data(offset, value)
196
- nil
197
- end
198
-
199
- def __internal_event_parent__
200
- nil
201
- end
202
-
203
- include Bridge::Methods
204
- js_methods %w[isEqualNode isSameNode getRootNode normalize hasChildNodes
205
- appendData insertData deleteData replaceData substringData compareDocumentPosition
206
- appendChild insertBefore removeChild replaceChild before after replaceWith remove
207
- addEventListener removeEventListener dispatchEvent]
208
- def __js_call__(method, args)
209
- case method
210
- when "hasChildNodes"
211
- false
212
- when "isEqualNode"
213
- is_equal_node(args[0])
214
- when "isSameNode"
215
- is_same_node(args[0])
216
- when "getRootNode"
217
- get_root_node(args[0])
218
- when "compareDocumentPosition"
219
- compare_document_position(args[0])
220
- when "appendChild", "insertBefore"
221
- raise DOMException::HierarchyRequestError, "a ProcessingInstruction may not have children"
222
- when "removeChild", "replaceChild"
223
- raise DOMException::NotFoundError, "the node to be removed is not a child of this node"
224
- when "before", "after", "replaceWith", "remove"
225
- # ChildNode mixin: a created PI has no parent, so these are no-ops per
226
- # spec (they act only when the node is inserted in a tree).
227
- nil
228
- when "normalize"
229
- nil
230
- when "substringData"
231
- substring_data(args[0], args[1])
232
- when "appendData"
233
- append_data(args[0])
234
- when "insertData"
235
- insert_data(args[0], args[1])
236
- when "deleteData"
237
- delete_data(args[0], args[1])
238
- when "replaceData"
239
- replace_data(args[0], args[1], args[2])
240
- when "addEventListener"
241
- add_event_listener(args[0], args[1], args[2])
242
- when "removeEventListener"
243
- remove_event_listener(args[0], args[1], args[2])
244
- when "dispatchEvent"
245
- dispatch_event(args[0])
246
- end
247
- end
248
- end
249
-
250
- # `document.implementation` — the DOMImplementation. Only the node factories
251
- # WPT exercises are provided; createDocument/createHTMLDocument are not yet
252
- # implemented (foreign documents).
132
+ # `document.implementation`the DOMImplementation.
253
133
  class DOMImplementation
254
134
  def initialize(document)
255
135
  @document = document
256
136
  end
257
137
 
138
+ # A created DocumentType's node document is the implementation's document.
139
+ # (Qualified-name validation against the QName production is not enforced —
140
+ # a couple of invalid-name WPT cases stay as documented gaps.)
258
141
  def create_document_type(qualified_name, public_id, system_id)
259
- DocumentType.new(qualified_name, public_id, system_id)
142
+ DocumentType.new(qualified_name, public_id, system_id, owner_document: @document)
143
+ end
144
+
145
+ # `hasFeature()` is a no-op that always returns true (DOM Standard).
146
+ def has_feature(*)
147
+ true
260
148
  end
261
149
 
262
150
  # createDocument(namespace, qualifiedName, doctype?) — a fresh XML document,
@@ -264,11 +152,21 @@ module Dommy
264
152
  # non-empty. (The doctype argument is accepted but not stored, as document
265
153
  # equality compares only structure that survives wrap_node.)
266
154
  def create_document(namespace, qualified_name, _doctype = nil)
267
- doc = Document.new(nil, nokogiri_doc: Backend.document_class.new)
155
+ doc = Document.new(nil, backend_doc: Backend.empty_xml_document)
156
+ # createDocument's content type is keyed off the namespace. None is
157
+ # "text/html", so tagName keeps its case; xhtml+xml still routes
158
+ # createElement to the HTML namespace (so an XHTML document isEqualNode
159
+ # an HTML one).
160
+ doc.content_type =
161
+ case namespace.to_s
162
+ when Internal::Namespaces::HTML then "application/xhtml+xml"
163
+ when Internal::Namespaces::SVG then "image/svg+xml"
164
+ else "application/xml"
165
+ end
268
166
  qn = qualified_name.to_s
269
167
  unless qn.empty?
270
168
  el = doc.send(:create_element_ns, namespace, qualified_name)
271
- doc.nokogiri_doc.root = el.__dommy_backend_node__
169
+ Backend.set_document_root(doc.backend_doc, el.__dommy_backend_node__)
272
170
  end
273
171
  doc
274
172
  end
@@ -276,15 +174,15 @@ module Dommy
276
174
  # createHTMLDocument(title?) — a fresh HTML document (doctype + html > head,
277
175
  # body), with an optional <title>.
278
176
  def create_html_document(title = nil)
279
- doc = Document.new(nil, nokogiri_doc: Backend.parse("<!DOCTYPE html><html><head></head><body></body></html>"))
177
+ doc = Document.new(nil, backend_doc: Backend.parse("<!DOCTYPE html><html><head></head><body></body></html>"))
280
178
  doc.title = title.to_s unless title.nil? || title.equal?(Bridge::UNDEFINED)
281
179
  doc
282
180
  end
283
181
 
284
- def __js_get__(_key) = nil
182
+ def __js_get__(_key) = Bridge::ABSENT # method-only; any property read is absent
285
183
 
286
184
  include Bridge::Methods
287
- js_methods %w[createDocumentType createDocument createHTMLDocument]
185
+ js_methods %w[createDocumentType createDocument createHTMLDocument hasFeature]
288
186
  def __js_call__(method, args)
289
187
  case method
290
188
  when "createDocumentType"
@@ -293,6 +191,8 @@ module Dommy
293
191
  create_document(args[0], args[1], args[2])
294
192
  when "createHTMLDocument"
295
193
  create_html_document(args[0])
194
+ when "hasFeature"
195
+ has_feature
296
196
  end
297
197
  end
298
198
  end
@@ -304,13 +204,108 @@ module Dommy
304
204
  include EventTarget
305
205
  include Node
306
206
 
307
- attr_reader :nokogiri_doc
207
+ attr_reader :backend_doc
308
208
  attr_accessor :default_view
209
+ # --- CSS cascade support (Internal::CSS) ---
210
+ # Monotonic counter bumped on every DOM mutation; the CSS layer
211
+ # invalidates its per-document style cache wholesale when it moves.
212
+ # The cache slot itself is owned by Internal::CSS::Cascade.
213
+ attr_accessor :__css_style_cache__
214
+
215
+ def style_generation
216
+ @style_generation || 0
217
+ end
218
+
219
+ def __internal_bump_style_generation__
220
+ @style_generation = style_generation + 1
221
+ nil
222
+ end
223
+
224
+ # A by-id/class/tag index of the backend element tree, memoized per DOM
225
+ # generation, for SelectorMatcher's document-scoped fast path (or nil to tell
226
+ # the caller to walk). Rebuilt lazily only after a mutation bumps
227
+ # style_generation, so it costs one tree walk per generation and pays off when
228
+ # several queries run before the next mutation.
229
+ #
230
+ # Adaptive bypass: if the index keeps getting invalidated after serving only a
231
+ # handful of queries (a mutation-between-every-query workload, where building
232
+ # it never pays back), stop building it and just walk — re-testing
233
+ # periodically. This keeps the worst case at walk speed rather than the ~15%
234
+ # regression an always-on index would add.
235
+ SELECTOR_INDEX_MIN_REUSE = 3 # queries an index must serve to have paid for its build
236
+ SELECTOR_INDEX_LOW_RUN_LIMIT = 8 # consecutive low-reuse generations before bypassing
237
+ SELECTOR_INDEX_RETEST_GAP = 64 # generations to wait before re-testing a bypass
238
+
239
+ def __internal_selector_index__
240
+ gen = style_generation
241
+ if @__sel_idx_gen != gen
242
+ if @__sel_idx
243
+ if @__sel_idx_served.to_i < SELECTOR_INDEX_MIN_REUSE
244
+ @__sel_idx_low = @__sel_idx_low.to_i + 1
245
+ @__sel_idx_bypass = true if @__sel_idx_low >= SELECTOR_INDEX_LOW_RUN_LIMIT
246
+ else
247
+ @__sel_idx_low = 0
248
+ end
249
+ end
250
+ if @__sel_idx_bypass && (@__sel_idx_retest = @__sel_idx_retest.to_i + 1) >= SELECTOR_INDEX_RETEST_GAP
251
+ @__sel_idx_bypass = false
252
+ @__sel_idx_low = 0
253
+ @__sel_idx_retest = 0
254
+ end
255
+ @__sel_idx = nil
256
+ @__sel_idx_served = 0
257
+ @__sel_idx_gen = gen
258
+ end
259
+ return nil if @__sel_idx_bypass
260
+
261
+ @__sel_idx ||= Internal::SelectorIndex.build(@backend_doc)
262
+ @__sel_idx_served += 1
263
+ @__sel_idx
264
+ end
265
+
266
+ # An element-scoped querySelector(All) result cache (the document-rooted one
267
+ # lives in NodeWrapperCache). jQuery `$(el).find(sel)` re-queries the same
268
+ # (element, selector) constantly between mutations; this memoizes the match
269
+ # set, keyed by [scope object_id, kind, selector] and tagged with the DOM
270
+ # generation, so a hit skips the whole combinator match. Capped, and a
271
+ # mutation (style_generation bump) makes every entry stale at once.
272
+ SCOPED_QUERY_CACHE_CAP = 4096
273
+
274
+ def __internal_scoped_query_get(key)
275
+ entry = (@__scoped_query_cache ||= {})[key]
276
+ entry && entry[0] == style_generation ? entry[1] : nil
277
+ end
278
+
279
+ def __internal_scoped_query_set(key, value)
280
+ cache = (@__scoped_query_cache ||= {})
281
+ cache.clear if cache.size >= SCOPED_QUERY_CACHE_CAP
282
+ cache[key] = [style_generation, value]
283
+ value
284
+ end
285
+
286
+ # A host-supplied `->(url) { css_text_or_nil }` resolving @import URLs to
287
+ # CSS (Dommy has no network of its own — same idea as <link> filling).
288
+ # Setting it invalidates cached styles so the next cascade picks up imports.
289
+ attr_reader :css_import_resolver
290
+
291
+ def css_import_resolver=(resolver)
292
+ @css_import_resolver = resolver
293
+ __internal_bump_style_generation__
294
+ end
309
295
  # content_type defaults to "text/html"; settable so an integration layer
310
296
  # can reflect the response Content-Type. Read-only over the JS bridge.
311
297
  attr_accessor :content_type
312
-
313
- def initialize(host = nil, nokogiri_doc: nil, default_view: nil)
298
+ # A `->(source_text) {}` set by the JS layer to execute a classic <script>'s
299
+ # body when it's connected (Dommy has no JS engine of its own). nil = inert
300
+ # scripts (the default for a standalone DOM).
301
+ attr_accessor :script_runner
302
+ # A `->(element, src) {}` set by the integration layer to fetch + execute a
303
+ # classic `<script src>` that's dynamically inserted into the document (e.g.
304
+ # webpack/Vite loading an on-demand chunk via document.head.appendChild). It
305
+ # owns firing the element's load / error event. nil = such scripts are inert.
306
+ attr_accessor :external_script_runner
307
+
308
+ def initialize(host = nil, backend_doc: nil, default_view: nil)
314
309
  @host = host
315
310
  @default_view = default_view
316
311
  @node_wrapper_cache = Internal::NodeWrapperCache.new(self)
@@ -320,8 +315,15 @@ module Dommy
320
315
  @template_content_registry = Internal::TemplateContentRegistry.new(self)
321
316
  @mutation_coordinator = Internal::MutationCoordinator.new(self, @observer_manager)
322
317
  @node_iterators = []
323
- @nokogiri_doc = nokogiri_doc || Backend.parse("<!doctype html><html><head></head><body></body></html>")
318
+ @backend_doc = backend_doc || Backend.parse("<!doctype html><html><head></head><body></body></html>")
324
319
  @content_type = "text/html"
320
+ # The document is fully parsed before scripts run (no incremental network
321
+ # parse), so it defaults to "complete" — ready-gated code takes the
322
+ # already-loaded path. An embedder can replay the real lifecycle
323
+ # ("loading" → "interactive" → "complete") via #__internal_set_ready_state__
324
+ # to drive code that waits on DOMContentLoaded / load.
325
+ @ready_state = "complete"
326
+ @__current_script__ = nil
325
327
  end
326
328
 
327
329
  # Whether this is an "HTML document" in the DOM sense (created by the HTML
@@ -339,7 +341,7 @@ module Dommy
339
341
  # public/system identifier) is no-quirks. (The full quirks algorithm keys off
340
342
  # specific legacy public ids; this covers the common cases.)
341
343
  def compat_mode
342
- dt = @nokogiri_doc.internal_subset
344
+ dt = @backend_doc.internal_subset
343
345
  return "BackCompat" unless dt
344
346
  return "CSS1Compat" if dt.name.to_s.downcase == "html" && dt.external_id.nil?
345
347
 
@@ -358,11 +360,11 @@ module Dommy
358
360
 
359
361
  def document_element
360
362
  # The document's root element — `<html>` for HTML, the actual root for XML.
361
- wrap_node(@nokogiri_doc.root)
363
+ wrap_node(@backend_doc.root)
362
364
  end
363
365
 
364
366
  def head
365
- wrap_node(@nokogiri_doc.at_css("head"))
367
+ wrap_node(@backend_doc.at_css("head"))
366
368
  end
367
369
 
368
370
  # Resolve `body` fresh from the tree (not memoized) so it tracks a swapped
@@ -371,22 +373,34 @@ module Dommy
371
373
  # wrapper would keep returning the detached old body. wrap_node caches by
372
374
  # node, so identity (`document.body === document.body`) still holds.
373
375
  def body
374
- wrap_node(@nokogiri_doc.at_css("body"))
376
+ wrap_node(@backend_doc.at_css("body"))
377
+ end
378
+
379
+ # The document's accessibility tree (built from <body>; the document itself
380
+ # has no accessible node). See Internal::AccessibilityTree.
381
+ def accessibility_tree
382
+ Internal::AccessibilityTree.build(self)
383
+ end
384
+ alias_method :aria_tree, :accessibility_tree
385
+
386
+ # A Playwright-compatible ARIA snapshot of the document.
387
+ def aria_snapshot
388
+ Internal::AriaSnapshot.serialize(accessibility_tree)
375
389
  end
376
390
 
377
391
  # Serialize the whole document to HTML (including the doctype).
378
392
  def to_html
379
- @nokogiri_doc.to_html
393
+ @backend_doc.to_html
380
394
  end
381
395
 
382
396
  # XPath queries returning wrapped nodes (Element / TextNode / etc).
383
397
  def at_xpath(expression)
384
- node = @nokogiri_doc.at_xpath(expression)
398
+ node = @backend_doc.at_xpath(expression)
385
399
  node && wrap_node(node)
386
400
  end
387
401
 
388
402
  def xpath(expression)
389
- @nokogiri_doc.xpath(expression).map { |node| wrap_node(node) }
403
+ @backend_doc.xpath(expression).map { |node| wrap_node(node) }
390
404
  end
391
405
 
392
406
  # `document.URL` / `documentURI` — both return location.href in
@@ -404,7 +418,7 @@ module Dommy
404
418
  # ignore subsequent <base> elements; we mirror that.
405
419
  def base_uri
406
420
  doc_url = url
407
- base_el = @nokogiri_doc.at_css("base[href]")
421
+ base_el = @backend_doc.at_css("base[href]")
408
422
  return doc_url unless base_el
409
423
 
410
424
  href = base_el["href"].to_s
@@ -445,25 +459,25 @@ module Dommy
445
459
  # document so post-mutation reads reflect the current state.
446
460
  def links
447
461
  HTMLCollection.new do
448
- @nokogiri_doc.css("a[href], area[href]").map { |n| wrap_node(n) }.compact
462
+ @backend_doc.css("a[href], area[href]").map { |n| wrap_node(n) }.compact
449
463
  end
450
464
  end
451
465
 
452
466
  def forms
453
467
  HTMLCollection.new do
454
- @nokogiri_doc.css("form").map { |n| wrap_node(n) }.compact
468
+ @backend_doc.css("form").map { |n| wrap_node(n) }.compact
455
469
  end
456
470
  end
457
471
 
458
472
  def scripts
459
473
  HTMLCollection.new do
460
- @nokogiri_doc.css("script").map { |n| wrap_node(n) }.compact
474
+ @backend_doc.css("script").map { |n| wrap_node(n) }.compact
461
475
  end
462
476
  end
463
477
 
464
478
  def images
465
479
  HTMLCollection.new do
466
- @nokogiri_doc.css("img").map { |n| wrap_node(n) }.compact
480
+ @backend_doc.css("img").map { |n| wrap_node(n) }.compact
467
481
  end
468
482
  end
469
483
 
@@ -471,7 +485,7 @@ module Dommy
471
485
  # in practice the `<html>` root).
472
486
  def children
473
487
  HTMLCollection.new do
474
- root = @nokogiri_doc.root
488
+ root = @backend_doc.root
475
489
  root ? [wrap_node(root)].compact : []
476
490
  end
477
491
  end
@@ -481,7 +495,7 @@ module Dommy
481
495
  # `document.childNodes === document.childNodes` and mutations are reflected.
482
496
  def child_nodes
483
497
  @live_child_nodes ||= LiveNodeList.new do
484
- @nokogiri_doc.children.map { |n| wrap_node(n) }.compact
498
+ @backend_doc.children.map { |n| wrap_node(n) }.compact
485
499
  end
486
500
  end
487
501
 
@@ -490,11 +504,11 @@ module Dommy
490
504
  end
491
505
 
492
506
  def first_element_child
493
- wrap_node(@nokogiri_doc.root)
507
+ wrap_node(@backend_doc.root)
494
508
  end
495
509
 
496
510
  def last_element_child
497
- wrap_node(@nokogiri_doc.root)
511
+ wrap_node(@backend_doc.root)
498
512
  end
499
513
 
500
514
  # Currently-focused element (or body if none). Updated via
@@ -511,13 +525,37 @@ module Dommy
511
525
  return false unless other.respond_to?(:__dommy_backend_node__)
512
526
 
513
527
  node = other.__dommy_backend_node__
514
- node.document == @nokogiri_doc && node.ancestors.include?(@nokogiri_doc)
528
+ node.document == @backend_doc && node.ancestors.include?(@backend_doc)
515
529
  end
516
530
 
517
531
  def __internal_set_active_element__(el)
532
+ # Focus is selector-observable state (:focus / :focus-within rules), so
533
+ # a change invalidates computed styles.
534
+ __internal_bump_style_generation__ unless @active_element.equal?(el)
518
535
  @active_element = el
519
536
  end
520
537
 
538
+ # The explicitly focused element (nil when nothing holds focus) — what
539
+ # :focus matches. Distinct from #active_element, which falls back to
540
+ # <body> per spec.
541
+ def __internal_focused_element__
542
+ @active_element
543
+ end
544
+
545
+ # The element the (virtual) pointer hovers — :hover matches it and its
546
+ # ancestors. Set from tests or capybara-dommy's Node#hover; nil clears.
547
+ def __internal_hovered_element__
548
+ @hovered_element
549
+ end
550
+
551
+ def __internal_set_hovered_element__(el)
552
+ return if @hovered_element.equal?(el)
553
+
554
+ @hovered_element = el
555
+ __internal_bump_style_generation__
556
+ nil
557
+ end
558
+
521
559
  # Create a detached Attr. `setAttributeNode` attaches it to an
522
560
  # element. Per spec, name must match the XML Name production —
523
561
  # invalid names throw InvalidCharacterError.
@@ -534,7 +572,15 @@ module Dommy
534
572
  # Ruby Proc, a JS-bridge callable, or an object with
535
573
  # `accept_node` / `acceptNode`.
536
574
  def create_tree_walker(root, what_to_show = NodeFilter::SHOW_ALL, filter = nil)
537
- TreeWalker.new(root, what_to_show, filter)
575
+ TreeWalker.new(require_node_root(root), what_to_show, filter)
576
+ end
577
+
578
+ # The `root` of a TreeWalker / NodeIterator is a non-nullable WebIDL `Node`:
579
+ # a null or non-Node argument is a TypeError before construction.
580
+ def require_node_root(root)
581
+ return root if root.is_a?(Dommy::Node)
582
+
583
+ raise Bridge::TypeError, "createTreeWalker/createNodeIterator root must be a Node"
538
584
  end
539
585
 
540
586
  # WebIDL `unsigned long whatToShow = 0xFFFFFFFF`: an omitted or `undefined`
@@ -576,25 +622,22 @@ module Dommy
576
622
  src.unlink if src.parent
577
623
 
578
624
  # Same document: just return the wrapper after the detach above.
579
- return wrap_node(src) if src.document == @nokogiri_doc
625
+ return wrap_node(src) if src.document == @backend_doc
580
626
 
581
- # Cross-document: Nokogiri reassigns `src.document` when src is
582
- # added under a node owned by another document. We transiently
583
- # attach to our root, then unlink so src ends up free-floating
584
- # but now belongs to @nokogiri_doc. The underlying Ruby object
585
- # identity is preserved.
627
+ # Cross-document: hand the detached source to the backend, which
628
+ # returns the node now owned by this document an imported copy for
629
+ # Makiri (a node can't move between arenas). Drop the stale source
630
+ # wrapper, then reseat the caller's Dommy wrapper onto the adopted
631
+ # node so `adopt_node(x).equal?(x)` stays true across documents.
586
632
  src_doc_wrapper = node.instance_variable_get(:@document)
587
- @nokogiri_doc.root.add_child(src)
588
- src.unlink
633
+ adopted = Backend.adopt(src, @backend_doc)
589
634
 
590
- # Move the caller's Dommy wrapper from the source document's
591
- # wrapper cache into ours, and re-point its @document. This
592
- # keeps `adopt_node(x).equal?(x)` true across documents.
593
- node.instance_variable_set(:@document, self)
594
635
  if src_doc_wrapper.respond_to?(:__internal_reset_wrapper__)
595
636
  src_doc_wrapper.__internal_reset_wrapper__(src)
596
637
  end
597
- @node_wrapper_cache.register(src, node)
638
+ node.instance_variable_set(:@document, self)
639
+ node.instance_variable_set(:@__node__, adopted)
640
+ @node_wrapper_cache.register(adopted, node)
598
641
  node
599
642
  end
600
643
 
@@ -670,13 +713,31 @@ module Dommy
670
713
  # `document.createNodeIterator(root, whatToShow?, filter?)` —
671
714
  # flat depth-first iteration.
672
715
  def create_node_iterator(root, what_to_show = NodeFilter::SHOW_ALL, filter = nil)
716
+ root = require_node_root(root)
673
717
  iterator = NodeIterator.new(root, what_to_show, filter)
674
- # Track live iterators so node removal can run the "NodeIterator
675
- # pre-removing steps" (adjusting referenceNode) before a node detaches.
676
- @node_iterators << iterator
718
+ # The "NodeIterator pre-removing steps" run for iterators whose root's node
719
+ # document is the removed node's document. Track the iterator on the root's
720
+ # document — which is `self` for a same-document root, but a different
721
+ # document when the root came from elsewhere (e.g.
722
+ # implementation.createHTMLDocument), where the removal fires.
723
+ node_iterator_document(root).__internal_track_node_iterator__(iterator)
677
724
  iterator
678
725
  end
679
726
 
727
+ # The document that owns `root`'s subtree (where its removals fire), so a
728
+ # NodeIterator is tracked where its pre-removing steps will run. Falls back
729
+ # to `self` for a root with no resolvable document.
730
+ def node_iterator_document(root)
731
+ return root if root.is_a?(Dommy::Document)
732
+
733
+ doc = root.instance_variable_get(:@document)
734
+ doc.is_a?(Dommy::Document) ? doc : self
735
+ end
736
+
737
+ def __internal_track_node_iterator__(iterator)
738
+ @node_iterators << iterator
739
+ end
740
+
680
741
  # Minimal DocumentType — represents the `<!doctype html>` line.
681
742
  # Always present in HTML5 documents we parse, so we synthesize a
682
743
  # stub object whose only useful field is `name`. Tests just need
@@ -692,7 +753,7 @@ module Dommy
692
753
  end
693
754
 
694
755
  def create_processing_instruction(target, data)
695
- ProcessingInstruction.new(target, data)
756
+ @node_wrapper_cache.create_processing_instruction(target, data)
696
757
  end
697
758
 
698
759
  # Append a node as a child of the document itself (e.g. a comment alongside
@@ -700,37 +761,38 @@ module Dommy
700
761
  def append_child(node)
701
762
  return node unless node.respond_to?(:__dommy_backend_node__)
702
763
 
703
- @nokogiri_doc.add_child(node.__dommy_backend_node__)
764
+ # appendChild adopts a node from another document (per spec). Only needed on
765
+ # a backend that can't move a node across documents (Makiri).
766
+ if !Backend.moves_nodes_across_documents? && node.respond_to?(:document) && !node.document.equal?(self)
767
+ node = adopt_node(node)
768
+ end
769
+ @backend_doc.add_child(node.__dommy_backend_node__)
704
770
  node
705
771
  end
706
772
 
707
773
  # ParentNode / Node mutation on the document's direct children (the doctype
708
- # and the document element). Operate on the Nokogiri document node; string
709
- # arguments (which would need a text child the document can't hold) are
710
- # ignored rather than raising.
774
+ # and the document element).
711
775
  def document_insert(args, prepend:)
712
776
  nodes = args.filter_map { |a| backend_node(a) }
713
- if prepend && (first = @nokogiri_doc.children.first)
777
+ if prepend && (first = @backend_doc.children.first)
714
778
  nodes.reverse_each { |n| first.add_previous_sibling(n) }
715
779
  else
716
- nodes.each { |n| @nokogiri_doc.add_child(n) }
780
+ nodes.each { |n| @backend_doc.add_child(n) }
717
781
  end
718
782
  nil
719
783
  end
720
784
 
721
785
  def document_replace_children(args)
722
- @nokogiri_doc.children.each(&:unlink)
723
- args.filter_map { |a| backend_node(a) }.each { |n| @nokogiri_doc.add_child(n) }
786
+ @backend_doc.children.each(&:unlink)
787
+ args.filter_map { |a| backend_node(a) }.each { |n| @backend_doc.add_child(n) }
724
788
  nil
725
789
  end
726
790
 
727
791
  def document_remove_child(node)
728
- # The doctype is synthesized from the Nokogiri DTD rather than wrapped as a
729
- # tree node, so remove the internal subset directly.
730
792
  return __internal_remove_doctype__(node) if node.is_a?(DocumentType)
731
793
 
732
794
  bn = backend_node(node)
733
- raise DOMException::NotFoundError, "node is not a child of this document" unless bn && bn.parent == @nokogiri_doc
795
+ raise DOMException::NotFoundError, "node is not a child of this document" unless bn && bn.parent == @backend_doc
734
796
 
735
797
  bn.unlink
736
798
  node
@@ -741,17 +803,17 @@ module Dommy
741
803
  return node unless bn
742
804
 
743
805
  ref_node = ref && backend_node(ref)
744
- if ref_node && ref_node.parent == @nokogiri_doc
806
+ if ref_node && ref_node.parent == @backend_doc
745
807
  ref_node.add_previous_sibling(bn)
746
808
  else
747
- @nokogiri_doc.add_child(bn)
809
+ @backend_doc.add_child(bn)
748
810
  end
749
811
  node
750
812
  end
751
813
 
752
814
  def document_replace_child(new_child, old_child)
753
815
  old_bn = backend_node(old_child)
754
- raise DOMException::NotFoundError, "node is not a child of this document" unless old_bn && old_bn.parent == @nokogiri_doc
816
+ raise DOMException::NotFoundError, "node is not a child of this document" unless old_bn && old_bn.parent == @backend_doc
755
817
 
756
818
  new_bn = backend_node(new_child)
757
819
  old_bn.add_previous_sibling(new_bn) if new_bn
@@ -763,7 +825,7 @@ module Dommy
763
825
  # remove the internal subset and mark it gone.
764
826
  def __internal_remove_doctype__(_doctype)
765
827
  @doctype_removed = true
766
- @nokogiri_doc.internal_subset&.unlink
828
+ @backend_doc.internal_subset&.unlink
767
829
  nil
768
830
  end
769
831
 
@@ -772,20 +834,20 @@ module Dommy
772
834
  def __internal_insert_at_doctype__(nodes, after:)
773
835
  bns = nodes.filter_map { |n| backend_node(n) }
774
836
  if after
775
- root = @nokogiri_doc.root
776
- root ? bns.each { |n| root.add_previous_sibling(n) } : bns.each { |n| @nokogiri_doc.add_child(n) }
837
+ root = @backend_doc.root
838
+ root ? bns.each { |n| root.add_previous_sibling(n) } : bns.each { |n| @backend_doc.add_child(n) }
777
839
  else
778
- first = @nokogiri_doc.children.first
779
- first ? bns.reverse_each { |n| first.add_previous_sibling(n) } : bns.each { |n| @nokogiri_doc.add_child(n) }
840
+ first = @backend_doc.children.first
841
+ first ? bns.reverse_each { |n| first.add_previous_sibling(n) } : bns.each { |n| @backend_doc.add_child(n) }
780
842
  end
781
843
  nil
782
844
  end
783
845
 
784
846
  # `document.cloneNode(deep)` → a fresh Document over a (deep) copy of the
785
- # Nokogiri tree, preserving the content type.
847
+ # Makiri tree, preserving the content type.
786
848
  def clone_node(deep)
787
- copy = deep ? @nokogiri_doc.dup : Backend.document_class.new
788
- Document.new(nil, nokogiri_doc: copy).tap { |d| d.content_type = @content_type }
849
+ copy = deep ? Backend.clone_document(@backend_doc) : Backend.empty_document_like(@backend_doc)
850
+ Document.new(nil, backend_doc: copy).tap { |d| d.content_type = @content_type }
789
851
  end
790
852
 
791
853
  def backend_node(node)
@@ -816,7 +878,7 @@ module Dommy
816
878
  end
817
879
 
818
880
  def get_elements_by_tag_name_ns(namespace, local_name)
819
- HTMLCollection.elements_by_tag_name_ns(@nokogiri_doc, self, namespace, local_name)
881
+ HTMLCollection.elements_by_tag_name_ns(@backend_doc, self, namespace, local_name)
820
882
  end
821
883
 
822
884
  # `document.write(html)` — legacy API. Appends parsed nodes to the
@@ -824,7 +886,7 @@ module Dommy
824
886
  # this stub is enough for tests that fire write() during teardown.
825
887
  def write(*args)
826
888
  html = args.join
827
- fragment = Parser.fragment(html, owner_doc: @nokogiri_doc)
889
+ fragment = Parser.fragment(html, owner_doc: @backend_doc)
828
890
  removed = []
829
891
  added = fragment.children.to_a
830
892
  body_node = body.__dommy_backend_node__
@@ -851,14 +913,22 @@ module Dommy
851
913
  __js_set__(key.to_s, value)
852
914
  end
853
915
 
854
- # Create a Comment node. Wraps the Nokogiri comment so it flows
916
+ # Create a Comment node. Wraps the Makiri comment so it flows
855
917
  # through the same wrap_node identity machinery as Element / TextNode.
856
918
  def create_comment(text)
857
919
  @node_wrapper_cache.create_comment(text)
858
920
  end
859
921
 
860
922
  def create_cdata_section(text)
861
- @node_wrapper_cache.create_cdata_section(text)
923
+ # WHATWG: createCDATASection throws NotSupportedError on an HTML document
924
+ # (CDATA sections exist only in XML). This also sidesteps Lexbor's HTML
925
+ # serializer, which can't emit a CDATA node.
926
+ raise DOMException::NotSupportedError, "createCDATASection is not supported on an HTML document" if html_document?
927
+
928
+ str = text.to_s
929
+ raise DOMException::InvalidCharacterError, "CDATA section data must not contain ']]>'" if str.include?("]]>")
930
+
931
+ @node_wrapper_cache.create_cdata_section(str)
862
932
  end
863
933
 
864
934
  def create_document_fragment
@@ -886,10 +956,10 @@ module Dommy
886
956
  when "fullscreenEnabled"
887
957
  true
888
958
  when "scrollingElement"
889
- wrap_node(@nokogiri_doc.at_css("html"))
959
+ wrap_node(@backend_doc.at_css("html"))
890
960
  when "documentElement"
891
961
  # The document's root element — `<html>` for HTML, the actual root for XML.
892
- wrap_node(@nokogiri_doc.root)
962
+ wrap_node(@backend_doc.root)
893
963
  when "title"
894
964
  read_title
895
965
  when "cookie"
@@ -921,11 +991,10 @@ module Dommy
921
991
  when "lastModified"
922
992
  @last_modified || "01/01/1970 00:00:00"
923
993
  when "readyState"
924
- # The document is fully parsed by the time scripts run against it (there
925
- # is no incremental network parse), so it is always "complete". Code that
926
- # gates on `document.readyState === "loading"` (e.g. Turbo's preloader)
927
- # therefore takes the already-loaded path.
928
- "complete"
994
+ # "complete" by default (the document is fully parsed before scripts
995
+ # run); an embedder can replay "loading" "interactive" "complete"
996
+ # via #__internal_set_ready_state__.
997
+ @ready_state
929
998
  when "visibilityState"
930
999
  # There's no real viewport/tab; the document is treated as the visible,
931
1000
  # foreground page (so `nextRepaint`-style code uses requestAnimationFrame,
@@ -943,18 +1012,23 @@ module Dommy
943
1012
  forms
944
1013
  when "scripts"
945
1014
  scripts
1015
+ when "currentScript"
1016
+ # The <script> currently executing, set by the host around each script
1017
+ # run (see #__internal_set_current_script__); null outside execution.
1018
+ @__current_script__
946
1019
  when "images"
947
1020
  images
948
1021
  when "embeds", "plugins"
949
1022
  # Both reflect the same list of <embed> elements.
950
- HTMLCollection.new { @nokogiri_doc.css("embed").map { |n| wrap_node(n) }.compact }
1023
+ HTMLCollection.new { @backend_doc.css("embed").map { |n| wrap_node(n) }.compact }
1024
+ when "applets"
1025
+ # `<applet>` was removed from HTML, so this collection is always empty.
1026
+ HTMLCollection.new { [] }
951
1027
  when "anchors"
952
1028
  # Historically `<a name>` (with a name attribute), not every link.
953
- HTMLCollection.new { @nokogiri_doc.css("a[name]").map { |n| wrap_node(n) }.compact }
1029
+ HTMLCollection.new { @backend_doc.css("a[name]").map { |n| wrap_node(n) }.compact }
954
1030
  when "styleSheets"
955
- # No CSS engine; expose an empty (but present + iterable) StyleSheetList
956
- # so `document.styleSheets.length` / iteration don't blow up.
957
- NodeList.new
1031
+ style_sheets
958
1032
  when "children"
959
1033
  children
960
1034
  when "childNodes"
@@ -976,7 +1050,7 @@ module Dommy
976
1050
  when "nodeName"
977
1051
  "#document"
978
1052
  else
979
- nil
1053
+ Bridge::ABSENT # unknown property: JS undefined, `in` reports absent
980
1054
  end
981
1055
  end
982
1056
 
@@ -1012,12 +1086,20 @@ module Dommy
1012
1086
  adoptNode hasFocus getSelection elementFromPoint queryCommandSupported addEventListener
1013
1087
  removeEventListener dispatchEvent write writeln open close isEqualNode appendChild
1014
1088
  hasChildNodes contains append prepend replaceChildren removeChild insertBefore replaceChild
1015
- cloneNode normalize
1089
+ cloneNode normalize compareDocumentPosition getRootNode
1016
1090
  ]
1017
1091
  def __js_call__(method, args)
1018
1092
  case method
1093
+ when "getRootNode"
1094
+ # A document is its own root (no shadow tree above it), for any options.
1095
+ # Exposing this is load-bearing: React's resource hoisting computes its
1096
+ # "resource root" as `container.getRootNode()` and throws (#446) if the
1097
+ # document lacks it, falling back to the document's null ownerDocument.
1098
+ self
1019
1099
  when "hasChildNodes"
1020
- @nokogiri_doc.children.any?
1100
+ @backend_doc.children.any?
1101
+ when "compareDocumentPosition"
1102
+ compare_document_position(args[0])
1021
1103
  when "contains"
1022
1104
  contains?(args[0])
1023
1105
  when "isEqualNode"
@@ -1129,6 +1211,36 @@ module Dommy
1129
1211
  @default_view
1130
1212
  end
1131
1213
 
1214
+ # Replay a document-lifecycle transition: set readyState, fire
1215
+ # `readystatechange`, then the milestone event for the new state —
1216
+ # `DOMContentLoaded` (bubbles, dispatched on the document) when it becomes
1217
+ # "interactive", and `load` (on the window) when it becomes "complete". Lets
1218
+ # an embedder drive code that waits on the document lifecycle (Stimulus /
1219
+ # Turbo startup, jQuery `ready`, …). No-op when already in `state`.
1220
+ def __internal_set_ready_state__(state)
1221
+ state = state.to_s
1222
+ return if @ready_state == state
1223
+
1224
+ @ready_state = state
1225
+ dispatch_event(Event.new("readystatechange"))
1226
+ case state
1227
+ when "interactive"
1228
+ dispatch_event(Event.new("DOMContentLoaded", "bubbles" => true))
1229
+ when "complete"
1230
+ @default_view&.dispatch_event(Event.new("load"))
1231
+ end
1232
+ nil
1233
+ end
1234
+
1235
+ # Set `document.currentScript` to the <script> element being executed (and
1236
+ # back to nil afterward). The host (script boot) brackets each classic
1237
+ # script run with this so code reading `document.currentScript` sees its own
1238
+ # element, matching browser behavior.
1239
+ def __internal_set_current_script__(element)
1240
+ @__current_script__ = element
1241
+ nil
1242
+ end
1243
+
1132
1244
  # Delegate node wrapping to NodeWrapperCache
1133
1245
  def wrap_node(node)
1134
1246
  @node_wrapper_cache.wrap(node)
@@ -1159,6 +1271,12 @@ module Dommy
1159
1271
  @shadow_registry.find_enclosing(node)
1160
1272
  end
1161
1273
 
1274
+ # Every ShadowRoot attached in this document — the cascade collects each
1275
+ # one's <style> sheets and scopes them to that shadow tree.
1276
+ def __internal_all_shadow_roots__
1277
+ @shadow_registry.all
1278
+ end
1279
+
1162
1280
  # Lifecycle callback dispatchers. Errors raised inside user
1163
1281
  # callbacks are swallowed so a single buggy custom element can't
1164
1282
  # break the whole mutation pipeline.
@@ -1273,6 +1391,16 @@ module Dommy
1273
1391
  @node_wrapper_cache.query_selector_all(selector)
1274
1392
  end
1275
1393
 
1394
+ # `document.styleSheets` — the CSSStyleSheet of each <style> and
1395
+ # <link rel=stylesheet> in document order (CSSOM). Computed on access so
1396
+ # it reflects the current tree.
1397
+ def style_sheets
1398
+ sheets = query_selector_all("style, link").filter_map do |element|
1399
+ element.sheet if element.respond_to?(:sheet)
1400
+ end
1401
+ NodeList.new(sheets)
1402
+ end
1403
+
1276
1404
  def get_element_by_id(id)
1277
1405
  @node_wrapper_cache.get_element_by_id(id)
1278
1406
  end
@@ -1301,25 +1429,38 @@ module Dommy
1301
1429
 
1302
1430
  private
1303
1431
 
1304
- # Build a Nokogiri copy of the given node inside our @nokogiri_doc.
1432
+ # Build a Nokogiri copy of the given node inside our @backend_doc.
1305
1433
  # `deep: true` recurses into children. Used by importNode and
1306
1434
  # adoptNode for cross-document transfer.
1307
1435
  def clone_into_doc(source, deep)
1308
1436
  copy = if source.element?
1309
- new_el = Backend.create_element(source.name, @nokogiri_doc)
1437
+ new_el = Backend.create_element(source.name, @backend_doc)
1310
1438
  Backend.attribute_nodes(source).each { |a| new_el[a.name] = a.value }
1311
1439
  new_el
1312
1440
  elsif source.text?
1313
- Backend.create_text(source.content, @nokogiri_doc)
1441
+ Backend.create_text(source.content, @backend_doc)
1314
1442
  elsif source.is_a?(Backend.comment_class)
1315
- Backend.create_comment(source.content, @nokogiri_doc)
1443
+ Backend.create_comment(source.content, @backend_doc)
1444
+ elsif source.is_a?(Backend.document_fragment_class)
1445
+ # A DocumentFragment clones to a fragment (its children are appended by
1446
+ # the deep pass below), NOT to its first child — `importNode(<template>
1447
+ # .content, true)` must return a fragment so `.firstElementChild` works
1448
+ # (Vue/Alpine x-for clone template content this way). Built via the
1449
+ # document's own `fragment` (as TemplateContentRegistry does) rather than
1450
+ # `document_fragment_class.new`, so it works on backends whose fragment
1451
+ # class isn't directly instantiable (Makiri).
1452
+ @backend_doc.fragment("")
1316
1453
  else
1317
1454
  # Fallback: serialize + reparse via fragment for unusual types.
1318
- fragment = Parser.fragment(source.to_html, owner_doc: @nokogiri_doc)
1319
- fragment.children.first || Backend.create_text("", @nokogiri_doc)
1455
+ fragment = Parser.fragment(source.to_html, owner_doc: @backend_doc)
1456
+ fragment.children.first || Backend.create_text("", @backend_doc)
1320
1457
  end
1321
1458
 
1322
- if deep && source.respond_to?(:children)
1459
+ if source.element? && source.name == "template"
1460
+ # A <template>'s contents live in a separate content fragment, not its
1461
+ # child list, so the generic deep pass over `children` misses them.
1462
+ clone_template_content(source, copy) if deep
1463
+ elsif deep && source.respond_to?(:children)
1323
1464
  source.children.each do |child|
1324
1465
  copy.add_child(clone_into_doc(child, true))
1325
1466
  end
@@ -1328,24 +1469,39 @@ module Dommy
1328
1469
  copy
1329
1470
  end
1330
1471
 
1472
+ # Clone a <template>'s content into a fragment registered as `copy`'s
1473
+ # template content. The source content lives backend-dependently — Makiri
1474
+ # keeps it in a native content fragment, Nokogiri keeps it as direct children
1475
+ # before migration and in the registry after — so source it from the registry
1476
+ # fragment when migrated, else from Backend.template_content_nodes.
1477
+ def clone_template_content(source, copy)
1478
+ src_frag = @template_content_registry.raw_fragment_for(source)
1479
+ content_nodes = src_frag ? src_frag.children.to_a : Backend.template_content_nodes(source)
1480
+ return if content_nodes.empty?
1481
+
1482
+ frag = @backend_doc.fragment("")
1483
+ content_nodes.each { |n| frag.add_child(clone_into_doc(n, true)) }
1484
+ @template_content_registry.store(copy, frag)
1485
+ end
1486
+
1331
1487
  def read_title
1332
- head = @nokogiri_doc.at_css("head")
1488
+ head = @backend_doc.at_css("head")
1333
1489
  title = head&.at_css("title")
1334
1490
  title ? title.text : ""
1335
1491
  end
1336
1492
 
1337
1493
  def write_title(value)
1338
- head = @nokogiri_doc.at_css("head")
1494
+ head = @backend_doc.at_css("head")
1339
1495
  return unless head
1340
1496
 
1341
1497
  title = head.at_css("title")
1342
1498
  unless title
1343
- title = Backend.create_element("title", @nokogiri_doc)
1499
+ title = Backend.create_element("title", @backend_doc)
1344
1500
  head.add_child(title)
1345
1501
  end
1346
1502
 
1347
1503
  title.children.each(&:unlink)
1348
- title.add_child(Backend.create_text(value, @nokogiri_doc))
1504
+ title.add_child(Backend.create_text(value, @backend_doc))
1349
1505
  end
1350
1506
 
1351
1507
  end
@@ -1384,6 +1540,8 @@ module Dommy
1384
1540
  @ready
1385
1541
  when "updateCallbackDone"
1386
1542
  @update_callback_done
1543
+ else
1544
+ Bridge::ABSENT
1387
1545
  end
1388
1546
  end
1389
1547