dommy 0.9.0 → 0.11.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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/lib/dommy/attr.rb +50 -13
  3. data/lib/dommy/backend/makiri_adapter.rb +56 -0
  4. data/lib/dommy/backend.rb +47 -0
  5. data/lib/dommy/blob.rb +48 -6
  6. data/lib/dommy/browser.rb +246 -36
  7. data/lib/dommy/callable_invoker.rb +16 -4
  8. data/lib/dommy/css.rb +33 -10
  9. data/lib/dommy/custom_elements.rb +42 -2
  10. data/lib/dommy/document.rb +1299 -152
  11. data/lib/dommy/dom_parser.rb +12 -1
  12. data/lib/dommy/element.rb +824 -413
  13. data/lib/dommy/event.rb +744 -115
  14. data/lib/dommy/fetch.rb +431 -25
  15. data/lib/dommy/history.rb +24 -3
  16. data/lib/dommy/html_collection.rb +148 -21
  17. data/lib/dommy/html_elements.rb +2307 -317
  18. data/lib/dommy/interaction/driver.rb +177 -14
  19. data/lib/dommy/interaction/event_synthesis.rb +111 -6
  20. data/lib/dommy/interaction/field_interactor.rb +73 -7
  21. data/lib/dommy/internal/accessible_name.rb +45 -8
  22. data/lib/dommy/internal/aria_role.rb +5 -3
  23. data/lib/dommy/internal/child_node.rb +313 -0
  24. data/lib/dommy/internal/css/cascade.rb +47 -3
  25. data/lib/dommy/internal/css/parser.rb +97 -14
  26. data/lib/dommy/internal/css/rule_index.rb +288 -18
  27. data/lib/dommy/internal/css_priority.rb +33 -0
  28. data/lib/dommy/internal/dom_matching.rb +1 -1
  29. data/lib/dommy/internal/global_functions.rb +23 -10
  30. data/lib/dommy/internal/leaf_node.rb +51 -0
  31. data/lib/dommy/internal/mutation_coordinator.rb +161 -34
  32. data/lib/dommy/internal/node_adopter.rb +190 -0
  33. data/lib/dommy/internal/node_traversal.rb +10 -0
  34. data/lib/dommy/internal/node_wrapper_cache.rb +109 -26
  35. data/lib/dommy/internal/observer_manager.rb +24 -0
  36. data/lib/dommy/internal/observer_matcher.rb +39 -8
  37. data/lib/dommy/internal/parent_node.rb +345 -82
  38. data/lib/dommy/internal/range_text_serializer.rb +5 -4
  39. data/lib/dommy/internal/selector_index.rb +24 -8
  40. data/lib/dommy/internal/selector_matcher.rb +221 -16
  41. data/lib/dommy/internal/selector_parser.rb +44 -7
  42. data/lib/dommy/internal/template_content_registry.rb +23 -7
  43. data/lib/dommy/internal/utf16.rb +45 -0
  44. data/lib/dommy/internal/xml_serialization.rb +18 -5
  45. data/lib/dommy/js/custom_element_bridge.rb +13 -2
  46. data/lib/dommy/js/dom_interfaces.rb +91 -13
  47. data/lib/dommy/js/host_bridge.rb +86 -15
  48. data/lib/dommy/js/host_runtime.js +1444 -98
  49. data/lib/dommy/js/marshaller.rb +12 -7
  50. data/lib/dommy/js/script_boot.rb +81 -0
  51. data/lib/dommy/location.rb +76 -13
  52. data/lib/dommy/mutation_observer.rb +109 -20
  53. data/lib/dommy/navigation.rb +263 -0
  54. data/lib/dommy/node.rb +207 -27
  55. data/lib/dommy/parser.rb +14 -0
  56. data/lib/dommy/promise.rb +4 -4
  57. data/lib/dommy/range.rb +373 -65
  58. data/lib/dommy/scheduler.rb +16 -0
  59. data/lib/dommy/shadow_root.rb +65 -17
  60. data/lib/dommy/storage.rb +61 -10
  61. data/lib/dommy/tree_walker.rb +25 -39
  62. data/lib/dommy/url.rb +4 -2
  63. data/lib/dommy/version.rb +1 -1
  64. data/lib/dommy/web_socket.rb +45 -2
  65. data/lib/dommy/window.rb +200 -11
  66. data/lib/dommy/xml_http_request.rb +30 -4
  67. data/lib/dommy.rb +5 -0
  68. metadata +9 -9
