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.
- checksums.yaml +4 -4
- data/README.md +3 -3
- data/lib/dommy/animation.rb +4 -0
- data/lib/dommy/attr.rb +11 -5
- data/lib/dommy/backend/makiri_adapter.rb +330 -0
- data/lib/dommy/backend.rb +114 -33
- data/lib/dommy/blob.rb +2 -0
- data/lib/dommy/bridge.rb +11 -0
- data/lib/dommy/browser.rb +217 -0
- data/lib/dommy/compression_streams.rb +4 -0
- data/lib/dommy/crypto.rb +4 -0
- data/lib/dommy/css.rb +487 -50
- data/lib/dommy/custom_elements.rb +2 -2
- data/lib/dommy/data_transfer.rb +2 -0
- data/lib/dommy/data_uri.rb +35 -0
- data/lib/dommy/deferred_response.rb +59 -0
- data/lib/dommy/document.rb +386 -228
- data/lib/dommy/dom_exception.rb +2 -0
- data/lib/dommy/dom_parser.rb +7 -17
- data/lib/dommy/element.rb +502 -155
- data/lib/dommy/event.rb +240 -9
- data/lib/dommy/fetch.rb +152 -34
- data/lib/dommy/form_data.rb +2 -0
- data/lib/dommy/history.rb +2 -0
- data/lib/dommy/html_canvas_element.rb +230 -0
- data/lib/dommy/html_collection.rb +5 -6
- data/lib/dommy/html_elements.rb +304 -27
- data/lib/dommy/interaction/debug.rb +35 -0
- data/lib/dommy/interaction/dom_summary.rb +131 -0
- data/lib/dommy/interaction/driver.rb +244 -0
- data/lib/dommy/interaction/event_synthesis.rb +56 -0
- data/lib/dommy/interaction/field_interactor.rb +117 -0
- data/lib/dommy/interaction/form_submission.rb +268 -0
- data/lib/dommy/interaction/locator.rb +158 -0
- data/lib/dommy/interaction/role_query.rb +58 -0
- data/lib/dommy/interaction.rb +32 -0
- data/lib/dommy/internal/accessibility_tree.rb +215 -0
- data/lib/dommy/internal/accessible_description.rb +38 -0
- data/lib/dommy/internal/accessible_name.rb +301 -0
- data/lib/dommy/internal/aria_role.rb +252 -0
- data/lib/dommy/internal/aria_snapshot.rb +64 -0
- data/lib/dommy/internal/aria_state.rb +151 -0
- data/lib/dommy/internal/css/calc.rb +242 -0
- data/lib/dommy/internal/css/cascade.rb +430 -0
- data/lib/dommy/internal/css/color.rb +381 -0
- data/lib/dommy/internal/css/computed_style_declaration.rb +130 -0
- data/lib/dommy/internal/css/counters.rb +227 -0
- data/lib/dommy/internal/css/custom_properties.rb +183 -0
- data/lib/dommy/internal/css/media_query.rb +302 -0
- data/lib/dommy/internal/css/parser.rb +265 -0
- data/lib/dommy/internal/css/property_registry.rb +512 -0
- data/lib/dommy/internal/css/rule_index.rb +494 -0
- data/lib/dommy/internal/css/supports.rb +158 -0
- data/lib/dommy/internal/css/ua_stylesheet.rb +53 -0
- data/lib/dommy/internal/css_pseudo_handlers.rb +283 -42
- data/lib/dommy/internal/css_rule_text.rb +160 -0
- data/lib/dommy/internal/dom_matching.rb +80 -9
- data/lib/dommy/internal/element_matching.rb +109 -0
- data/lib/dommy/internal/global_functions.rb +33 -0
- data/lib/dommy/internal/mutation_coordinator.rb +95 -4
- data/lib/dommy/internal/namespaces.rb +49 -5
- data/lib/dommy/internal/node_wrapper_cache.rb +163 -26
- data/lib/dommy/internal/parent_node.rb +82 -5
- data/lib/dommy/internal/selector_ast.rb +124 -0
- data/lib/dommy/internal/selector_index.rb +146 -0
- data/lib/dommy/internal/selector_matcher.rb +756 -0
- data/lib/dommy/internal/selector_parser.rb +283 -131
- data/lib/dommy/internal/shadow_root_registry.rb +9 -2
- data/lib/dommy/internal/template_content_registry.rb +26 -18
- data/lib/dommy/internal/xml_serialization.rb +344 -0
- data/lib/dommy/intersection_observer.rb +2 -0
- data/lib/dommy/js/bridge_conformance.rb +80 -0
- data/lib/dommy/js/constructor_resolver.rb +44 -0
- data/lib/dommy/js/custom_element_bridge.rb +90 -0
- data/lib/dommy/js/dom_interfaces.rb +162 -0
- data/lib/dommy/js/handle_table.rb +60 -0
- data/lib/dommy/js/host_bridge.rb +517 -0
- data/lib/dommy/js/host_runtime.js +1495 -0
- data/lib/dommy/js/import_map.rb +58 -0
- data/lib/dommy/js/marshaller.rb +240 -0
- data/lib/dommy/js/module_loader.rb +99 -0
- data/lib/dommy/js/observable_runtime.js +742 -0
- data/lib/dommy/js/runtime.rb +115 -0
- data/lib/dommy/js/script_boot.rb +221 -0
- data/lib/dommy/js/wire_tags.rb +62 -0
- data/lib/dommy/location.rb +2 -0
- data/lib/dommy/media_query_list.rb +50 -14
- data/lib/dommy/message_channel.rb +22 -6
- data/lib/dommy/minitest/assertions.rb +27 -0
- data/lib/dommy/mutation_observer.rb +89 -4
- data/lib/dommy/navigator.rb +34 -2
- data/lib/dommy/node.rb +24 -14
- data/lib/dommy/notification.rb +2 -0
- data/lib/dommy/parser.rb +1 -1
- data/lib/dommy/performance.rb +21 -1
- data/lib/dommy/promise.rb +94 -10
- data/lib/dommy/range.rb +173 -31
- data/lib/dommy/resources.rb +178 -0
- data/lib/dommy/rspec/capy_style_matchers.rb +126 -0
- data/lib/dommy/scheduler.rb +149 -13
- data/lib/dommy/screen.rb +91 -0
- data/lib/dommy/shadow_root.rb +76 -13
- data/lib/dommy/storage.rb +2 -1
- data/lib/dommy/streams.rb +6 -0
- data/lib/dommy/text_codec.rb +7 -1
- data/lib/dommy/tree_walker.rb +33 -10
- data/lib/dommy/url.rb +13 -1
- data/lib/dommy/version.rb +1 -1
- data/lib/dommy/window.rb +199 -11
- data/lib/dommy/worker.rb +8 -4
- data/lib/dommy/xml_http_request.rb +47 -6
- data/lib/dommy.rb +36 -1
- metadata +96 -10
- data/lib/dommy/backend/nokogiri_adapter.rb +0 -127
- data/lib/dommy/backend/nokolexbor_adapter.rb +0 -117
|
@@ -0,0 +1,1495 @@
|
|
|
1
|
+
// JS half of the Dommy <-> Ruby DOM bridge. Loaded once per backend and
|
|
2
|
+
// eval'd into the VM. Defines globalThis.__rbHost.{makeProxy, invokeCallback,
|
|
3
|
+
// tag, interfaceOf, seedInterfaces}.
|
|
4
|
+
//
|
|
5
|
+
// Values crossing the boundary are tagged: a bridge-able Ruby object is
|
|
6
|
+
// `{ __rb_handle: id }`, a JS function passed to Ruby is `{ __rb_callback: id }`.
|
|
7
|
+
globalThis.__rbHost = (function () {
|
|
8
|
+
const HKEY = Symbol("rbHandle");
|
|
9
|
+
const cache = new Map(); // handle -> WeakRef(proxy)
|
|
10
|
+
// proxy -> handle, by object identity. Used to recognise our own proxies
|
|
11
|
+
// WITHOUT reading a property off the candidate: probing `v[HKEY]` would fire
|
|
12
|
+
// the get trap of a *foreign* proxy (e.g. a Vue/Alpine `reactive()` Proxy),
|
|
13
|
+
// and because HKEY is a non-well-known symbol, Vue's reactivity tracks it as a
|
|
14
|
+
// dependency. That stray symbol key then poisons the array's dep map, so a
|
|
15
|
+
// later length-shrinking mutation (pop/shift/splice) does `symbol >= newLen`
|
|
16
|
+
// and throws "cannot convert symbol to number". A WeakMap lookup is pure.
|
|
17
|
+
const proxyHandles = new WeakMap(); // proxy -> handle (identity, trap-free)
|
|
18
|
+
// handle -> proxy, STRONG. A proxy is normally cached only weakly (so it can
|
|
19
|
+
// be GC'd and its Ruby handle released), but once JS code stores an expando on
|
|
20
|
+
// it — framework bookkeeping like lit-html's `_$litPart$` or React's
|
|
21
|
+
// `__reactFiber$…` — that state must survive as long as the node is reachable,
|
|
22
|
+
// exactly as in a browser. We therefore pin the proxy here so a later access
|
|
23
|
+
// (e.g. the next `getElementById` + render) returns the SAME proxy with its
|
|
24
|
+
// expandos intact, rather than a fresh proxy that lost them to GC.
|
|
25
|
+
const pinned = new Map();
|
|
26
|
+
const callbacks = new Map();
|
|
27
|
+
const callbackIds = new WeakMap();
|
|
28
|
+
let nextCb = 1;
|
|
29
|
+
|
|
30
|
+
// Whether `handle` is the GLOBAL window (globalThis.window) — distinct from an
|
|
31
|
+
// iframe's contentWindow. In a browser the window IS the global object, so its
|
|
32
|
+
// proxy traps treat globalThis's OWN properties as a shared namespace BOTH
|
|
33
|
+
// ways: a global a script creates via `globalThis.X = …` (or a top-level `var`
|
|
34
|
+
// in a UMD bundle) reads back as `window.X`, and a `window.X = …` write lands
|
|
35
|
+
// on globalThis so a later bare `X` / `globalThis.X` resolves to it.
|
|
36
|
+
//
|
|
37
|
+
// Resolved against the LIVE globalThis.window each call (not cached): a host
|
|
38
|
+
// can rebind the window (a fresh document per WPT file in a reused VM), and
|
|
39
|
+
// Ruby handles are recycled once a proxy is GC'd, so a captured handle could
|
|
40
|
+
// go stale and misidentify a later element proxy as the window. Two O(1)
|
|
41
|
+
// lookups (own-prop read + WeakMap get); module-scoped so it isn't a fresh
|
|
42
|
+
// closure on every makeHandler (one per DOM proxy).
|
|
43
|
+
const isGlobalWindow = (handle) => {
|
|
44
|
+
const w = globalThis.window;
|
|
45
|
+
return w != null && proxyHandles.get(w) === handle;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// 2a: array-like DOM collections that cross as proxies (not as JS arrays the
|
|
49
|
+
// way NodeList does) need Symbol.iterator so for-of / spread work. They expose
|
|
50
|
+
// length + integer indices through the ABI, so the iterator walks those.
|
|
51
|
+
const ARRAY_LIKE_COLLECTIONS = new Set([
|
|
52
|
+
"HTMLCollection", "NodeList", "DOMTokenList", "NamedNodeMap", "DOMStringList",
|
|
53
|
+
"FileList", "CSSRuleList", "StyleSheetList", "DataTransferItemList", "MediaList"
|
|
54
|
+
]);
|
|
55
|
+
// Map-like collections iterated as [key, value] pairs via .entries().
|
|
56
|
+
const ENTRIES_ITERABLES = new Set(["URLSearchParams", "FormData", "Headers"]);
|
|
57
|
+
|
|
58
|
+
// Array-like collections that are iterable ONLY via @@iterator (their IDL is
|
|
59
|
+
// not declared `iterable<>`, so they lack keys()/values()/entries()/forEach()).
|
|
60
|
+
const INDEXED_ONLY_ITERABLE = new Set(["HTMLCollection", "HTMLOptionsCollection"]);
|
|
61
|
+
|
|
62
|
+
// WebIDL legacy platform objects with a named property getter, and whether
|
|
63
|
+
// their named properties are enumerable (DOMStringMap) and writable/deletable
|
|
64
|
+
// (DOMStringMap has a named setter/deleter; HTMLCollection/NamedNodeMap are
|
|
65
|
+
// read-only — `coll[name] = x` / `delete coll[name]` reject in strict mode).
|
|
66
|
+
const NAMED_PROP_COLLECTIONS = new Map([
|
|
67
|
+
["HTMLCollection", { enumerable: false, writable: false }],
|
|
68
|
+
["HTMLOptionsCollection", { enumerable: false, writable: false }],
|
|
69
|
+
["NamedNodeMap", { enumerable: false, writable: false }],
|
|
70
|
+
["DOMStringMap", { enumerable: true, writable: true }],
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
// [LegacyNullToEmptyString] DOMString setters: null becomes "", any other
|
|
74
|
+
// value is ToString-coerced JS-side before crossing into Ruby.
|
|
75
|
+
const NULL_TO_EMPTY_STRING_SETTERS = new Set(["innerHTML", "outerHTML"]);
|
|
76
|
+
|
|
77
|
+
// Form-control value-like properties exposed as accessor descriptors on the
|
|
78
|
+
// interface prototype (see protoForChain) — what React's value-tracker reads
|
|
79
|
+
// and wraps to detect user input on controlled components.
|
|
80
|
+
const FORM_VALUE_FIELDS = {
|
|
81
|
+
HTMLInputElement: ["value", "checked"],
|
|
82
|
+
HTMLTextAreaElement: ["value"],
|
|
83
|
+
HTMLSelectElement: ["value"],
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// Read-only WebIDL attributes that need a real getter-only descriptor on the
|
|
87
|
+
// prototype: normal reads still go through the proxy get trap, but reflection
|
|
88
|
+
// (Object.getOwnPropertyDescriptor walking the chain, e.g. testharness's
|
|
89
|
+
// assert_readonly) must find an accessor with no [[Set]].
|
|
90
|
+
const READONLY_ATTRS = {
|
|
91
|
+
TreeWalker: ["root", "whatToShow", "filter"],
|
|
92
|
+
NodeIterator: ["root", "whatToShow", "filter", "referenceNode", "pointerBeforeReferenceNode"],
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// [LegacyUnforgeable] attributes are own accessor properties on EACH instance
|
|
96
|
+
// (not the prototype), so `getOwnPropertyDescriptor(instance, name)` finds the
|
|
97
|
+
// getter directly. The getter is shared per name (memoized) so its identity is
|
|
98
|
+
// stable across instances — `Object.getOwnPropertyDescriptor(a, x).get ===
|
|
99
|
+
// Object.getOwnPropertyDescriptor(b, x).get`, as the spec requires.
|
|
100
|
+
const UNFORGEABLE_ATTRS = { Event: ["isTrusted"] };
|
|
101
|
+
const unforgeableGetters = new Map();
|
|
102
|
+
function unforgeableGetter(name) {
|
|
103
|
+
let fn = unforgeableGetters.get(name);
|
|
104
|
+
if (!fn) {
|
|
105
|
+
fn = function () { return rehydrate(__rb_host_get(this[HKEY], name)); };
|
|
106
|
+
unforgeableGetters.set(name, fn);
|
|
107
|
+
}
|
|
108
|
+
return fn;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// WebIDL [Constant]s exposed on Node (and inherited by every node interface):
|
|
112
|
+
// the nodeType values plus the compareDocumentPosition bit flags.
|
|
113
|
+
const NODE_CONSTANTS = {
|
|
114
|
+
ELEMENT_NODE: 1, ATTRIBUTE_NODE: 2, TEXT_NODE: 3, CDATA_SECTION_NODE: 4,
|
|
115
|
+
ENTITY_REFERENCE_NODE: 5, ENTITY_NODE: 6, PROCESSING_INSTRUCTION_NODE: 7,
|
|
116
|
+
COMMENT_NODE: 8, DOCUMENT_NODE: 9, DOCUMENT_TYPE_NODE: 10,
|
|
117
|
+
DOCUMENT_FRAGMENT_NODE: 11, NOTATION_NODE: 12,
|
|
118
|
+
DOCUMENT_POSITION_DISCONNECTED: 1, DOCUMENT_POSITION_PRECEDING: 2,
|
|
119
|
+
DOCUMENT_POSITION_FOLLOWING: 4, DOCUMENT_POSITION_CONTAINS: 8,
|
|
120
|
+
DOCUMENT_POSITION_CONTAINED_BY: 16, DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC: 32
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// WebIDL [Constant]s exposed on the Event interface object + prototype.
|
|
124
|
+
const EVENT_CONSTANTS = {
|
|
125
|
+
NONE: 0, CAPTURING_PHASE: 1, AT_TARGET: 2, BUBBLING_PHASE: 3
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// NodeFilter whatToShow bitmasks + filter return values (TreeWalker/NodeIterator).
|
|
129
|
+
const NODEFILTER_CONSTANTS = {
|
|
130
|
+
FILTER_ACCEPT: 1, FILTER_REJECT: 2, FILTER_SKIP: 3,
|
|
131
|
+
SHOW_ALL: 0xffffffff, SHOW_ELEMENT: 0x1, SHOW_ATTRIBUTE: 0x2, SHOW_TEXT: 0x4,
|
|
132
|
+
SHOW_CDATA_SECTION: 0x8, SHOW_ENTITY_REFERENCE: 0x10, SHOW_ENTITY: 0x20,
|
|
133
|
+
SHOW_PROCESSING_INSTRUCTION: 0x40, SHOW_COMMENT: 0x80, SHOW_DOCUMENT: 0x100,
|
|
134
|
+
SHOW_DOCUMENT_TYPE: 0x200, SHOW_DOCUMENT_FRAGMENT: 0x400, SHOW_NOTATION: 0x800
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// WebSocket ready-state [Constant]s (on the interface object + prototype, so
|
|
138
|
+
// `WebSocket.OPEN` and `ws.OPEN` both resolve).
|
|
139
|
+
const WEBSOCKET_CONSTANTS = { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 };
|
|
140
|
+
|
|
141
|
+
// Range.compareBoundaryPoints `how` [Constant]s (interface object + prototype).
|
|
142
|
+
const RANGE_CONSTANTS = { START_TO_START: 0, START_TO_END: 1, END_TO_END: 2, END_TO_START: 3 };
|
|
143
|
+
|
|
144
|
+
// Interface name -> its [Constant]s (placed on both the interface object and
|
|
145
|
+
// its prototype; instances inherit via the proxy get `prop in target` path).
|
|
146
|
+
const INTERFACE_CONSTANTS = {
|
|
147
|
+
Node: NODE_CONSTANTS, Event: EVENT_CONSTANTS, NodeFilter: NODEFILTER_CONSTANTS,
|
|
148
|
+
WebSocket: WEBSOCKET_CONSTANTS, Range: RANGE_CONSTANTS
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// 1d: custom elements. ceRegistry maps a tag name to its JS constructor;
|
|
152
|
+
// constructionStack carries the element being upgraded so the interface base
|
|
153
|
+
// constructor (see protoForChain) adopts it when `super()` runs; cePending
|
|
154
|
+
// holds whenDefined() resolvers waiting for a name to be defined.
|
|
155
|
+
const ceRegistry = new Map();
|
|
156
|
+
const constructionStack = [];
|
|
157
|
+
const cePending = new Map();
|
|
158
|
+
|
|
159
|
+
// When a proxy is garbage-collected, drop the Ruby-side handle entry
|
|
160
|
+
// (unless a live re-proxy for the same handle exists). Keeps the
|
|
161
|
+
// registry bounded on long-lived VMs. Handles are monotonic on the
|
|
162
|
+
// Ruby side, so a handle never refers to two different objects.
|
|
163
|
+
const finalizers = new FinalizationRegistry((handle) => {
|
|
164
|
+
const ref = cache.get(handle);
|
|
165
|
+
if (!ref || ref.deref() === undefined) {
|
|
166
|
+
cache.delete(handle);
|
|
167
|
+
__rb_release_handle(handle);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
function isProxy(v) {
|
|
172
|
+
return (typeof v === "object" || typeof v === "function") && v !== null && proxyHandles.has(v);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// The set of property names a prototype chain exposes via accessor setters
|
|
176
|
+
// (a framework's reactive properties, e.g. Lit), computed once per prototype
|
|
177
|
+
// and cached — so the set trap doesn't walk the chain on every write.
|
|
178
|
+
const setterPropsCache = new WeakMap();
|
|
179
|
+
function settersOf(proto) {
|
|
180
|
+
let names = setterPropsCache.get(proto);
|
|
181
|
+
if (names) return names;
|
|
182
|
+
names = new Set();
|
|
183
|
+
for (let o = proto; o && o !== Object.prototype; o = Object.getPrototypeOf(o)) {
|
|
184
|
+
const descs = Object.getOwnPropertyDescriptors(o);
|
|
185
|
+
for (const k of Object.keys(descs)) {
|
|
186
|
+
if (typeof descs[k].set === "function") names.add(k);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
setterPropsCache.set(proto, names);
|
|
190
|
+
return names;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Same function -> same id, so addEventListener / removeEventListener
|
|
194
|
+
// round-trip to the same Ruby HostCallback (Dommy matches by identity).
|
|
195
|
+
function registerCallback(fn) {
|
|
196
|
+
if (callbackIds.has(fn)) return callbackIds.get(fn);
|
|
197
|
+
const id = nextCb++;
|
|
198
|
+
callbacks.set(id, fn);
|
|
199
|
+
callbackIds.set(fn, id);
|
|
200
|
+
return id;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Called from Ruby when a host event dispatch reaches a JS-registered
|
|
204
|
+
// listener. The live function (closure intact) is invoked; tagged args
|
|
205
|
+
// (e.g. an Event handle) are rehydrated to proxies first.
|
|
206
|
+
function invokeCallback(id, args, thisArg) {
|
|
207
|
+
const fn = callbacks.get(id);
|
|
208
|
+
if (!fn) return undefined;
|
|
209
|
+
// A null/absent thisArg keeps the historical undefined receiver; a tagged
|
|
210
|
+
// value (e.g. a MutationObserver handle) sets the callback's `this`.
|
|
211
|
+
const receiver = thisArg == null ? undefined : rehydrate(thisArg);
|
|
212
|
+
// Catch a throwing callback and hand the thrown value back tagged, so the
|
|
213
|
+
// Ruby side can decide whether to swallow (event listeners) or re-throw it
|
|
214
|
+
// with identity preserved (NodeFilter, where the exception must propagate
|
|
215
|
+
// out of the traversal method that ran the filter).
|
|
216
|
+
try {
|
|
217
|
+
return dehydrateReturn(fn.apply(receiver, rehydrate(args || [])));
|
|
218
|
+
} catch (e) {
|
|
219
|
+
// Preserve the thrown value's identity through the round trip, even for a
|
|
220
|
+
// plain object (`throw {name:"x"}`) which dehydrate would otherwise flatten
|
|
221
|
+
// to a map — assert_throws_exactly compares by identity.
|
|
222
|
+
const tagged = (e !== null && (typeof e === "object" || typeof e === "function"))
|
|
223
|
+
? { __rb_js_ref: registerJsRef(e) }
|
|
224
|
+
: dehydrate(e);
|
|
225
|
+
return { __rb_cb_threw__: tagged };
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Enqueue a host-side microtask (by id) onto the engine's native promise-job
|
|
230
|
+
// queue, so a Dommy Ruby microtask (e.g. MutationObserver delivery) runs in
|
|
231
|
+
// FIFO order with JS `await`/Promise reactions rather than on a separate pass.
|
|
232
|
+
function scheduleMicrotask(id) {
|
|
233
|
+
Promise.resolve().then(() => { __rb_run_microtask(id); });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Replace unpaired UTF-16 surrogates with U+FFFD. Ruby strings can't hold lone
|
|
237
|
+
// surrogates, so any string crossing into Ruby loses them regardless; doing the
|
|
238
|
+
// scalar-value substitution here (what the spec's USVString conversion mandates,
|
|
239
|
+
// e.g. for TextEncoder) yields a single U+FFFD rather than invalid bytes.
|
|
240
|
+
function scrubLoneSurrogates(s) {
|
|
241
|
+
let out = "";
|
|
242
|
+
for (let i = 0; i < s.length; i++) {
|
|
243
|
+
const c = s.charCodeAt(i);
|
|
244
|
+
if (c >= 0xd800 && c <= 0xdbff) {
|
|
245
|
+
const next = s.charCodeAt(i + 1);
|
|
246
|
+
if (next >= 0xdc00 && next <= 0xdfff) { out += s[i] + s[i + 1]; i++; }
|
|
247
|
+
else out += "�";
|
|
248
|
+
} else if (c >= 0xdc00 && c <= 0xdfff) {
|
|
249
|
+
out += "�";
|
|
250
|
+
} else {
|
|
251
|
+
out += s[i];
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return out;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// The spec's "flatten options": addEventListener / removeEventListener observe
|
|
258
|
+
// only `capture` (plus once / passive / signal for add), each read exactly once.
|
|
259
|
+
// Flattening here — before dehydrate walks the bag — means an option object's
|
|
260
|
+
// unrelated getters never run (e.g. a `{ get dummy() {…} }` probe stays cold).
|
|
261
|
+
function flattenListenerOptions(method, options) {
|
|
262
|
+
if (options == null || typeof options !== "object") return options;
|
|
263
|
+
const out = { capture: !!options.capture };
|
|
264
|
+
if (method === "addEventListener") {
|
|
265
|
+
out.once = !!options.once;
|
|
266
|
+
out.passive = !!options.passive;
|
|
267
|
+
const signal = options.signal;
|
|
268
|
+
if (signal !== undefined) out.signal = signal;
|
|
269
|
+
}
|
|
270
|
+
return out;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function dehydrate(v, seen) {
|
|
274
|
+
if (typeof v === "string") return /[\ud800-\udfff]/.test(v) ? scrubLoneSurrogates(v) : v;
|
|
275
|
+
if (typeof v === "function") return { __rb_callback: registerCallback(v) };
|
|
276
|
+
if (isProxy(v)) return { __rb_handle: proxyHandles.get(v) };
|
|
277
|
+
// A BufferSource (ArrayBuffer or any typed-array/DataView view) crosses as
|
|
278
|
+
// its raw bytes, so host code gets a uniform byte buffer (TextDecoder.decode,
|
|
279
|
+
// Blob, …) rather than a key→value object from Object.keys.
|
|
280
|
+
if (typeof ArrayBuffer !== "undefined") {
|
|
281
|
+
if (v instanceof ArrayBuffer) return { __rb_bytes: Array.from(new Uint8Array(v)) };
|
|
282
|
+
if (ArrayBuffer.isView(v)) return { __rb_bytes: Array.from(new Uint8Array(v.buffer, v.byteOffset, v.byteLength)) };
|
|
283
|
+
}
|
|
284
|
+
// SharedArrayBuffer is a separate type (not an ArrayBuffer subclass), but a
|
|
285
|
+
// BufferSource all the same — cross it as raw bytes too.
|
|
286
|
+
if (typeof SharedArrayBuffer !== "undefined" && v instanceof SharedArrayBuffer) {
|
|
287
|
+
return { __rb_bytes: Array.from(new Uint8Array(v)) };
|
|
288
|
+
}
|
|
289
|
+
if (v !== null && typeof v === "object") {
|
|
290
|
+
seen = seen || new WeakSet();
|
|
291
|
+
if (seen.has(v)) return undefined; // break reference cycles
|
|
292
|
+
seen.add(v);
|
|
293
|
+
// A nested `undefined` array element collapses to null (like JSON), so
|
|
294
|
+
// arrays cross uniformly across engines (see the object branch below).
|
|
295
|
+
if (Array.isArray(v)) return v.map((e) => (e === undefined ? null : dehydrate(e, seen)));
|
|
296
|
+
// An "exotic" object — anything that is NOT a plain data object (Error,
|
|
297
|
+
// DOMException, Map, a class instance, …) — crosses as an opaque JS-side
|
|
298
|
+
// reference, so a value Ruby merely stores and hands back (an
|
|
299
|
+
// AbortSignal's reason, a CustomEvent detail) round-trips with IDENTITY
|
|
300
|
+
// rather than being flattened to a key→value map (which also loses an
|
|
301
|
+
// Error's non-enumerable message/stack). Plain `{}` objects stay maps so
|
|
302
|
+
// option bags keep behaving like Ruby Hashes.
|
|
303
|
+
const proto = Object.getPrototypeOf(v);
|
|
304
|
+
if (proto !== Object.prototype && proto !== null) {
|
|
305
|
+
const ref = { __rb_js_ref: registerJsRef(v) };
|
|
306
|
+
// An object implementing the EventListener interface (a `handleEvent`
|
|
307
|
+
// method — e.g. Stimulus's action listeners) is a valid DOM event
|
|
308
|
+
// listener. Tag it so the Ruby side wraps it as a listener whose
|
|
309
|
+
// `handle_event` routes back here to call its handleEvent.
|
|
310
|
+
if (typeof v.handleEvent === "function") ref.__rb_handle_event = true;
|
|
311
|
+
if ("acceptNode" in v) ref.__rb_accept_node = true;
|
|
312
|
+
return ref;
|
|
313
|
+
}
|
|
314
|
+
// A NodeFilter callback-interface object (`{ acceptNode }`) crosses as a
|
|
315
|
+
// live reference so acceptNode is fetched fresh on each traverse (its
|
|
316
|
+
// getter runs per call), invoked with `this` = the object, and a thrown
|
|
317
|
+
// value propagates — none of which survives flattening to a map. Detected
|
|
318
|
+
// with `in` so merely constructing the walker performs no Get.
|
|
319
|
+
if ("acceptNode" in v) {
|
|
320
|
+
return { __rb_js_ref: registerJsRef(v), __rb_accept_node: true };
|
|
321
|
+
}
|
|
322
|
+
const out = {};
|
|
323
|
+
// Scrub lone surrogates in keys too (not just values): a property key is a
|
|
324
|
+
// string the spec converts to a USVString, so `{ "\uD835x": … }` must reach
|
|
325
|
+
// Ruby as "�x" — leaving it raw lets the gem mangle it (e.g. "U+d835").
|
|
326
|
+
for (const k of Object.keys(v)) {
|
|
327
|
+
const key = /[\ud800-\udfff]/.test(k) ? scrubLoneSurrogates(k) : k;
|
|
328
|
+
// A NESTED `undefined` collapses to null (option-bag semantics, like
|
|
329
|
+
// JSON) on every engine — done here rather than left to the backend,
|
|
330
|
+
// whose undefined marshalling varies (QuickJS keeps it as a sentinel,
|
|
331
|
+
// V8 gives null). Only top-level values are tagged (dehydrateTop).
|
|
332
|
+
out[key] = v[k] === undefined ? null : dehydrate(v[k], seen);
|
|
333
|
+
}
|
|
334
|
+
return out;
|
|
335
|
+
}
|
|
336
|
+
return v;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Opaque JS-value registry: lets a non-plain JS object survive a round trip
|
|
340
|
+
// through Ruby with identity preserved (keyed by the value so the same object
|
|
341
|
+
// reuses its id). Entries are retained for the VM's lifetime.
|
|
342
|
+
const jsRefs = new Map();
|
|
343
|
+
const jsRefIds = new Map();
|
|
344
|
+
let jsRefSeq = 0;
|
|
345
|
+
function registerJsRef(v) {
|
|
346
|
+
let id = jsRefIds.get(v);
|
|
347
|
+
if (id === undefined) {
|
|
348
|
+
id = ++jsRefSeq;
|
|
349
|
+
jsRefs.set(id, v);
|
|
350
|
+
jsRefIds.set(v, id);
|
|
351
|
+
}
|
|
352
|
+
return id;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Called from Ruby when a host event dispatch reaches a listener that is an
|
|
356
|
+
// *object* implementing EventListener (handleEvent) rather than a function.
|
|
357
|
+
// Invokes handleEvent with the object itself as `this`; the tagged event is
|
|
358
|
+
// rehydrated to a proxy first.
|
|
359
|
+
function invokeJsRefHandleEvent(ref, event) {
|
|
360
|
+
const o = jsRefs.get(ref);
|
|
361
|
+
if (!o || typeof o.handleEvent !== "function") return undefined;
|
|
362
|
+
return dehydrateTop(o.handleEvent(rehydrate(event)));
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Invoke a NodeFilter object's acceptNode for one node. acceptNode is fetched
|
|
366
|
+
// fresh (running its getter, per WHATWG callback-interface invocation) and
|
|
367
|
+
// called with `this` = the filter; a thrown value (from the getter or the
|
|
368
|
+
// call) is tagged so the Ruby side can re-throw it out of the traversal.
|
|
369
|
+
function invokeJsRefAcceptNode(ref, node) {
|
|
370
|
+
const o = jsRefs.get(ref);
|
|
371
|
+
if (!o) return undefined;
|
|
372
|
+
try {
|
|
373
|
+
const fn = o.acceptNode;
|
|
374
|
+
return dehydrateTop(fn.call(o, rehydrate(node)));
|
|
375
|
+
} catch (e) {
|
|
376
|
+
const tagged = (e !== null && (typeof e === "object" || typeof e === "function"))
|
|
377
|
+
? { __rb_js_ref: registerJsRef(e) }
|
|
378
|
+
: dehydrate(e);
|
|
379
|
+
return { __rb_cb_threw__: tagged };
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Dehydrate a TOP-LEVEL value crossing to Ruby, tagging an explicit
|
|
384
|
+
// `undefined` so it arrives as Dommy::Bridge::UNDEFINED (distinct from the
|
|
385
|
+
// `nil` a JS `null` becomes). Tagging here — rather than relying on the
|
|
386
|
+
// backend to marshal a bare JS `undefined` to a sentinel — keeps the protocol
|
|
387
|
+
// engine-neutral: every backend gets `{__rb_undefined:true}`, whether or not
|
|
388
|
+
// its value marshalling can tell `undefined` from `null` (V8/mini_racer
|
|
389
|
+
// cannot). Only top-level values are tagged; `undefined` nested inside an
|
|
390
|
+
// object still dehydrates to null, preserving option-bag behavior.
|
|
391
|
+
function dehydrateTop(v) {
|
|
392
|
+
return v === undefined ? { __rb_undefined: true } : dehydrate(v);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Dehydrate a value a host PromiseValue settles WITH (its fulfillment value or
|
|
396
|
+
// rejection reason). Unlike dehydrateTop — which flattens a plain `{}` to a
|
|
397
|
+
// Ruby Hash — this keeps every JS object/function as an opaque `__rb_js_ref`,
|
|
398
|
+
// so a value that merely passes JS → Ruby (the promise's slot) → JS round-trips
|
|
399
|
+
// with IDENTITY. Promises/A+ settles with sentinel objects compared by `===`;
|
|
400
|
+
// flattening them would make every `assert.strictEqual(value, sentinel)` fail.
|
|
401
|
+
// Host proxies still cross as their handle; primitives cross by value.
|
|
402
|
+
function dehydrateSettle(v) {
|
|
403
|
+
if (v === undefined) return { __rb_undefined: true };
|
|
404
|
+
if (v === null) return null;
|
|
405
|
+
const t = typeof v;
|
|
406
|
+
if (t === "object" || t === "function") {
|
|
407
|
+
if (isProxy(v)) return { __rb_handle: proxyHandles.get(v) };
|
|
408
|
+
return { __rb_js_ref: registerJsRef(v) };
|
|
409
|
+
}
|
|
410
|
+
return v;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// The Promises/A+ §2.3 "Promise Resolution Procedure" for a host PromiseValue
|
|
414
|
+
// (referenced by `handle`), run engine-side because adopting a JS thenable
|
|
415
|
+
// means calling its `then`. Resolving with a thenable (a native Promise, a
|
|
416
|
+
// host promise proxy, or any `{ then }`) ADOPTS it — taking its eventual
|
|
417
|
+
// state; resolving with a plain value fulfills. §2.3.1 self-resolution is a
|
|
418
|
+
// TypeError; §2.3.3.3.3 a thenable settles at most once; §2.3.3.3.4 a throwing
|
|
419
|
+
// `then` rejects.
|
|
420
|
+
function resolveHostPromise(handle, value, knownThen) {
|
|
421
|
+
if (isProxy(value) && proxyHandles.get(value) === handle) {
|
|
422
|
+
__rb_settle_host_promise(handle, false, dehydrateSettle(new TypeError("Chaining cycle detected for promise")));
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
if (value !== null && (typeof value === "object" || typeof value === "function")) {
|
|
426
|
+
// §2.3.3.1 — `then` is retrieved exactly ONCE. A caller that already read it
|
|
427
|
+
// (dehydrateReturn, off a returned value) passes it as knownThen so a
|
|
428
|
+
// one-time `then` getter isn't consumed twice. Recursive resolutions (a
|
|
429
|
+
// thenable resolving with a fresh `y`) re-read, per [[Resolve]](promise, y).
|
|
430
|
+
let then = knownThen;
|
|
431
|
+
if (arguments.length < 3) {
|
|
432
|
+
try { then = value.then; } catch (e) {
|
|
433
|
+
__rb_settle_host_promise(handle, false, dehydrateSettle(e));
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
if (typeof then === "function") {
|
|
438
|
+
let called = false;
|
|
439
|
+
try {
|
|
440
|
+
then.call(value,
|
|
441
|
+
(v) => { if (!called) { called = true; resolveHostPromise(handle, v); } },
|
|
442
|
+
(r) => { if (!called) { called = true; __rb_settle_host_promise(handle, false, dehydrateSettle(r)); } });
|
|
443
|
+
} catch (e) {
|
|
444
|
+
if (!called) { called = true; __rb_settle_host_promise(handle, false, dehydrateSettle(e)); }
|
|
445
|
+
}
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
__rb_settle_host_promise(handle, true, dehydrateSettle(value));
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// A thenable returned from a callback (notably a Promise `.then` handler) is
|
|
453
|
+
// ADOPTED into a host promise so the host chain WAITS for it (Promises/A+),
|
|
454
|
+
// instead of crossing as an opaque ref the host machinery resolves with
|
|
455
|
+
// immediately — the microtask reorder that fires note.com's Apollo HttpLink
|
|
456
|
+
// "completed without emitting" (#95). Host proxies (already host promises) and
|
|
457
|
+
// plain values are unaffected. Used only for callback RETURN values, never for
|
|
458
|
+
// arguments (a promise passed as an argument must not be resolved).
|
|
459
|
+
function dehydrateReturn(v) {
|
|
460
|
+
if (v !== null && (typeof v === "object" || typeof v === "function") && !isProxy(v)) {
|
|
461
|
+
// Read `then` ONCE here (§2.3.3.1) and reuse it, so a one-time getter is not
|
|
462
|
+
// consumed by a separate type-probe before resolveHostPromise reads it.
|
|
463
|
+
let then;
|
|
464
|
+
try { then = v.then; } catch (e) {
|
|
465
|
+
// §2.3.3.2 — retrieving `then` threw: the chain rejects with the error.
|
|
466
|
+
const handle = __rb_new_host_promise();
|
|
467
|
+
__rb_settle_host_promise(handle, false, dehydrateSettle(e));
|
|
468
|
+
return { __rb_handle: handle };
|
|
469
|
+
}
|
|
470
|
+
if (typeof then === "function") {
|
|
471
|
+
const handle = __rb_new_host_promise();
|
|
472
|
+
resolveHostPromise(handle, v, then);
|
|
473
|
+
return { __rb_handle: handle };
|
|
474
|
+
}
|
|
475
|
+
// A non-thenable object (`then` absent or not callable, §2.3.3.4 / §2.3.4):
|
|
476
|
+
// it fulfills as itself — crossed identity-preserving so `x === value`
|
|
477
|
+
// holds downstream, rather than flattened to a Ruby Hash.
|
|
478
|
+
return dehydrateSettle(v);
|
|
479
|
+
}
|
|
480
|
+
return dehydrateTop(v);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// A `{ promise, resolve, reject }` deferred backed by a host PromiseValue,
|
|
484
|
+
// whose `resolve` runs the full §2.3 resolution procedure. The Promises/A+
|
|
485
|
+
// conformance adapter builds on this.
|
|
486
|
+
function makeHostDeferred() {
|
|
487
|
+
const handle = __rb_new_host_promise();
|
|
488
|
+
return {
|
|
489
|
+
promise: makeProxy(handle),
|
|
490
|
+
resolve: (value) => resolveHostPromise(handle, value),
|
|
491
|
+
reject: (reason) => __rb_settle_host_promise(handle, false, dehydrateSettle(reason)),
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Dehydrate a top-level call/constructor argument list (each arg via
|
|
496
|
+
// dehydrateTop), so an explicit `undefined` argument is distinguishable from
|
|
497
|
+
// null — letting WebIDL-style dispatch tell an omitted optional argument from
|
|
498
|
+
// an explicit null.
|
|
499
|
+
function dehydrateArgs(args) {
|
|
500
|
+
return Array.prototype.map.call(args, dehydrateTop);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// A host call that raised a Dommy::DOMException comes back tagged so it can be
|
|
504
|
+
// re-thrown JS-side as a real DOMException (name + legacy code, and
|
|
505
|
+
// `instanceof DOMException`). Without this the quickjs gem flattens it to a
|
|
506
|
+
// plain Error, breaking assert_throws_dom and the DOM's error contracts.
|
|
507
|
+
function makeHostError(info) {
|
|
508
|
+
const G = globalThis;
|
|
509
|
+
// A deliberate JS-native error (TypeError, RangeError, …): build the real
|
|
510
|
+
// constructor so `instanceof` holds. URL construction failures arrive here
|
|
511
|
+
// as TypeError (per the URL Standard), not as a DOMException.
|
|
512
|
+
if (info.js_native && typeof G[info.name] === "function") {
|
|
513
|
+
return new G[info.name](info.message);
|
|
514
|
+
}
|
|
515
|
+
if (typeof G.DOMException === "function") {
|
|
516
|
+
try {
|
|
517
|
+
return new G.DOMException(info.message, info.name);
|
|
518
|
+
} catch (_) {
|
|
519
|
+
/* fall through to a plain Error */
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
const e = new Error(info.message);
|
|
523
|
+
if (info.name) e.name = info.name;
|
|
524
|
+
if (info.code !== undefined && info.code !== null) e.code = info.code;
|
|
525
|
+
return e;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function rehydrate(v) {
|
|
529
|
+
if (Array.isArray(v)) return v.map(rehydrate);
|
|
530
|
+
if (v !== null && typeof v === "object") {
|
|
531
|
+
if (v.__rb_exception__) throw makeHostError(v.__rb_exception__);
|
|
532
|
+
// A host-created native error crossing as a VALUE (a promise rejection
|
|
533
|
+
// reason that must be `instanceof TypeError`): build the real error and
|
|
534
|
+
// RETURN it (unlike __rb_exception__, which throws).
|
|
535
|
+
if (v.__rb_error_value) return makeHostError(v.__rb_error_value);
|
|
536
|
+
// A host method that threw an arbitrary value (throwIfAborted's reason):
|
|
537
|
+
// re-throw the rehydrated value verbatim.
|
|
538
|
+
if ("__rb_throw__" in v) throw rehydrate(v.__rb_throw__);
|
|
539
|
+
// A void DOM op marshals as this marker so it becomes `undefined`, not the
|
|
540
|
+
// `null` a bare Ruby nil would (e.g. DOMTokenList add/remove return undefined).
|
|
541
|
+
if (v.__rb_undefined) return undefined;
|
|
542
|
+
// A genuinely-absent property: the VALUE is `undefined` (the get trap and
|
|
543
|
+
// has trap inspect the raw `__rb_absent` tag for absence semantics).
|
|
544
|
+
if (v.__rb_absent) return undefined;
|
|
545
|
+
// A host byte buffer (TextEncoder.encode, …) rehydrates to a Uint8Array.
|
|
546
|
+
if (v.__rb_bytes) return new Uint8Array(v.__rb_bytes);
|
|
547
|
+
// A host byte buffer tagged as an ArrayBuffer (Response/Blob/FileReader/
|
|
548
|
+
// XHR arrayBuffer) rehydrates to a bare ArrayBuffer.
|
|
549
|
+
if (v.__rb_arraybuffer) return new Uint8Array(v.__rb_arraybuffer).buffer;
|
|
550
|
+
if ("__rb_handle" in v) return makeProxy(v.__rb_handle, v.__rb_if, v.__rb_ce);
|
|
551
|
+
// An opaque JS-value reference round-tripping back from Ruby — restore the
|
|
552
|
+
// exact original object (identity-preserving).
|
|
553
|
+
if ("__rb_js_ref" in v) return jsRefs.get(v.__rb_js_ref);
|
|
554
|
+
// Symmetric with dehydrate: a tagged callback restores to the live JS
|
|
555
|
+
// function it was registered from (so functions nested in objects — e.g.
|
|
556
|
+
// an event's detail — survive a round trip through Ruby).
|
|
557
|
+
if ("__rb_callback" in v) {
|
|
558
|
+
const fn = callbacks.get(v.__rb_callback);
|
|
559
|
+
if (fn) return fn;
|
|
560
|
+
}
|
|
561
|
+
const out = {};
|
|
562
|
+
for (const k of Object.keys(v)) out[k] = rehydrate(v[k]);
|
|
563
|
+
return out;
|
|
564
|
+
}
|
|
565
|
+
return v;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// ===== wasm host bridge (handle-oriented JS access) =====
|
|
569
|
+
//
|
|
570
|
+
// A second embedding model, distinct from the Proxy-based one above: a wasm
|
|
571
|
+
// guest (e.g. mruby-in-wasm under wasmtime-rb) drives JS through a small set
|
|
572
|
+
// of imports — js_eval / js_global / js_get / js_set / js_call / js_new /
|
|
573
|
+
// js_make_callback — that operate on opaque JS *handles*, not on Ruby objects
|
|
574
|
+
// exposed as proxies. So the guest needs the inverse of makeProxy: any JS
|
|
575
|
+
// value referenced by an integer ref it can get/set/call/new on.
|
|
576
|
+
//
|
|
577
|
+
// The marshalling is uniform: every non-primitive (object, function, DOM
|
|
578
|
+
// proxy, exotic) crosses as `{ __rb_js_ref: id }` via the shared jsRefs table
|
|
579
|
+
// (so a function can be the receiver of `new` or sit in a `.then(...)` arg
|
|
580
|
+
// list — unlike dehydrate, which would flatten it to `{ __rb_callback }`).
|
|
581
|
+
// Primitives cross as themselves. This pair (wasmTag/wasmUntag) is used only
|
|
582
|
+
// by the wasm* entry points; the Proxy model's dehydrate/rehydrate are
|
|
583
|
+
// untouched.
|
|
584
|
+
function wasmTag(v) {
|
|
585
|
+
if (v === undefined) return { __rb_undefined: true };
|
|
586
|
+
if (v === null) return null;
|
|
587
|
+
const t = typeof v;
|
|
588
|
+
if (t === "string") return /[\ud800-\udfff]/.test(v) ? scrubLoneSurrogates(v) : v;
|
|
589
|
+
if (t === "number" || t === "boolean") return v;
|
|
590
|
+
if (t === "bigint") return Number(v);
|
|
591
|
+
// object / function / symbol — keep identity behind a stable ref.
|
|
592
|
+
return { __rb_js_ref: registerJsRef(v) };
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function wasmUntag(v) {
|
|
596
|
+
if (Array.isArray(v)) return v.map(wasmUntag);
|
|
597
|
+
if (v !== null && typeof v === "object") {
|
|
598
|
+
if (v.__rb_undefined) return undefined;
|
|
599
|
+
if ("__rb_js_ref" in v) return jsRefs.get(v.__rb_js_ref);
|
|
600
|
+
if (v.__rb_bytes) return new Uint8Array(v.__rb_bytes);
|
|
601
|
+
if (v.__rb_arraybuffer) return new Uint8Array(v.__rb_arraybuffer).buffer;
|
|
602
|
+
if ("__rb_handle" in v) return makeProxy(v.__rb_handle, v.__rb_if, v.__rb_ce);
|
|
603
|
+
const out = {};
|
|
604
|
+
for (const k of Object.keys(v)) out[k] = wasmUntag(v[k]);
|
|
605
|
+
return out;
|
|
606
|
+
}
|
|
607
|
+
return v;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function wasmDeref(ref) {
|
|
611
|
+
const v = jsRefs.get(ref);
|
|
612
|
+
if (v === undefined && !jsRefs.has(ref)) {
|
|
613
|
+
throw new Error("wasm bridge: stale or unknown JS ref " + ref);
|
|
614
|
+
}
|
|
615
|
+
return v;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// globalThis as a (tagged) ref, so the guest's `js_global` has a handle to
|
|
619
|
+
// operate on.
|
|
620
|
+
function wasmGlobalRef() { return wasmTag(globalThis); }
|
|
621
|
+
|
|
622
|
+
// Indirect eval runs in global scope: `globalThis.fetch = …` and top-level
|
|
623
|
+
// var/function declarations land on the global, matching a browser's
|
|
624
|
+
// host-eval escape hatch (JS.eval_javascript).
|
|
625
|
+
const indirectEval = eval;
|
|
626
|
+
function wasmEval(src) { return wasmTag(indirectEval(src)); }
|
|
627
|
+
|
|
628
|
+
// Execute a connected classic <script>'s body. Called from Ruby
|
|
629
|
+
// (Document#script_runner) when such a script is inserted into the document,
|
|
630
|
+
// so dynamically-added scripts run as a browser would. Runs inside
|
|
631
|
+
// `with (window)` so bare identifiers resolve against the window object first
|
|
632
|
+
// (in a browser `window` IS the global, but here it is a distinct proxy, so
|
|
633
|
+
// `window.foo = …` would otherwise be invisible to a later bare `foo`). The
|
|
634
|
+
// `with` is skipped for a "use strict" body (where it is illegal). The
|
|
635
|
+
// completion value is voided so a trailing expression never trips the
|
|
636
|
+
// unawaited-Promise guard.
|
|
637
|
+
function runScript(src) {
|
|
638
|
+
const body = String(src);
|
|
639
|
+
const strict = /^\s*(["'])use strict\1/.test(body);
|
|
640
|
+
if (!strict && typeof globalThis.window !== "undefined" && globalThis.window !== globalThis) {
|
|
641
|
+
indirectEval("with (globalThis.window) {\n" + body + "\n}\n;void 0;");
|
|
642
|
+
} else {
|
|
643
|
+
indirectEval(body + "\n;void 0;");
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function wasmGet(ref, prop) { return wasmTag(wasmDeref(ref)[prop]); }
|
|
648
|
+
|
|
649
|
+
function wasmSet(ref, prop, value) { wasmDeref(ref)[prop] = wasmUntag(value); }
|
|
650
|
+
|
|
651
|
+
function wasmCall(ref, method, args) {
|
|
652
|
+
const recv = wasmDeref(ref);
|
|
653
|
+
const fn = recv[method];
|
|
654
|
+
if (typeof fn !== "function") {
|
|
655
|
+
throw new TypeError("wasm bridge: " + String(method) + " is not a function");
|
|
656
|
+
}
|
|
657
|
+
return wasmTag(fn.apply(recv, args.map(wasmUntag)));
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// Apply a function ref directly (optionally with an explicit `this` ref).
|
|
661
|
+
function wasmApply(ref, thisRef, args) {
|
|
662
|
+
const fn = wasmDeref(ref);
|
|
663
|
+
const thisArg = thisRef == null ? undefined : wasmDeref(thisRef);
|
|
664
|
+
return wasmTag(fn.apply(thisArg, args.map(wasmUntag)));
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function wasmNew(ref, args) {
|
|
668
|
+
const ctor = wasmDeref(ref);
|
|
669
|
+
return wasmTag(Reflect.construct(ctor, args.map(wasmUntag)));
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function wasmTypeof(ref) { return typeof wasmDeref(ref); }
|
|
673
|
+
function wasmToString(ref) { return String(wasmDeref(ref)); }
|
|
674
|
+
function wasmStrictEqual(a, b) { return wasmDeref(a) === wasmDeref(b); }
|
|
675
|
+
function wasmIsNull(ref) {
|
|
676
|
+
const v = jsRefs.get(ref);
|
|
677
|
+
return v === null || v === undefined;
|
|
678
|
+
}
|
|
679
|
+
function wasmInstanceof(ref, ctorRef) {
|
|
680
|
+
const ctor = wasmDeref(ctorRef);
|
|
681
|
+
return typeof ctor === "function" && wasmDeref(ref) instanceof ctor;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Create a JS function that calls back into the wasm guest by invoke-id.
|
|
685
|
+
// Returned as a ref so it can be passed to Promise.then / setTimeout / etc.
|
|
686
|
+
// `globalThis.__rbWasmInvoke(id, taggedArgs)` is installed by the embedder
|
|
687
|
+
// (Runtime#enable_wasm_bridge!) and routes into the guest's js_invoke_proc.
|
|
688
|
+
function wasmMakeCallback(invokeId) {
|
|
689
|
+
const fn = function (...args) {
|
|
690
|
+
const result = globalThis.__rbWasmInvoke(invokeId, args.map(wasmTag));
|
|
691
|
+
return wasmUntag(result);
|
|
692
|
+
};
|
|
693
|
+
return wasmTag(fn);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function wasmReleaseRef(ref) {
|
|
697
|
+
const v = jsRefs.get(ref);
|
|
698
|
+
if (v !== undefined || jsRefs.has(ref)) {
|
|
699
|
+
jsRefs.delete(ref);
|
|
700
|
+
jsRefIds.delete(v);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// ===== DOM interface prototypes & constructors (1a/1b/1c) =====
|
|
705
|
+
|
|
706
|
+
// 1c: build a host object from a bare interface constructor
|
|
707
|
+
// (new Event(...) / new DOMException(...)). Ruby resolves the named
|
|
708
|
+
// constructor by interface name; null means "not constructable" so we throw.
|
|
709
|
+
// WebIDL dictionary members for the constructors that take an init dictionary,
|
|
710
|
+
// in the order the spec reads them (inherited members first, then own, each
|
|
711
|
+
// group lexicographic). "boolean" members are coerced with JS ToBoolean; "any"
|
|
712
|
+
// is passed through. Only interfaces with a COMPLETE member list belong here —
|
|
713
|
+
// a partial list would silently drop members.
|
|
714
|
+
const CONSTRUCTOR_DICTS = {
|
|
715
|
+
Event: { bubbles: "boolean", cancelable: "boolean", composed: "boolean" },
|
|
716
|
+
CustomEvent: { bubbles: "boolean", cancelable: "boolean", composed: "boolean", detail: "any" },
|
|
717
|
+
};
|
|
718
|
+
|
|
719
|
+
// WebIDL argument coercion for a constructor that takes `(DOMString type,
|
|
720
|
+
// optional XInit dict)`: the required `type` is ToString-coerced (so a throwing
|
|
721
|
+
// `toString` propagates, and a missing argument is a TypeError), and the dict
|
|
722
|
+
// is rebuilt by reading ONLY its declared members, in declaration order — so
|
|
723
|
+
// unrelated getters (a stray `sweet`/`dummy`) are never invoked and a member's
|
|
724
|
+
// boolean coercion follows JS, not Ruby, truthiness. Other interfaces pass
|
|
725
|
+
// through untouched.
|
|
726
|
+
function coerceConstructorArgs(name, args) {
|
|
727
|
+
if (name === "URLSearchParams") {
|
|
728
|
+
// Per spec a non-string iterable init (another URLSearchParams, a Map, an
|
|
729
|
+
// object with a custom @@iterator) is a *sequence* of pairs — materialize
|
|
730
|
+
// it through its live iterator HERE so the iterator runs JS-side; Ruby only
|
|
731
|
+
// ever sees plain pair arrays. Plain records (no @@iterator) and strings
|
|
732
|
+
// fall through unchanged to the record / string paths.
|
|
733
|
+
const init = args[0];
|
|
734
|
+
if (init !== null && typeof init === "object" && typeof init[Symbol.iterator] === "function") {
|
|
735
|
+
return [Array.from(init, (pair) => Array.from(pair))];
|
|
736
|
+
}
|
|
737
|
+
return args;
|
|
738
|
+
}
|
|
739
|
+
const members = CONSTRUCTOR_DICTS[name];
|
|
740
|
+
if (!members) return args;
|
|
741
|
+
if (args.length < 1) {
|
|
742
|
+
throw new TypeError("Failed to construct '" + name + "': 1 argument required, but only 0 present.");
|
|
743
|
+
}
|
|
744
|
+
const type = String(args[0]);
|
|
745
|
+
const init = args[1];
|
|
746
|
+
const dict = {};
|
|
747
|
+
if (init !== undefined && init !== null) {
|
|
748
|
+
for (const member in members) {
|
|
749
|
+
const value = init[member];
|
|
750
|
+
if (value === undefined) continue;
|
|
751
|
+
dict[member] = members[member] === "boolean" ? !!value : value;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
return [type, dict];
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function constructInterface(name, args) {
|
|
758
|
+
const r = rehydrate(__rb_construct(name, dehydrateArgs(coerceConstructorArgs(name, args))));
|
|
759
|
+
if (r == null) throw new TypeError("Illegal constructor");
|
|
760
|
+
return r;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// 1b: lazily build a JS prototype chain + constructor per DOM interface,
|
|
764
|
+
// mirroring the chain Ruby reports (most-derived first). Cached by name so the
|
|
765
|
+
// shared tail (…Element→Node→EventTarget) is built once and every node links
|
|
766
|
+
// into the same prototypes — making `instanceof` and Object.prototype.toString
|
|
767
|
+
// (via Symbol.toStringTag) work. Constructable interfaces (Event, DOMException,
|
|
768
|
+
// …) build via Ruby; the rest throw Illegal constructor (HTMLElement until 1d).
|
|
769
|
+
const protos = new Map();
|
|
770
|
+
// 2d: method name sets are per-interface (class), so cache them by interface
|
|
771
|
+
// name and reuse across every proxy of that interface instead of rebuilding.
|
|
772
|
+
const methodsByInterface = new Map();
|
|
773
|
+
// Full per-interface descriptor (name + prototype chain + method names) keyed
|
|
774
|
+
// by interface name. A handle that crosses tagged with its interface (see the
|
|
775
|
+
// marshaller) reuses this instead of a `__rb_host_describe` round trip; the
|
|
776
|
+
// describe path (untagged / first sighting of an interface) fills it.
|
|
777
|
+
const descByInterface = new Map();
|
|
778
|
+
function protoForChain(chain, i) {
|
|
779
|
+
const name = chain[i];
|
|
780
|
+
const cached = protos.get(name);
|
|
781
|
+
if (cached) return cached;
|
|
782
|
+
const parent = (i + 1 < chain.length) ? protoForChain(chain, i + 1) : Object.prototype;
|
|
783
|
+
const proto = Object.create(parent);
|
|
784
|
+
Object.defineProperty(proto, Symbol.toStringTag, { value: name, configurable: true });
|
|
785
|
+
// Only node/element constructors adopt an element being upgraded. Otherwise
|
|
786
|
+
// a non-element `new` (e.g. `new IntersectionObserver()` inside a custom
|
|
787
|
+
// element's constructor) would greedily adopt the queued element off the
|
|
788
|
+
// shared construction stack and hijack its prototype.
|
|
789
|
+
const consultsStack = chain.includes("Node");
|
|
790
|
+
const ctor = function (...args) {
|
|
791
|
+
const nt = new.target;
|
|
792
|
+
if (nt === undefined) throw new TypeError(name + " requires 'new'");
|
|
793
|
+
// 1d: custom element upgrade — when a construction is queued, `super()`
|
|
794
|
+
// adopts the element being upgraded (its proxy) and stamps it with the
|
|
795
|
+
// derived class's prototype, rather than minting a new backing object.
|
|
796
|
+
if (consultsStack && constructionStack.length > 0) {
|
|
797
|
+
const el = constructionStack[constructionStack.length - 1];
|
|
798
|
+
Object.setPrototypeOf(el, nt.prototype);
|
|
799
|
+
return el;
|
|
800
|
+
}
|
|
801
|
+
return constructInterface(name, args);
|
|
802
|
+
};
|
|
803
|
+
Object.defineProperty(ctor, "name", { value: name, configurable: true });
|
|
804
|
+
ctor.prototype = proto;
|
|
805
|
+
Object.defineProperty(proto, "constructor", { value: ctor, configurable: true, writable: true });
|
|
806
|
+
// WebIDL [Constant]s live on both the interface object and its prototype
|
|
807
|
+
// (so `Node.ELEMENT_NODE`, `el.ELEMENT_NODE`, `Event.CAPTURING_PHASE`, …
|
|
808
|
+
// all === the numeric value). Instances reach the prototype copy via the
|
|
809
|
+
// proxy get trap's `prop in target` fallback.
|
|
810
|
+
const constants = INTERFACE_CONSTANTS[name];
|
|
811
|
+
if (constants) {
|
|
812
|
+
for (const [k, val] of Object.entries(constants)) {
|
|
813
|
+
const desc = { value: val, enumerable: true, writable: false, configurable: false };
|
|
814
|
+
Object.defineProperty(proto, k, desc);
|
|
815
|
+
Object.defineProperty(ctor, k, desc);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
if (ARRAY_LIKE_COLLECTIONS.has(name)) {
|
|
819
|
+
// WebIDL: a value-iterator interface (indexed getter + `iterable<>`) gets
|
|
820
|
+
// keys()/values()/entries()/forEach()/@@iterator whose values ARE the
|
|
821
|
+
// %Array.prototype% functions — so `list.values === Array.prototype.values`.
|
|
822
|
+
// They operate on the proxy via its live length + indexed getter, and each
|
|
823
|
+
// returns a real Array Iterator (so `list.keys() instanceof Array` is false).
|
|
824
|
+
const A = Array.prototype;
|
|
825
|
+
const define = (key, fn) => Object.defineProperty(proto, key, { value: fn, configurable: true, writable: true });
|
|
826
|
+
define(Symbol.iterator, A[Symbol.iterator]);
|
|
827
|
+
// HTMLCollection is iterable only via @@iterator (its IDL is NOT declared
|
|
828
|
+
// `iterable<>`); the keys()/values()/entries()/forEach() pair methods are
|
|
829
|
+
// exclusive to interfaces that ARE (NodeList, DOMTokenList, …).
|
|
830
|
+
if (!INDEXED_ONLY_ITERABLE.has(name)) {
|
|
831
|
+
define("values", A.values);
|
|
832
|
+
define("keys", A.keys);
|
|
833
|
+
define("entries", A.entries);
|
|
834
|
+
define("forEach", A.forEach);
|
|
835
|
+
}
|
|
836
|
+
} else if (ENTRIES_ITERABLES.has(name)) {
|
|
837
|
+
// A LIVE entries iterator: re-read entries() at each step (indexed by a
|
|
838
|
+
// running cursor) so a mutation mid-loop is observed — e.g. URLSearchParams
|
|
839
|
+
// `for (const e of params) { params.delete(...) }` must see the new state.
|
|
840
|
+
Object.defineProperty(proto, Symbol.iterator, {
|
|
841
|
+
value: function () {
|
|
842
|
+
let i = 0;
|
|
843
|
+
const self = this;
|
|
844
|
+
const it = {
|
|
845
|
+
next() {
|
|
846
|
+
const entries = self.entries();
|
|
847
|
+
if (i >= entries.length) return { value: undefined, done: true };
|
|
848
|
+
return { value: entries[i++], done: false };
|
|
849
|
+
},
|
|
850
|
+
};
|
|
851
|
+
it[Symbol.iterator] = function () { return this; };
|
|
852
|
+
return it;
|
|
853
|
+
},
|
|
854
|
+
configurable: true, writable: true
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
if (name === "TextEncoder") {
|
|
858
|
+
// encodeInto mutates the destination Uint8Array in place, so it must run
|
|
859
|
+
// JS-side (a host round trip would only see a copy). Encodes scalar values
|
|
860
|
+
// to UTF-8, stops before a code point that wouldn't fit, and returns
|
|
861
|
+
// {read (source UTF-16 units), written (bytes)}.
|
|
862
|
+
Object.defineProperty(proto, "encodeInto", {
|
|
863
|
+
value: function (source, destination) {
|
|
864
|
+
if (!(destination instanceof Uint8Array)) {
|
|
865
|
+
throw new TypeError("encodeInto's destination must be a Uint8Array");
|
|
866
|
+
}
|
|
867
|
+
source = String(source);
|
|
868
|
+
const cap = destination.length;
|
|
869
|
+
let read = 0, written = 0;
|
|
870
|
+
for (let i = 0; i < source.length;) {
|
|
871
|
+
let cp = source.codePointAt(i);
|
|
872
|
+
let units = cp > 0xffff ? 2 : 1;
|
|
873
|
+
if (cp >= 0xd800 && cp <= 0xdfff) { cp = 0xfffd; units = 1; } // lone surrogate
|
|
874
|
+
const need = cp <= 0x7f ? 1 : cp <= 0x7ff ? 2 : cp <= 0xffff ? 3 : 4;
|
|
875
|
+
if (written + need > cap) break;
|
|
876
|
+
if (need === 1) {
|
|
877
|
+
destination[written++] = cp;
|
|
878
|
+
} else if (need === 2) {
|
|
879
|
+
destination[written++] = 0xc0 | (cp >> 6);
|
|
880
|
+
destination[written++] = 0x80 | (cp & 0x3f);
|
|
881
|
+
} else if (need === 3) {
|
|
882
|
+
destination[written++] = 0xe0 | (cp >> 12);
|
|
883
|
+
destination[written++] = 0x80 | ((cp >> 6) & 0x3f);
|
|
884
|
+
destination[written++] = 0x80 | (cp & 0x3f);
|
|
885
|
+
} else {
|
|
886
|
+
destination[written++] = 0xf0 | (cp >> 18);
|
|
887
|
+
destination[written++] = 0x80 | ((cp >> 12) & 0x3f);
|
|
888
|
+
destination[written++] = 0x80 | ((cp >> 6) & 0x3f);
|
|
889
|
+
destination[written++] = 0x80 | (cp & 0x3f);
|
|
890
|
+
}
|
|
891
|
+
read += units;
|
|
892
|
+
i += units;
|
|
893
|
+
}
|
|
894
|
+
return { read, written };
|
|
895
|
+
},
|
|
896
|
+
configurable: true, writable: true,
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
if (name === "ReadableStream") {
|
|
900
|
+
// WHATWG: a ReadableStream is async-iterable — `for await (const chunk of
|
|
901
|
+
// stream)` acquires a reader and yields each chunk. Real browsers expose
|
|
902
|
+
// this; code that streams a fetch body (e.g. Apollo Client's multipart /
|
|
903
|
+
// incremental-delivery reader) depends on it, and without it such a read
|
|
904
|
+
// sees an immediately-"done" iterator and produces nothing. Backed by the
|
|
905
|
+
// stream's own getReader()/read().
|
|
906
|
+
Object.defineProperty(proto, Symbol.asyncIterator, {
|
|
907
|
+
value: function () {
|
|
908
|
+
const reader = this.getReader();
|
|
909
|
+
return {
|
|
910
|
+
async next() {
|
|
911
|
+
const { value, done } = await reader.read();
|
|
912
|
+
if (done) { reader.releaseLock(); return { value: undefined, done: true }; }
|
|
913
|
+
return { value, done: false };
|
|
914
|
+
},
|
|
915
|
+
async return(v) { reader.releaseLock(); return { value: v, done: true }; },
|
|
916
|
+
[Symbol.asyncIterator]() { return this; },
|
|
917
|
+
};
|
|
918
|
+
},
|
|
919
|
+
configurable: true, writable: true,
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
// Form-control value-like properties as real accessor descriptors on the
|
|
923
|
+
// prototype, routing to the host. React's input value-tracker reads the
|
|
924
|
+
// descriptor off `node.constructor.prototype` and wraps its get/set to
|
|
925
|
+
// detect user edits; with no prototype accessor it bails and controlled
|
|
926
|
+
// inputs never fire onChange. Normal `el.value` reads still go straight
|
|
927
|
+
// through the proxy get trap (host_get); these accessors are what
|
|
928
|
+
// getOwnPropertyDescriptor(prototype, …) and React's wrapper call.
|
|
929
|
+
const valueFields = FORM_VALUE_FIELDS[name];
|
|
930
|
+
if (valueFields) {
|
|
931
|
+
for (const field of valueFields) {
|
|
932
|
+
Object.defineProperty(proto, field, {
|
|
933
|
+
configurable: true,
|
|
934
|
+
enumerable: true,
|
|
935
|
+
get() { return rehydrate(__rb_host_get(this[HKEY], field)); },
|
|
936
|
+
set(v) { __rb_host_set(this[HKEY], field, dehydrateTop(v)); },
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
const readonlyAttrs = READONLY_ATTRS[name];
|
|
941
|
+
if (readonlyAttrs) {
|
|
942
|
+
for (const field of readonlyAttrs) {
|
|
943
|
+
Object.defineProperty(proto, field, {
|
|
944
|
+
configurable: true,
|
|
945
|
+
enumerable: true,
|
|
946
|
+
get() { return rehydrate(__rb_host_get(this[HKEY], field)); },
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
if (!(name in globalThis)) globalThis[name] = ctor;
|
|
951
|
+
protos.set(name, proto);
|
|
952
|
+
return proto;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
// Legacy named constructors (HTML `[LegacyFactoryFunction]`): a global factory
|
|
956
|
+
// function whose `.prototype` IS the target interface's prototype, so
|
|
957
|
+
// `new Image() instanceof HTMLImageElement` holds and `(new Image).constructor`
|
|
958
|
+
// is HTMLImageElement (the prototype's own `constructor`). Construction routes
|
|
959
|
+
// to Ruby (which builds the actual <img>/<audio>/<option> element).
|
|
960
|
+
const NAMED_CONSTRUCTORS = { Image: "HTMLImageElement", Audio: "HTMLAudioElement", Option: "HTMLOptionElement" };
|
|
961
|
+
|
|
962
|
+
function exposeNamedConstructors() {
|
|
963
|
+
for (const alias in NAMED_CONSTRUCTORS) {
|
|
964
|
+
if (alias in globalThis) continue;
|
|
965
|
+
const proto = protos.get(NAMED_CONSTRUCTORS[alias]);
|
|
966
|
+
if (!proto) continue;
|
|
967
|
+
const ctor = function (...args) {
|
|
968
|
+
if (new.target === undefined) throw new TypeError(alias + " requires 'new'");
|
|
969
|
+
return constructInterface(alias, args);
|
|
970
|
+
};
|
|
971
|
+
Object.defineProperty(ctor, "name", { value: alias, configurable: true });
|
|
972
|
+
ctor.prototype = proto; // share the interface prototype -> instanceof works
|
|
973
|
+
globalThis[alias] = ctor;
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// Eagerly build the base interfaces (chains supplied by Ruby, the single
|
|
978
|
+
// source of hierarchy knowledge) so `instanceof Node` / `typeof HTMLElement`
|
|
979
|
+
// resolve before an instance of that exact type has crossed.
|
|
980
|
+
function seedInterfaces(chains) {
|
|
981
|
+
chains.forEach((c) => protoForChain(c, 0));
|
|
982
|
+
exposeNamedConstructors();
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// 1c: expose an interface constructor's static/class methods (URL.createObjectURL,
|
|
986
|
+
// URL.parse, …) on the seeded global, delegating to the window's constructor.
|
|
987
|
+
// Called once the window is bound (statics live on the window's constructors).
|
|
988
|
+
function attachStatics() {
|
|
989
|
+
for (const name of protos.keys()) {
|
|
990
|
+
const ctor = globalThis[name];
|
|
991
|
+
if (typeof ctor !== "function") continue;
|
|
992
|
+
for (const m of __rb_static_names(name)) {
|
|
993
|
+
if (m in ctor) continue;
|
|
994
|
+
ctor[m] = (...args) => rehydrate(__rb_static_call(name, m, dehydrateArgs(args)));
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// Expose the interface constructors as own properties of the `window` proxy
|
|
1000
|
+
// so `window.Node` / `document.defaultView.DOMException` / … resolve to the
|
|
1001
|
+
// same constructor functions as the bare globals. In a browser window IS the
|
|
1002
|
+
// global object; here it's a separate host proxy whose host get returns null
|
|
1003
|
+
// for these, which broke e.g. assert_throws_dom(type, doc.defaultView.DOMException, …)
|
|
1004
|
+
// (it read `.name` off null). Defining them on the proxy target means the get
|
|
1005
|
+
// trap's own-property fast path returns the real function with no round trip.
|
|
1006
|
+
function exposeConstructorsOnWindow(target) {
|
|
1007
|
+
// Defaults to the top window, but a secondary window (an iframe's
|
|
1008
|
+
// contentWindow) can be passed so `subWin.Element` / `subWin.DOMException`
|
|
1009
|
+
// resolve to the same seeded constructors — needed for cross-window
|
|
1010
|
+
// `instanceof` and `doc.defaultView.X` in iframe documents.
|
|
1011
|
+
const w = target || globalThis.window;
|
|
1012
|
+
if (!w) return;
|
|
1013
|
+
const names = [...protos.keys()];
|
|
1014
|
+
if (typeof globalThis.DOMException === "function") names.push("DOMException");
|
|
1015
|
+
// Mirror the JS built-in constructors too, so an iframe's contentWindow
|
|
1016
|
+
// resolves `defaultView.TypeError` / `defaultView.Array` like a real window
|
|
1017
|
+
// (WPT reaches for `(root.ownerDocument).defaultView.TypeError`).
|
|
1018
|
+
names.push(
|
|
1019
|
+
"Object", "Array", "Function", "String", "Boolean", "Number", "BigInt",
|
|
1020
|
+
"Symbol", "Date", "RegExp", "Promise", "Map", "Set", "WeakMap", "WeakSet",
|
|
1021
|
+
"Error", "TypeError", "RangeError", "SyntaxError", "ReferenceError",
|
|
1022
|
+
"Proxy", "Reflect", "JSON", "Math"
|
|
1023
|
+
);
|
|
1024
|
+
const interfaceNames = new Set(protos.keys());
|
|
1025
|
+
// Legacy named constructors are seeded JS functions too; the window
|
|
1026
|
+
// otherwise resolves them to the host-backed Constructor proxy (a
|
|
1027
|
+
// non-constructable "object"), so replace those the same way as interfaces.
|
|
1028
|
+
for (const alias in NAMED_CONSTRUCTORS) { names.push(alias); interfaceNames.add(alias); }
|
|
1029
|
+
for (const name of names) {
|
|
1030
|
+
const ctor = globalThis[name];
|
|
1031
|
+
if (typeof ctor !== "function") continue;
|
|
1032
|
+
try {
|
|
1033
|
+
const current = w[name];
|
|
1034
|
+
// Fill in names the window doesn't resolve at all; AND replace a
|
|
1035
|
+
// host-backed interface object (e.g. window.Event / window.MutationObserver
|
|
1036
|
+
// crossing as a non-constructable Dommy proxy) with the constructable
|
|
1037
|
+
// seeded constructor, so `new document.defaultView.MutationObserver(cb)`
|
|
1038
|
+
// works — in a real window, window.X IS the constructor function X.
|
|
1039
|
+
if (current == null || (typeof current !== "function" && interfaceNames.has(name))) {
|
|
1040
|
+
Object.defineProperty(w, name, { value: ctor, configurable: true, writable: true });
|
|
1041
|
+
}
|
|
1042
|
+
} catch (e) { /* non-configurable / frozen — leave as-is */ }
|
|
1043
|
+
}
|
|
1044
|
+
// JS builtins must BE the engine's native globals on the window too
|
|
1045
|
+
// (`window.Object === Object`, `window.console === console`, `x in
|
|
1046
|
+
// window.console`), like a real browser. The host's __js_get__ returns
|
|
1047
|
+
// sentinels for some of these (console / Object / Array / JSON) that
|
|
1048
|
+
// otherwise cross as the WRONG type — a string — so `window.console.foo`
|
|
1049
|
+
// and `x in window.console` throw ("invalid 'in' operand"); note.com's
|
|
1050
|
+
// console wrapper hit this. The loop above misses them (non-function
|
|
1051
|
+
// builtins are skipped; sentinel-stringed ones aren't interfaces). Promise
|
|
1052
|
+
// also MUST be the native one so feature detection (core-js et al.) doesn't
|
|
1053
|
+
// swap in a polyfill whose microtasks the host can't flush. Force them all.
|
|
1054
|
+
for (const name of JS_GLOBALS) {
|
|
1055
|
+
if (!(name in globalThis)) continue;
|
|
1056
|
+
try {
|
|
1057
|
+
Object.defineProperty(w, name, { value: globalThis[name], configurable: true, writable: true });
|
|
1058
|
+
} catch (e) { /* non-configurable / frozen — leave as-is */ }
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
// The engine's native globals that `window.X` must mirror exactly.
|
|
1063
|
+
const JS_GLOBALS = [
|
|
1064
|
+
"Object", "Array", "Function", "String", "Boolean", "Number", "BigInt",
|
|
1065
|
+
"Symbol", "Date", "RegExp", "Promise", "Map", "Set", "WeakMap", "WeakSet",
|
|
1066
|
+
"Error", "TypeError", "RangeError", "SyntaxError", "ReferenceError",
|
|
1067
|
+
"Proxy", "Reflect", "JSON", "Math", "console",
|
|
1068
|
+
];
|
|
1069
|
+
|
|
1070
|
+
// ===== Host object proxy =====
|
|
1071
|
+
|
|
1072
|
+
// The proxy traps route each access to one of the bridge's layers. The order
|
|
1073
|
+
// is deliberate — changing it breaks subtle cases, so it's spelled out here:
|
|
1074
|
+
//
|
|
1075
|
+
// get(prop):
|
|
1076
|
+
// 1. HKEY symbol -> the Ruby handle (identity tag)
|
|
1077
|
+
// 2. any other symbol -> target/prototype (Symbol.toStringTag/iterator)
|
|
1078
|
+
// 3. own property on target -> a JS-side expando (object identity intact)
|
|
1079
|
+
// 4. ABI method name -> a per-proxy memoized fn (__rb_host_call)
|
|
1080
|
+
// 5. ABI property (non-null) -> the __rb_host_get value
|
|
1081
|
+
// 6. prototype member -> constructor / connectedCallback / etc.
|
|
1082
|
+
//
|
|
1083
|
+
// set(prop, value):
|
|
1084
|
+
// 1. symbol -> store on the target
|
|
1085
|
+
// 2. prototype setter -> run it (framework reactive props, e.g. Lit)
|
|
1086
|
+
// 3. Dommy handled it -> a DOM property write
|
|
1087
|
+
// 4. otherwise -> a JS-side expando on the target
|
|
1088
|
+
// An array index property name: "0", "1", … (canonical, no leading zeros).
|
|
1089
|
+
function isArrayIndex(prop) {
|
|
1090
|
+
return typeof prop === "string" && /^(0|[1-9][0-9]*)$/.test(prop);
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
function makeHandler(handle, methods, methodCache, arrayLike, named) {
|
|
1094
|
+
// The live length of an array-like collection (NodeList/HTMLCollection/…),
|
|
1095
|
+
// so indexed own-property reflection (hasOwnProperty / Object.keys / spread)
|
|
1096
|
+
// tracks the current children. 0 for non-collections.
|
|
1097
|
+
const liveLength = () => {
|
|
1098
|
+
if (!arrayLike) return 0;
|
|
1099
|
+
const n = rehydrate(__rb_host_get(handle, "length"));
|
|
1100
|
+
return typeof n === "number" && n >= 0 ? n : 0;
|
|
1101
|
+
};
|
|
1102
|
+
// The live WebIDL "supported property names" (named getter keys), re-queried
|
|
1103
|
+
// each call so it tracks DOM mutations; [] when there is no named getter.
|
|
1104
|
+
const namedKeys = () => {
|
|
1105
|
+
if (!named) return [];
|
|
1106
|
+
const r = rehydrate(__rb_named_props(handle));
|
|
1107
|
+
return Array.isArray(r) ? r : [];
|
|
1108
|
+
};
|
|
1109
|
+
const isIndexInRange = (prop) => arrayLike && isArrayIndex(prop) && Number(prop) < liveLength();
|
|
1110
|
+
const isNamedKey = (prop) => named && typeof prop === "string" && namedKeys().indexOf(prop) !== -1;
|
|
1111
|
+
return {
|
|
1112
|
+
get(t, prop, receiver) {
|
|
1113
|
+
if (prop === HKEY) return handle;
|
|
1114
|
+
if (typeof prop === "symbol") return Reflect.get(t, prop, receiver);
|
|
1115
|
+
if (Object.hasOwn(t, prop)) return Reflect.get(t, prop, receiver);
|
|
1116
|
+
if (methods.has(prop)) {
|
|
1117
|
+
let fn = methodCache.get(prop);
|
|
1118
|
+
if (!fn) {
|
|
1119
|
+
if (prop === "addEventListener" || prop === "removeEventListener") {
|
|
1120
|
+
fn = (...args) => {
|
|
1121
|
+
if (args.length >= 3) args[2] = flattenListenerOptions(prop, args[2]);
|
|
1122
|
+
return rehydrate(__rb_host_call(handle, prop, dehydrateArgs(args)));
|
|
1123
|
+
};
|
|
1124
|
+
} else {
|
|
1125
|
+
fn = (...args) => rehydrate(__rb_host_call(handle, prop, dehydrateArgs(args)));
|
|
1126
|
+
}
|
|
1127
|
+
methodCache.set(prop, fn);
|
|
1128
|
+
}
|
|
1129
|
+
return fn;
|
|
1130
|
+
}
|
|
1131
|
+
const raw = __rb_host_get(handle, prop);
|
|
1132
|
+
// The host signals a genuinely-absent property with the ABSENT tag (value
|
|
1133
|
+
// is `undefined`); a present-but-null property is bare nil (→ JS null).
|
|
1134
|
+
// "Host owns nothing here" = absent OR (legacy) null, and only that drives
|
|
1135
|
+
// the global / collection fallbacks below — NOT a real null value.
|
|
1136
|
+
const isAbsent = raw !== null && typeof raw === "object" && raw.__rb_absent === true;
|
|
1137
|
+
const v = rehydrate(raw);
|
|
1138
|
+
const hostHasNoValue = isAbsent || v === null;
|
|
1139
|
+
if (v == null && (prop in t)) return Reflect.get(t, prop, receiver);
|
|
1140
|
+
// The global window: a name the host doesn't resolve falls back to a JS
|
|
1141
|
+
// global of the same name (an OWN globalThis prop — inherited names already
|
|
1142
|
+
// resolved via `prop in t` above), so e.g. a UMD bundle's
|
|
1143
|
+
// `globalThis.Stimulus = …` is visible as `window.Stimulus`.
|
|
1144
|
+
if (hostHasNoValue && isGlobalWindow(handle) && Object.hasOwn(globalThis, prop)) return globalThis[prop];
|
|
1145
|
+
// A legacy platform collection returns `undefined` (not the host's null)
|
|
1146
|
+
// for a string property that resolves to no value. An out-of-range array
|
|
1147
|
+
// index is `undefined` and does NOT fall back to a named lookup (so
|
|
1148
|
+
// `coll[2147483648]` is undefined even if an element's id is that digit
|
|
1149
|
+
// string); other unsupported strings (`coll[""]`, `coll["x"]`) too.
|
|
1150
|
+
if (hostHasNoValue && (arrayLike || named) && typeof prop === "string" && prop !== "length") {
|
|
1151
|
+
if (arrayLike && isArrayIndex(prop)) return undefined;
|
|
1152
|
+
if (!isNamedKey(prop)) return undefined;
|
|
1153
|
+
}
|
|
1154
|
+
return v;
|
|
1155
|
+
},
|
|
1156
|
+
set(t, prop, value, receiver) {
|
|
1157
|
+
if (typeof prop === "symbol") {
|
|
1158
|
+
t[prop] = value;
|
|
1159
|
+
if (proxyHandles.has(receiver)) pinned.set(handle, receiver);
|
|
1160
|
+
return true;
|
|
1161
|
+
}
|
|
1162
|
+
if (settersOf(Object.getPrototypeOf(t)).has(prop)) {
|
|
1163
|
+
Reflect.set(t, prop, value, receiver);
|
|
1164
|
+
return true;
|
|
1165
|
+
}
|
|
1166
|
+
// Legacy platform object with NO indexed setter: an array-index
|
|
1167
|
+
// assignment never becomes an expando — it is a no-op (sloppy) /
|
|
1168
|
+
// TypeError (strict), so the trap returns false.
|
|
1169
|
+
if (arrayLike && isArrayIndex(prop)) return false;
|
|
1170
|
+
// A read-only named property (HTMLCollection/NamedNodeMap) likewise
|
|
1171
|
+
// rejects — unless an own expando already shadows it (then update it).
|
|
1172
|
+
if (named && !named.writable && !Object.hasOwn(t, prop) && isNamedKey(prop)) return false;
|
|
1173
|
+
// The global window: a write to a name the host doesn't already
|
|
1174
|
+
// resolve becomes a JS global (window.X = … ≡ globalThis.X = …), so
|
|
1175
|
+
// window-attached and globalThis-attached globals converge on ONE
|
|
1176
|
+
// storage. A host-resolved property (location, navigator, a Ruby-side
|
|
1177
|
+
// stash, …) keeps routing to the host below. (globalThis is NOT this
|
|
1178
|
+
// proxy's prototype, so the plain assignment can't recurse here.)
|
|
1179
|
+
if (isGlobalWindow(handle)) {
|
|
1180
|
+
const cur = __rb_host_get(handle, prop);
|
|
1181
|
+
const curAbsent = cur !== null && typeof cur === "object" && cur.__rb_absent === true;
|
|
1182
|
+
if (curAbsent || rehydrate(cur) === null) {
|
|
1183
|
+
globalThis[prop] = value;
|
|
1184
|
+
return true;
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
// WebIDL [LegacyNullToEmptyString] DOMString setters coerce JS-side
|
|
1188
|
+
// (null → "", else ToString — so `innerHTML = 42` / `{toString…}` work and
|
|
1189
|
+
// a toString that throws propagates) before the value crosses into Ruby.
|
|
1190
|
+
if (NULL_TO_EMPTY_STRING_SETTERS.has(prop)) value = value === null ? "" : String(value);
|
|
1191
|
+
const handled = __rb_host_set(handle, prop, dehydrateTop(value));
|
|
1192
|
+
// A throwing setter comes back as a tagged exception — re-throw it.
|
|
1193
|
+
if (handled && typeof handled === "object" && handled.__rb_exception__) {
|
|
1194
|
+
throw makeHostError(handled.__rb_exception__);
|
|
1195
|
+
}
|
|
1196
|
+
if (!handled) {
|
|
1197
|
+
t[prop] = value;
|
|
1198
|
+
// A genuine JS-side expando: pin the proxy so the node's JS state
|
|
1199
|
+
// outlives GC of this proxy (see the `pinned` declaration).
|
|
1200
|
+
if (proxyHandles.has(receiver)) pinned.set(handle, receiver);
|
|
1201
|
+
}
|
|
1202
|
+
return true;
|
|
1203
|
+
},
|
|
1204
|
+
// Array-like collections reflect their indices as own enumerable
|
|
1205
|
+
// properties so `hasOwnProperty(i)` / `Object.keys` / `{...spread}` see the
|
|
1206
|
+
// live children (testharness's assert_array_equals checks hasOwnProperty).
|
|
1207
|
+
// Named properties (HTMLCollection ids/names, dataset keys, attr names)
|
|
1208
|
+
// are reflected too — non-enumerable for [LegacyUnenumerableNamedProperties].
|
|
1209
|
+
getOwnPropertyDescriptor(t, prop) {
|
|
1210
|
+
if (typeof prop !== "symbol" && Object.hasOwn(t, prop)) return Reflect.getOwnPropertyDescriptor(t, prop);
|
|
1211
|
+
// The global window reflects JS globals as own properties. Clamp
|
|
1212
|
+
// configurable (a top-level `var` is non-configurable on globalThis,
|
|
1213
|
+
// but the proxy invariant forbids reporting non-configurable for a
|
|
1214
|
+
// prop absent from the target).
|
|
1215
|
+
if (typeof prop !== "symbol" && isGlobalWindow(handle) && Object.hasOwn(globalThis, prop)) {
|
|
1216
|
+
const d = Reflect.getOwnPropertyDescriptor(globalThis, prop);
|
|
1217
|
+
if (d) { d.configurable = true; return d; }
|
|
1218
|
+
}
|
|
1219
|
+
if (isIndexInRange(prop)) {
|
|
1220
|
+
// Indexed properties are enumerable + configurable but NOT writable
|
|
1221
|
+
// (these collections have no indexed property setter).
|
|
1222
|
+
return {
|
|
1223
|
+
value: rehydrate(__rb_host_get(handle, prop)),
|
|
1224
|
+
writable: false, enumerable: true, configurable: true,
|
|
1225
|
+
};
|
|
1226
|
+
}
|
|
1227
|
+
if (isNamedKey(prop)) {
|
|
1228
|
+
return {
|
|
1229
|
+
value: rehydrate(__rb_host_get(handle, prop)),
|
|
1230
|
+
writable: named.writable, enumerable: named.enumerable, configurable: true,
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
return Reflect.getOwnPropertyDescriptor(t, prop);
|
|
1234
|
+
},
|
|
1235
|
+
defineProperty(t, prop, desc) {
|
|
1236
|
+
// Cannot redefine a live indexed or read-only named property.
|
|
1237
|
+
if (arrayLike && isArrayIndex(prop)) return false;
|
|
1238
|
+
if (named && !named.writable && !Object.hasOwn(t, prop) && isNamedKey(prop)) return false;
|
|
1239
|
+
return Reflect.defineProperty(t, prop, desc);
|
|
1240
|
+
},
|
|
1241
|
+
deleteProperty(t, prop) {
|
|
1242
|
+
if (typeof prop !== "symbol" && Object.hasOwn(t, prop)) return Reflect.deleteProperty(t, prop);
|
|
1243
|
+
// The global window: deleting a JS global through the window drops it
|
|
1244
|
+
// from globalThis (the shared namespace).
|
|
1245
|
+
if (typeof prop !== "symbol" && isGlobalWindow(handle) && Object.hasOwn(globalThis, prop)) {
|
|
1246
|
+
return delete globalThis[prop];
|
|
1247
|
+
}
|
|
1248
|
+
if (isIndexInRange(prop)) return false;
|
|
1249
|
+
if (named && typeof prop === "string") {
|
|
1250
|
+
if (named.writable) {
|
|
1251
|
+
// Named deleter (dataset): remove the backing attribute.
|
|
1252
|
+
if (rehydrate(__rb_host_delete(handle, prop))) return true;
|
|
1253
|
+
} else if (isNamedKey(prop)) {
|
|
1254
|
+
return false; // read-only named property cannot be deleted
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
return Reflect.deleteProperty(t, prop);
|
|
1258
|
+
},
|
|
1259
|
+
ownKeys(t) {
|
|
1260
|
+
const keys = Reflect.ownKeys(t);
|
|
1261
|
+
if (!arrayLike && !named) return keys;
|
|
1262
|
+
const n = arrayLike ? liveLength() : 0;
|
|
1263
|
+
const result = [];
|
|
1264
|
+
for (let i = 0; i < n; i++) result.push(String(i));
|
|
1265
|
+
for (const nm of namedKeys()) if (result.indexOf(nm) === -1) result.push(nm);
|
|
1266
|
+
// Then expandos / symbols that don't collide with an index or named key.
|
|
1267
|
+
for (const k of keys) {
|
|
1268
|
+
if (typeof k !== "symbol" && isArrayIndex(k) && Number(k) < n) continue;
|
|
1269
|
+
if (result.indexOf(k) !== -1) continue;
|
|
1270
|
+
result.push(k);
|
|
1271
|
+
}
|
|
1272
|
+
return result;
|
|
1273
|
+
},
|
|
1274
|
+
has(t, prop) {
|
|
1275
|
+
// An out-of-range index on an array-like is genuinely absent (`2 in
|
|
1276
|
+
// nodeList` is false past its length). A supported named key is present.
|
|
1277
|
+
if (arrayLike && isArrayIndex(prop)) return Number(prop) < liveLength() || Reflect.has(t, prop);
|
|
1278
|
+
if (isNamedKey(prop)) return true;
|
|
1279
|
+
// A real expando, prototype member (incl. symbols like Symbol.iterator),
|
|
1280
|
+
// or ABI method is present.
|
|
1281
|
+
if (Reflect.has(t, prop)) return true;
|
|
1282
|
+
if (typeof prop === "symbol") return false;
|
|
1283
|
+
if (methods.has(prop) || prop === "length") return true;
|
|
1284
|
+
// The global window also reports its JS globals (`"Stimulus" in window`);
|
|
1285
|
+
// inherited names already answered true via Reflect.has(t) above.
|
|
1286
|
+
if (isGlobalWindow(handle) && Object.hasOwn(globalThis, prop)) return true;
|
|
1287
|
+
// Event-handler IDL attributes (onclick, oninput, …) exist on event
|
|
1288
|
+
// targets as null-default properties, so `("oninput" in document)` is
|
|
1289
|
+
// true even when unset — React's isEventSupported feature-detect relies
|
|
1290
|
+
// on this to use the native input event (else it falls back to a keydown
|
|
1291
|
+
// polyfill and controlled-input onChange never fires).
|
|
1292
|
+
if (typeof prop === "string" && /^on[a-z]/.test(prop)) return true;
|
|
1293
|
+
// Otherwise reflect the ABI: a property whose host value is non-null is
|
|
1294
|
+
// present; a null/absent one reports missing. We can't distinguish
|
|
1295
|
+
// present-but-null from genuinely-absent across the ABI, and reporting
|
|
1296
|
+
// missing is what lets `(prop in proxy)` feature-detection work — e.g.
|
|
1297
|
+
// Stimulus's extendEvent guards on `"immediatePropagationStopped" in
|
|
1298
|
+
// event` before installing its override, and a blanket `true` made it
|
|
1299
|
+
// skip the override so stopImmediatePropagation never halted siblings.
|
|
1300
|
+
// Dommy distinguishes a genuinely-absent property (host returns null)
|
|
1301
|
+
// from one that is present-but-undefined (it returns the UNDEFINED
|
|
1302
|
+
// sentinel, tagged `__rb_undefined`) — e.g. AbortSignal's `reason`
|
|
1303
|
+
// before abort — so report the latter present (`"reason" in signal`).
|
|
1304
|
+
const raw = __rb_host_get(handle, prop);
|
|
1305
|
+
// A genuinely-absent property (ABSENT tag) reports MISSING; a
|
|
1306
|
+
// present-but-undefined one (UNDEFINED tag) reports present.
|
|
1307
|
+
if (raw !== null && typeof raw === "object" && raw.__rb_absent) return false;
|
|
1308
|
+
if (raw !== null && typeof raw === "object" && raw.__rb_undefined) return true;
|
|
1309
|
+
return rehydrate(raw) != null;
|
|
1310
|
+
}
|
|
1311
|
+
};
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
function makeProxy(handle, iface, ce) {
|
|
1315
|
+
const ref = cache.get(handle);
|
|
1316
|
+
if (ref) {
|
|
1317
|
+
const existing = ref.deref();
|
|
1318
|
+
if (existing) return existing;
|
|
1319
|
+
}
|
|
1320
|
+
// Reuse the cached per-interface descriptor when the handle crossed tagged
|
|
1321
|
+
// with a known interface — skipping the describe round trip. Otherwise (no
|
|
1322
|
+
// tag, or first sighting of this interface) describe once and cache it. The
|
|
1323
|
+
// custom-element tag is per-instance, so it comes from the handle tag (the
|
|
1324
|
+
// describe path falls back to the describe's own `ce`).
|
|
1325
|
+
let desc = (iface != null) ? descByInterface.get(iface) : undefined;
|
|
1326
|
+
let ceName = ce;
|
|
1327
|
+
if (!desc) {
|
|
1328
|
+
const d = __rb_host_describe(handle);
|
|
1329
|
+
desc = { name: d.name, chain: d.chain, methods: d.methods };
|
|
1330
|
+
if (d.name != null) descByInterface.set(d.name, desc);
|
|
1331
|
+
if (ceName === undefined) ceName = d.ce;
|
|
1332
|
+
}
|
|
1333
|
+
// 2d: method-name sets are per-interface; reuse across proxies of that type.
|
|
1334
|
+
let methods = methodsByInterface.get(desc.name);
|
|
1335
|
+
if (!methods) {
|
|
1336
|
+
methods = new Set(desc.methods);
|
|
1337
|
+
methodsByInterface.set(desc.name, methods);
|
|
1338
|
+
}
|
|
1339
|
+
const target = (desc.chain && desc.chain.length)
|
|
1340
|
+
? Object.create(protoForChain(desc.chain, 0))
|
|
1341
|
+
: {};
|
|
1342
|
+
// [LegacyUnforgeable] attributes live as own (non-configurable) accessors on
|
|
1343
|
+
// the instance target — `getOwnPropertyDescriptor(event, "isTrusted")` then
|
|
1344
|
+
// resolves them. The get trap still returns the live host value (it reads the
|
|
1345
|
+
// own prop via Reflect.get, invoking this shared getter).
|
|
1346
|
+
if (desc.chain) {
|
|
1347
|
+
for (const iface of desc.chain) {
|
|
1348
|
+
const attrs = UNFORGEABLE_ATTRS[iface];
|
|
1349
|
+
if (!attrs) continue;
|
|
1350
|
+
for (const name of attrs) {
|
|
1351
|
+
Object.defineProperty(target, name, {
|
|
1352
|
+
get: unforgeableGetter(name), enumerable: true, configurable: false,
|
|
1353
|
+
});
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
// 2c: memoize method functions per proxy so `el.foo === el.foo`.
|
|
1358
|
+
const p = new Proxy(target, makeHandler(handle, methods, new Map(),
|
|
1359
|
+
ARRAY_LIKE_COLLECTIONS.has(desc.name), NAMED_PROP_COLLECTIONS.get(desc.name) || null));
|
|
1360
|
+
cache.set(handle, new WeakRef(p));
|
|
1361
|
+
proxyHandles.set(p, handle);
|
|
1362
|
+
finalizers.register(p, handle);
|
|
1363
|
+
// 1d: a Dommy-registered custom element node is upgraded to its JS class on
|
|
1364
|
+
// first crossing — so the constructor runs before any lifecycle callback.
|
|
1365
|
+
if (ceName) upgradeElement(p, ceName);
|
|
1366
|
+
return p;
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
// ===== Custom elements (1d) =====
|
|
1370
|
+
|
|
1371
|
+
// Run a JS custom element's constructor against an existing Dommy-backed proxy
|
|
1372
|
+
// (the construction-stack adoption proven by the Step 0 spike), making the
|
|
1373
|
+
// proxy an instance of the registered class with its constructor side effects.
|
|
1374
|
+
function upgradeElement(proxy, name) {
|
|
1375
|
+
const ctor = ceRegistry.get(name);
|
|
1376
|
+
if (!ctor) return;
|
|
1377
|
+
constructionStack.push(proxy);
|
|
1378
|
+
try { Reflect.construct(ctor, [], ctor); }
|
|
1379
|
+
finally { constructionStack.pop(); }
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
// Ruby calls this when a registered custom element fires a lifecycle reaction.
|
|
1383
|
+
// makeProxy upgrades on first crossing, so the constructor has already run.
|
|
1384
|
+
function invokeLifecycle(handle, callback, args) {
|
|
1385
|
+
const p = makeProxy(handle);
|
|
1386
|
+
const fn = p[callback];
|
|
1387
|
+
if (typeof fn !== "function") return undefined;
|
|
1388
|
+
return dehydrateTop(fn.apply(p, rehydrate(args || [])));
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
// customElements.define(name, JSClass): register JS-side and ask Ruby to wire
|
|
1392
|
+
// a Dommy custom element whose reactions route back through invokeLifecycle.
|
|
1393
|
+
function defineCustomElement(name, ctor) {
|
|
1394
|
+
ceRegistry.set(name, ctor);
|
|
1395
|
+
const observed = Array.isArray(ctor.observedAttributes) ? ctor.observedAttributes : [];
|
|
1396
|
+
__rb_define_custom_element(name, observed);
|
|
1397
|
+
const waiters = cePending.get(name);
|
|
1398
|
+
if (waiters) { cePending.delete(name); waiters.forEach((resolve) => resolve(ctor)); }
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
// whenDefined stays pending until the name is defined (spec semantics), so
|
|
1402
|
+
// `await customElements.whenDefined(x)` before define() doesn't resolve early.
|
|
1403
|
+
function whenDefinedCustomElement(name) {
|
|
1404
|
+
const ctor = ceRegistry.get(name);
|
|
1405
|
+
if (ctor) return Promise.resolve(ctor);
|
|
1406
|
+
return new Promise((resolve) => {
|
|
1407
|
+
if (!cePending.has(name)) cePending.set(name, []);
|
|
1408
|
+
cePending.get(name).push(resolve);
|
|
1409
|
+
});
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
globalThis.customElements = {
|
|
1413
|
+
define: (name, ctor) => defineCustomElement(name, ctor),
|
|
1414
|
+
get: (name) => ceRegistry.get(name),
|
|
1415
|
+
whenDefined: (name) => whenDefinedCustomElement(name),
|
|
1416
|
+
// Delegate manual upgrades to Dommy's registry (define() already upgrades
|
|
1417
|
+
// existing nodes; this covers subtrees attached without reactions).
|
|
1418
|
+
upgrade: (root) => { if (isProxy(root)) __rb_upgrade_custom_elements(root[HKEY]); }
|
|
1419
|
+
};
|
|
1420
|
+
|
|
1421
|
+
// ===== Unhandled-rejection detail capture (opt-in diagnostics) =====
|
|
1422
|
+
//
|
|
1423
|
+
// The engine stringifies a non-Error rejection reason to "[object Object]"
|
|
1424
|
+
// before Ruby sees it, hiding what actually failed (e.g. note.com's React
|
|
1425
|
+
// error). When installed, record a RICH description (message/stack, or the
|
|
1426
|
+
// own-property JSON) of each rejection AS IT HAPPENS — wrapping the Promise
|
|
1427
|
+
// constructor (so `.then`-chain and executor rejections are seen) and the
|
|
1428
|
+
// static reject — so the Ruby side can replace the detail-less report with the
|
|
1429
|
+
// truth. Behavior-preserving (only records), and only installed when asked.
|
|
1430
|
+
function describeRejection(reason) {
|
|
1431
|
+
try {
|
|
1432
|
+
if (reason !== null && typeof reason === "object" &&
|
|
1433
|
+
typeof reason.stack === "string" && typeof reason.message === "string") {
|
|
1434
|
+
return (reason.name || "Error") + ": " + reason.message + "\n" + reason.stack;
|
|
1435
|
+
}
|
|
1436
|
+
if (reason === null) return "null";
|
|
1437
|
+
if (reason === undefined) return "undefined";
|
|
1438
|
+
if (typeof reason === "object") {
|
|
1439
|
+
let json = null;
|
|
1440
|
+
try { json = JSON.stringify(reason, (k, v) => (typeof v === "function" ? "[Function]" : v)); } catch (e) {}
|
|
1441
|
+
const keys = Object.keys(reason).slice(0, 40).join(", ");
|
|
1442
|
+
return "[non-Error rejection] keys: {" + keys + "}" + (json ? " " + json.slice(0, 4000) : "");
|
|
1443
|
+
}
|
|
1444
|
+
return String(reason);
|
|
1445
|
+
} catch (e) { return "(rejection reason could not be described)"; }
|
|
1446
|
+
}
|
|
1447
|
+
function installRejectionTracker() {
|
|
1448
|
+
const P = globalThis.Promise;
|
|
1449
|
+
if (!P || P.__rbTracked) return;
|
|
1450
|
+
// Push to a Ruby buffer AT REJECT TIME (normal JS context, a safe crossing) —
|
|
1451
|
+
// NOT from the engine's rejection callback, where re-entering the VM is
|
|
1452
|
+
// unsafe. The Ruby side pairs it with the detail-less report by recency.
|
|
1453
|
+
const record = (reason) => {
|
|
1454
|
+
try { __rb_record_rejection_detail(describeRejection(reason)); } catch (e) {}
|
|
1455
|
+
};
|
|
1456
|
+
const Tracked = function (executor) {
|
|
1457
|
+
return Reflect.construct(P, [function (resolve, reject) {
|
|
1458
|
+
executor(resolve, function (reason) { record(reason); return reject(reason); });
|
|
1459
|
+
}], new.target || Tracked);
|
|
1460
|
+
};
|
|
1461
|
+
Tracked.prototype = P.prototype;
|
|
1462
|
+
Object.setPrototypeOf(Tracked, P); // inherit statics + Symbol.species
|
|
1463
|
+
const origReject = P.reject.bind(P);
|
|
1464
|
+
Tracked.reject = function (reason) { record(reason); return origReject(reason); };
|
|
1465
|
+
Tracked.__rbTracked = true;
|
|
1466
|
+
globalThis.Promise = Tracked;
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
// 1a: report the DOM interface chain of a host proxy, most-derived first
|
|
1470
|
+
// (e.g. ["HTMLDivElement","HTMLElement","Element","Node","EventTarget"]).
|
|
1471
|
+
// Returns null for non-proxies.
|
|
1472
|
+
function interfaceOf(proxy) {
|
|
1473
|
+
if (!isProxy(proxy)) return null;
|
|
1474
|
+
return __rb_host_describe(proxy[HKEY]);
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
return {
|
|
1478
|
+
makeProxy, invokeCallback, invokeJsRefHandleEvent, invokeJsRefAcceptNode, runScript, scheduleMicrotask,
|
|
1479
|
+
// `tag` is the public top-level dehydrate (used by engine gems' eval_tagged
|
|
1480
|
+
// for evaluate() results): it tags a top-level `undefined` so
|
|
1481
|
+
// `evaluate("undefined")` yields UNDEFINED on every engine, not just those
|
|
1482
|
+
// whose value marshalling distinguishes undefined from null.
|
|
1483
|
+
tag: dehydrateTop, interfaceOf,
|
|
1484
|
+
// A host-PromiseValue deferred whose resolve runs the full §2.3 resolution
|
|
1485
|
+
// procedure — the Promises/A+ conformance adapter's primitive.
|
|
1486
|
+
makeHostDeferred,
|
|
1487
|
+
// Opt-in rejection-detail capture (see installRejectionTracker).
|
|
1488
|
+
installRejectionTracker,
|
|
1489
|
+
seedInterfaces, invokeLifecycle, attachStatics, exposeConstructorsOnWindow,
|
|
1490
|
+
// wasm host bridge (handle-oriented access for a wasm guest)
|
|
1491
|
+
wasmGlobalRef, wasmEval, wasmGet, wasmSet, wasmCall, wasmApply, wasmNew,
|
|
1492
|
+
wasmTypeof, wasmToString, wasmStrictEqual, wasmIsNull, wasmInstanceof,
|
|
1493
|
+
wasmMakeCallback, wasmReleaseRef,
|
|
1494
|
+
};
|
|
1495
|
+
})();
|