dommy 0.8.1 → 0.10.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 (121) 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 +57 -13
  5. data/lib/dommy/backend/makiri_adapter.rb +364 -0
  6. data/lib/dommy/backend.rb +143 -33
  7. data/lib/dommy/blob.rb +50 -6
  8. data/lib/dommy/bridge.rb +11 -0
  9. data/lib/dommy/browser.rb +420 -0
  10. data/lib/dommy/callable_invoker.rb +6 -2
  11. data/lib/dommy/compression_streams.rb +4 -0
  12. data/lib/dommy/crypto.rb +4 -0
  13. data/lib/dommy/css.rb +487 -50
  14. data/lib/dommy/custom_elements.rb +2 -2
  15. data/lib/dommy/data_transfer.rb +2 -0
  16. data/lib/dommy/data_uri.rb +35 -0
  17. data/lib/dommy/deferred_response.rb +59 -0
  18. data/lib/dommy/document.rb +901 -275
  19. data/lib/dommy/dom_exception.rb +2 -0
  20. data/lib/dommy/dom_parser.rb +7 -17
  21. data/lib/dommy/element.rb +940 -353
  22. data/lib/dommy/event.rb +529 -17
  23. data/lib/dommy/fetch.rb +577 -53
  24. data/lib/dommy/form_data.rb +2 -0
  25. data/lib/dommy/history.rb +26 -3
  26. data/lib/dommy/html_canvas_element.rb +230 -0
  27. data/lib/dommy/html_collection.rb +150 -27
  28. data/lib/dommy/html_elements.rb +1968 -282
  29. data/lib/dommy/interaction/debug.rb +35 -0
  30. data/lib/dommy/interaction/dom_summary.rb +131 -0
  31. data/lib/dommy/interaction/driver.rb +385 -0
  32. data/lib/dommy/interaction/event_synthesis.rb +164 -0
  33. data/lib/dommy/interaction/field_interactor.rb +177 -0
  34. data/lib/dommy/interaction/form_submission.rb +268 -0
  35. data/lib/dommy/interaction/locator.rb +158 -0
  36. data/lib/dommy/interaction/role_query.rb +58 -0
  37. data/lib/dommy/interaction.rb +32 -0
  38. data/lib/dommy/internal/accessibility_tree.rb +215 -0
  39. data/lib/dommy/internal/accessible_description.rb +38 -0
  40. data/lib/dommy/internal/accessible_name.rb +301 -0
  41. data/lib/dommy/internal/aria_role.rb +252 -0
  42. data/lib/dommy/internal/aria_snapshot.rb +64 -0
  43. data/lib/dommy/internal/aria_state.rb +151 -0
  44. data/lib/dommy/internal/child_node.rb +199 -0
  45. data/lib/dommy/internal/css/calc.rb +242 -0
  46. data/lib/dommy/internal/css/cascade.rb +472 -0
  47. data/lib/dommy/internal/css/color.rb +381 -0
  48. data/lib/dommy/internal/css/computed_style_declaration.rb +130 -0
  49. data/lib/dommy/internal/css/counters.rb +227 -0
  50. data/lib/dommy/internal/css/custom_properties.rb +183 -0
  51. data/lib/dommy/internal/css/media_query.rb +302 -0
  52. data/lib/dommy/internal/css/parser.rb +265 -0
  53. data/lib/dommy/internal/css/property_registry.rb +512 -0
  54. data/lib/dommy/internal/css/rule_index.rb +494 -0
  55. data/lib/dommy/internal/css/supports.rb +158 -0
  56. data/lib/dommy/internal/css/ua_stylesheet.rb +53 -0
  57. data/lib/dommy/internal/css_pseudo_handlers.rb +283 -42
  58. data/lib/dommy/internal/css_rule_text.rb +160 -0
  59. data/lib/dommy/internal/dom_matching.rb +80 -9
  60. data/lib/dommy/internal/element_matching.rb +109 -0
  61. data/lib/dommy/internal/global_functions.rb +55 -9
  62. data/lib/dommy/internal/mutation_coordinator.rb +122 -11
  63. data/lib/dommy/internal/namespaces.rb +49 -5
  64. data/lib/dommy/internal/node_wrapper_cache.rb +217 -38
  65. data/lib/dommy/internal/observer_manager.rb +6 -0
  66. data/lib/dommy/internal/observer_matcher.rb +6 -8
  67. data/lib/dommy/internal/parent_node.rb +98 -57
  68. data/lib/dommy/internal/selector_ast.rb +124 -0
  69. data/lib/dommy/internal/selector_index.rb +146 -0
  70. data/lib/dommy/internal/selector_matcher.rb +845 -0
  71. data/lib/dommy/internal/selector_parser.rb +321 -132
  72. data/lib/dommy/internal/shadow_root_registry.rb +9 -2
  73. data/lib/dommy/internal/template_content_registry.rb +26 -18
  74. data/lib/dommy/internal/xml_serialization.rb +344 -0
  75. data/lib/dommy/intersection_observer.rb +2 -0
  76. data/lib/dommy/js/bridge_conformance.rb +80 -0
  77. data/lib/dommy/js/constructor_resolver.rb +44 -0
  78. data/lib/dommy/js/custom_element_bridge.rb +101 -0
  79. data/lib/dommy/js/dom_interfaces.rb +177 -0
  80. data/lib/dommy/js/handle_table.rb +60 -0
  81. data/lib/dommy/js/host_bridge.rb +538 -0
  82. data/lib/dommy/js/host_runtime.js +2357 -0
  83. data/lib/dommy/js/import_map.rb +58 -0
  84. data/lib/dommy/js/marshaller.rb +240 -0
  85. data/lib/dommy/js/module_loader.rb +99 -0
  86. data/lib/dommy/js/observable_runtime.js +742 -0
  87. data/lib/dommy/js/runtime.rb +115 -0
  88. data/lib/dommy/js/script_boot.rb +301 -0
  89. data/lib/dommy/js/wire_tags.rb +62 -0
  90. data/lib/dommy/location.rb +78 -13
  91. data/lib/dommy/media_query_list.rb +50 -14
  92. data/lib/dommy/message_channel.rb +22 -6
  93. data/lib/dommy/minitest/assertions.rb +27 -0
  94. data/lib/dommy/mutation_observer.rb +89 -5
  95. data/lib/dommy/navigation.rb +263 -0
  96. data/lib/dommy/navigator.rb +34 -2
  97. data/lib/dommy/node.rb +198 -35
  98. data/lib/dommy/notification.rb +2 -0
  99. data/lib/dommy/parser.rb +1 -1
  100. data/lib/dommy/performance.rb +21 -1
  101. data/lib/dommy/promise.rb +94 -10
  102. data/lib/dommy/range.rb +187 -33
  103. data/lib/dommy/resources.rb +178 -0
  104. data/lib/dommy/rspec/capy_style_matchers.rb +126 -0
  105. data/lib/dommy/scheduler.rb +165 -13
  106. data/lib/dommy/screen.rb +91 -0
  107. data/lib/dommy/shadow_root.rb +109 -13
  108. data/lib/dommy/storage.rb +63 -11
  109. data/lib/dommy/streams.rb +6 -0
  110. data/lib/dommy/text_codec.rb +7 -1
  111. data/lib/dommy/tree_walker.rb +51 -46
  112. data/lib/dommy/url.rb +17 -3
  113. data/lib/dommy/version.rb +1 -1
  114. data/lib/dommy/web_socket.rb +45 -2
  115. data/lib/dommy/window.rb +322 -15
  116. data/lib/dommy/worker.rb +8 -4
  117. data/lib/dommy/xml_http_request.rb +76 -9
  118. data/lib/dommy.rb +38 -1
  119. metadata +82 -14
  120. data/lib/dommy/backend/nokogiri_adapter.rb +0 -127
  121. data/lib/dommy/backend/nokolexbor_adapter.rb +0 -117
