Class: Dommy::Window

Inherits:
Object
  • Object
show all
Includes:
Bridge::Methods, EventTarget
Defined in:
lib/dommy/window.rb

Overview

The browser global. JS.global from inside wasm resolves to this. Property access (JS.global[:document], JS.global[:console]) is routed through #__js_get__. Method calls (JS.global.call(:foo)) are routed through #__js_call__.

Constant Summary collapse

WINDOW_EVENT_HANDLER_NAMES =

Event handler IDL attributes the Window exposes (GlobalEventHandlers + WindowEventHandlers). Setting one (window.onload = fn) registers a listener; only these known names are intercepted so an arbitrary on-prefixed global (window.onboarding = {...}) still stays a plain expando rather than being mistaken for an event handler.

%w[
  onabort onauxclick onbeforeinput onbeforematch onbeforetoggle onblur oncancel oncanplay
  oncanplaythrough onchange onclick onclose oncontextlost oncontextmenu oncontextrestored oncopy
  oncuechange oncut ondblclick ondrag ondragend ondragenter ondragleave ondragover ondragstart
  ondrop ondurationchange onemptied onended onerror onfocus onformdata oninput oninvalid onkeydown
  onkeypress onkeyup onload onloadeddata onloadedmetadata onloadstart onmousedown onmouseenter
  onmouseleave onmousemove onmouseout onmouseover onmouseup onpaste onpause onplay onplaying
  onprogress onratechange onreset onresize onscroll onscrollend onsecuritypolicyviolation onseeked
  onseeking onselect onslotchange onstalled onsubmit onsuspend ontimeupdate ontoggle onvolumechange
  onwaiting onwheel onafterprint onbeforeprint onbeforeunload onhashchange onlanguagechange onmessage
  onmessageerror onoffline ononline onpagehide onpageshow onpopstate onrejectionhandled onstorage
  onunhandledrejection onunload
].to_set.freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Methods included from Bridge::Methods

included

Methods included from EventTarget

#__dommy_dump_event_failure__, #__internal_deliver_event__, #__internal_process_event_handler_return__, #add_event_listener, capture_flag, #deliver_at, #dispatch_event, #event_name_from_on, #invoke_listener_isolated, js_truthy?, #on_handler, #remove_event_listener, #set_on_handler

Constructor Details

#initialize(host = nil, backend_doc: nil) ⇒ Window

Returns a new instance of Window.



82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
# File 'lib/dommy/window.rb', line 82

def initialize(host = nil, backend_doc: nil)
  @host = host
  @navigation_delegate = Navigation::NullDelegate.new
  @scheduler = Scheduler.new
  @crypto = Crypto.new(self)
  @css_namespace = CSSNamespace.new
  @cookie_store = CookieStore.new(self)
  @local_storage = Storage.new
  @session_storage = Storage.new
  @location = Location.new(self)
  @history = History.new(self, @location)
  # `JS.global[:__some_key__] = ...` from user code lands here. Test code
  # uses this for stub installation (e.g. a custom `__fetch_stub__`);
  # production code stays on the typed accessors. Kept last in the read
  # fallback so it can't shadow intentional getters.
  @globals = {}
  @document = Document.new(host, backend_doc: backend_doc)
  @document.default_view = self
  # Per the HTML parsing algorithm, a <template>'s contents are parsed into a
  # separate "template contents" DocumentFragment, not as children of the
  # element. Backends (libxml2) leave them as direct children, so migrate
  # eagerly at page-load time — before any framework walks the tree. Without
  # this, a tree-walk (Alpine's x-for/x-if scan, etc.) descends into the
  # template's inert content and evaluates directives there out of scope.
  @document.migrate_template_descendants(@document.backend_doc)
  @custom_elements = CustomElementRegistry.new(self)
  @navigator = Navigator.new(self)
  # All JS global constructors (`new Event()`, `new URL()`, ...) live in a
  # single name→Constructor registry rather than one ivar + one __js_get__
  # arm each.
  @constructors = Bridge::ConstructorRegistry.new(build_constructors)
end

Instance Attribute Details

#approximate_layoutObject

Opt into best-effort geometry: when true, getBoundingClientRect / client* / offset* return non-zero estimates from a cheap pseudo-layout (viewport width

  • text content) instead of all-zero. Off by default so the no-layout contract (and the tests asserting 0) is unchanged; a browser front end (dommynx) turns it on so sites that bail on all-zero rects can proceed.


