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
@@ -4,10 +4,18 @@ module Dommy
4
4
  # Deterministic host-side scheduler for timers, rAF, and microtasks.
5
5
  # Time advances only when the host explicitly calls `advance_time`.
6
6
  class Scheduler
7
- Timer = Struct.new(:id, :kind, :callback, :due_at, :interval_ms, :active)
7
+ Timer = Struct.new(:id, :kind, :callback, :due_at, :interval_ms, :active, :nesting)
8
8
 
9
9
  FRAME_MS = 16
10
10
 
11
+ # HTML timer initialization steps: once a timer is nested deeper than 5, a
12
+ # sub-4ms timeout is clamped to 4ms. Besides matching browsers, this is what
13
+ # stops a self-rescheduling setTimeout(0) / setInterval(0) from spinning
14
+ # forever at the same virtual instant (the clamp pushes it into a future
15
+ # frame, so `advance_time(0)`'s due-now loop terminates).
16
+ MAX_NESTING_BEFORE_CLAMP = 5
17
+ MIN_NESTED_DELAY_MS = 4
18
+
11
19
  # requestIdleCallback has no real idle period here; the callback always
12
20
  # sees a fixed budget and didTimeout: false.
13
21
  IDLE_DEADLINE = {"timeRemaining" => 50.0, "didTimeout" => false}.freeze
@@ -18,10 +26,40 @@ module Dommy
18
26
  @timers = {}
19
27
  @microtasks = []
20
28
  @native_microtask_scheduler = nil
29
+ @external_inbox = Thread::Queue.new
30
+ # The nesting level of the timer task currently running (0 at top level);
31
+ # a timer scheduled while it runs nests one deeper. Drives the 4ms clamp.
32
+ @nesting_level = 0
21
33
  end
22
34
 
23
35
  attr_reader :now_ms
24
36
 
37
+ # Post a completion to be run on the page (JS) thread the next time the event
38
+ # loop drains. THREAD-SAFE: this is the one place another thread may hand work
39
+ # back — e.g. a network worker delivering a response. It only enqueues a
40
+ # thunk; the thunk runs single-threaded with the DOM/JS (see #deliver_external),
41
+ # so workers never touch Dommy/QuickJS state. The thunk takes no arguments.
42
+ def post_external(&thunk)
43
+ @external_inbox << thunk
44
+ nil
45
+ end
46
+
47
+ # Run all externally-posted completions. PAGE THREAD ONLY — drained as part of
48
+ # advancing the event loop (see #advance_time).
49
+ def deliver_external
50
+ until @external_inbox.empty?
51
+ thunk = @external_inbox.pop(true)
52
+ CallableInvoker.invoke(thunk)
53
+ end
54
+ nil
55
+ rescue ThreadError
56
+ nil # raced empty between empty? and pop — nothing left to do
57
+ end
58
+
59
+ # True when a worker has handed back work not yet delivered. Lets the host
60
+ # keep the loop alive (ticking) until in-flight network responses are applied.
61
+ def external_pending? = !@external_inbox.empty?
62
+
25
63
  # An optional hook (set by a JS runtime) that enqueues a microtask onto the
26
64
  # engine's NATIVE promise-job queue. When present, `queue_microtask` routes
27
65
  # through it so host-side microtasks (e.g. MutationObserver delivery)
@@ -29,6 +67,28 @@ module Dommy
29
67
  # separate pass. Absent in vanilla CRuby use (falls back to `@microtasks`).
30
68
  attr_accessor :native_microtask_scheduler
31
69
 