data/lib/dommy/browser.rb CHANGED
@@ -56,47 +56,45 @@ module Dommy
56
56
  end
57
57
  end
58
58
 
59
+ # Start a navigable session by fetching the initial document from
60
+ # `resources`, rather than passing literal HTML. Links / forms / location
61
+ # then perform real cross-document navigation (fetch → replace Window + JS
62
+ # realm), with the browser handle (history, resources, error log) surviving.
63
+ # Dommy::Browser.visit("http://localhost/", resources: my_resources)
64
+ def self.visit(url, resources:, **opts)
65
+ browser = new("<!doctype html><html><head></head><body></body></html>",
66
+ url: "about:blank", resources: resources, navigable: true, **opts)
67
+ browser.visit(url, replace: true)
68
+ browser
69
+ end
70
+
59
71
  def initialize(html, url: "http://localhost/", resources: nil, execute_scripts: true, strict: true, settle: true,
60
- wasm_memory_shim: false, backend: nil)
72
+ wasm_memory_shim: false, backend: nil, navigable: false, same_origin: false)
61
73
  @resources = resources
74
+ @same_origin = same_origin
62
75
  @strict = strict
76
+ @backend = backend
77
+ @execute_scripts = execute_scripts
78
+ @settle_after_boot = settle
79
+ @wasm_memory_shim = wasm_memory_shim
80
+ @navigable = navigable
63
81
  @js_errors = []
64
82
  @console = []
65
83
  @acknowledged = 0
66
84
  @allow_errors = false
67
85
  @disposed = false
86
+ @pending_navigation = nil
87
+ @runtime = nil
68
88
 
69
89
  @window = Dommy.parse(html)
70
90
  @window.location.__internal_set_url__(url) if url
91
+ install_runtime(@window)
71
92
 
72
- # The JS engine is pluggable: `backend:` selects a registered runtime
73
- # (nil → the configured default, QuickJS when dommy-js-quickjs is loaded).
74
- @runtime = Js.build_runtime(backend)
75
- @runtime.on_unhandled_rejection { |err| @js_errors << err }
76
- @runtime.on_callback_error { |err| @js_errors << err } if @runtime.respond_to?(:on_callback_error)
77
- @runtime.on_log { |log| @console << log }
78
- @runtime.define_host_object("document", @window.document)
79
- @runtime.install_window(@window)
80
- @runtime.install_browser_globals
81
- # Opt-in WPT scaffolding (common/sab.js derives SharedArrayBuffer through
82
- # WebAssembly.Memory); off by default so real pages don't see the shim.
83
- @runtime.install_wasm_memory_shim if wasm_memory_shim && @runtime.respond_to?(:install_wasm_memory_shim)
84
- @window.globals["__fetch_handler__"] = Resources::FetchHandler.new(@resources) if @resources
85
-
86
- if execute_scripts
87
- doc = @window.document
88
- # Dynamically-inserted `<script src>` (webpack/Vite on-demand chunks)
89
- # fetch + run through the same resources adapter, after boot.
90
- doc.external_script_runner = lambda do |element, src|
91
- Js::ScriptBoot.run_external_script(@runtime, doc, element, src,
92
- resources: @resources, on_error: ->(e) { @js_errors << e })
93
- end
94
- Js::ScriptBoot.run_document_scripts(
95
- @runtime, doc, resources: @resources, on_error: ->(e) { @js_errors << e }
96
- )
97
- # Leave the page in a ready state: run on-load promises, due-now timers,
98
- # and rAF (not future timers). `settle: false` observes it mid-flight.
99
- @runtime.settle if settle
93
+ if navigable
94
+ @fetcher = Navigation::Fetcher.new(@resources, same_origin: @same_origin)
95
+ @history = Navigation::JointHistory.new
96
+ @window.navigation_delegate = self
97
+ @history.push(current_url, window: @window, windex: @window.history.__internal_index__)
100
98
  end
101
99
  check_js_errors!
102
100
  end
@@ -106,6 +104,70 @@ module Dommy
106
104
  # Current document HTML (serialized).
107
105
  def html = @window.document.document_element&.outer_html
108
106
 