52
53
54
# File 'lib/dommy/window.rb', line 52

def approximate_layout
  @approximate_layout
end

#custom_elementsObject (readonly)

Returns the value of attribute custom_elements.



45
46
47
# File 'lib/dommy/window.rb', line 45

def custom_elements
  @custom_elements
end

#documentObject (readonly)

Returns the value of attribute document.



45
46
47
# File 'lib/dommy/window.rb', line 45

def document
  @document
end

#frame_elementObject

The <iframe>/frame element hosting this window's browsing context (nil for a top-level window). Lets rendering-dependent code (getComputedStyle) tell whether this document is inside a non-rendered frame.



56
57
58
# File 'lib/dommy/window.rb', line 56

def frame_element
  @frame_element
end

#globalsObject (readonly)

Returns the value of attribute globals.



45
46
47
# File 'lib/dommy/window.rb', line 45

def globals
  @globals
end

#historyObject (readonly)

Returns the value of attribute history.



45
46
47
# File 'lib/dommy/window.rb', line 45

def history
  @history
end

#locationObject (readonly)

Returns the value of attribute location.



45
46
47
# File 'lib/dommy/window.rb', line 45

def location
  @location
end

Navigation host seam (see Dommy::Navigation). Cross-document navigation intents (link activation, location.assign/replace/reload, history traversal across a document boundary) are routed to this delegate. The default NullDelegate records attempts without navigating, so behaviour is unchanged until an embedder installs a real delegate.



80
81
82
# File 'lib/dommy/window.rb', line 80

def navigation_delegate
  @navigation_delegate
end

Returns the value of attribute navigator.



45
46
47
# File 'lib/dommy/window.rb', line 45

def navigator
  @navigator
end

#schedulerObject (readonly)

Returns the value of attribute scheduler.



45
46
47
# File 'lib/dommy/window.rb', line 45

def scheduler
  @scheduler
end

#websocket_connectorObject