70
+ # An optional hook (set by a JS runtime) invoked when a timer / rAF / idle
71
+ # callback raises. The host inspects the error and returns truthy to swallow
72
+ # it — e.g. a runtime execution-budget interrupt: a runaway callback the
73
+ # engine force-killed should be recorded and let browsing continue, not crash
74
+ # it (WHATWG: a timer callback's exception must not escape its dispatch). When
75
+ # swallowed, the offending timer is dropped so a self-rescheduling interval
76
+ # does not re-fire and re-stall every tick. Returning falsy (or no handler at
77
+ # all — vanilla CRuby use) re-raises, so genuine host bugs still surface.
78
+ attr_accessor :timer_error_handler
79
+
80
+ # An optional hook (set by a JS runtime) that drains the ENGINE's microtask
81
+ # (promise-job) queue — the other half of a microtask checkpoint. The
82
+ # scheduler owns only its own `@microtasks`; the real microtask queue lives
83
+ # in the JS engine, so a spec-compliant checkpoint must drain both. WHATWG
84
+ # §8.1.7.3: the event loop performs a microtask checkpoint after running each
85
+ # task. Without this the scheduler would run every due timer task back-to-back
86
+ # and only drain microtasks once at the end, which reorders a microtask queued
87
+ # by one task after the next task — breaking code (Apollo/RxJS link chains)
88
+ # that relies on the per-task checkpoint. Absent in vanilla CRuby use (then a
89
+ # checkpoint drains only `@microtasks`).
90
+ attr_accessor :microtask_checkpoint
91
+
32
92
  def set_timeout(callback, delay_ms)
33
93
  register_timer(:timeout, callback, delay_ms.to_i, nil)
34
94
  end
@@ -49,7 +109,9 @@ module Dommy
49
109
  def request_animation_frame(callback)
50
110
  frames = ((@now_ms / FRAME_MS) + 1) * FRAME_MS
51
111
  id = next_id
52
- @timers[id] = Timer.new(id, :raf, callback, frames, nil, true)
112
+ # rAF is frame-aligned (never the same instant twice), so it needs no
113
+ # nesting clamp; nesting 0.
114
+ @timers[id] = Timer.new(id, :raf, callback, frames, nil, true, 0)
53
115
  id
54
116
  end
55
117
 
@@ -85,16 +147,30 @@ module Dommy
85
147
  nil
86
148
  end
87
149
 
150
+ # A WHATWG microtask checkpoint: drain the scheduler's own microtask queue
151
+ # AND the engine's promise-job queue (via the runtime hook). Host-side
152
+ # microtasks route onto the engine queue when a runtime is wired, so in that
153
+ # mode `@microtasks` is usually empty and the engine drain does the work; in
154
+ # vanilla CRuby the engine hook is absent and only `@microtasks` drains.
155
+ def perform_microtask_checkpoint
156
+ drain_microtasks
157
+ @microtask_checkpoint&.call
158
+ nil
159
+ end
160
+
88
161
  def advance_time(delta_ms)
162
+ deliver_external # apply any responses that arrived since the last drain
89
163
  target = @now_ms + [delta_ms.to_i, 0].max
90
164
  while next_due_timer_at && next_due_timer_at <= target
91
165
  @now_ms = next_due_timer_at
92
166
  run_due_timers
93
- drain_microtasks
167
+ perform_microtask_checkpoint
168
+ deliver_external
94
169
  end
95
170
 
96
171
  @now_ms = target
97
- drain_microtasks
172
+ perform_microtask_checkpoint
173
+ deliver_external
98
174
  nil
99
175
  end
100
176
 
@@ -108,6 +184,14 @@ module Dommy
108
184
  @timers.values.select(&:active).map(&:due_at).min
109
185
  end
110
186
 
187
+ # The earliest due time among pending requestAnimationFrame callbacks, or
188
+ # nil when none are scheduled. Lets a host flush RAF (advancing to the next
189
+ # frame boundary) during a `settle` without jumping the clock past
190
+ # not-yet-due `setTimeout`s.
191
+ def next_animation_frame_at
192
+ @timers.values.select { |t| t.active && t.kind == :raf }.map(&:due_at).min
193
+ end
194
+
111
195
  private
112
196
 
113
197
  def next_id
