dommy-js-quickjs 0.1.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +6 -0
- data/LICENSE.txt +21 -0
- data/README.md +150 -0
- data/Rakefile +49 -0
- data/docs/bridge-redesign.md +559 -0
- data/docs/wpt-conformance.md +752 -0
- data/lib/dommy/js/constructor_registry.rb +40 -0
- data/lib/dommy/js/custom_elements.rb +55 -0
- data/lib/dommy/js/dom_interfaces.rb +139 -0
- data/lib/dommy/js/handle_table.rb +52 -0
- data/lib/dommy/js/host_bridge.rb +400 -0
- data/lib/dommy/js/host_runtime.js +922 -0
- data/lib/dommy/js/observable_runtime.js +728 -0
- data/lib/dommy/js/quickjs/backend.rb +64 -0
- data/lib/dommy/js/quickjs/capybara.rb +80 -0
- data/lib/dommy/js/quickjs/runtime.rb +210 -0
- data/lib/dommy/js/quickjs/version.rb +9 -0
- data/lib/dommy/js/quickjs/wasm_bridge.rb +151 -0
- data/lib/dommy/js/quickjs.rb +20 -0
- data/sig/dommy/js/quickjs.rbs +8 -0
- metadata +95 -0
|
@@ -0,0 +1,400 @@
|
|
|
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
|