107
+ # The current document's URL (the address bar).
108
+ def current_url = @window.location.href
109
+
110
+ # The joint (tab) history of a navigable browser, or nil for a plain
111
+ # single-document browser.
112
+ attr_reader :history
113
+
114
+ # Programmatically navigate to `url` (a Ruby-initiated visit). Only
115
+ # meaningful for a navigable browser; performs the fetch + document swap
116
+ # immediately (there is no JS on the stack).
117
+ def visit(url, replace: false)
118
+ raise "browser is not navigable (use Browser.visit or navigable: true)" unless @navigable
119
+
120
+ @pending_navigation = {url: url.to_s, method: "GET", source: :visit, replace: replace}
121
+ flush_navigation!
122
+ self
123
+ end
124
+
125
+ # Reload the current document (re-fetch, replace the current history entry).
126
+ def reload
127
+ visit(current_url, replace: true)
128
+ end
129
+
130
+ # Move back / forward one entry in the joint history. A same-document target
131
+ # (the entry's window is still live) traverses in place (popstate); a
132
+ # document-boundary target is re-fetched (no bfcache — D2).
133
+ def back = traverse(-1)
134
+ def forward = traverse(1)
135
+
136
+ # --- NavigationDelegate port (see Dommy::Navigation) ---
137
+
138
+ # A cross-document navigation intent (link / form / location). Navigation is
139
+ # a task: rather than swap the Window + JS realm synchronously (which may run
140
+ # while the outgoing realm's JS is still on the stack — e.g. `location.href =`
141
+ # inside a script), record it and perform the fetch + swap at the next drain
142
+ # boundary (settle / after_interaction / advance_time). Ruby-initiated visits
143
+ # flush immediately since no JS is on the stack.
144
+ def navigate(url:, source:, method: "GET", body: nil, params: nil, enctype: nil, headers: {}, replace: false)
145
+ @pending_navigation = {
146
+ url: url, method: method, body: body, params: params, enctype: enctype,
147
+ headers: headers, replace: replace, source: source
148
+ }
149
+ nil
150
+ end
151
+
152
+ # A cross-document history traversal. Ruby-initiated (back / forward), so it
153
+ # runs immediately: a same-document target (its window is still live)
154
+ # traverses in place (popstate); a document-boundary target is re-fetched.
155
+ def traverse(delta)
156
+ return self unless @navigable
157
+
158
+ entry = delta.negative? ? @history.back : @history.forward
159
+ return self unless entry
160
+
161
+ if entry.window && entry.window.equal?(@window)
162
+ @window.history.__internal_go_to__(entry.windex)
163
+ @runtime.drain_microtasks
164
+ check_js_errors!
165
+ else
166
+ perform_navigation!({url: entry.url, method: "GET", source: :traverse}, rebind: true)
167
+ end
168
+ self
169
+ end
170
+
109
171
  # Evaluate an expression / statement body and return the decoded value.
110
172
  def evaluate(js)
111
173
  result = @runtime.evaluate(js)
@@ -125,6 +187,7 @@ module Dommy
125
187
  # `setTimeout(300)` — use `advance_time(300)` for debounce/throttle.
126
188
  def settle
127
189
  @runtime.settle
190
+ flush_navigation!
128
191
  check_js_errors!
129
192
  self
130
193
  end
@@ -133,6 +196,7 @@ module Dommy
133
196
  def advance_time(ms)
134
197
  @window.scheduler.advance_time(ms)
135
198
  @runtime.drain_microtasks
199
+ flush_navigation!
136
200
  check_js_errors!
137
201
  self
138
202
  end
@@ -142,25 +206,29 @@ module Dommy
142
206
  # land before the next line, then enforce strict mode.
143
207
  def after_interaction
144
208
  @runtime.drain_microtasks
209
+ flush_navigation!
145
210
  check_js_errors!
146
211
  end
147
212
 
148
213
  # Click a submit-capable button. The button's click event fires (JS may
149
214
  # handle / preventDefault it); if it is an un-prevented submit button, the
150
- # form's `submit` event is dispatched too (a SPA's JS handles it). Real
151
- # navigation on an un-prevented submit is a Session concern (out of scope).
215
+ # owning form's submission algorithm runs (a real SubmitEvent a SPA can
216
+ # intercept, then the delegate navigation). In a navigable browser that
217
+ # follows the submit for real; otherwise the delegate just records it.
152
218
  def click_button(locator)
153
219
  button = finder.find_button(locator)