@@ -118,39 +202,91 @@ module Dommy
118
202
 
119
203
  def register_timer(kind, callback, delay_ms, interval_ms)
120
204
  id = next_id
121
- due_at = @now_ms + [delay_ms, 0].max
122
- @timers[id] = Timer.new(id, kind, callback, due_at, interval_ms, true)
205
+ nesting = @nesting_level + 1
206
+ delay = clamp_nested_delay([delay_ms, 0].max, nesting)
207
+ due_at = @now_ms + delay
208
+ @timers[id] = Timer.new(id, kind, callback, due_at, interval_ms, true, nesting)
123
209
  id
124
210
  end
125
211
 
212
+ # HTML timer initialization step: a timer nested deeper than 5 with a sub-4ms
213
+ # timeout is clamped to 4ms.
214
+ def clamp_nested_delay(delay, nesting)
215
+ nesting > MAX_NESTING_BEFORE_CLAMP && delay < MIN_NESTED_DELAY_MS ? MIN_NESTED_DELAY_MS : delay
216
+ end
217
+
126
218
  def cancel_timer(id)
127
- timer = @timers[id.to_i]
219
+ # WHATWG: clearTimeout/clearInterval with a missing or non-numeric handle
220
+ # (e.g. `clearTimeout(undefined)`, which React's scheduler emits) is a
221
+ # silent no-op rather than an error.
222
+ return unless id.respond_to?(:to_i)
223
+
224
+ key = id.to_i
225
+ timer = @timers[key]
128
226
  timer.active = false if timer
129
- @timers.delete(id.to_i)
227
+ @timers.delete(key)
130
228
  nil
131
229
  end
132
230
 
133
231
  def run_due_timers
134
232
  due = @timers.values.select { |timer| timer.active && timer.due_at <= @now_ms }
135
233
  due.sort_by!(&:id)
234
+ # Each ordinary timer task is followed by a microtask checkpoint (WHATWG
235
+ # §8.1.7.3). The animation-frame callbacks of one rendering update are an
236
+ # exception: they run consecutively and share a single checkpoint after the
237
+ # batch (a microtask queued by one rAF must not run before the next rAF in
238
+ # the same frame), so they are checkpointed once at the end.
239
+ raf_ran = false
136
240
  due.each do |timer|
137
241
  next unless timer.active
138
242
 
139
243
  case timer.kind
140
244
  when :raf
141
245
  @timers.delete(timer.id)
142
- CallableInvoker.invoke(timer.callback, @now_ms.to_f)
246
+ invoke_timer(timer, @now_ms.to_f)
247
+ raf_ran = true
143
248
  when :interval
144
- CallableInvoker.invoke(timer.callback)
145
- timer.due_at = @now_ms + timer.interval_ms if timer.active
249
+ invoke_timer(timer)
250
+ if timer.active
251
+ # Each interval iteration nests one deeper, so a setInterval(0) is
252
+ # clamped to 4ms once past the nesting threshold (HTML timer steps).
253
+ timer.nesting += 1
254
+ timer.due_at = @now_ms + clamp_nested_delay(timer.interval_ms, timer.nesting)
255
+ end
256
+ perform_microtask_checkpoint
146
257
  when :idle
147
258
  @timers.delete(timer.id)
148
- CallableInvoker.invoke(timer.callback, IDLE_DEADLINE.dup)
259
+ invoke_timer(timer, IDLE_DEADLINE.dup)
260
+ perform_microtask_checkpoint
149
261
  else
150
262
  @timers.delete(timer.id)
151
- CallableInvoker.invoke(timer.callback)
263
+ invoke_timer(timer)
264
+ perform_microtask_checkpoint
152
265
  end
153
266
  end