@@ -0,0 +1,538 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Dommy
6
+ module Js
7
+ # Engine-agnostic core of the JS<->Ruby DOM bridge. Given a `backend` that
8
+ # can evaluate JS, register Ruby host functions, and call back into JS,
9
+ # HostBridge exposes a Ruby object to the JS side as an ES Proxy whose
10
+ # property/method access routes into the bridge ABI:
11
+ # __js_get__(name) / __js_set__(name, value) / __js_call__(method, args)
12
+ #
13
+ # Nothing here is QuickJS-specific; this layer is intended to move into a
14
+ # future `dommy-js` gem with QuickJS/wasm backends plugged in underneath.
15
+ #
16
+ # Collaborators keep the bridge focused on the engine ABI:
17
+ # Marshaller — Ruby<->JS value conversion + the handle/callback
18
+ # identity tables (#wrap / #unwrap, delegated below)
19
+ # DomInterfaces — interface name/chain derivation (instanceof support)
20
+ # ConstructorResolver — `new Event(...)` style reverse construction
21
+ # CustomElementBridge — JS customElements.define -> Dommy wiring
22
+ #
23
+ # Backend contract:
24
+ # backend.eval(js) -> evaluate top-level JS
25
+ # backend.define_host_function(name) { } -> expose a Ruby block as a JS global
26
+ # backend.call_js(path, *args) -> invoke a JS global function by path
27
+ # backend.run_bundle(cache_key, source) -> run a reused-across-VMs source
28
+ # bundle (engine may compile-cache)
29
+ #
30
+ # Value representation a backend must deliver across the boundary (host
31
+ # function arguments and call_js results): JSON-ish Ruby values
32
+ # (Hash/Array/String/Numeric/true/false/nil) carrying the WireTags protocol.
33
+ # The backend does NOT need to distinguish JS `undefined` from `null` in its
34
+ # value marshalling: host_runtime.js tags every top-level `undefined` crossing
35
+ # to Ruby (dehydrateTop — callback/host returns, property-set values, call
36
+ # args) as `{__rb_undefined: true}`, so it arrives as Dommy::Bridge::UNDEFINED
37
+ # regardless of the engine (V8/mini_racer cannot tell the two apart, and need
38
+ # not). As a defensive fallback, the bridge also normalizes a *bare* JS
39
+ # `undefined` delivered as the Ruby symbol `:undefined` (a backend that can
40
+ # produce it, like QuickJS) to Dommy::Bridge::UNDEFINED, but no backend is
41
+ # required to.
42
+ #
43
+ # The host object must implement __js_get__/__js_set__/__js_call__, and the
44
+ # bridge needs to know which names are methods (callable via __js_call__)
45
+ # vs. properties (read via __js_get__) — see #method_names.
46
+ class HostBridge
47
+ # JS half of the bridge (globalThis.__rbHost). Read from a companion file
48
+ # so it stays lintable/highlightable rather than buried in a heredoc.
49
+ # ::File — inside module Dommy, bare `File` resolves to Dommy::File (the
50
+ # File API class), not Ruby's file class. These bundles are identical
51
+ # across VMs; the backend's #run_bundle keeps them parsed once per process,
52
+ # so the bridge itself stays free of any bytecode/engine knowledge.
53
+ HOST_RUNTIME_JS = ::File.read(::File.join(__dir__, "host_runtime.js")).freeze
54
+ # The WICG Observable polyfill (Observable/Subscriber + EventTarget.when),
55
+ # evaluated after the DOM interface prototypes are seeded.
56
+ OBSERVABLE_RUNTIME_JS = ::File.read(::File.join(__dir__, "observable_runtime.js")).freeze
57
+
58
+ def initialize(backend)
59
+ @backend = backend
60
+ @crossing_counts = crossing_profile_enabled? ? new_crossing_counts : nil
61
+ # The marshaller owns value conversion + the handle/callback identity
62
+ # tables; the bridge keeps the engine ABI and lifecycle wiring.
63
+ @codec = Marshaller.new(self)
64
+ @constructor_resolver = ConstructorResolver.new
65
+ @custom_element_bridge = CustomElementBridge.new(self)
66
+ @microtask_procs = {}
67
+ @microtask_seq = 0
68
+ @rejection_details = [] # opt-in rejection-debug capture (see take_rejection_detail)
69
+ install!
70
+ end
71
+
72
+ # Bind a Ruby object to a JS global of the given name.
73
+ def define_host_object(name, obj)
74
+ handle = @codec.register(obj)
75
+ @backend.eval("globalThis[#{name.to_s.to_json}] = __rbHost.makeProxy(#{handle}); undefined;")
76
+ obj
77
+ end
78
+
79
+ # Bind the window the bridge draws on for JS constructors (new Event(...))
80
+ # and custom element registration. Called by Runtime#install_window — kept
81
+ # distinct from define_host_object so the generic binder has no hidden
82
+ # side effects.
83
+ def window=(win)
84
+ @window = win # the window host promises (thenable adoption) schedule on
85
+ @constructor_resolver.source = win
86
+ @custom_element_bridge.window = win
87
+ # Now that constructors are resolvable, expose their static methods
88
+ # (URL.createObjectURL, …) on the seeded interface globals, and expose
89
+ # the constructors themselves on the window proxy (window.Node,
90
+ # document.defaultView.DOMException, …).
91
+ @backend.call_js("__rbHost.attachStatics")
92
+ @backend.call_js("__rbHost.exposeConstructorsOnWindow")
93
+ wire_scheduler!(win)
94
+ wire_script_runner!(win)
95
+ end
96
+
97
+ # Enqueue a Ruby callback as a NATIVE microtask (a resolved-promise job), so
98
+ # it runs in FIFO order with the engine's other promise jobs.
99
+ def schedule_native_microtask(callback)
100
+ id = (@microtask_seq += 1)
101
+ @microtask_procs[id] = callback
102
+ @backend.call_js("__rbHost.scheduleMicrotask", id)
103
+ nil
104
+ end
105
+
106
+ # Expose the seeded interface constructors (Element, Node, DOMException, …)
107
+ # on a secondary window object — an iframe's contentWindow — so cross-window
108
+ # `instanceof subWin.Element` and `subDoc.defaultView.DOMException` resolve
109
+ # to the same constructors the top window uses. Idempotent per window.
110
+ def expose_constructors_on(window_obj)
111
+ handle = @codec.register(window_obj)
112
+ # Retain the proxy in a JS-side registry: the constructors are defined as
113
+ # own properties on the proxy's target, so the proxy must stay alive (and
114
+ # keep its handle) — otherwise GC releases it and a later
115
+ # `iframe.contentWindow` rebuilds a fresh, constructor-less proxy.
116
+ @backend.eval(<<~JS)
117
+ (globalThis.__rbSubWindows ||= []).push(__rbHost.makeProxy(#{handle}));
118
+ __rbHost.exposeConstructorsOnWindow(globalThis.__rbSubWindows.at(-1));
119
+ JS
120
+ window_obj
121
+ end
122
+
123
+ # Invoke a JS custom element lifecycle callback (connectedCallback etc.) for
124
+ # a Dommy node. Called by the bridged custom element class (see CustomElementBridge).
125
+ def invoke_lifecycle(node, callback, args)
126
+ handle = @codec.register(node)
127
+ unwrap(@backend.call_js("__rbHost.invokeLifecycle", handle, callback, wrap(Array(args))))
128
+ end
129
+
130
+ # Invoke a retained live JS function by id (used by HostCallback). The JS
131
+ # side returns a `dehydrate`d (tagged) value, so unwrap it back to Ruby:
132
+ # a callback that returns e.g. a Promise proxy must come back as the live
133
+ # PromiseValue, otherwise Dommy can't adopt it (breaking
134
+ # `fetch().then(r => r.json()).then(…)` chains).
135
+ # `raising: true` re-raises a thrown value (as a ThrowValue dom_guard
136
+ # rethrows verbatim) where the spec requires the exception to propagate — a
137
+ # NodeFilter whose error must surface out of the traversal method that ran
138
+ # it. The default swallows it: event listeners / observers / timers must not
139
+ # let a callback error escape their dispatch.
140
+ def invoke_callback(id, args, this_arg = nil, raising: false)
141
+ callback_result(@backend.call_js("__rbHost.invokeCallback", id, wrap(Array(args)), wrap(this_arg)), raising)
142
+ end
143
+
144
+ # Invoke a JS EventListener *object*'s handleEvent (see HostEventListener),
145
+ # passing the dispatched event as a proxy.
146
+ def invoke_js_ref_handle_event(ref, event)
147
+ unwrap(@backend.call_js("__rbHost.invokeJsRefHandleEvent", ref, wrap(event)))
148
+ end
149
+
150
+ # Invoke a JS NodeFilter object's acceptNode (see HostNodeFilter). `raising`
151
+ # re-raises a thrown value (the traversal must propagate it) rather than
152
+ # swallowing it.
153
+ def invoke_js_ref_accept_node(ref, node, raising: false)
154
+ callback_result(@backend.call_js("__rbHost.invokeJsRefAcceptNode", ref, wrap(node)), raising)
155
+ end
156
+
157
+ # Turn a JS-side tagged value (produced by __rbHost.tag) back into Ruby:
158
+ # tagged handles become the original Ruby DOM objects. Used for return
159
+ # values that may contain DOM nodes (e.g. evaluate_script).
160
+ def decode(tagged)
161
+ unwrap(tagged)
162
+ end
163
+
164
+ # Number of live handle entries. Introspection for lifetime tests.
165
+ def registered_count
166
+ @codec.size
167
+ end
168
+
169
+ # Snapshot bridge crossing counts, enabled with DOMMY_JS_BRIDGE_PROFILE=1.
170
+ # Counts are grouped by ABI function name, with a nested breakdown of the
171
+ # hottest interface/property or interface/method labels where available.
172
+ def crossing_counts(limit: nil)
173
+ return {} unless @crossing_counts
174
+
175
+ @crossing_counts.transform_values do |counts|
176
+ sorted = counts.sort_by { |(_key, count)| -count }
177
+ sorted = sorted.first(limit) if limit
178
+ sorted.to_h
179
+ end
180
+ end
181
+
182
+ def reset_crossing_counts
183
+ @crossing_counts = new_crossing_counts if @crossing_counts
184
+ self
185
+ end
186
+
187
+ # Drain the most-recent recorded rejection detail (paired by recency with the
188
+ # engine's detail-less "[object Object]" report). Nil when none recorded /
189
+ # the opt-in tracker (installRejectionTracker) isn't installed.
190
+ def take_rejection_detail
191
+ @rejection_details.pop
192
+ end
193
+
194
+ private
195
+
196
+ def crossing_profile_enabled?
197
+ !ENV["DOMMY_JS_BRIDGE_PROFILE"].to_s.empty?
198
+ end
199
+
200
+ def new_crossing_counts
201
+ Hash.new { |h, k| h[k] = Hash.new(0) }
202
+ end
203
+
204
+ def count_crossing(abi_name, obj = nil, member = nil)
205
+ return unless @crossing_counts
206
+
207
+ @crossing_counts[abi_name.to_s]["__total__"] += 1
208
+ return unless member
209
+
210
+ @crossing_counts[abi_name.to_s][crossing_label(obj, member)] += 1
211
+ end
212
+
213
+ def crossing_label(obj, member)
214
+ return member.to_s unless obj
215
+
216
+ iface = obj ? DomInterfaces.info(obj)["name"] : nil
217
+ "#{iface || obj.class.name}##{member}"
218
+ rescue StandardError
219
+ "#{obj.class.name}##{member}"
220
+ end
221
+
222
+ # Route Dommy's host-side microtasks (MutationObserver delivery, …) onto
223
+ # the engine's native promise-job queue, so they interleave FIFO with JS
224
+ # `await`/Promise reactions instead of draining on a separate pass (which
225
+ # would deliver e.g. MutationObserver records only after `await
226
+ # Promise.resolve()`, batching several mutations into one callback).
227
+ def wire_scheduler!(win)
228
+ return unless win.respond_to?(:scheduler) && win.scheduler.respond_to?(:native_microtask_scheduler=)
229
+
230
+ win.scheduler.native_microtask_scheduler = ->(callback) { schedule_native_microtask(callback) }
231
+ end
232
+
233
+ # Let a classic <script> inserted into the document execute (Dommy has no
234
+ # JS engine; it calls back here to run the body in global scope).
235
+ def wire_script_runner!(win)
236
+ return unless win.respond_to?(:document) && win.document.respond_to?(:script_runner=)
237
+
238
+ win.document.script_runner = ->(source) { @backend.call_js("__rbHost.runScript", source.to_s) }
239
+ end
240
+
241
+ # Register the host-function ABI in cohesive groups, then run the JS-side
242
+ # runtime that consumes it. Registration order among the groups is
243
+ # irrelevant (they only define functions); seed_runtime! must run last,
244
+ # since the runtime it loads calls back into these functions.
245
+ def install!
246
+ install_object_abi!
247
+ install_lifecycle_abi!
248
+ install_construction_abi!
249
+ install_custom_elements_abi!
250
+ seed_runtime!
251
+ end
252
+
253
+ # A host object's property/method ABI, plus the legacy-platform-object
254
+ # named-property protocol (named getter/deleter) and the self-describe call
255
+ # makeProxy uses to build the right JS prototype.
256
+ def install_object_abi!
257
+ @backend.define_host_function("__rb_host_get") do |handle, prop|
258
+ dom_guard do
259
+ obj = host(handle)
260
+ count_crossing(:__rb_host_get, obj, prop)
261
+ wrap(obj.respond_to?(:__js_get__) ? obj.__js_get__(prop) : nil)
262
+ end
263
+ end
264
+ @backend.define_host_function("__rb_host_set") do |handle, prop, value|
265
+ # Returns whether Dommy handled the write as a DOM property. When it
266
+ # didn't (or the object has no __js_set__), the JS side keeps the value
267
+ # as an expando (preserving object/instance field identity). Wrapped in
268
+ # dom_guard so a throwing setter (e.g. `documentElement.outerHTML = …` →
269
+ # NoModificationAllowedError) crosses as a tagged exception the JS set
270
+ # trap re-throws, rather than escaping as a raw Ruby error.
271
+ dom_guard do
272
+ obj = host(handle)
273
+ count_crossing(:__rb_host_set, obj, prop)
274
+ obj.respond_to?(:__js_set__) ? dommy_handled?(obj.__js_set__(prop, unwrap(value))) : false
275
+ end
276
+ end
277
+ @backend.define_host_function("__rb_host_call") do |handle, method, args|
278
+ dom_guard do
279
+ obj = host(handle)
280
+ count_crossing(:__rb_host_call, obj, method)
281
+ obj.respond_to?(:__js_call__) ? wrap(obj.__js_call__(method, unwrap(args))) : nil
282
+ end
283
+ end
284
+ # A pending host PromiseValue used to ADOPT a JS thenable returned from a
285
+ # `.then` callback (Promises/A+): the JS side subscribes the thenable to
286
+ # settle this promise, so the host chain WAITS for the thenable instead of
287
+ # resolving immediately with an opaque ref (the HttpLink #95 reorder).
288
+ @backend.define_host_function("__rb_new_host_promise") do
289
+ count_crossing(:__rb_new_host_promise)
290
+ dom_guard { @codec.register(Dommy::PromiseValue.new(@window)) }
291
+ end
292
+ @backend.define_host_function("__rb_settle_host_promise") do |handle, fulfilled, value|
293
+ count_crossing(:__rb_settle_host_promise)
294
+ dom_guard do
295
+ promise = host(handle)
296
+ fulfilled ? promise.fulfill(unwrap(value)) : promise.reject(unwrap(value))
297
+ nil
298
+ end
299
+ end
300
+ # 2d: one call returns everything makeProxy needs — interface name +
301
+ # chain, method names, and the custom element tag (if any).
302
+ @backend.define_host_function("__rb_host_describe") do |handle|
303
+ obj = host(handle)
304
+ count_crossing(:__rb_host_describe, obj)
305
+ info = DomInterfaces.info(obj)
306
+ info["methods"] = method_names(obj)
307
+ # Mark JS-defined custom elements so makeProxy upgrades them on crossing.
308
+ info["ce"] = obj.__js_custom_element_name__ if obj.respond_to?(:__js_custom_element_name__)
309
+ info
310
+ end
311
+ # One-shot attribute snapshot for the JS-side attribute cache: a plain
312
+ # {qualified_name => value} Hash (an element with case-insensitive
313
+ # attribute lookups), or nil (anything else — the JS side keeps the
314
+ # per-call path). Staleness is the JS side's concern (the DOM epoch).
315
+ @backend.define_host_function("__rb_host_attrs") do |handle|
316
+ dom_guard do
317
+ obj = host(handle)
318
+ count_crossing(:__rb_host_attrs, obj)
319
+ obj.respond_to?(:__js_attribute_snapshot__) ? obj.__js_attribute_snapshot__ : nil
320
+ end
321
+ end
322
+ # WebIDL "supported property names" for a legacy platform object (a live
323
+ # array-like/maplike collection): the current ordered named-property
324
+ # keys. Queried per ownKeys / getOwnPropertyDescriptor so it tracks DOM
325
+ # mutations. Nil when the object has no named getter.
326
+ @backend.define_host_function("__rb_named_props") do |handle|
327
+ obj = host(handle)
328
+ count_crossing(:__rb_named_props, obj)
329
+ obj.respond_to?(:__js_named_props__) ? Array(obj.__js_named_props__).map(&:to_s) : nil
330
+ end
331
+ # Named deleter (`delete el.dataset.foo`): true when the object handled
332
+ # the delete, false/UNHANDLED when the JS side should fall back to its
333
+ # own (expando) delete.
334
+ @backend.define_host_function("__rb_host_delete") do |handle, prop|
335
+ dom_guard do
336
+ obj = host(handle)
337
+ count_crossing(:__rb_host_delete, obj, prop)
338
+ obj.respond_to?(:__js_delete__) ? dommy_handled?(obj.__js_delete__(prop)) : false
339
+ end
340
+ end
341
+ end
342
+
343
+ # VM bookkeeping: proxy-handle release (driven by JS GC) and the native
344
+ # microtask drain hook.
345
+ def install_lifecycle_abi!
346
+ @backend.define_host_function("__rb_release_handle") do |handle|
347
+ count_crossing(:__rb_release_handle)
348
+ @codec.release(handle)
349
+ nil
350
+ end
351
+ # Run a Ruby microtask previously registered by schedule_native_microtask,
352
+ # invoked from the resolved-promise job scheduleMicrotask queued.
353
+ @backend.define_host_function("__rb_run_microtask") do |id|
354
+ count_crossing(:__rb_run_microtask)
355
+ callback = @microtask_procs.delete(id)
356
+ callback&.call
357
+ nil
358
+ end
359
+ # Opt-in rejection diagnostics: __rbHost.installRejectionTracker (only run
360
+ # when asked) records a rich description of each promise rejection here, at
361
+ # reject time, so #take_rejection_detail can replace the engine's
362
+ # detail-less "[object Object]" unhandled-rejection report with the truth.
363
+ @backend.define_host_function("__rb_record_rejection_detail") do |detail|
364
+ @rejection_details.push(detail.to_s)
365
+ @rejection_details.shift if @rejection_details.size > 256
366
+ nil
367
+ end
368
+ end
369
+
370
+ # Reverse construction (`new Event(...)`) and interface static methods
371
+ # (URL.createObjectURL, …).
372
+ def install_construction_abi!
373
+ # `new Event(...)` / `new DOMException(...)` from a bare interface
374
+ # constructor — resolve the named constructor and build. Returns nil when
375
+ # the interface isn't constructable, so the JS side throws.
376
+ @backend.define_host_function("__rb_construct") do |name, args|
377
+ count_crossing(:__rb_construct, nil, name)
378
+ dom_guard do
379
+ ctor = @constructor_resolver.resolve(name)
380
+ ctor ? wrap(ctor.__js_new__(unwrap(args))) : nil
381
+ end
382
+ end
383
+ # Static/class methods on an interface constructor (URL.createObjectURL,
384
+ # URL.parse, …): names to expose, and the dispatch.
385
+ @backend.define_host_function("__rb_static_names") do |name|
386
+ count_crossing(:__rb_static_names, nil, name)
387
+ ctor = @constructor_resolver.resolve(name)
388
+ ctor.respond_to?(:__js_class_method_names__) ? ctor.__js_class_method_names__ : []
389
+ end
390
+ @backend.define_host_function("__rb_static_call") do |name, method, args|
391
+ count_crossing(:__rb_static_call, nil, "#{name}.#{method}")
392
+ dom_guard do
393
+ ctor = @constructor_resolver.resolve(name)
394
+ ctor.respond_to?(:__js_call__) ? wrap(ctor.__js_call__(method, unwrap(args))) : nil
395
+ end
396
+ end
397
+ end
398
+
399
+ # customElements.define / .upgrade, delegated to Dommy's registry.
400
+ def install_custom_elements_abi!
401
+ # 1d: customElements.define(name, JSClass) wires a Dommy custom element.
402
+ @backend.define_host_function("__rb_define_custom_element") do |name, observed|
403
+ count_crossing(:__rb_define_custom_element, nil, name)
404
+ @custom_element_bridge.define(name, Array(observed))
405
+ nil
406
+ end
407
+ # 1d: customElements.upgrade(root) — delegate to Dommy's registry.
408
+ @backend.define_host_function("__rb_upgrade_custom_elements") do |handle|
409
+ count_crossing(:__rb_upgrade_custom_elements)
410
+ @custom_element_bridge.upgrade(host(handle))
411
+ nil
412
+ end
413
+ # 1d: direct `new MyElement()` — mint the backing Dommy element for a
414
+ # registered tag so the HTMLElement constructor has an element to adopt.
415
+ # Returns nil when the tag isn't defined (JS then throws Illegal constructor).
416
+ @backend.define_host_function("__rb_create_custom_element") do |name|
417
+ count_crossing(:__rb_create_custom_element, nil, name)
418
+ dom_guard do
419
+ el = @custom_element_bridge.create(name)
420
+ el ? wrap(el) : nil
421
+ end
422
+ end
423
+ end
424
+
425
+ # Run the JS half of the bridge and seed the interface prototypes. Must run
426
+ # after every host function above is registered.
427
+ def seed_runtime!
428
+ @backend.run_bundle("host_runtime.js", HOST_RUNTIME_JS)
429
+ # Seed base interface prototypes from the single Ruby-side hierarchy.
430
+ @backend.eval("__rbHost.seedInterfaces(#{JSON.generate(DomInterfaces::BASE_CHAINS)});")
431
+ # Observable depends on EventTarget.prototype existing (seeded above).
432
+ @backend.run_bundle("observable_runtime.js", OBSERVABLE_RUNTIME_JS)
433
+ end
434
+
435
+ # The strict handle lookup for a receiver (raises on a missing handle,
436
+ # unlike the tolerant argument path in Marshaller#unwrap).
437
+ def host(handle)
438
+ @codec.host(handle)
439
+ end
440
+
441
+ # Marshalling is the Marshaller's job; the bridge calls these from its ABI
442
+ # blocks and invoke_* helpers.
443
+ def wrap(value) = @codec.wrap(value)
444
+ def unwrap(value) = @codec.unwrap(value)
445
+ def dom_guard(&block) = @codec.dom_guard(&block)
446
+ def callback_result(raw, raising) = @codec.callback_result(raw, raising)
447
+
448
+ # Which property names should be treated as callable methods. The ABI
449
+ # keeps properties (__js_get__) and methods (__js_call__) in disjoint
450
+ # namespaces, so the proxy asks the object to self-describe via the bridge
451
+ # ABI method __js_method_names__. method_defined? (not respond_to?) avoids
452
+ # classes whose respond_to_missing? answers true for arbitrary names (e.g.
453
+ # StyleDeclaration's CSS-property accessors).
454
+ def method_names(obj)
455
+ return [] unless obj.class.method_defined?(:__js_method_names__)
456
+
457
+ Array(obj.__js_method_names__).map(&:to_s)
458
+ end
459
+
460
+ # Did Dommy treat a __js_set__ as a real DOM property? A returned UNHANDLED
461
+ # sentinel means "no" (the JS side then keeps it as an expando).
462
+ def dommy_handled?(result)
463
+ result != Dommy::Bridge::UNHANDLED
464
+ end
465
+ end
466
+
467
+ # An event listener backed by a live JS function. Implements only the bridge
468
+ # ABI (__js_call__) — not #call/#handle_event — so Dommy's invoke_listener
469
+ # routes through the __js_call__("call", [event]) branch.
470
+ class HostCallback
471
+ attr_reader :id
472
+
473
+ def initialize(bridge, id)
474
+ @bridge = bridge
475
+ @id = id
476
+ end
477
+
478
+ def __js_call__(method, args)
479
+ return nil unless method == "call"
480
+
481
+ @bridge.invoke_callback(@id, args)
482
+ end
483
+
484
+ # Invoke with an explicit `this` receiver — e.g. a MutationObserver
485
+ # callback whose `this` must be the observer, or an event listener whose
486
+ # `this` is the currentTarget.
487
+ def __js_call_with_this__(args, this_arg)
488
+ @bridge.invoke_callback(@id, args, this_arg)
489
+ end
490
+
491
+ # Invoke and re-raise a thrown value instead of swallowing it — for a
492
+ # NodeFilter, whose exception must propagate out of the traversal method.
493
+ def __js_call_with_raise__(args)
494
+ @bridge.invoke_callback(@id, args, raising: true)
495
+ end
496
+ end
497
+
498
+ # An event listener backed by a live JS *object* implementing the
499
+ # EventListener interface (a `handleEvent` method, e.g. Stimulus's action
500
+ # listeners). Implements #handle_event so Dommy's invoke_listener routes to
501
+ # the object's handleEvent (with `this` bound to the object). Holds the
502
+ # JS-side ref so it also wraps back to the same JS object (identity kept).
503
+ class HostEventListener
504
+ attr_reader :ref
505
+
506
+ def initialize(bridge, ref, label = nil)
507
+ @bridge = bridge
508
+ @ref = ref
509
+ @label = label
510
+ end
511
+
512
+ def handle_event(event)
513
+ @bridge.invoke_js_ref_handle_event(@ref, event)
514
+ end
515
+ end
516
+
517
+ # A NodeFilter backed by a live JS object implementing the callback interface
518
+ # (`{ acceptNode }`). TreeWalker/NodeIterator treat it as the filter callable;
519
+ # each invocation runs acceptNode on the JS object (this = object), and the
520
+ # raising variant lets the filter's exception propagate out of the traversal.
521
+ class HostNodeFilter
522
+ attr_reader :ref
523
+
524
+ def initialize(bridge, ref)
525
+ @bridge = bridge
526
+ @ref = ref
527
+ end
528
+
529
+ def __js_call__(_method, args)
530
+ @bridge.invoke_js_ref_accept_node(@ref, args[0])
531
+ end
532
+
533
+ def __js_call_with_raise__(args)
534
+ @bridge.invoke_js_ref_accept_node(@ref, args[0], raising: true)
535
+ end
536
+ end
537
+ end
538
+ end