154
- prevented = Dommy::Interaction::EventSynthesis.click(button)
155
- if !prevented && submit_button?(button) && (form = finder.form_for(button))
156
- form.dispatch_event(Dommy::Event.new("submit", "bubbles" => true, "cancelable" => true))
157
- end
220
+ # An un-prevented click runs the button's activation behavior — a submit
221
+ # button submits its owning form (real SubmitEvent + delegate navigation),
222
+ # so a navigable browser follows the submit for real.
223
+ Dommy::Interaction::EventSynthesis.click(button)
158
224
  after_interaction
159
225
  button
160
226
  end
161
227
 
162
228
  # Click a link, firing its click event so SPA JS (Turbo, React Router, …)
163
- # can intercept. Real navigation on an un-prevented click is out of scope.
229
+ # can intercept. An un-prevented click runs the anchor's activation behavior
230
+ # (follow-the-hyperlink); in a navigable browser that navigates for real,
231
+ # otherwise the delegate records it.
164
232
  def click_link(locator)
165
233
  link = finder.find_link(locator)
166
234
  Dommy::Interaction::EventSynthesis.click(link)
@@ -190,6 +258,148 @@ module Dommy
190
258
 
191
259
  private
192
260
 
261
+ # Build a fresh JS realm for `window`, wire error/console/fetch/external-
262
+ # script seams, and boot its `<script>` tags. Disposes the previous realm
263
+ # first (a no-op on the initial load), so a navigation tears the outgoing
264
+ # realm — and with it every pending timer / microtask on the old Window's
265
+ # scheduler — down before the new page runs.
266
+ def install_runtime(window)
267
+ @runtime&.dispose
268
+ # The JS engine is pluggable: `@backend` selects a registered runtime
269
+ # (nil → the configured default, QuickJS when dommy-js-quickjs is loaded).
270
+ runtime = Js.build_runtime(@backend)
271
+ runtime.on_unhandled_rejection { |err| @js_errors << err }
272
+ runtime.on_callback_error { |err| @js_errors << err } if runtime.respond_to?(:on_callback_error)
273
+ runtime.on_log { |log| @console << log }
274
+ runtime.define_host_object("document", window.document)
275
+ runtime.install_window(window)
276
+ runtime.install_browser_globals
277
+ # Opt-in WPT scaffolding (common/sab.js derives SharedArrayBuffer through
278
+ # WebAssembly.Memory); off by default so real pages don't see the shim.
279
+ runtime.install_wasm_memory_shim if @wasm_memory_shim && runtime.respond_to?(:install_wasm_memory_shim)
280
+ window.globals["__fetch_handler__"] = Resources::FetchHandler.new(@resources) if @resources
281
+ @runtime = runtime
282
+ doc = window.document
283
+ # An `on*` attribute that arrived after boot (a cloned template, an
284
+ # innerHTML fragment) is compiled on first dispatch, which replays the scan.
285
+ # Installed whenever a runtime is attached — an embedder that drives script
286
+ # boot itself (`execute_scripts: false`) still needs inline handlers wired.
287
+ doc.inline_handler_wirer = lambda do
288
+ Js::ScriptBoot.wire_inline_handlers(runtime, on_error: ->(e) { @js_errors << e })
289
+ end
290
+ return unless @execute_scripts
291
+
292
+ # Dynamically-inserted `<script src>` (webpack/Vite on-demand chunks)
293
+ # fetch + run through the same resources adapter, after boot.
294
+ doc.external_script_runner = lambda do |element, src|
295
+ Js::ScriptBoot.run_external_script(runtime, doc, element, src,
296
+ resources: @resources, on_error: ->(e) { @js_errors << e })
297
+ end
298
+ Js::ScriptBoot.run_document_scripts(
299
+ runtime, doc, resources: @resources, on_error: ->(e) { @js_errors << e }
300
+ )
301
+ # Leave the page in a ready state: run on-load promises, due-now timers,
302
+ # and rAF (not future timers). `settle: false` observes it mid-flight.
303
+ runtime.settle if @settle_after_boot
304
+ end
305
+
306
+ # Perform a recorded navigation: fetch the target (following redirects),
307
+ # fire the old document's unload, then replace the Window + JS realm with the
308
+ # freshly parsed document and update the joint history. A network miss or a
309
+ # non-document response leaves the current page in place.
310
+ MAX_META_REFRESHES = 20
311
+
312
+ def perform_navigation!(nav, rebind: false, refresh_depth: 0)
313
+ response, final_url = @fetcher.request(
314
+ method: nav[:method] || "GET", url: nav[:url], params: nav[:params],
315
+ body: nav[:body], enctype: nav[:enctype], headers: nav[:headers] || {}
316
+ )
317
+ return unless response&.success? && document_response?(response)
318
+
319
+ # Fire the outgoing document's unload sequence while its realm is still
320
+ # alive, then surface any of its errors before the realm is torn down.
321
+ fire_unload(@window)
322
+ check_js_errors!
323
+
324
+ new_window = Dommy.parse(response.body)
325
+ new_window.location.__internal_set_url__(final_url)
326
+ new_window.navigation_delegate = self
327
+ @window = new_window
328
+ install_runtime(new_window)
329
+
330
+ windex = new_window.history.__internal_index__
331
+ if rebind || nav[:replace]
332
+ @history.rebind_current(url: final_url, window: new_window, windex: windex)
333
+ else
334
+ @history.push(final_url, window: new_window, windex: windex)
335
+ end
336
+
337
+ follow_meta_refresh!(refresh_depth)
338
+ end
339
+
340
+ # If the freshly loaded document asks for an immediate `<meta http-equiv=
341
+ # refresh>`, follow it (as a replace, like a redirect), capped so a page that
342
+ # refreshes to itself can't loop forever.
343
+ def follow_meta_refresh!(depth)
344
+ return if depth >= MAX_META_REFRESHES
345
+
346
+ target = meta_refresh_target(@window.document)
347
+ return unless target
348
+
349
+ perform_navigation!({url: target, method: "GET", source: :meta_refresh},
350
+ rebind: true, refresh_depth: depth + 1)
351
+ end
352
+
353
+ # The resolved URL a `<meta http-equiv="refresh" content="0; url=…">` points
354
+ # at, or nil when the document has none (or a refresh with no URL, which just
355
+ # reloads and is left alone to avoid a busy loop).
356
+ def meta_refresh_target(document)
357
+ document.query_selector_all("meta").each do |meta|
358
+ next unless meta.get_attribute("http-equiv").to_s.casecmp?("refresh")
359
+
360
+ _delay, separator, rest = meta.get_attribute("content").to_s.partition(";")
361
+ next if separator.empty?
362
+
363
+ url = rest.strip.sub(/\Aurl\s*=\s*/i, "").gsub(/\A["']|["']\z/, "").strip
364
+ return resolve_against_current(url) unless url.empty?
365
+ end
366
+ nil
367
+ end
368
+
369
+ def resolve_against_current(url)
370
+ URI.join(current_url, url).to_s
371
+ rescue URI::InvalidURIError
372
+ url
373
+ end
374
+
375
+ # Perform a pending navigation recorded by the delegate (JS-initiated
376
+ # location.href= / form submit / link click). Called at drain boundaries so
377
+ # the swap never runs with the outgoing realm's JS on the stack.
378
+ def flush_navigation!
379
+ return unless @navigable
380
+
381
+ nav = @pending_navigation
382
+ return unless nav
383
+
384
+ @pending_navigation = nil
385
+ perform_navigation!(nav)
386
+ end
387
+
388
+ def fire_unload(window)
389
+ window.dispatch_event(Dommy::Event.new("pagehide"))
390
+ window.dispatch_event(Dommy::Event.new("unload"))
391
+ end
392
+
393
+ # Only HTML/XML responses replace the document; other content types (a JSON
394
+ # API hit, an image) leave the current page. A response with no Content-Type
395
+ # is treated as a document (fixtures commonly omit it).
396
+ def document_response?(response)
397
+ headers = response.headers || {}
398
+ key = headers.keys.find { |k| k.to_s.casecmp?("content-type") }
399
+ content_type = key ? headers[key].to_s.downcase : ""
400
+ content_type.empty? || content_type.include?("html") || content_type.include?("xml")
401
+ end
402
+
193
403
  def unacknowledged = @js_errors[@acknowledged..] || []
194
404
 
195
405
  def submit_button?(button)
@@ -22,14 +22,26 @@ module Dommy
22
22
 
23
23
  # Invoke a DOM event listener per the EventTarget rule: an object with
24
24
  # `handle_event`, else a Ruby callable, else a JS-bridged callable (tried in
25
- # that order).
26
- def invoke_listener(listener, event)
25
+ # that order). A JS function listener's `this` must be the event's
26
+ # currentTarget (the node the listener is attached to), so pass it through
27
+ # when the bridge supports an explicit receiver.
28
+ # `args:` overrides the single-event argument list — the special error
29
+ # event handler (`window.onerror`) is called with (message, filename,
30
+ # lineno, colno, error) instead of the event. An EventListener object's
31
+ # handleEvent always receives the event.
32
+ def invoke_listener(listener, event, current_target = nil, args: nil)
33
+ args ||= [event]
27
34
  if listener.respond_to?(:handle_event)
28
35
  listener.handle_event(event)
29
36
  elsif listener.respond_to?(:call) && !listener.is_a?(Module)
30
- listener.call(event)
37
+ listener.call(*args)
38
+ elsif listener.respond_to?(:__js_invoke__)
39
+ # A JS function listener: `this` is the currentTarget, and a thrown value
40
+ # surfaces (as a ThrowValue) so the dispatch reports it as a window
41
+ # `error` event instead of swallowing it.
42
+ listener.__js_invoke__(args, this: current_target, raising: true)
31
43
  elsif listener.respond_to?(:__js_call__)
32
- listener.__js_call__("call", [event])
44
+ listener.__js_call__("call", args)
33
45
  end
34
46
  end
35
47
  end
data/lib/dommy/css.rb CHANGED
@@ -18,7 +18,9 @@ module Dommy
18
18
  class CSSStyleSheet
19
19
  attr_reader :owner_node, :css_rules
20
20
 
21
- def initialize(owner_node:, href: nil, media: nil, title: nil, type: "text/css", source_text: nil)
21
+ # A stylesheet constructed by `new CSSStyleSheet()` has no owner node.
22
+ # Element-owned sheets still pass their <style> / <link> node explicitly.
23
+ def initialize(owner_node: nil, href: nil, media: nil, title: nil, type: "text/css", source_text: nil)
22
24
  @owner_node = owner_node
23
25
  @href = href
24
26
  @media = media
@@ -291,21 +293,27 @@ module Dommy
291
293
  end
292
294
 
293
295
  def get_property_value(name)
294
- entry = @props[name.to_s]
296
+ entry = @props[property_key(name)]
295
297
  entry ? entry[:value] : ""
296
298
  end
297
299
 
298
300
  def get_property_priority(name)
299
- entry = @props[name.to_s]
301
+ entry = @props[property_key(name)]
300
302
  entry ? entry[:priority] : ""
301
303
  end
302
304
 
303
305
  def set_property(name, value, priority = nil)
304
- key = name.to_s
306
+ key = property_key(name)
305
307
  if value.nil? || value.to_s.empty?
308
+ # CSSOM step 3 — an empty value removes the declaration, and it runs
309
+ # before the priority check, so the priority is irrelevant here.
306
310
  @props.delete(key)
307
311
  else
308
- important = priority.to_s.downcase == "important" ? "important" : ""
312
+ # CSSOM step 4 — an invalid priority abandons the call, leaving the
313
+ # declaration block exactly as it was.
314
+ important = Internal::CssPriority.normalize(priority)
315
+ return nil if important.nil?
316
+
309
317
  @props[key] = {value: value.to_s, priority: important}
310
318
  end
311
319
  flush!
@@ -313,7 +321,7 @@ module Dommy
313
321
  end
314
322
 
315
323
  def remove_property(name)
316
- removed = @props.delete(name.to_s)
324
+ removed = @props.delete(property_key(name))
317
325
  flush!
318
326
  removed ? removed[:value] : ""
319
327
  end
@@ -403,6 +411,13 @@ module Dommy
403
411
 
404
412
  private
405
413
 
414
+ # CSSOM normalizes every property name it is handed (see
415
+ # Internal::CSS::Parser.property_name); `css_name` below is the separate
416
+ # camelCase / snake_case → kebab conversion for the IDL accessors.
417
+ def property_key(name)
418
+ Internal::CSS::Parser.property_name(name)
419
+ end
420
+
406
421
  def css_name(name)
407
422
  str = name.to_s
408
423
  return str if str.start_with?("--")
@@ -414,8 +429,8 @@ module Dommy
414
429
  # reusing the cascade's declaration parser (same normalization the cascade
415
430
  # sees) so reads agree with computed style.
416
431
  def parse(body_text)
417
- Internal::CSS::Parser.parse_declarations(body_text.to_s).each_with_object({}) do |decl, out|
418
- out[decl.name] = {value: decl.value, priority: decl.important ? "important" : ""}
432
+ Internal::CSS::Parser.parse_block(body_text).transform_values do |decl|
433
+ {value: decl.value, priority: decl.important ? "important" : ""}
419
434
  end
420
435
  end
421
436
 
@@ -458,8 +473,16 @@ module Dommy
458
473
  @parent_style_sheet = parent_style_sheet
459
474
  end
460
475
 
476
+ # CSSOM serializes a style rule as its selector plus its declaration block,
477
+ # so the text is rebuilt from the parsed declarations rather than echoed
478
+ # back: `#foo { color: red }` reads as `#foo { color: red; }`, and a block
479
+ # whose contents are not declarations at all serializes empty. An at-rule
480
+ # keeps its text as given.
461
481
  def css_text
462
- @css_text
482
+ return @css_text unless style_rule?
483
+
484
+ declarations = style&.css_text.to_s
485
+ declarations.empty? ? "#{selector_text} { }" : "#{selector_text} { #{declarations} }"
463
486
  end
464
487
 
465
488
  def css_text=(v)
@@ -553,7 +576,7 @@ module Dommy
553
576
 
554
577
  def __js_get__(key)
555
578
  case key
556
- when "cssText" then @css_text
579
+ when "cssText" then css_text
557
580
  when "type" then type
558
581
  when "selectorText" then selector_text
559
582
  when "style" then style
@@ -34,6 +34,15 @@ module Dommy
34
34
  @pending_promises = {}
35
35
  end
36
36
 
37
+ # Whether `name` is a valid custom element name. Also consulted by
38
+ # `element_class_for`: an unrecognized HTML-namespace name that is valid here
39
+ # is an *undefined custom element* (interface HTMLElement), not an unknown
40
+ # element (HTMLUnknownElement).
41
+ def self.valid_name?(name)
42
+ key = name.to_s
43
+ key.match?(NAME_RE) && !RESERVED_NAMES.include?(key)
44
+ end
45
+
37
46
  def define(name, klass, _options = nil)
38
47
  key = name.to_s
39
48
  unless key.match?(NAME_RE)
@@ -90,7 +99,13 @@ module Dommy
90
99
  # Force re-wrap by clearing the document's cached wrapper.
91
100
  @window.document.__internal_reset_wrapper__(nk)
92
101
  wrapped = @window.document.wrap_node(nk)
93
- @window.document.__internal_notify_connected__(wrapped) if wrapped
102
+ next unless wrapped
103
+
104
+ replay_observed_attributes(wrapped)
105
+ # connectedCallback is enqueued only for an element that is actually in
106
+ # a document tree — `customElements.upgrade()` on a detached subtree
107
+ # upgrades it without connecting it.
108
+ @window.document.__internal_notify_connected__(wrapped) if wrapped.is_connected?
94
109
  end
95
110
 
96
111
  nil
@@ -135,7 +150,32 @@ module Dommy
135
150
 
136
151
  doc.__internal_reset_wrapper__(nk)
137
152
  wrapped = doc.wrap_node(nk)
138
- doc.__internal_notify_connected__(wrapped) if wrapped
153
+ next unless wrapped
154
+
155
+ replay_observed_attributes(wrapped)
156
+ doc.__internal_notify_connected__(wrapped)
157
+ end
158
+ end
159
+
160
+ # "Upgrade an element" step 6: the element's *existing* attributes are
161
+ # replayed through attributeChangedCallback (oldValue null) before
162
+ # connectedCallback, so a definition registered after the markup was parsed
163
+ # still sees the attributes that were already there.
164
+ def replay_observed_attributes(element)
165
+ klass = element.class
166
+ return unless klass.respond_to?(:observed_attributes)
167
+
168
+ observed = Array(klass.observed_attributes).map { |a| a.to_s.downcase }
169
+ return if observed.empty?
170
+
171
+ element.get_attribute_names.each do |name|
172
+ next unless observed.include?(name.to_s.downcase)
173
+
174
+ # Routed through the coordinator so the callback's arity handling (the
175
+ # optional 4th namespace argument) stays in one place.
176
+ @window.document.__internal_notify_attribute_changed__(
177
+ element, name, nil, element.get_attribute(name)
178
+ )
139
179
  end
140
180
  end
141
181