267
+ perform_microtask_checkpoint if raf_ran
268
+ end
269
+
270
+
271
+ # Run a single timer's callback. A raised error is offered to
272
+ # `timer_error_handler` (set by the JS runtime); if it swallows the error the
273
+ # timer is dropped (so a runaway interval cannot re-stall every tick) and
274
+ # browsing continues. With no handler — or one that declines — the error
275
+ # propagates, preserving the default crash-on-bug behavior.
276
+ def invoke_timer(timer, *args)
277
+ # Run the callback at this timer's nesting level, so a timer it schedules
278
+ # nests one deeper (driving the 4ms clamp). Restored even if it throws.
279
+ prev_nesting = @nesting_level
280
+ @nesting_level = timer.nesting || 0
281
+ CallableInvoker.invoke(timer.callback, *args)
282
+ rescue StandardError => e
283
+ raise unless @timer_error_handler&.call(e, timer)
284
+
285
+ timer.active = false
286
+ @timers.delete(timer.id)
287
+ nil
288
+ ensure
289
+ @nesting_level = prev_nesting
154
290
  end
155
291
  end
156
292
  end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ # `window.screen` — the Screen interface. A headless browser has no physical
5
+ # display, so the screen reports the (approximate) viewport size, with standard
6
+ # desktop colour depth and an orientation derived from the aspect ratio.
7
+ #
8
+ # `window.screen` is always present in a real browser, and `screen.width` /
9
+ # `screen.height` are routinely read *unguarded* (analytics, responsive logic),
10
+ # so a missing `screen` makes them throw "cannot read property 'width' of
11
+ # undefined" — which is exactly what real sites hit when Dommy lacked it.
12
+ class Screen
13
+ def initialize(window)
14
+ @window = window
15
+ end
16
+
17
+ def __js_get__(key)
18
+ case key
19
+ when "width", "availWidth"
20
+ @window.inner_width
21
+ when "height", "availHeight"
22
+ @window.inner_height
23
+ when "availLeft", "availTop"
24
+ 0
25
+ when "colorDepth", "pixelDepth"
26
+ 24
27
+ when "isExtended"
28
+ false
29
+ when "orientation"
30
+ @orientation ||= ScreenOrientation.new(@window)
31
+ else
32
+ # Anything else is genuinely absent (JS `undefined`, `"x" in screen`
33
+ # false) so feature detection takes the not-supported path.
34
+ Bridge::ABSENT
35
+ end
36
+ end
37
+
38
+ def __js_set__(_key, _value)
39
+ Bridge::UNHANDLED
40
+ end
41
+
42
+ include Bridge::Methods
43
+ # Screen is an EventTarget; Dommy never fires screen events, so a listener is
44
+ # accepted and simply never invoked.
45
+ js_methods %w[addEventListener removeEventListener dispatchEvent]
46
+ def __js_call__(method, _args)
47
+ case method
48
+ when "addEventListener", "removeEventListener" then nil
49
+ when "dispatchEvent" then true
50
+ end
51
+ end
52
+ end
53
+
54
+ # `screen.orientation` — the ScreenOrientation interface. Reports a fixed
55
+ # orientation from the viewport aspect ratio; `lock`/`unlock` are no-ops (a
56
+ # headless browser cannot rotate), `lock` still returning a resolved Promise so
57
+ # callers that `await screen.orientation.lock(...)` proceed.
58
+ class ScreenOrientation
59
+ def initialize(window)
60
+ @window = window
61
+ end
62
+
63
+ def __js_get__(key)
64
+ case key
65
+ when "type"
66
+ @window.inner_width >= @window.inner_height ? "landscape-primary" : "portrait-primary"
67
+ when "angle"
68
+ 0
69
+ else
70
+ Bridge::ABSENT
71
+ end
72
+ end
73
+
74
+ def __js_set__(_key, _value)
75
+ Bridge::UNHANDLED
76
+ end
77
+
78
+ include Bridge::Methods
79
+ js_methods %w[lock unlock addEventListener removeEventListener dispatchEvent]
80
+ def __js_call__(method, _args)
81
+ case method
82
+ when "lock"
83
+ PromiseValue.resolve(@window, nil)
84
+ when "unlock", "addEventListener", "removeEventListener"
85
+ nil
86
+ when "dispatchEvent"
87
+ true
88
+ end
89
+ end
90
+ end
91
+ end
@@ -9,7 +9,9 @@ module Dommy
9
9
  #
