dommy-js-quickjs 0.1.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.
@@ -1,139 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Dommy
4
- module Js
5
- # Derives WebIDL interface metadata for a Dommy DOM object: the most-derived
6
- # interface name and the single-inheritance chain up to the root
7
- # (EventTarget for nodes). This mirrors the JS prototype chain the bridge
8
- # builds so `instanceof` / Object.prototype.toString resolve correctly.
9
- #
10
- # Engine-agnostic, and the single home for DOM interface hierarchy knowledge:
11
- # BASE_CHAINS (seeded eagerly on the JS side) must stay consistent with what
12
- # #chain_for derives from real objects.
13
- module DomInterfaces
14
- # Dommy class basename -> WebIDL interface name, where they diverge.
15
- # Anything not listed uses the class basename verbatim (HTMLDivElement, …).
16
- NAME_OVERRIDES = {
17
- "TextNode" => "Text",
18
- "CommentNode" => "Comment",
19
- "CharacterDataNode" => "CharacterData",
20
- "Fragment" => "DocumentFragment",
21
- "ClassList" => "DOMTokenList",
22
- "DatasetMap" => "DOMStringMap",
23
- "StyleDeclaration" => "CSSStyleDeclaration",
24
- "LiveNodeList" => "NodeList",
25
- "StandaloneEventTarget" => "EventTarget"
26
- }.freeze
27
-
28
- # Concrete HTML element interfaces. A browser exposes every one of these as
29
- # a global constructor whether or not an instance exists, so a framework's
30
- # bare `instanceof HTMLInputElement` feature check resolves regardless of
31
- # page content (idiomorph, Turbo's morph engine, probes `instanceof
32
- # HTMLInputElement`/`HTMLTextAreaElement` during focus restoration even when
33
- # the page has no such element). Each is a direct HTMLElement subclass
34
- # except the two media leaves, appended with their chains below. Mirrors the
35
- # `class HTMLxxxElement < HTMLElement` set in the dommy gem's html_elements.
36
- HTML_LEAF_INTERFACES = %w[
37
- HTMLAnchorElement HTMLAreaElement HTMLBaseElement HTMLBodyElement
38
- HTMLBRElement HTMLButtonElement HTMLDataElement HTMLDetailsElement
39
- HTMLDialogElement HTMLDivElement HTMLEmbedElement HTMLFieldsetElement
40
- HTMLFormElement HTMLHeadElement HTMLHeadingElement HTMLHRElement
41
- HTMLHtmlElement HTMLIFrameElement HTMLImageElement HTMLInputElement
42
- HTMLLabelElement HTMLLegendElement HTMLLIElement HTMLLinkElement
43
- HTMLMapElement HTMLMetaElement HTMLMeterElement HTMLModElement
44
- HTMLObjectElement HTMLOListElement HTMLOptGroupElement HTMLOptionElement
45
- HTMLOutputElement HTMLParagraphElement HTMLPictureElement HTMLPreElement
46
- HTMLProgressElement HTMLQuoteElement HTMLScriptElement HTMLSelectElement
47
- HTMLSlotElement HTMLSourceElement HTMLSpanElement HTMLStyleElement
48
- HTMLTableCaptionElement HTMLTableCellElement HTMLTableElement
49
- HTMLTableRowElement HTMLTableSectionElement HTMLTemplateElement
50
- HTMLTextAreaElement HTMLTimeElement HTMLTitleElement HTMLTrackElement
51
- HTMLUListElement
52
- ].freeze
53
-
54
- # Base interface chains seeded eagerly on the JS side so `instanceof Node`
55
- # / `typeof HTMLElement` resolve before an instance of that exact type has
56
- # crossed. Concrete leaves (HTMLButtonElement, …) are built lazily from
57
- # #chain_for when an instance crosses. Keep consistent with #chain_for.
58
- BASE_CHAINS = [
59
- %w[Node EventTarget],
60
- %w[Element Node EventTarget],
61
- %w[HTMLElement Element Node EventTarget],
62
- %w[SVGElement Element Node EventTarget],
63
- %w[CharacterData Node EventTarget],
64
- %w[Text CharacterData Node EventTarget],
65
- %w[Comment CharacterData Node EventTarget],
66
- %w[Document Node EventTarget],
67
- %w[DocumentFragment Node EventTarget],
68
- %w[DocumentType Node EventTarget],
69
- %w[Attr Node EventTarget],
70
- %w[Event],
71
- %w[CustomEvent Event],
72
- %w[MessageEvent Event],
73
- %w[PopStateEvent Event],
74
- %w[CloseEvent Event],
75
- %w[MouseEvent Event],
76
- %w[KeyboardEvent Event],
77
- %w[DOMException],
78
- # Window-exposed constructors that frameworks call bare (new X(...)).
79
- # Seeding them creates the global; construction routes to the window.
80
- %w[MutationObserver], %w[IntersectionObserver], %w[ResizeObserver],
81
- %w[PerformanceObserver], %w[AbortController], %w[AbortSignal EventTarget],
82
- %w[FormData], %w[URL], %w[URLSearchParams], %w[Headers], %w[Request], %w[Response],
83
- %w[Blob], %w[File], %w[FileList], %w[FileReader], %w[XMLHttpRequest],
84
- %w[TextEncoder], %w[TextDecoder], %w[DOMParser], %w[XMLSerializer],
85
- %w[MessageChannel], %w[BroadcastChannel], %w[WebSocket], %w[EventSource],
86
- %w[Notification], %w[Worker], %w[DataTransfer],
87
- %w[ReadableStream], %w[WritableStream], %w[TransformStream],
88
- %w[Range],
89
- # Collection interfaces, seeded so `result instanceof NodeList` /
90
- # `instanceof HTMLCollection` resolve (querySelectorAll, children, …).
91
- %w[NodeList], %w[HTMLCollection],
92
- # Traversal: NodeFilter exposes only [Constant]s (NodeFilter.SHOW_ELEMENT,
93
- # .FILTER_ACCEPT, …); TreeWalker/NodeIterator are instances.
94
- %w[NodeFilter], %w[TreeWalker], %w[NodeIterator],
95
- # Concrete HTML element interfaces (see HTML_LEAF_INTERFACES) + the media
96
- # subtree, so bare `instanceof HTMLInputElement` always resolves.
97
- *HTML_LEAF_INTERFACES.map { |n| [n, "HTMLElement", "Element", "Node", "EventTarget"] },
98
- %w[HTMLMediaElement HTMLElement Element Node EventTarget],
99
- %w[HTMLAudioElement HTMLMediaElement HTMLElement Element Node EventTarget],
100
- %w[HTMLVideoElement HTMLMediaElement HTMLElement Element Node EventTarget]
101
- ].freeze
102
-
103
- module_function
104
-
105
- # { "name" => most-derived interface, "chain" => [...] } for a host object.
106
- def info(obj)
107
- chain = chain_for(obj)
108
- {"name" => chain.first, "chain" => chain}
109
- end
110
-
111
- # Walk the Dommy class superclass chain (HTMLDivElement < HTMLElement <
112
- # Element), then append the module-provided base interfaces (Node ->
113
- # EventTarget) for nodes, since Dommy models Node as a mixin rather than a
114
- # superclass. Stops at the first foreign superclass (Object, or
115
- # StandardError for DOMException) so non-DOM ancestors stay out.
116
- def chain_for(obj)
117
- names = []
118
- klass = obj.class
119
- while klass && klass.name&.start_with?("Dommy::")
120
- name = name_for(klass)
121
- names << name if name && !names.include?(name)
122
- klass = klass.superclass
123
- end
124
- if defined?(Dommy::Node) && obj.is_a?(Dommy::Node)
125
- names << "Node" unless names.include?("Node")
126
- names << "EventTarget" unless names.include?("EventTarget")
127
- end
128
- names
129
- end
130
-
131
- def name_for(klass)
132
- base = klass.name&.split("::")&.last
133
- return nil unless base
134
-
135
- NAME_OVERRIDES.fetch(base, base)
136
- end
137
- end
138
- end
139
- end
@@ -1,52 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Dommy
4
- module Js
5
- # Maps JS-facing integer handles to live Ruby objects. Engine-agnostic.
6
- #
7
- # Handles are monotonic — never reused — so a handle can never refer to two
8
- # different objects over the table's lifetime. That invariant is what makes
9
- # GC-driven #release race-free: a finalizer releasing handle N can't clobber
10
- # a different object that happened to get the same id.
11
- #
12
- # The same Ruby object reuses its handle while still registered (keyed by
13
- # object_id), so it maps to a single JS proxy (stable identity). A registry
14
- # entry also keeps the object reachable while JS holds a proxy for it.
15
- class HandleTable
16
- def initialize
17
- @by_handle = {} # handle (Integer) -> object
18
- @handle_by_oid = {} # object_id -> handle (for identity reuse)
19
- @next_handle = 0
20
- end
21
-
22
- def register(obj)
23
- oid = obj.object_id
24
- existing = @handle_by_oid[oid]
25
- return existing if existing && @by_handle[existing].equal?(obj)
26
-
27
- handle = (@next_handle += 1)
28
- @by_handle[handle] = obj
29
- @handle_by_oid[oid] = handle
30
- handle
31
- end
32
-
33
- def fetch(handle)
34
- @by_handle.fetch(handle.to_i)
35
- end
36
-
37
- # Forget a handle (called when its JS proxy is garbage-collected). Only
38
- # trims the mapping; the object itself lives on via its other references.
39
- def release(handle)
40
- obj = @by_handle.delete(handle.to_i)
41
- return unless obj
42
-
43
- oid = obj.object_id
44
- @handle_by_oid.delete(oid) if @handle_by_oid[oid] == handle.to_i
45
- end
46
-
47
- def size
48
- @by_handle.size
49
- end
50
- end
51
- end
52
- end
@@ -1,400 +0,0 @@
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
- # Two collaborators keep the marshalling core free of DOM specifics:
17
- # DomInterfaces — interface name/chain derivation (instanceof support)
18
- # ConstructorRegistry — `new Event(...)` style reverse construction
19
- #
20
- # Backend contract:
21
- # backend.eval(js) -> evaluate top-level JS
22
- # backend.define_host_function(name) { } -> expose a Ruby block as a JS global
23
- # backend.call_js(path, *args) -> invoke a JS global function by path
24
- #
25
- # The host object must implement __js_get__/__js_set__/__js_call__, and the
26
- # bridge needs to know which names are methods (callable via __js_call__)
27
- # vs. properties (read via __js_get__) — see #method_names.
28
- class HostBridge
29
- # JS half of the bridge (globalThis.__rbHost). Read from a companion file
30
- # so it stays lintable/highlightable rather than buried in a heredoc.
31
- # ::File — inside module Dommy, bare `File` resolves to Dommy::File (the
32
- # File API class), not Ruby's file class.
33
- HOST_RUNTIME_JS = ::File.read(::File.join(__dir__, "host_runtime.js")).freeze
34
- # The WICG Observable polyfill (Observable/Subscriber + EventTarget.when),
35
- # evaluated after the DOM interface prototypes are seeded.
36
- OBSERVABLE_RUNTIME_JS = ::File.read(::File.join(__dir__, "observable_runtime.js")).freeze
37
-
38
- def initialize(backend)
39
- @backend = backend
40
- @handles = HandleTable.new
41
- @callback_objects = {}
42
- @constructors = ConstructorRegistry.new
43
- @custom_elements = CustomElements.new(self)
44
- @microtask_procs = {}
45
- @microtask_seq = 0
46
- install!
47
- end
48
-
49
- # Bind a Ruby object to a JS global of the given name.
50
- def define_host_object(name, obj)
51
- handle = @handles.register(obj)
52
- @backend.eval("globalThis[#{name.to_s.to_json}] = __rbHost.makeProxy(#{handle}); undefined;")
53
- obj
54
- end
55
-
56
- # Bind the window the bridge draws on for JS constructors (new Event(...))
57
- # and custom element registration. Called by Runtime#install_window — kept
58
- # distinct from define_host_object so the generic binder has no hidden
59
- # side effects.
60
- def window=(win)
61
- @constructors.source = win
62
- @custom_elements.window = win
63
- # Now that constructors are resolvable, expose their static methods
64
- # (URL.createObjectURL, …) on the seeded interface globals, and expose
65
- # the constructors themselves on the window proxy (window.Node,
66
- # document.defaultView.DOMException, …).
67
- @backend.call_js("__rbHost.attachStatics")
68
- @backend.call_js("__rbHost.exposeConstructorsOnWindow")
69
- # Route Dommy's host-side microtasks (MutationObserver delivery, …) onto
70
- # the engine's native promise-job queue, so they interleave FIFO with JS
71
- # `await`/Promise reactions instead of draining on a separate pass (which
72
- # would deliver e.g. MutationObserver records only after `await
73
- # Promise.resolve()`, batching several mutations into one callback).
74
- if win.respond_to?(:scheduler) && win.scheduler.respond_to?(:native_microtask_scheduler=)
75
- win.scheduler.native_microtask_scheduler = ->(callback) { schedule_native_microtask(callback) }
76
- end
77
- end
78
-
79
- # Enqueue a Ruby callback as a NATIVE microtask (a resolved-promise job), so
80
- # it runs in FIFO order with the engine's other promise jobs.
81
- def schedule_native_microtask(callback)
82
- id = (@microtask_seq += 1)
83
- @microtask_procs[id] = callback
84
- @backend.call_js("__rbHost.scheduleMicrotask", id)
85
- nil
86
- end
87
-
88
- # Expose the seeded interface constructors (Element, Node, DOMException, …)
89
- # on a secondary window object — an iframe's contentWindow — so cross-window
90
- # `instanceof subWin.Element` and `subDoc.defaultView.DOMException` resolve
91
- # to the same constructors the top window uses. Idempotent per window.
92
- def expose_constructors_on(window_obj)
93
- handle = @handles.register(window_obj)
94
- # Retain the proxy in a JS-side registry: the constructors are defined as
95
- # own properties on the proxy's target, so the proxy must stay alive (and
96
- # keep its handle) — otherwise GC releases it and a later
97
- # `iframe.contentWindow` rebuilds a fresh, constructor-less proxy.
98
- @backend.eval(<<~JS)
99
- (globalThis.__rbSubWindows ||= []).push(__rbHost.makeProxy(#{handle}));
100
- __rbHost.exposeConstructorsOnWindow(globalThis.__rbSubWindows.at(-1));
101
- JS
102
- window_obj
103
- end
104
-
105
- # Invoke a JS custom element lifecycle callback (connectedCallback etc.) for
106
- # a Dommy node. Called by the bridged custom element class (see CustomElements).
107
- def invoke_lifecycle(node, callback, args)
108
- handle = @handles.register(node)
109
- unwrap(@backend.call_js("__rbHost.invokeLifecycle", handle, callback, wrap(Array(args))))
110
- end
111
-
112
- # Invoke a retained live JS function by id (used by HostCallback). The JS
113
- # side returns a `dehydrate`d (tagged) value, so unwrap it back to Ruby:
114
- # a callback that returns e.g. a Promise proxy must come back as the live
115
- # PromiseValue, otherwise Dommy can't adopt it (breaking
116
- # `fetch().then(r => r.json()).then(…)` chains).
117
- def invoke_callback(id, args, this_arg = nil)
118
- unwrap(@backend.call_js("__rbHost.invokeCallback", id, wrap(Array(args)), wrap(this_arg)))
119
- end
120
-
121
- # Turn a JS-side tagged value (produced by __rbHost.tag) back into Ruby:
122
- # tagged handles become the original Ruby DOM objects. Used for return
123
- # values that may contain DOM nodes (e.g. evaluate_script).
124
- def decode(tagged)
125
- unwrap(tagged)
126
- end
127
-
128
- # Number of live handle entries. Introspection for lifetime tests.
129
- def registered_count
130
- @handles.size
131
- end
132
-
133
- private
134
-
135
- def install!
136
- @backend.define_host_function("__rb_host_get") do |handle, prop|
137
- dom_guard do
138
- obj = host(handle)
139
- wrap(obj.respond_to?(:__js_get__) ? obj.__js_get__(prop) : nil)
140
- end
141
- end
142
- @backend.define_host_function("__rb_host_set") do |handle, prop, value|
143
- # Returns whether Dommy handled the write as a DOM property. When it
144
- # didn't (or the object has no __js_set__), the JS side keeps the value
145
- # as an expando (preserving object/instance field identity). Wrapped in
146
- # dom_guard so a throwing setter (e.g. `documentElement.outerHTML = …` →
147
- # NoModificationAllowedError) crosses as a tagged exception the JS set
148
- # trap re-throws, rather than escaping as a raw Ruby error.
149
- dom_guard do
150
- obj = host(handle)
151
- obj.respond_to?(:__js_set__) ? dommy_handled?(obj.__js_set__(prop, unwrap(value))) : false
152
- end
153
- end
154
- @backend.define_host_function("__rb_host_call") do |handle, method, args|
155
- dom_guard do
156
- obj = host(handle)
157
- obj.respond_to?(:__js_call__) ? wrap(obj.__js_call__(method, unwrap(args))) : nil
158
- end
159
- end
160
- # 2d: one call returns everything makeProxy needs — interface name +
161
- # chain, method names, and the custom element tag (if any).
162
- @backend.define_host_function("__rb_host_describe") do |handle|
163
- obj = host(handle)
164
- info = DomInterfaces.info(obj)
165
- info["methods"] = method_names(obj)
166
- # Mark JS-defined custom elements so makeProxy upgrades them on crossing.
167
- info["ce"] = obj.__js_custom_element_name__ if obj.respond_to?(:__js_custom_element_name__)
168
- info
169
- end
170
- @backend.define_host_function("__rb_release_handle") do |handle|
171
- @handles.release(handle)
172
- nil
173
- end
174
- # Run a Ruby microtask previously registered by schedule_native_microtask,
175
- # invoked from the resolved-promise job scheduleMicrotask queued.
176
- @backend.define_host_function("__rb_run_microtask") do |id|
177
- callback = @microtask_procs.delete(id)
178
- callback&.call
179
- nil
180
- end
181
- # WebIDL "supported property names" for a legacy platform object (a live
182
- # array-like/maplike collection): the current ordered named-property
183
- # keys. Queried per ownKeys / getOwnPropertyDescriptor so it tracks DOM
184
- # mutations. Nil when the object has no named getter.
185
- @backend.define_host_function("__rb_named_props") do |handle|
186
- obj = host(handle)
187
- obj.respond_to?(:__js_named_props__) ? Array(obj.__js_named_props__).map(&:to_s) : nil
188
- end
189
- # Named deleter (`delete el.dataset.foo`): true when the object handled
190
- # the delete, false/UNHANDLED when the JS side should fall back to its
191
- # own (expando) delete.
192
- @backend.define_host_function("__rb_host_delete") do |handle, prop|
193
- dom_guard do
194
- obj = host(handle)
195
- obj.respond_to?(:__js_delete__) ? dommy_handled?(obj.__js_delete__(prop)) : false
196
- end
197
- end
198
- # `new Event(...)` / `new DOMException(...)` from a bare interface
199
- # constructor — resolve the named constructor and build. Returns nil when
200
- # the interface isn't constructable, so the JS side throws.
201
- @backend.define_host_function("__rb_construct") do |name, args|
202
- dom_guard do
203
- ctor = @constructors.resolve(name)
204
- ctor ? wrap(ctor.__js_new__(unwrap(args))) : nil
205
- end
206
- end
207
- # Static/class methods on an interface constructor (URL.createObjectURL,
208
- # URL.parse, …): names to expose, and the dispatch.
209
- @backend.define_host_function("__rb_static_names") do |name|
210
- ctor = @constructors.resolve(name)
211
- ctor.respond_to?(:__js_class_method_names__) ? ctor.__js_class_method_names__ : []
212
- end
213
- @backend.define_host_function("__rb_static_call") do |name, method, args|
214
- dom_guard do
215
- ctor = @constructors.resolve(name)
216
- ctor.respond_to?(:__js_call__) ? wrap(ctor.__js_call__(method, unwrap(args))) : nil
217
- end
218
- end
219
- # 1d: customElements.define(name, JSClass) wires a Dommy custom element.
220
- @backend.define_host_function("__rb_define_custom_element") do |name, observed|
221
- @custom_elements.define(name, Array(observed))
222
- nil
223
- end
224
- # 1d: customElements.upgrade(root) — delegate to Dommy's registry.
225
- @backend.define_host_function("__rb_upgrade_custom_elements") do |handle|
226
- @custom_elements.upgrade(host(handle))
227
- nil
228
- end
229
- @backend.eval(HOST_RUNTIME_JS)
230
- # Seed base interface prototypes from the single Ruby-side hierarchy.
231
- @backend.eval("__rbHost.seedInterfaces(#{JSON.generate(DomInterfaces::BASE_CHAINS)});")
232
- # Observable depends on EventTarget.prototype existing (seeded above).
233
- @backend.eval(OBSERVABLE_RUNTIME_JS)
234
- end
235
-
236
- def host(handle)
237
- @handles.fetch(handle)
238
- end
239
-
240
- # Run a host-function body, converting a raised Dommy::DOMException into a
241
- # tagged marker that the JS side (rehydrate) re-throws as a real
242
- # DOMException (name + legacy code, `instanceof DOMException`). Otherwise
243
- # the quickjs gem flattens it to a plain Error — no name/code — which
244
- # breaks `assert_throws_dom` and every DOM error contract (removeChild
245
- # NotFoundError, classList SyntaxError/InvalidCharacterError, …).
246
- def dom_guard
247
- yield
248
- rescue Dommy::Bridge::ThrowValue => e
249
- # A host method threw an arbitrary value (e.g. throwIfAborted's reason);
250
- # re-throw it verbatim JS-side, identity preserved.
251
- {"__rb_throw__" => wrap(e.value)}
252
- rescue Dommy::DOMException => e
253
- {"__rb_exception__" => {"name" => e.name, "message" => e.message, "code" => e.code}}
254
- rescue Dommy::Bridge::TypeError => e
255
- # A deliberate, spec-mandated JS TypeError (e.g. `new URL(bad)`). Tagged
256
- # so rehydrate rethrows a real `TypeError` — `assert_throws_js(TypeError,
257
- # …)` checks `instanceof TypeError`, which a DOMException/Error fails.
258
- {"__rb_exception__" => {"name" => "TypeError", "message" => e.message, "js_native" => true}}
259
- rescue Dommy::Bridge::RangeError => e
260
- # A spec-mandated JS RangeError (e.g. `new Response(b, {status: 42})`).
261
- {"__rb_exception__" => {"name" => "RangeError", "message" => e.message, "js_native" => true}}
262
- end
263
-
264
- # Ruby -> JS: tag bridge-able objects so the JS side can proxy them.
265
- # Recurses Array and Hash so nested DOM nodes are tagged too (symmetric
266
- # with #unwrap).
267
- def wrap(value)
268
- # A `__js_call__` may return the UNDEFINED sentinel for a void op; marshal
269
- # it so the JS side yields `undefined` rather than `null`.
270
- if defined?(Dommy::Bridge::UNDEFINED) && value.equal?(Dommy::Bridge::UNDEFINED)
271
- return {"__rb_undefined" => true}
272
- end
273
- # A byte buffer tagged ArrayBuffer crosses back as a bare ArrayBuffer
274
- # (checked before Bytes, since ArrayBuffer < Bytes).
275
- if defined?(Dommy::Bridge::ArrayBuffer) && value.is_a?(Dommy::Bridge::ArrayBuffer)
276
- return {"__rb_arraybuffer" => value.to_a}
277
- end
278
- # A byte buffer crosses back as a JS Uint8Array.
279
- if defined?(Dommy::Bridge::Bytes) && value.is_a?(Dommy::Bridge::Bytes)
280
- return {"__rb_bytes" => value.to_a}
281
- end
282
- # An opaque JS value returns as its original JS object (identity kept).
283
- if defined?(Dommy::Bridge::JSValue) && value.is_a?(Dommy::Bridge::JSValue)
284
- return {"__rb_js_ref" => value.ref}
285
- end
286
-
287
- case value
288
- when Array
289
- value.map { |element| wrap(element) }
290
- when Hash
291
- value.transform_values { |element| wrap(element) }
292
- when HostCallback
293
- # A JS function that crossed into Ruby returns as the same live JS
294
- # function (not a proxy), so callbacks nested in objects round-trip.
295
- {"__rb_callback" => value.id}
296
- else
297
- if bridgeable?(value)
298
- {"__rb_handle" => @handles.register(value)}
299
- else
300
- value
301
- end
302
- end
303
- end
304
-
305
- # A value crosses as a proxy if it implements any of the bridge ABI — not
306
- # only __js_get__: method-only objects (observers) and constructors expose
307
- # __js_call__ / __js_new__ without properties.
308
- def bridgeable?(value)
309
- value.respond_to?(:__js_get__) ||
310
- value.respond_to?(:__js_call__) ||
311
- value.respond_to?(:__js_new__)
312
- end
313
-
314
- # JS -> Ruby: rebuild tagged handles / callbacks into Ruby objects.
315
- def unwrap(value)
316
- case value
317
- when Array
318
- value.map { |element| unwrap(element) }
319
- when Hash
320
- if value.key?("__rb_handle")
321
- host(value["__rb_handle"])
322
- elsif value.key?("__rb_callback")
323
- id = value["__rb_callback"]
324
- @callback_objects[id] ||= HostCallback.new(self, id)
325
- elsif value.key?("__rb_js_ref")
326
- # An opaque JS value (a non-plain object Ruby just stores and returns,
327
- # e.g. an abort reason) — kept as a handle so it round-trips with
328
- # identity rather than being flattened to a Hash.
329
- if defined?(Dommy::Bridge::JSValue)
330
- Dommy::Bridge::JSValue.new(value["__rb_js_ref"], value["__rb_js_label"])
331
- else
332
- value
333
- end
334
- elsif value.key?("__rb_undefined")
335
- # A top-level JS `undefined` argument — distinct from JS null (nil).
336
- defined?(Dommy::Bridge::UNDEFINED) ? Dommy::Bridge::UNDEFINED : nil
337
- elsif value.key?("__rb_bytes")
338
- # A JS ArrayBuffer / TypedArray argument arrives as a byte buffer.
339
- defined?(Dommy::Bridge::Bytes) ? Dommy::Bridge::Bytes.new(value["__rb_bytes"]) : value["__rb_bytes"]
340
- else
341
- value.transform_values { |element| unwrap(element) }
342
- end
343
- when :undefined
344
- # A raw JS `undefined` (e.g. a property-set value, which crosses via
345
- # `dehydrate` rather than the tagged `dehydrateArgs`) reaches Ruby as
346
- # the gem's `:undefined` symbol. Normalize it to the same sentinel a
347
- # tagged top-level undefined produces, so setters can distinguish it
348
- # from `null` (e.g. `el.ariaLabel = undefined` removes the attribute).
349
- defined?(Dommy::Bridge::UNDEFINED) ? Dommy::Bridge::UNDEFINED : nil
350
- else
351
- value
352
- end
353
- end
354
-
355
- # Which property names should be treated as callable methods. The ABI
356
- # keeps properties (__js_get__) and methods (__js_call__) in disjoint
357
- # namespaces, so the proxy asks the object to self-describe via the bridge
358
- # ABI method __js_method_names__. method_defined? (not respond_to?) avoids
359
- # classes whose respond_to_missing? answers true for arbitrary names (e.g.
360
- # StyleDeclaration's CSS-property accessors).
361
- def method_names(obj)
362
- return [] unless obj.class.method_defined?(:__js_method_names__)
363
-
364
- Array(obj.__js_method_names__).map(&:to_s)
365
- end
366
-
367
- # Did Dommy treat a __js_set__ as a real DOM property? A returned UNHANDLED
368
- # sentinel means "no" (the JS side then keeps it as an expando). Tolerant of
369
- # older Dommy without the sentinel (treats everything as handled).
370
- def dommy_handled?(result)
371
- !(defined?(Dommy::Bridge::UNHANDLED) && result == Dommy::Bridge::UNHANDLED)
372
- end
373
- end
374
-
375
- # An event listener backed by a live JS function. Implements only the bridge
376
- # ABI (__js_call__) — not #call/#handle_event — so Dommy's invoke_listener
377
- # routes through the __js_call__("call", [event]) branch.
378
- class HostCallback
379
- attr_reader :id
380
-
381
- def initialize(bridge, id)
382
- @bridge = bridge
383
- @id = id
384
- end
385
-
386
- def __js_call__(method, args)
387
- return nil unless method == "call"
388
-
389
- @bridge.invoke_callback(@id, args)
390
- end
391
-
392
- # Invoke with an explicit `this` receiver — e.g. a MutationObserver
393
- # callback whose `this` must be the observer, or an event listener whose
394
- # `this` is the currentTarget.
395
- def __js_call_with_this__(args, this_arg)
396
- @bridge.invoke_callback(@id, args, this_arg)
397
- end
398
- end
399
- end
400
- end