Optional WebSocket transport factory (a host seam, like the document's external_script_runner): ->(ws, url, protocols) -> transport | nil. A returned transport owns the connection — WebSocket#send / #close delegate to it, and it reports lifecycle back through the transport*_ callbacks (on the page thread). nil falls back to the in-memory stub (auto-open + test_simulate*_ seams).



73
74
75
# File 'lib/dommy/window.rb', line 73

def websocket_connector
  @websocket_connector
end

Instance Method Details

#__internal_event_parent__Object



306
307
308
# File 'lib/dommy/window.rb', line 306

def __internal_event_parent__
  nil
end

#__internal_media_environment_changed__Object



382
383
384
385
386
# File 'lib/dommy/window.rb', line 382

def __internal_media_environment_changed__
  @document&.__internal_bump_style_generation__
  (@media_query_lists || []).each(&:__internal_environment_changed__)
  nil
end

#__internal_navigate__(url:, source:, method: "GET", body: nil, params: nil, enctype: nil, headers: {}, replace: false) ⇒ Object

Single firing point for cross-document navigation intents. Link activation, location.assign/replace/reload and cross-boundary history traversal all route here; the attached delegate (NullDelegate by default) decides what happens. Same-document navigation never reaches this — it is handled directly by Location/History (hashchange / popstate).



330
331
332
333
334
335
# File 'lib/dommy/window.rb', line 330

def __internal_navigate__(url:, source:, method: "GET", body: nil, params: nil, enctype: nil, headers: {}, replace: false)
  @navigation_delegate&.navigate(
    url: url, method: method, body: body, params: params, enctype: enctype,
    headers: headers, replace: replace, source: source
  )
end

#__internal_register_media_query_list__(mql) ⇒ Object



388
389
390
391
# File 'lib/dommy/window.rb', line 388

def __internal_register_media_query_list__(mql)
  (@media_query_lists ||= []) << mql
  nil
end

#__internal_resolve_url__(url) ⇒ Object

Resolve a (possibly relative) URL against the document base URL — the API base URL of this window's environment, as fetch/XHR use when constructing a request. Returns the input unchanged if it can't resolve.



353
354
355
356
357
358
359
360
# File 'lib/dommy/window.rb', line 353

def __internal_resolve_url__(url)
  base = @document&.base_uri
  return url.to_s if base.to_s.empty?

  URI.join(base.to_s, url.to_s).to_s
rescue URI::Error
  url.to_s
end

#__internal_url_path__(url) ⇒ Object

The path (with query) of a URL — lets a stub keyed by a path ("/api") match its resolved absolute form ("http://host/api&quot;).



364
365
366
367
368
369
# File 'lib/dommy/window.rb', line 364

def __internal_url_path__(url)
  uri = URI.parse(url.to_s)
  uri.query ? "#{uri.path}?#{uri.query}" : uri.path
rescue URI::Error
  url.to_s
end

#__js_call__(method, args) ⇒ Object



237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
# File 'lib/dommy/window.rb', line 237

def __js_call__(method, args)
  case method
  when "fetch"
    FetchFn.new(self).__js_call__("call", args)
  when "encodeURIComponent"
    Internal::GlobalFunctions.encode_uri_component(args[0])
  when "decodeURIComponent"
    Internal::GlobalFunctions.decode_uri_component(args[0])
  when "btoa"
    Internal::GlobalFunctions.btoa(args[0])
  when "atob"
    Internal::GlobalFunctions.atob(args[0])
  when "addEventListener"
    add_event_listener(args[0], args[1], args[2])
  when "removeEventListener"
    remove_event_listener(args[0], args[1], args[2])
  when "dispatchEvent"
    dispatch_event(args[0])
  when "setTimeout"
    @scheduler.set_timeout(args[0], timer_delay(args[1]))
  when "clearTimeout"
    @scheduler.clear_timeout(args[0])
  when "setInterval"
    @scheduler.set_interval(args[0], timer_delay(args[1]))
  when "clearInterval"
    @scheduler.clear_interval(args[0])
  when "requestAnimationFrame"
    @scheduler.request_animation_frame(args[0])
  when "cancelAnimationFrame"
    @scheduler.cancel_animation_frame(args[0])
  when "queueMicrotask"
    @scheduler.queue_microtask(args[0])
  when "requestIdleCallback"
    @scheduler.request_idle_callback(args[0], (args[1].is_a?(Hash) && args[1]["timeout"]) || 0)
  when "cancelIdleCallback"
    @scheduler.cancel_idle_callback(args[0])
  when "structuredClone"
    Dommy.structured_clone(args[0])
  when "matchMedia"
    MediaQueryList.new(self, args[0].to_s)
  when "getComputedStyle"
    get_computed_style(args[0], args[1])
  when "resizeTo"
    resize_to(args[0], args[1])
  when "scroll", "scrollTo"
    scroll_to(*args)
  when "scrollBy"
    scroll_by(*args)
  when "alert"
    nil # headless: no dialog (happy-dom semantics)
  when "confirm"
    false # no user -> treated as "Cancel"
  when "prompt"
    nil # no user input
  when "open"
    nil # cannot open a new browsing context headlessly
  when "reportError"
    nil # swallow programmatic error reports (no uncaught surfacing here)
  when "getSelection"
    document&.get_selection
  when "postMessage"
    post_message(args[0])
  else
    # Additional window-level methods (fetch, location, history,
    # Promise, MutationObserver, etc.) arrive in later sessions.
    nil
  end
end

#__js_get__(key) ⇒ Object

Bridge protocol: respond to a JS-style property read by name. Returns either a Ruby primitive (Integer / String / true / false / nil), a Hash/Array (for JS object/array literals), or a Dom::* instance for live DOM/BOM objects.

Anything outside the surface we've explicitly polyfilled returns nil (= JS undefined). Spec failures here are the signal to widen the surface in a future session.



123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
# File 'lib/dommy/window.rb', line 123

def __js_get__(key)
  ctor = @constructors[key]
  return ctor if ctor

  case key
  when "document"
    @document
  when "window", "self", "parent", "top", "frames"
    # A top-level browsing context refers to itself for these. Returning the
    # window (not nil) lets `window === window.parent` and frame-walking
    # loops (e.g. testharness.js's `while (w != w.parent)`) terminate.
    self
  when "crypto"
    @crypto
  when "cookieStore"
    @cookie_store
  when "console"
    :console
  when "Object"
    :object_ctor
  when "Array"
    :array_ctor
  when "JSON"
    :json_ctor
  when "performance"
    @performance ||= Performance.new(self)
  when "localStorage"
    @local_storage
  when "sessionStorage"
    @session_storage
  when "location"
    @location
  when "history"
    @history
  when "CSS"
    @css_namespace
  when "fetch"
    FetchFn.new(self)
  when "customElements"
    @custom_elements
  when "navigator"
    @navigator
  when "screen"
    @screen ||= Screen.new(self)
  when "innerWidth", "outerWidth"
    media_environment.viewport_width
  when "innerHeight", "outerHeight"
    media_environment.viewport_height
  when "devicePixelRatio"
    media_environment.device_pixel_ratio
  when "scrollX", "pageXOffset"
    @scroll_x || 0
  when "scrollY", "pageYOffset"
    @scroll_y || 0
  when "scrollMaxX", "scrollMaxY"
    # No real content box to scroll past, so the max offset is 0.
    0
  when "event"
    # The legacy global current-event is *absent* (undefined, not null) when
    # no event is being dispatched, so feature detection like
    # `window.event === undefined` (React's getCurrentEventPriority) takes the
    # not-supported path instead of dereferencing null. An explicitly-set
    # value still wins.
    @globals.key?("event") ? @globals["event"] : Bridge::UNDEFINED
  when /\A\d+\z/
    # `window[i]` / `window.frames[i]` — the i-th child browsing context's
    # window (the i-th `<iframe>`'s contentWindow), or ABSENT past the end.
    frame = frame_windows[key.to_i]
    frame.nil? ? Bridge::ABSENT : frame
  when ->(k) { k.is_a?(String) && WINDOW_EVENT_HANDLER_NAMES.include?(k) }
    # An event handler IDL attribute: the registered handler, or null (not
    # undefined) when unset — matching the spec and Element's on* getter.
    on_handler(event_name_from_on(key))
  else
    # A stashed global wins (even if its value is nil/null); a key never set
    # is genuinely absent → ABSENT so JS sees `undefined` and `"x" in window`
    # is false (feature detection like `isUndefined(window.Vue)` works).
    @globals.key?(key) ? @globals[key] : Bridge::ABSENT
  end
end

#__js_set__(key, value) ⇒ Object



204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
# File 'lib/dommy/window.rb', line 204

def __js_set__(key, value)
  # `window.location = url` forwards to the Location object (WebIDL
  # [PutForwards=href]): equivalent to `location.href = url`, i.e. navigate.
  if key == "location" && @location
    @location.__js_set__("href", value.to_s)
    return nil
  end
  # `window.onload = fn` (and the other window event handlers) registers a
  # listener rather than stashing an expando, so the handler actually fires.
  if key.is_a?(String) && WINDOW_EVENT_HANDLER_NAMES.include?(key)
    set_on_handler(event_name_from_on(key), value)
    return nil
  end
  # Stash arbitrary keys for later reads (e.g.
  # `JS.global[:__fetchy_stub__] = map`).
  @globals[key] = value
  # The Fetchy spec's `install_fetch_stub` resets `__fetch_count__`
  # to 0 inside its JS installer (`globalThis.__fetch_count__ = 0;
  # globalThis.fetch = ...`). Our polyfill ignores raw JS, so we
  # piggy-back on the stub assignment to perform the same reset
  # — without it the count accumulates across tests in one VM run.
  @globals["__fetch_count__"] = 0 if %w[__fetchy_stub__ __resource_fetch_stub__ __inject_fetch_stub__].include?(key)
  nil
end

#fire_hashchange(old_url, new_url) ⇒ Object



320
321
322
323
# File 'lib/dommy/window.rb', line 320

def fire_hashchange(old_url, new_url)
  event = HashChangeEvent.new("hashchange", "oldURL" => old_url.to_s, "newURL" => new_url.to_s)
  dispatch_event(event)
end

#fire_popstate(state) ⇒ Object

Called by History#go and Location.href= to fire popstate / hashchange events. Listeners registered on the Window via addEventListener("popstate"|"hashchange", cb) receive them.



313
314
315
316
317
318
# File 'lib/dommy/window.rb', line 313

def fire_popstate(state)
  # PopStateEvent exposes the entry's state as `event.state` (the spec
  # property). Routers (Turbo) branch on `event.state`.
  event = PopStateEvent.new("popstate", "state" => state)
  dispatch_event(event)
end

#frame_windowsObject

The child browsing contexts' windows, in document order — one per <iframe> (nil for a frame whose content document isn't wired). Backs window[i] / window.frames[i].



61
62
63
64
65
# File 'lib/dommy/window.rb', line 61

def frame_windows
  @document.query_selector_all("iframe").map do |frame|
    frame.respond_to?(:content_window) ? frame.content_window : nil
  end
end

#get_computed_style(element, pseudo_element = nil) ⇒ Object

CSSOM getComputedStyle. With the makiri-backed CSS parser available this resolves the full cascade (UA sheet +