10
10
  # Tree manipulation works the same as a normal Element/Fragment;
11
11
  # the boundary is enforced only on outer queries and event
12
- # composition. CSS scoping (`:host`, `::slotted`) is out of scope.
12
+ # composition. CSS scoping (`:host` / `::slotted` / `::part`, and this
13
+ # tree's `<style>` rules) is handled by the cascade — see
14
+ # `internal/css/rule_index.rb` and css-cascade.md.
13
15
  class ShadowRoot
14
16
  include EventTarget
15
17
  include Node
@@ -25,7 +27,7 @@ module Dommy
25
27
  @delegates_focus = !!delegates_focus
26
28
  @slot_assignment = slot_assignment.to_s
27
29
  @document = host.document
28
- @__node__ = @document.nokogiri_doc.fragment("")
30
+ @__node__ = @document.backend_doc.fragment("")
29
31
  @document.__internal_register_shadow_fragment__(@__node__, self)
30
32
  end
31
33
 
@@ -38,7 +40,7 @@ module Dommy
38
40
  def inner_html=(html)
39
41
  removed = @__node__.children.to_a
40
42
  removed.each(&:unlink)
41
- fragment = Parser.fragment(html.to_s, owner_doc: @document.nokogiri_doc)
43
+ fragment = Parser.fragment(html.to_s, owner_doc: @document.backend_doc)
42
44
  added = fragment.children.to_a
43
45
  added.each { |n| @__node__.add_child(n) }
44
46
  notify_child_list(added: added, removed: removed)
@@ -51,7 +53,7 @@ module Dommy
51
53
 
52
54
  def text_content=(value)
53
55
  @__node__.children.each(&:unlink)
54
- @__node__.add_child(Backend.create_text(value.to_s, @document.nokogiri_doc))
56
+ @__node__.add_child(Backend.create_text(value.to_s, @document.backend_doc))
55
57
  end
56
58
 
57
59
  def children
@@ -83,26 +85,73 @@ module Dommy
83
85
  end
84
86
 
85
87
  def query_selector(selector)
86
- return nil if selector.nil? || selector.to_s.empty?
88
+ return nil if selector.nil?
87
89
 
88
- @document.wrap_node(@__node__.at_css(selector.to_s))
90
+ ast = Internal::SelectorParser.parse!(selector)
91
+ Internal::SelectorMatcher.query_first(self, ast, scope: self)
89
92
  end
90
93
 
91
94
  def query_selector_all(selector)
92
- return NodeList.new if selector.nil? || selector.to_s.empty?
95
+ return NodeList.new if selector.nil?
93
96
 
94
- NodeList.new(@__node__.css(selector.to_s).map { |n| @document.wrap_node(n) }.compact)
97
+ ast = Internal::SelectorParser.parse!(selector)
98
+ NodeList.new(Internal::SelectorMatcher.query(self, ast, scope: self))
95
99
  end
96
100
 
97
101
  def get_element_by_id(id)
98
- return nil if id.nil?
102
+ return nil if id.nil? || id.to_s.empty?
99
103
 
100
- @document.wrap_node(@__node__.at_css("##{id}"))
104
+ # getElementById matches the `id` attribute literally, not as a CSS
105
+ # selector, so escape special characters (e.g. React `useId` `:rjm:`) to a
106
+ # valid id-selector ident — a raw "##{id}" would be an invalid selector.
107
+ @document.wrap_node(@__node__.at_css("##{Dommy::CSSNamespace.escape(id)}"))
101
108
  end
102
109
 
103
- # `getRootNode()` returns the ShadowRoot itself (closed-shadow
104
- # semantics; `composed: true` callers go through the Event path).
105
- def get_root_node(_options = nil)
110
+ # Node-level child mutations the spec exposes on a DocumentFragment (and so
111
+ # on a ShadowRoot). lit-html drives rendering through `insertBefore` /
112
+ # `removeChild` / `replaceChild` against the render root, so a shadow root
113
+ # must support them — not just appendChild.
114
+ def insert_before(node, ref)
115
+ nodes = detach_dom_nodes(node)
116
+ ref_bn = ref.respond_to?(:__dommy_backend_node__) ? ref.__dommy_backend_node__ : nil
117
+ if ref_bn && ref_bn.parent == @__node__
118
+ nodes.each { |n| ref_bn.add_previous_sibling(n) }
119
+ else
120
+ nodes.each { |n| @__node__.add_child(n) }
121
+ end
122
+ notify_child_list(added: nodes)
123
+ node
124
+ end
125
+
126
+ def remove_child(node)
127
+ bn = node.respond_to?(:__dommy_backend_node__) ? node.__dommy_backend_node__ : nil
128
+ raise DOMException::NotFoundError, "node is not a child of this shadow root" unless bn && bn.parent == @__node__
129
+
130
+ bn.unlink
131
+ notify_child_list(removed: [bn])
132
+ node
133
+ end
134
+
135
+ def replace_child(new_child, old_child)
136
+ old_bn = old_child.respond_to?(:__dommy_backend_node__) ? old_child.__dommy_backend_node__ : nil
137
+ raise DOMException::NotFoundError, "node is not a child of this shadow root" unless old_bn && old_bn.parent == @__node__
138
+
139
+ added = detach_dom_nodes(new_child)
140
+ added.each { |n| old_bn.add_previous_sibling(n) }
141
+ old_bn.unlink
142
+ notify_child_list(added: added, removed: [old_bn])
143
+ old_child
144
+ end
145
+
146
+ # `getRootNode()` returns the ShadowRoot itself; `getRootNode({composed:
147
+ # true})` crosses the shadow boundary and returns the root of the host's
148
+ # tree (the document, or an outer shadow root for nested shadows).
149
+ def get_root_node(options = nil)
150
+ composed = options.is_a?(Hash) &&
151
+ EventTarget.js_truthy?(options.key?("composed") ? options["composed"] : options[:composed])
152
+ return self unless composed
153
+ return @host.root_node({"composed" => true}) if @host.respond_to?(:root_node)
154
+
106
155
  self
107
156
  end
108
157
 
@@ -154,6 +203,13 @@ module Dommy
154
203
  last_element_child
155
204
  when "nodeType"
156
205
  11
206
+ when "nodeName"
207
+ "#document-fragment"
208
+ else
209
+ # Any unknown key (incl. framework-private `_`/`$` expandos like
210
+ # lit-html's `_$litPart$`, which it probes with `=== undefined`) is
211
+ # genuinely absent: JS `undefined`, `in` false — matching Element.
212
+ Bridge::ABSENT
157
213
  end
158
214
  end
159
215
 
@@ -173,6 +229,7 @@ module Dommy
173
229
  include Bridge::Methods
174
230
  js_methods %w[
175
231
  querySelector querySelectorAll getElementById append prepend replaceChildren appendChild
232
+ insertBefore removeChild replaceChild
176
233
  getRootNode contains addEventListener removeEventListener dispatchEvent
177
234
  isEqualNode isSameNode hasChildNodes normalize compareDocumentPosition
178
235
  ]
@@ -202,6 +259,12 @@ module Dommy
202
259
  replace_children(*args)
203
260
  when "appendChild"
204
261
  append_child(args[0])
262
+ when "insertBefore"
263
+ insert_before(args[0], args[1])
264
+ when "removeChild"
265
+ remove_child(args[0])
266
+ when "replaceChild"
267
+ replace_child(args[0], args[1])
205
268
  when "getRootNode"
206
269
  get_root_node(args[0])
207
270
  when "contains"
data/lib/dommy/storage.rb CHANGED
@@ -83,7 +83,8 @@ module Dommy
83
83
  when "length"
84
84
  @store.size
85
85
  else
86
- @store[key.to_s]
86
+ # A named-property miss is JS `undefined` (and `"k" in storage` false).
87
+ @store.key?(key.to_s) ? @store[key.to_s] : Bridge::ABSENT
87
88
  end
88
89
  end
89
90
 
data/lib/dommy/streams.rb CHANGED
@@ -99,6 +99,8 @@ module Dommy
99
99
  case key
100
100
  when "locked"
101
101
  locked
102
+ else
103
+ Bridge::ABSENT
102
104
  end
103
105
  end
104
106
 
@@ -244,6 +246,8 @@ module Dommy
244
246
  case key
245
247
  when "locked"
246
248
  locked
249
+ else
250
+ Bridge::ABSENT
247
251
  end
248
252
  end
249
253
 
@@ -340,6 +344,8 @@ module Dommy
340
344
  @readable
341
345
  when "writable"
342
346
  @writable
347
+ else
348
+ Bridge::ABSENT
343
349
  end
344
350
  end
345
351
  end
@@ -19,7 +19,7 @@ module Dommy
19
19
  end
20
20
 
21
21
  def __js_get__(key)
22
- key == "encoding" ? encoding : nil
22
+ key == "encoding" ? encoding : Bridge::ABSENT
23
23
  end
24
24
 
25
25
  include Bridge::Methods
@@ -76,6 +76,8 @@ module Dommy
76
76
  when "encoding" then @encoding
77
77
  when "fatal" then @fatal
78
78
  when "ignoreBOM" then @ignore_bom
79
+ else
80
+ Bridge::ABSENT
79
81
  end
80
82
  end
81
83
 
@@ -289,6 +291,8 @@ module Dommy
289
291
  @writable
290
292
  when "encoding"
291
293
  encoding
294
+ else
295
+ Bridge::ABSENT
292
296
  end
293
297
  end
294
298
  end
@@ -322,6 +326,8 @@ module Dommy
322
326
  @writable
323
327
  when "encoding"
324
328
  @encoding
329
+ else
330
+ Bridge::ABSENT
325
331
  end
326
332
  end
327
333
  end
@@ -42,9 +42,13 @@ module Dommy
42
42
  private
43
43
 
44
44
  # Returns FILTER_ACCEPT / FILTER_REJECT / FILTER_SKIP for the
45
- # given wrapped node.
45
+ # given wrapped node. Per WHATWG "filter", the active-flag re-entrancy
46
+ # check comes first: a NodeFilter that re-enters its own walker/iterator
47
+ # (calls nextNode etc. while the filter is still running) is an
48
+ # InvalidStateError.
46
49
  def accept(node)
47
50
  return NodeFilter::FILTER_REJECT unless node
51
+ raise DOMException::InvalidStateError, "NodeFilter is already active" if @active
48
52
  return NodeFilter::FILTER_SKIP if (NodeFilter.bitmask_for(node) & @what_to_show) == 0
49
53
 
50
54
  result = invoke_filter(node)
@@ -58,11 +62,25 @@ module Dommy
58
62
  # A non-null filter with no callable acceptNode is a TypeError when invoked.
59
63
  raise Bridge::TypeError, "NodeFilter is not callable" unless cb
60
64
 
61
- result = if cb.respond_to?(:__js_call__)
62
- cb.__js_call__("call", [node])
63
- else
64
- cb.call(node)
65
- end
65
+ # The active flag is set around the user callback only; a re-entrant
66
+ # traversal during this window trips the guard in `accept`. It must be
67
+ # cleared even when the callback throws so the walker stays usable.
68
+ @active = true
69
+ result =
70
+ begin
71
+ # A NodeFilter's exception must propagate out of the traversal method,
72
+ # so use the raising invocation when the callback supports it (a JS
73
+ # function); a Ruby callable propagates naturally.
74
+ if cb.respond_to?(:__js_call_with_raise__)
75
+ cb.__js_call_with_raise__([node])
76
+ elsif cb.respond_to?(:__js_call__)
77
+ cb.__js_call__("call", [node])
78
+ else
79
+ cb.call(node)
80
+ end
81
+ ensure
82
+ @active = false
83
+ end
66
84
  # WebIDL coerces the filter return (an `unsigned short`): booleans and
67
85
  # null become 0/1, everything else ToInteger.
68
86
  return 1 if result == true
@@ -200,6 +218,8 @@ module Dommy
200
218
  @filter
201
219
  when "currentNode"
202
220
  @current_node
221
+ else
222
+ Bridge::ABSENT
203
223
  end
204
224
  end
205
225
 
@@ -345,13 +365,13 @@ module Dommy
345
365
  end
346
366
 
347
367
  # The Nokogiri node backing a wrapper — an element's `__dommy_backend_node__`
348
- # or, for a Document (which isn't a wrapped node), its `nokogiri_doc`. Lets a
368
+ # or, for a Document (which isn't a wrapped node), its `backend_doc`. Lets a
349
369
  # walker rooted at the document descend into its children.
350
370
  def backend_node_of(node)
351
371
  if node.respond_to?(:__dommy_backend_node__)
352
372
  node.__dommy_backend_node__
353
- elsif node.respond_to?(:nokogiri_doc)
354
- node.nokogiri_doc
373
+ elsif node.respond_to?(:backend_doc)
374
+ node.backend_doc
355
375
  end
356
376
  end
357
377
 
@@ -372,7 +392,8 @@ module Dommy
372
392
 
373
393
  def last_wrapped_child(node)
374
394
  bn = backend_node_of(node)
375
- bn ? first_wrappable(bn.children.reverse, node) : nil
395
+ # `.to_a` first: Nokogiri's NodeSet has `#reverse`, Makiri's does not.
396
+ bn ? first_wrappable(bn.children.to_a.reverse, node) : nil
376
397
  end
377
398
 
378
399
  def next_sibling_wrapped(node)
@@ -490,6 +511,8 @@ module Dommy
490
511
  @reference_node
491
512
  when "pointerBeforeReferenceNode"
492
513
  @pointer_before_reference
514
+ else
515
+ Bridge::ABSENT
493
516
  end
494
517
  end
495
518
 
data/lib/dommy/url.rb CHANGED
@@ -260,6 +260,8 @@ module Dommy
260
260
  when "username" then username
261
261
  when "password" then password
262
262
  when "searchParams" then @search_params
263
+ else
264
+ Bridge::ABSENT
263
265
  end
264
266
  end
265
267
 
@@ -461,6 +463,8 @@ module Dommy
461
463
  case key
462
464
  when "size", "length"
463
465
  size
466
+ else
467
+ Bridge::ABSENT
464
468
  end
465
469
  end
466
470
 
@@ -517,7 +521,15 @@ module Dommy
517
521
  def parse(input)
518
522
  case input
519
523
  when Array
520
- input.map { |k, v| [k.to_s, v.to_s] }
524
+ # A sequence init is a sequence of [name, value] pairs: each inner element
525
+ # must itself be a 2-element sequence, else the constructor throws.
526
+ input.map do |pair|
527
+ unless pair.is_a?(Array) && pair.length == 2
528
+ raise Bridge::TypeError, "Failed to construct 'URLSearchParams': each pair must have exactly two elements"
529
+ end
530
+
531
+ [pair[0].to_s, pair[1].to_s]
532
+ end
521
533
  when Hash
522
534
  input.map { |k, v| [k.to_s, v.to_s] }
523
535
  else