Skip to main content

blitz_script/
runtime.rs

1//! The script runtime: owns the Boa [`Context`], registers the DOM globals and
2//! dispatches events / timers into JavaScript.
3
4use std::cell::RefCell;
5use std::collections::VecDeque;
6use std::path::Path;
7use std::rc::Rc;
8use std::sync::LazyLock;
9
10use blitz_dom::BaseDocument;
11use blitz_dom::NodeId;
12use blitz_traits::events::{BlitzPointerId, DomEvent, DomEventData, EventState};
13use boa_engine::builtins::promise::PromiseState;
14use boa_engine::job::SimpleJobExecutor;
15use boa_engine::object::builtins::JsPromise;
16use boa_engine::object::{JsObject, ObjectInitializer};
17use boa_engine::property::Attribute;
18use boa_engine::value::JsValue;
19use boa_engine::{
20    Context, JsError, JsNativeError, JsResult, JsString, Module, NativeFunction, Source, js_string,
21};
22use boa_gc::{Finalize, Trace};
23use boa_runtime::Console;
24use boa_runtime::console::{ConsoleState, DefaultLogger, Logger};
25use url::Url;
26use web_time::{Duration, Instant};
27
28use crate::dom::event::{EventRef, create_event, create_event_for_dom_event, set_event_path};
29use crate::dom::{define_accessor, dom_ctx, node_wrapper};
30use crate::job_budget::{JobBudget, JobSlice};
31use crate::module::{BlitzModuleLoader, SharedFetcher};
32use crate::state::{DomCtx, Listener};
33
34const DIAGNOSTIC_CAPACITY: usize = 1_000;
35
36#[derive(Debug, Clone)]
37#[cfg_attr(not(feature = "debug-control"), allow(dead_code))]
38pub struct DiagnosticEntry {
39    pub sequence: u64,
40    pub order: u64,
41    pub level: String,
42    pub message: String,
43    pub stack: String,
44}
45
46#[derive(Debug, Clone)]
47pub struct DiagnosticStreamBatch {
48    pub entries: Vec<DiagnosticEntry>,
49    pub lost: u64,
50    pub next_sequence: u64,
51}
52
53#[derive(Debug, Clone)]
54pub struct DiagnosticBatch {
55    pub console: DiagnosticStreamBatch,
56    pub errors: DiagnosticStreamBatch,
57}
58
59fn diagnostic_stream(
60    queue: &VecDeque<DiagnosticEntry>,
61    after: u64,
62    limit: usize,
63) -> DiagnosticStreamBatch {
64    let lost = queue.front().map_or(0, |entry| {
65        entry.sequence.saturating_sub(after.saturating_add(1))
66    });
67    let entries: Vec<_> = queue
68        .iter()
69        .filter(|entry| entry.sequence > after)
70        .take(limit.clamp(1, DIAGNOSTIC_CAPACITY))
71        .cloned()
72        .collect();
73    let next_sequence = entries.last().map_or(after, |entry| entry.sequence);
74    DiagnosticStreamBatch {
75        entries,
76        lost,
77        next_sequence,
78    }
79}
80
81#[derive(Debug, Default)]
82struct RuntimeDiagnostics {
83    next_order: u64,
84    next_console_sequence: u64,
85    next_error_sequence: u64,
86    console: VecDeque<DiagnosticEntry>,
87    errors: VecDeque<DiagnosticEntry>,
88}
89
90impl RuntimeDiagnostics {
91    fn push_console(&mut self, level: &str, message: String) {
92        self.next_console_sequence += 1;
93        self.next_order += 1;
94        push_bounded(
95            &mut self.console,
96            DiagnosticEntry {
97                sequence: self.next_console_sequence,
98                order: self.next_order,
99                level: level.into(),
100                message,
101                stack: String::new(),
102            },
103        );
104    }
105
106    fn push_error(&mut self, what: &str, error: &boa_engine::JsError) {
107        self.next_error_sequence += 1;
108        self.next_order += 1;
109        push_bounded(
110            &mut self.errors,
111            DiagnosticEntry {
112                sequence: self.next_error_sequence,
113                order: self.next_order,
114                level: "error".into(),
115                message: format!("Uncaught JS error in {what}: {error}"),
116                stack: format!("{error:?}"),
117            },
118        );
119    }
120}
121
122fn push_bounded(queue: &mut VecDeque<DiagnosticEntry>, entry: DiagnosticEntry) {
123    if queue.len() == DIAGNOSTIC_CAPACITY {
124        queue.pop_front();
125    }
126    queue.push_back(entry);
127}
128
129#[derive(Debug, Trace, Finalize)]
130struct CapturingLogger {
131    #[unsafe_ignore_trace]
132    diagnostics: Rc<RefCell<RuntimeDiagnostics>>,
133}
134
135impl Logger for CapturingLogger {
136    fn log(&self, msg: String, state: &ConsoleState, context: &mut Context) -> JsResult<()> {
137        self.diagnostics
138            .borrow_mut()
139            .push_console("log", msg.clone());
140        DefaultLogger.log(msg, state, context)
141    }
142
143    fn info(&self, msg: String, state: &ConsoleState, context: &mut Context) -> JsResult<()> {
144        self.diagnostics
145            .borrow_mut()
146            .push_console("info", msg.clone());
147        DefaultLogger.info(msg, state, context)
148    }
149
150    fn warn(&self, msg: String, state: &ConsoleState, context: &mut Context) -> JsResult<()> {
151        self.diagnostics
152            .borrow_mut()
153            .push_console("warn", msg.clone());
154        DefaultLogger.warn(msg, state, context)
155    }
156
157    fn error(&self, msg: String, state: &ConsoleState, context: &mut Context) -> JsResult<()> {
158        self.diagnostics
159            .borrow_mut()
160            .push_console("error", msg.clone());
161        DefaultLogger.error(msg, state, context)
162    }
163}
164
165/// Print and retain an unhandled JavaScript error.
166fn report_js_error(
167    diagnostics: &Rc<RefCell<RuntimeDiagnostics>>,
168    what: &str,
169    error: &boa_engine::JsError,
170) {
171    diagnostics.borrow_mut().push_error(what, error);
172    #[cfg(feature = "tracing")]
173    tracing::error!("Uncaught JS error in {what}: {error}");
174    eprintln!("Uncaught JS error in {what}: {error}");
175}
176
177#[derive(Clone, Trace, Finalize, boa_engine::JsData)]
178struct HandlerErrorReporter {
179    #[unsafe_ignore_trace]
180    diagnostics: Rc<RefCell<RuntimeDiagnostics>>,
181}
182
183pub(crate) fn report_handler_error(context: &Context, what: &str, error: &JsError) {
184    if let Some(reporter) = context.get_data::<HandlerErrorReporter>() {
185        report_js_error(&reporter.diagnostics, what, error);
186    } else {
187        eprintln!("Uncaught JS error in {what}: {error}");
188    }
189}
190
191fn listener_error_context(
192    event_name: &str,
193    target: &str,
194    callback: &JsObject,
195    context: &mut Context,
196) -> String {
197    let source = JsValue::from(callback.clone())
198        .to_string(context)
199        .map(|source| source.to_std_string_escaped())
200        .unwrap_or_else(|_| "<source unavailable>".to_owned());
201    let source = source.chars().take(500).collect::<String>();
202    format!("{event_name} event listener on {target}: {source}")
203}
204
205pub(crate) type ScriptRuntime = crate::engine::Engine<BoaScriptEngine>;
206
207pub(crate) struct BoaScriptEngine {
208    pub context: Context,
209    pub ctx: DomCtx,
210    diagnostics: Rc<RefCell<RuntimeDiagnostics>>,
211    module_loader: Rc<BlitzModuleLoader>,
212    job_executor: Rc<SimpleJobExecutor>,
213    job_budget: Rc<JobBudget>,
214    /// Module evaluations that had not settled when their script ran.
215    ///
216    /// Top-level `await` makes this ordinary rather than exceptional: a module
217    /// that opens with `await fetch(...)` is pending until a timer or a network
218    /// completion resolves it, which happens on a later poll. Treating that as
219    /// a failure at evaluation time would report an error against every module
220    /// on the modern web that waits for anything.
221    pending_modules: Vec<(JsPromise, String)>,
222    /// The value a text control held when it was focussed, so that losing
223    /// focus can tell an edited field from an untouched one. `change` is a
224    /// commit event, not a blur notification.
225    focussed_text_value: Option<(NodeId, String)>,
226    /// The page's platform poll functions, looked up on first use.
227    domc_polls: Option<[Option<JsObject>; 2]>,
228    /// The viewport generation the media-query poll last saw.
229    domc_generation: Option<u64>,
230}
231
232impl BoaScriptEngine {
233    pub(crate) fn retire(&mut self) {
234        use zeroize::Zeroize;
235        self.job_executor
236            .get_cancellation_token()
237            .store(true, std::sync::atomic::Ordering::Release);
238        let _ = boa_engine::job::JobExecutor::run_jobs(
239            Rc::clone(&self.job_executor),
240            &mut self.context,
241        );
242        self.pending_modules.clear();
243        self.domc_polls.take();
244        if let Some((_, mut text)) = self.focussed_text_value.take() {
245            text.zeroize();
246        }
247        self.module_loader.retire();
248        crate::docwrite::retire(&self.context);
249        self.context.clear_kept_objects();
250        let state = std::mem::take(&mut *self.ctx.state.borrow_mut());
251        drop(state);
252    }
253
254    pub fn new(
255        doc: Rc<RefCell<BaseDocument>>,
256        base_url: Option<&Url>,
257        fetcher: SharedFetcher,
258    ) -> Self {
259        // A module loader can only be given to a context at construction, so
260        // this cannot be `Context::default()` any more. The default is not
261        // merely "no loader" either: it is a filesystem loader rooted at the
262        // process's working directory, which for a browser is both useless and
263        // the wrong thing to expose to a page.
264        let module_loader = Rc::new(BlitzModuleLoader::new(fetcher, base_url.cloned()));
265        let job_executor = Rc::new(SimpleJobExecutor::new());
266        let mut context = Context::builder()
267            .module_loader(Rc::clone(&module_loader))
268            .job_executor(Rc::clone(&job_executor))
269            .build()
270            .expect("building the script context should not fail");
271        // Retired guest runtime: direct use raises ReferenceError, while
272        // feature detection sees an absent WebAssembly global.
273        context
274            .global_object()
275            .clone()
276            .delete_property_or_throw(js_string!("WebAssembly"), &mut context)
277            .expect("remove WebAssembly before page scripts run");
278        /*
279         * A call depth a real application actually reaches.
280         *
281         * Boa defaults to 512 nested calls. That is a sandbox's number, not a
282         * browser's: every engine on the web allows several thousand, and a
283         * framework spends most of them before the page's own code runs. A
284         * Solid application renders its component tree as nested calls and
285         * threads each one through the reactive graph's owner chain, so depth
286         * grows with how deeply the page is nested rather than with anything
287         * the author would recognise as recursion.
288         *
289         * Two budgets, because they are not the same resource. The recursion
290         * limit counts JavaScript frames. V8 allows roughly 10_000 to 12_000
291         * frames of ordinary code; 16384 covers that and a 9_000-deep call.
292         * Crossing it throws a catchable RangeError ("Maximum call stack size
293         * exceeded"), which is what `try/catch` plus `instanceof RangeError`
294         * stack probes look for. The old engine error was not catchable, so
295         * the probe became an uncaught failure and the page stopped.
296         *
297         * Native re-entry is separate. `JsObject::call` nests `Context::run`
298         * on this thread's Rust stack (a getter, a listener, a promise
299         * reaction). Counting that as a JavaScript frame made every accessor
300         * cost two frames, so a page Chrome runs crossed 8192 here. The host
301         * cap is 4096, the depth the old combined budget already allowed,
302         * which fits the GUI thread and a worker's 2 MB stack. The headless
303         * page thread reserves 256 MB for the same nesting.
304         *
305         * The value stack is the third number. A plain recursive function
306         * uses about 7 slots a frame: the default 10240 slots stop a page
307         * near frame 1460, and raising only the call limit used to run out
308         * of slots at 1462. 32 slots of room per frame (16384 * 32) lets a
309         * function with locals reach the frame limit first. Past either
310         * limit the same RangeError is thrown.
311         */
312        let limits = context.runtime_limits_mut();
313        limits.set_recursion_limit(16384);
314        limits.set_host_recursion_limit(4096);
315        limits.set_stack_size_limit(16384 * 32);
316
317        let ctx = DomCtx::new(doc);
318        context.insert_data(ctx.clone());
319        let diagnostics = Rc::new(RefCell::new(RuntimeDiagnostics::default()));
320        context.insert_data(HandlerErrorReporter {
321            diagnostics: Rc::clone(&diagnostics),
322        });
323
324        Console::register_with_logger(
325            CapturingLogger {
326                diagnostics: Rc::clone(&diagnostics),
327            },
328            &mut context,
329        )
330        .expect("failed to register console");
331
332        // `URL` and `URLSearchParams`. These are WHATWG globals that every
333        // browser has, and a router is the first thing to reach for them:
334        // `@solidjs/router` builds one during module evaluation, so a page
335        // without them does not fail at navigation time, it fails to boot at
336        // all. `boa_runtime` implements both over the `url` crate; they were
337        // simply never registered here, because nothing but `Console` was.
338        //
339        // Registered one extension at a time rather than through
340        // `boa_runtime::register`, which would also install its own
341        // `setTimeout`/`setInterval` over the ones in `timers.rs` that are
342        // wired to this document's event loop.
343        boa_runtime::register_extensions(
344            (
345                boa_runtime::extensions::UrlExtension,
346                boa_runtime::extensions::EncodingExtension,
347            ),
348            None,
349            &mut context,
350        )
351        .expect("failed to register URL and text-encoding globals");
352
353        crate::dom::init_protos(&ctx, &mut context);
354
355        // `document`
356        let root_id = ctx.doc.borrow().root_node().id;
357        let document_wrapper = node_wrapper(&ctx, root_id, &mut context);
358        register_global(&mut context, "document", document_wrapper.into());
359
360        // `window` and friends (aliases for the global object)
361        let global: JsValue = context.global_object().into();
362        register_global(&mut context, "window", global.clone());
363        register_global(&mut context, "self", global);
364        let global_object = context.global_object().clone();
365        define_accessor(
366            &global_object,
367            "innerWidth",
368            Some(window_inner_width),
369            None,
370            &mut context,
371        );
372        define_accessor(
373            &global_object,
374            "innerHeight",
375            Some(window_inner_height),
376            None,
377            &mut context,
378        );
379        define_accessor(
380            &global_object,
381            "outerWidth",
382            Some(window_inner_width),
383            None,
384            &mut context,
385        );
386        define_accessor(
387            &global_object,
388            "outerHeight",
389            Some(window_inner_height),
390            None,
391            &mut context,
392        );
393        define_accessor(
394            &global_object,
395            "devicePixelRatio",
396            Some(window_device_pixel_ratio),
397            None,
398            &mut context,
399        );
400
401        // Tauri and other webview embedders conventionally provide this object. It is inert
402        // until the embedder installs a callback on `ScriptDocument`.
403        let ipc = ObjectInitializer::new(&mut context)
404            .function(
405                NativeFunction::from_fn_ptr(ipc_post_message),
406                js_string!("postMessage"),
407                1,
408            )
409            .build();
410        register_global(&mut context, "ipc", ipc.into());
411
412        // `location`
413        let location = build_location(base_url, &mut context);
414        register_global(&mut context, "location", location);
415
416        // On the global rather than only on `window`: pages call it bare as
417        // often as they qualify it.
418        let computed_style =
419            NativeFunction::from_fn_ptr(get_computed_style).to_js_function(context.realm());
420        register_global(&mut context, "getComputedStyle", computed_style.into());
421
422        // `navigator`, including the async clipboard.
423        //
424        // Without `navigator.clipboard` every "copy" button in an embedding app
425        // fails silently: the usual shape is `navigator.clipboard.writeText(t)`
426        // inside a try/catch that falls back to `document.execCommand("copy")`,
427        // and neither existed here, so the catch swallowed a TypeError and the
428        // fallback returned nothing. The shell already speaks to the system
429        // clipboard for text selection; this is the same channel.
430        let clipboard = ObjectInitializer::new(&mut context)
431            .function(
432                NativeFunction::from_fn_ptr(clipboard_write_text),
433                js_string!("writeText"),
434                1,
435            )
436            .function(
437                NativeFunction::from_fn_ptr(clipboard_read_text),
438                js_string!("readText"),
439                0,
440            )
441            .build();
442        let navigator = ObjectInitializer::new(&mut context)
443            .property(
444                js_string!("userAgent"),
445                js_string!("Mozilla/5.0 (compatible; Blitz)"),
446                Attribute::all(),
447            )
448            .property(js_string!("clipboard"), clipboard, Attribute::all())
449            .build();
450        register_global(&mut context, "navigator", navigator.into());
451
452        crate::dom::custom_elements::install(&ctx, &mut context);
453
454        // `performance`
455        let performance = ObjectInitializer::new(&mut context)
456            .function(
457                NativeFunction::from_fn_ptr(performance_now),
458                js_string!("now"),
459                0,
460            )
461            .build();
462        register_global(&mut context, "performance", performance.into());
463        register_global_fn(&mut context, "__blitzViewportScroll", 0, viewport_scroll);
464        register_global_fn(&mut context, "__blitzScrollViewport", 2, scroll_viewport);
465
466        // The switch the prelude's `MutationObserver` flips: the document
467        // records changes only while an observer is registered.
468        register_global_fn(
469            &mut context,
470            "__blitzObserveMutations",
471            1,
472            observe_mutations,
473        );
474
475        // Timers and window event listeners
476        register_global_fn(&mut context, "setTimeout", 2, set_timeout);
477        register_global_fn(&mut context, "clearTimeout", 1, clear_timer);
478        register_global_fn(&mut context, "setInterval", 2, set_interval);
479        register_global_fn(&mut context, "clearInterval", 1, clear_timer);
480        register_global_fn(
481            &mut context,
482            "requestAnimationFrame",
483            1,
484            request_animation_frame,
485        );
486        register_global_fn(&mut context, "cancelAnimationFrame", 1, clear_timer);
487        register_global_fn(
488            &mut context,
489            "addEventListener",
490            2,
491            window_add_event_listener,
492        );
493        register_global_fn(
494            &mut context,
495            "removeEventListener",
496            2,
497            window_remove_event_listener,
498        );
499        register_global_fn(&mut context, "dispatchEvent", 1, window_dispatch_event);
500        register_global_fn(
501            &mut context,
502            "__blitzDispatchHistoryEvent",
503            1,
504            window_dispatch_history_event,
505        );
506        register_global_fn(
507            &mut context,
508            "__blitzDispatchMessageEvent",
509            1,
510            window_dispatch_history_event,
511        );
512        register_global_fn(&mut context, "__blitzRandomU32", 0, random_u32);
513
514        let mut runtime = Self {
515            context,
516            ctx,
517            diagnostics,
518            module_loader,
519            job_executor,
520            job_budget: Rc::new(JobBudget::default()),
521            pending_modules: Vec::new(),
522            focussed_text_value: None,
523            domc_polls: None,
524            domc_generation: None,
525        };
526
527        // Small JS bootstrap for APIs that are easiest to define in JS
528        runtime.eval_internal(
529            r##"
530            if (typeof globalThis.queueMicrotask !== "function") {
531                globalThis.queueMicrotask = function (callback) {
532                    Promise.resolve().then(callback);
533                };
534            }
535            // `MutationObserver`, fed by the document's change log. The log
536            // records only while a registration exists, and `run_jobs` hands
537            // it to `__blitzDeliverMutations` at the end of each script turn.
538            // Records list plain arrays for `addedNodes`/`removedNodes`, which
539            // is what code iterating them with `for`, `length` or `forEach`
540            // needs; a live `NodeList` is not modelled.
541            if (typeof globalThis.MutationObserver !== "function") {
542                (function () {
543                    const registrations = [];
544                    const syncRecording = function () {
545                        globalThis.__blitzObserveMutations(registrations.length > 0);
546                    };
547                    const normalize = function (options) {
548                        const o = Object.assign({}, options || {});
549                        if ((o.attributeOldValue || o.attributeFilter) && o.attributes === undefined) {
550                            o.attributes = true;
551                        }
552                        if (o.characterDataOldValue && o.characterData === undefined) {
553                            o.characterData = true;
554                        }
555                        if (!o.childList && !o.attributes && !o.characterData) {
556                            throw new TypeError(
557                                "Failed to execute 'observe' on 'MutationObserver': The options object must set at least one of 'attributes', 'characterData', or 'childList' to true."
558                            );
559                        }
560                        return o;
561                    };
562                    const within = function (ancestor, node) {
563                        try {
564                            return ancestor === node
565                                || (typeof ancestor.contains === "function" && ancestor.contains(node));
566                        } catch (error) {
567                            return false;
568                        }
569                    };
570                    const MutationObserver = function (callback) {
571                        if (typeof callback !== "function") {
572                            throw new TypeError(
573                                "Failed to construct 'MutationObserver': parameter 1 is not of type 'Function'."
574                            );
575                        }
576                        this._callback = callback;
577                        this._records = [];
578                    };
579                    MutationObserver.prototype.observe = function (target, options) {
580                        if (!target || typeof target !== "object") {
581                            throw new TypeError(
582                                "Failed to execute 'observe' on 'MutationObserver': parameter 1 is not of type 'Node'."
583                            );
584                        }
585                        const normalized = normalize(options);
586                        const existing = registrations.find(
587                            (r) => r.observer === this && r.target === target
588                        );
589                        if (existing) {
590                            existing.options = normalized;
591                        } else {
592                            registrations.push({ observer: this, target: target, options: normalized });
593                        }
594                        syncRecording();
595                    };
596                    MutationObserver.prototype.disconnect = function () {
597                        for (let i = registrations.length - 1; i >= 0; i--) {
598                            if (registrations[i].observer === this) registrations.splice(i, 1);
599                        }
600                        this._records = [];
601                        syncRecording();
602                    };
603                    MutationObserver.prototype.takeRecords = function () {
604                        const records = this._records;
605                        this._records = [];
606                        return records;
607                    };
608                    globalThis.__blitzDeliverMutations = function (raw) {
609                        const notified = [];
610                        for (const entry of raw) {
611                            const type = entry[0];
612                            const target = entry[1];
613                            const name = entry[6];
614                            // One record per observer per change, however many
615                            // of its registrations match.
616                            const seen = [];
617                            for (const r of registrations) {
618                                if (seen.indexOf(r.observer) !== -1) continue;
619                                const o = r.options;
620                                if (r.target !== target && !(o.subtree && within(r.target, target))) continue;
621                                if (type === "childList" && !o.childList) continue;
622                                if (type === "attributes") {
623                                    if (!o.attributes) continue;
624                                    if (o.attributeFilter && o.attributeFilter.indexOf(name) === -1) continue;
625                                }
626                                if (type === "characterData" && !o.characterData) continue;
627                                const keepOld = type === "attributes"
628                                    ? o.attributeOldValue
629                                    : type === "characterData" && o.characterDataOldValue;
630                                seen.push(r.observer);
631                                r.observer._records.push({
632                                    type: type,
633                                    target: target,
634                                    addedNodes: entry[2],
635                                    removedNodes: entry[3],
636                                    previousSibling: entry[4],
637                                    nextSibling: entry[5],
638                                    attributeName: type === "attributes" ? name : null,
639                                    attributeNamespace: null,
640                                    oldValue: keepOld ? entry[7] : null
641                                });
642                                if (notified.indexOf(r.observer) === -1) notified.push(r.observer);
643                            }
644                        }
645                        for (const observer of notified) {
646                            const records = observer.takeRecords();
647                            if (!records.length) continue;
648                            try {
649                                observer._callback.call(observer, records, observer);
650                            } catch (error) {
651                                setTimeout(function () { throw error; }, 0);
652                            }
653                        }
654                    };
655                    globalThis.MutationObserver = MutationObserver;
656                })();
657            }
658            if (typeof globalThis.structuredClone !== "function") {
659                const Exception = globalThis.DOMException;
660                const exceptionName = Object.getOwnPropertyDescriptor(Exception.prototype, "name").get;
661                const exceptionMessage = Object.getOwnPropertyDescriptor(Exception.prototype, "message").get;
662                globalThis.structuredClone = function (input, options) {
663                    if (options && options.transfer && options.transfer.length) {
664                        throw new Exception("structuredClone transfer is not supported", "DataCloneError");
665                    }
666
667                    const seen = new Map();
668                    const clone = function (value) {
669                        if (value === null || typeof value !== "object") {
670                            if (typeof value === "function" || typeof value === "symbol") {
671                                throw new Exception("value cannot be structured-cloned", "DataCloneError");
672                            }
673                            return value;
674                        }
675                        if (value === globalThis || typeof value.nodeType === "number") {
676                            throw new Exception("value cannot be structured-cloned", "DataCloneError");
677                        }
678                        if (seen.has(value)) return seen.get(value);
679
680                        let copy;
681                        if (value instanceof Exception) {
682                            copy = new Exception(
683                                exceptionMessage.call(value),
684                                exceptionName.call(value),
685                            );
686                            seen.set(value, copy);
687                            return copy;
688                        } else if (Array.isArray(value)) {
689                            copy = [];
690                        } else if (value instanceof Date) {
691                            return new Date(value.getTime());
692                        } else if (value instanceof RegExp) {
693                            return new RegExp(value.source, value.flags);
694                        } else if (value instanceof Map) {
695                            copy = new Map();
696                            seen.set(value, copy);
697                            for (const [key, entry] of value) copy.set(clone(key), clone(entry));
698                            return copy;
699                        } else if (value instanceof Set) {
700                            copy = new Set();
701                            seen.set(value, copy);
702                            for (const entry of value) copy.add(clone(entry));
703                            return copy;
704                        } else if (typeof ArrayBuffer !== "undefined" && value instanceof ArrayBuffer) {
705                            return value.slice(0);
706                        } else if (typeof ArrayBuffer !== "undefined" && ArrayBuffer.isView(value)) {
707                            const buffer = clone(value.buffer);
708                            if (typeof DataView !== "undefined" && value instanceof DataView) {
709                                return new DataView(buffer, value.byteOffset, value.byteLength);
710                            }
711                            return new value.constructor(buffer, value.byteOffset, value.length);
712                        } else if (value instanceof WeakMap || value instanceof WeakSet || value instanceof Promise ||
713                            (typeof MessagePort === "function" && value instanceof MessagePort)) {
714                            throw new Exception("value cannot be structured-cloned", "DataCloneError");
715                        } else {
716                            copy = Object.create(Object.getPrototypeOf(value) === null ? null : Object.prototype);
717                        }
718
719                        seen.set(value, copy);
720                        for (const key of Object.keys(value)) copy[key] = clone(value[key]);
721                        return copy;
722                    };
723                    return clone(input);
724                };
725            }
726            if (typeof globalThis.crypto !== "object" || globalThis.crypto === null) {
727                Object.defineProperty(globalThis, "crypto", {
728                    value: {},
729                    writable: false,
730                    enumerable: true,
731                    configurable: true,
732                });
733            }
734            if (typeof globalThis.crypto.getRandomValues !== "function") {
735                const randomU32 = globalThis.__blitzRandomU32;
736                Object.defineProperty(globalThis.crypto, "getRandomValues", {
737                    value: function (array) {
738                        if (typeof ArrayBuffer === "undefined" || !ArrayBuffer.isView(array)) {
739                            throw new TypeError("getRandomValues requires an integer TypedArray");
740                        }
741                        const supported = [
742                            Int8Array,
743                            Uint8Array,
744                            Uint8ClampedArray,
745                            Int16Array,
746                            Uint16Array,
747                            Int32Array,
748                            Uint32Array,
749                        ];
750                        if (!supported.some((Type) => array instanceof Type)) {
751                            throw new TypeError("getRandomValues requires an integer TypedArray");
752                        }
753                        if (array.byteLength > 65536) {
754                            throw new DOMException("getRandomValues quota exceeded", "QuotaExceededError");
755                        }
756                        for (let index = 0; index < array.length; index += 1) {
757                            array[index] = randomU32();
758                        }
759                        return array;
760                    },
761                    writable: true,
762                    enumerable: true,
763                    configurable: true,
764                });
765            }
766            if (typeof globalThis.history !== "object" || globalThis.history === null) {
767                const entries = [{ state: null, url: globalThis.location.href }];
768                let index = 0;
769                const dispatchHistoryEvent = globalThis.__blitzDispatchHistoryEvent;
770                let applyingUrl = false;
771                const resolveHistoryUrl = function (url) {
772                    if (url === undefined || url === null) return globalThis.location.href;
773                    let resolved;
774                    try {
775                        resolved = new URL(String(url), globalThis.location.href);
776                    } catch (error) {
777                        throw new DOMException("Invalid history URL", "SecurityError");
778                    }
779                    const current = new URL(globalThis.location.href);
780                    if (resolved.origin !== current.origin) {
781                        throw new DOMException("History URL must have the document's origin", "SecurityError");
782                    }
783                    return resolved.href;
784                };
785                const applyUrl = function (url) {
786                    if (url === undefined || url === null) return;
787                    const value = String(url);
788                    let pathname = globalThis.location.pathname;
789                    let search = globalThis.location.search;
790                    let hash = globalThis.location.hash;
791
792                    if (value.startsWith("#")) {
793                        hash = value;
794                    } else if (value.startsWith("?")) {
795                        const hashAt = value.indexOf("#");
796                        search = hashAt < 0 ? value : value.slice(0, hashAt);
797                        hash = hashAt < 0 ? "" : value.slice(hashAt);
798                    } else {
799                        let relative = value;
800                        const schemeAt = relative.indexOf("://");
801                        if (schemeAt >= 0) {
802                            const pathAt = relative.indexOf("/", schemeAt + 3);
803                            relative = pathAt < 0 ? "/" : relative.slice(pathAt);
804                        }
805                        if (!relative.startsWith("/")) {
806                            const slashAt = pathname.lastIndexOf("/");
807                            relative = pathname.slice(0, slashAt + 1) + relative;
808                        }
809                        const hashAt = relative.indexOf("#");
810                        hash = hashAt < 0 ? "" : relative.slice(hashAt);
811                        const withoutHash = hashAt < 0 ? relative : relative.slice(0, hashAt);
812                        const searchAt = withoutHash.indexOf("?");
813                        search = searchAt < 0 ? "" : withoutHash.slice(searchAt);
814                        pathname = searchAt < 0 ? withoutHash : withoutHash.slice(0, searchAt);
815                    }
816
817                    globalThis.location.pathname = pathname || "/";
818                    globalThis.location.search = search;
819                    globalThis.location.hash = hash;
820                    applyingUrl = true;
821                    try {
822                        globalThis.location.href = globalThis.location.protocol + "//" +
823                            globalThis.location.host + globalThis.location.pathname + search + hash;
824                    } finally {
825                        applyingUrl = false;
826                    }
827                };
828                const history = {
829                    scrollRestoration: "auto",
830                    get length() { return entries.length; },
831                    get state() { return entries[index].state; },
832                    pushState(state, _unused, url) {
833                        const entry = { state: structuredClone(state), url: resolveHistoryUrl(url) };
834                        entries.splice(index + 1, entries.length, entry);
835                        index += 1;
836                        applyUrl(entry.url);
837                    },
838                    replaceState(state, _unused, url) {
839                        const entry = { state: structuredClone(state), url: resolveHistoryUrl(url) };
840                        entries[index] = entry;
841                        applyUrl(entry.url);
842                    },
843                    go(delta) {
844                        const number = Number(delta === undefined ? 0 : delta);
845                        const integer = Number.isFinite(number) ? Math.trunc(number) : 0;
846                        const distance = (integer >>> 0) | 0;
847                        if (distance === 0) {
848                            globalThis.location.reload();
849                            return;
850                        }
851                        setTimeout(function () {
852                            const next = index + distance;
853                            if (next < 0 || next >= entries.length) return;
854                            const oldURL = globalThis.location.href;
855                            const oldHash = globalThis.location.hash;
856                            index = next;
857                            applyUrl(entries[index].url);
858                            const newURL = globalThis.location.href;
859                            dispatchHistoryEvent(new PopStateEvent("popstate", {
860                                state: entries[index].state,
861                            }));
862                            if (oldHash !== globalThis.location.hash) {
863                                dispatchHistoryEvent(new HashChangeEvent("hashchange", {
864                                    oldURL: oldURL,
865                                    newURL: newURL,
866                                }));
867                            }
868                        }, 0);
869                    },
870                    back() { this.go(-1); },
871                    forward() { this.go(1); },
872                };
873                Object.defineProperty(globalThis, "history", {
874                    value: history,
875                    writable: false,
876                    enumerable: true,
877                    configurable: true,
878                });
879                // `location.href = url` navigates. A plain data property only
880                // stored the string, so pages that redirect by assignment never
881                // left. A change of fragment alone stays in this document, as a
882                // history entry with a hashchange; applyUrl keeps the value in sync.
883                let currentHref = globalThis.location.href;
884                Object.defineProperty(globalThis.location, "href", {
885                    get() { return currentHref; },
886                    set(value) {
887                        if (applyingUrl) {
888                            currentHref = String(value);
889                            return;
890                        }
891                        const target = new URL(String(value), currentHref);
892                        const current = new URL(currentHref);
893                        const withoutHash = function (href) {
894                            const hashAt = href.indexOf("#");
895                            return hashAt < 0 ? href : href.slice(0, hashAt);
896                        };
897                        if (target.hash && withoutHash(target.href) === withoutHash(current.href)) {
898                            const oldURL = currentHref;
899                            history.pushState(null, "", target.href);
900                            if (current.hash !== target.hash) {
901                                dispatchHistoryEvent(new HashChangeEvent("hashchange", {
902                                    oldURL: oldURL,
903                                    newURL: currentHref,
904                                }));
905                            }
906                            return;
907                        }
908                        globalThis.location.assign(target.href);
909                    },
910                    enumerable: true,
911                    configurable: true,
912                });
913            }
914            const htmlConstructor = globalThis.__blitzHTMLConstructor;
915            const domPrototype = globalThis.__blitzDOMPrototype;
916            const sequenceFrom = Array.from;
917            const sequenceString = String;
918            globalThis.__blitzCEInitialize(function (value) {
919                if (value === null || value === undefined) {
920                    throw new TypeError("cannot convert 'null' or 'undefined' to object");
921                }
922                const iterator = value[Symbol.iterator];
923                if (typeof iterator !== "function") {
924                    throw new TypeError("not a callable function");
925                }
926                return sequenceFrom(value, sequenceString);
927            });
928            delete globalThis.__blitzCEInitialize;
929            const defineDomInterface = function (name, matches) {
930                if (typeof globalThis[name] === "function") return;
931                const html = name === "HTMLElement"
932                    || (name.startsWith("HTML") && name.endsWith("Element"));
933                const Interface = html ? function () {
934                    return htmlConstructor(new.target, name);
935                } : function () {
936                    throw new TypeError("Illegal constructor");
937                };
938                Object.defineProperty(Interface, "name", { value: name, configurable: true });
939                Interface.prototype = domPrototype(name);
940                Object.defineProperty(Interface.prototype, "constructor", {
941                    value: Interface,
942                    writable: true,
943                    configurable: true,
944                });
945                if (html) {
946                    Object.setPrototypeOf(Interface,
947                        name === "HTMLElement" ? globalThis.Element : globalThis.HTMLElement);
948                }
949                Object.defineProperty(Interface, Symbol.hasInstance, {
950                    value: html ? Function.prototype[Symbol.hasInstance] : matches,
951                    configurable: true,
952                });
953                Object.defineProperty(globalThis, name, {
954                    value: Interface,
955                    writable: true,
956                    enumerable: false,
957                    configurable: true,
958                });
959            };
960            const isNode = function (value) {
961                return value !== null && typeof value === "object" && typeof value.nodeType === "number";
962            };
963            const isElement = function (value) { return isNode(value) && value.nodeType === 1; };
964            const isTag = function (tagName) {
965                return function (value) { return isElement(value) && value.tagName === tagName; };
966            };
967            defineDomInterface("Node", isNode);
968            Object.defineProperties(globalThis.Node, {
969                ELEMENT_NODE: { value: 1 },
970                ATTRIBUTE_NODE: { value: 2 },
971                TEXT_NODE: { value: 3 },
972                CDATA_SECTION_NODE: { value: 4 },
973                PROCESSING_INSTRUCTION_NODE: { value: 7 },
974                COMMENT_NODE: { value: 8 },
975                DOCUMENT_NODE: { value: 9 },
976                DOCUMENT_TYPE_NODE: { value: 10 },
977                DOCUMENT_FRAGMENT_NODE: { value: 11 },
978                DOCUMENT_POSITION_DISCONNECTED: { value: 0x01 },
979                DOCUMENT_POSITION_PRECEDING: { value: 0x02 },
980                DOCUMENT_POSITION_FOLLOWING: { value: 0x04 },
981                DOCUMENT_POSITION_CONTAINS: { value: 0x08 },
982                DOCUMENT_POSITION_CONTAINED_BY: { value: 0x10 },
983                DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC: { value: 0x20 },
984            });
985            defineDomInterface("Element", isElement);
986            defineDomInterface("HTMLElement", isElement);
987            defineDomInterface("Document", function (value) { return isNode(value) && value.nodeType === 9; });
988            defineDomInterface("HTMLDocument", function (value) { return isNode(value) && value.nodeType === 9; });
989            defineDomInterface("HTMLHeadElement", isTag("HEAD"));
990            defineDomInterface("HTMLBodyElement", isTag("BODY"));
991            defineDomInterface("HTMLAnchorElement", isTag("A"));
992            defineDomInterface("HTMLButtonElement", isTag("BUTTON"));
993            defineDomInterface("HTMLFormElement", isTag("FORM"));
994            defineDomInterface("HTMLImageElement", isTag("IMG"));
995            defineDomInterface("HTMLInputElement", isTag("INPUT"));
996            defineDomInterface("HTMLOptionElement", isTag("OPTION"));
997            defineDomInterface("HTMLScriptElement", isTag("SCRIPT"));
998            defineDomInterface("HTMLSelectElement", isTag("SELECT"));
999            defineDomInterface("HTMLStyleElement", isTag("STYLE"));
1000            defineDomInterface("HTMLTemplateElement", isTag("TEMPLATE"));
1001            defineDomInterface("HTMLTextAreaElement", isTag("TEXTAREA"));
1002            delete globalThis.__blitzDispatchHistoryEvent;
1003            defineDomInterface("HTMLDivElement", isTag("DIV"));
1004            defineDomInterface("HTMLSpanElement", isTag("SPAN"));
1005            defineDomInterface("HTMLParagraphElement", isTag("P"));
1006            defineDomInterface("HTMLLIElement", isTag("LI"));
1007            defineDomInterface("HTMLUListElement", isTag("UL"));
1008            defineDomInterface("HTMLOListElement", isTag("OL"));
1009            delete globalThis.__blitzHTMLConstructor;
1010            delete globalThis.__blitzDOMPrototype;
1011            delete globalThis.__blitzRandomU32;
1012            "##,
1013            "<blitz-bootstrap>",
1014        );
1015
1016        crate::domc::install(&mut runtime.context);
1017        runtime.eval_internal(include_str!("domc.js"), "<blitz-domc>");
1018        runtime.eval_internal(include_str!("domexc.js"), "<blitz-domexc>");
1019        let loader = Rc::clone(&runtime.module_loader);
1020        crate::docwrite::install(&mut runtime.context, loader);
1021        crate::fonts::install(&mut runtime.context);
1022        runtime.eval_internal(include_str!("docwrite.js"), "<blitz-docwrite>");
1023
1024        runtime
1025    }
1026
1027    /// Evaluate a script, logging (but not propagating) any uncaught errors,
1028    /// then drain the microtask queue.
1029    pub fn eval(&mut self, code: &str, description: &str) {
1030        self.eval_at(code, None, description);
1031    }
1032
1033    /// Evaluate a classic script that has a URL of its own.
1034    ///
1035    /// The URL is not decoration: a classic script may still call dynamic
1036    /// `import()`, and the specifier in that call resolves against the script,
1037    /// not the document. Without it, `import("./chunk.js")` from a bundle
1038    /// served out of `/assets/` would look for the chunk beside the HTML.
1039    pub fn eval_at(&mut self, code: &str, url: Option<&Url>, description: &str) {
1040        let path = url.map(|url| url.as_str().to_owned());
1041        let source = Source::from_bytes(code.as_bytes());
1042        let source = match &path {
1043            Some(path) => source.with_path(Path::new(path)),
1044            None => source,
1045        };
1046
1047        if let Err(error) = self.context.eval(source) {
1048            report_js_error(&self.diagnostics, description, &error);
1049        }
1050        crate::domc::set_current_script(&self.context, None);
1051        self.run_jobs(description);
1052    }
1053
1054    /// Install the page's `<script type="importmap">`.
1055    ///
1056    /// Bare specifiers (`import "preact"`) have no meaning without one, so a
1057    /// page that ships unbundled modules stops at its first dependency until
1058    /// this is set.
1059    pub fn set_import_map(&self, json: &str, base_url: Option<&Url>) {
1060        self.module_loader
1061            .set_import_map(crate::module::ImportMap::parse(json, base_url));
1062    }
1063
1064    /// Evaluate a `<script type="module">`.
1065    ///
1066    /// `url` is the module's own URL: the resolved `src` for an external
1067    /// module, or the document's URL for an inline one. It is what relative
1068    /// imports and `import.meta.url` resolve against, so a module without one
1069    /// can still run but cannot import anything relative.
1070    pub fn eval_module(&mut self, code: &str, url: Option<&Url>, description: &str) {
1071        let path = url.map(|url| url.as_str().to_owned());
1072        let source = Source::from_bytes(code.as_bytes());
1073        let source = match &path {
1074            Some(path) => source.with_path(Path::new(path)),
1075            None => source,
1076        };
1077
1078        let module = match Module::parse(source, None, &mut self.context) {
1079            Ok(module) => module,
1080            Err(error) => {
1081                report_js_error(&self.diagnostics, description, &error);
1082                return;
1083            }
1084        };
1085
1086        // Registered under its own URL before it runs, so that a module which
1087        // is both loaded by a `<script src>` and imported by a sibling is one
1088        // module with one set of bindings, not two.
1089        if let Some(url) = url {
1090            self.module_loader.register(url, module.clone());
1091        }
1092
1093        // Loading, linking and evaluating are all asynchronous, and the
1094        // returned promise only settles once the job queue has been drained.
1095        // Draining it here keeps a module script as synchronous from the
1096        // document's point of view as the classic script it replaces.
1097        let promise = module.load_link_evaluate(&mut self.context);
1098        self.run_jobs(description);
1099
1100        match promise.state() {
1101            PromiseState::Fulfilled(_) => {}
1102            PromiseState::Rejected(error) => {
1103                report_js_error(&self.diagnostics, description, &JsError::from_opaque(error));
1104            }
1105            // Not a failure. A module with top-level `await` is pending until
1106            // whatever it waits on completes, and that happens on a later poll.
1107            // Kept so that when it does reject, the rejection is still
1108            // attributed to the module rather than vanishing.
1109            PromiseState::Pending => self.pending_modules.push((promise, description.to_owned())),
1110        }
1111    }
1112
1113    /// Report module evaluations that have since failed, and forget those that
1114    /// have since succeeded.
1115    ///
1116    /// Without this, a module whose top-level `await` eventually rejects fails
1117    /// silently: the page half-mounts and there is nothing anywhere saying why.
1118    pub fn poll_module_evaluations(&mut self) {
1119        if self.pending_modules.is_empty() {
1120            return;
1121        }
1122
1123        let mut still_pending = Vec::new();
1124        for (promise, description) in std::mem::take(&mut self.pending_modules) {
1125            match promise.state() {
1126                PromiseState::Pending => still_pending.push((promise, description)),
1127                PromiseState::Fulfilled(_) => {}
1128                PromiseState::Rejected(error) => {
1129                    report_js_error(
1130                        &self.diagnostics,
1131                        &description,
1132                        &JsError::from_opaque(error),
1133                    );
1134                }
1135            }
1136        }
1137        self.pending_modules = still_pending;
1138    }
1139
1140    pub fn eval_json(
1141        &mut self,
1142        code: &str,
1143        description: &str,
1144    ) -> Result<serde_json::Value, String> {
1145        match self.context.eval(Source::from_bytes(code)) {
1146            Ok(value) => {
1147                self.run_jobs(description);
1148                value
1149                    .to_json(&mut self.context)
1150                    .map(|value| value.unwrap_or(serde_json::Value::Null))
1151                    .map_err(|error| error.to_string())
1152            }
1153            Err(error) => {
1154                report_js_error(&self.diagnostics, description, &error);
1155                Err(error.to_string())
1156            }
1157        }
1158    }
1159
1160    pub fn console_entries_after(&self, sequence: u64) -> Vec<DiagnosticEntry> {
1161        self.diagnostics
1162            .borrow()
1163            .console
1164            .iter()
1165            .filter(|entry| entry.sequence > sequence)
1166            .cloned()
1167            .collect()
1168    }
1169
1170    pub fn runtime_errors_after(&self, sequence: u64) -> Vec<DiagnosticEntry> {
1171        self.diagnostics
1172            .borrow()
1173            .errors
1174            .iter()
1175            .filter(|entry| entry.sequence > sequence)
1176            .cloned()
1177            .collect()
1178    }
1179
1180    pub fn diagnostic_batch(
1181        &self,
1182        console_after: u64,
1183        errors_after: u64,
1184        limit: usize,
1185    ) -> DiagnosticBatch {
1186        let diagnostics = self.diagnostics.borrow();
1187        DiagnosticBatch {
1188            console: diagnostic_stream(&diagnostics.console, console_after, limit),
1189            errors: diagnostic_stream(&diagnostics.errors, errors_after, limit),
1190        }
1191    }
1192
1193    pub(crate) fn eval_internal(&mut self, code: &str, description: &str) {
1194        // The description doubles as the source path, so stack traces and
1195        // profiles name the prelude (`<blitz-domc>:500`) instead of a bare line.
1196        let source = Source::from_bytes(code).with_path(Path::new(description));
1197        if let Err(error) = self.context.eval(source) {
1198            report_js_error(&self.diagnostics, description, &error);
1199        }
1200    }
1201
1202    pub(crate) fn begin_job_slice(&self) -> JobSlice {
1203        self.job_budget.begin()
1204    }
1205
1206    pub(crate) fn set_document_ready_state(&mut self, next: &'static str) -> bool {
1207        if !crate::domc::set_ready_state(&self.context, next) {
1208            return false;
1209        }
1210        let root_id = self.ctx.doc.borrow().root_node().id;
1211        self.dispatch_node_event(root_id, "readystatechange");
1212        true
1213    }
1214
1215    pub(crate) fn has_pending_module_evaluations(&self) -> bool {
1216        self.pending_modules
1217            .iter()
1218            .any(|(promise, _)| matches!(promise.state(), PromiseState::Pending))
1219    }
1220
1221    pub(crate) fn has_pending_module_loads(&self) -> bool {
1222        self.module_loader.has_pending_loads()
1223    }
1224
1225    pub(crate) fn poll_domc(&mut self) -> bool {
1226        let polls = match &self.domc_polls {
1227            Some(polls) => polls.clone(),
1228            None => {
1229                let global = self.context.global_object().clone();
1230                let polls = ["__blitzDOMCPoll", "__blitzDocwritePoll"].map(|name| {
1231                    global
1232                        .get(boa_engine::JsString::from(name), &mut self.context)
1233                        .ok()
1234                        .and_then(|value| value.as_callable())
1235                });
1236                self.domc_polls = Some(polls.clone());
1237                polls
1238            }
1239        };
1240        // Media query results only move with the device, so the media-query poll runs only
1241        // after the viewport generation changes. A list gaining its first listener takes its
1242        // own baseline, so skipping a poll misses nothing.
1243        let generation = self.ctx.doc.borrow().viewport_generation();
1244        let device_changed = self.domc_generation != Some(generation);
1245        self.domc_generation = Some(generation);
1246        let mut changed = false;
1247        for (index, function) in polls.into_iter().enumerate() {
1248            let Some(function) = function else {
1249                continue;
1250            };
1251            if index == 0 && !device_changed {
1252                continue;
1253            }
1254            let result = function.call(&JsValue::undefined(), &[], &mut self.context);
1255            match result {
1256                Ok(value) => changed |= value.to_boolean(),
1257                Err(error) => {
1258                    report_js_error(&self.diagnostics, "document platform changes", &error);
1259                }
1260            }
1261        }
1262        changed
1263    }
1264
1265    pub(crate) fn job_budget(&self) -> Duration {
1266        self.job_budget.duration()
1267    }
1268
1269    pub(crate) fn set_job_budget(&self, duration: Duration) {
1270        self.job_budget.set_duration(duration);
1271    }
1272
1273    /// Callbacks ready to run on the next host tick: every due timer and frame
1274    /// callback, plus one while Boa holds runnable jobs. Boa reports only
1275    /// whether its job queues are empty, so promise jobs count as one entry.
1276    pub(crate) fn pending_task_count(&self) -> u64 {
1277        let due = self.ctx.state.borrow().timers.due_count(Instant::now()) as u64;
1278        due + u64::from(self.jobs_remain())
1279    }
1280
1281    pub(crate) fn job_slice_exhausted(&self) -> bool {
1282        self.job_budget.exhausted()
1283    }
1284
1285    pub(crate) fn jobs_remain(&self) -> bool {
1286        self.job_executor.has_pending_jobs(&self.context)
1287            || self.ctx.doc.borrow().has_pending_mutations()
1288    }
1289
1290    /// Run pending promise jobs (microtasks) within the current slice.
1291    pub fn run_jobs(&mut self, description: &str) {
1292        self.run_jobs_with_progress(description);
1293    }
1294
1295    /// Run a slice and report jobs executed, queued work, or mutation delivery.
1296    ///
1297    /// Every drain in a poll shares one deadline. Observer delivery uses the
1298    /// remaining budget. When it expires, unconsumed records remain in the
1299    /// document for the next poll, alongside queued promise jobs.
1300    /// Observer callbacks retain the existing delivery cap.
1301    pub(crate) fn run_jobs_with_progress(&mut self, description: &str) -> bool {
1302        let _slice = self.begin_job_slice();
1303        let before = self.job_executor.executed_job_count();
1304        const MUTATION_DELIVERY_ROUNDS: usize = 32;
1305        let mut delivered = false;
1306        for _ in 0..MUTATION_DELIVERY_ROUNDS {
1307            crate::dom::inline_handlers::sync(&self.ctx, &mut self.context);
1308            crate::dom::custom_elements::checkpoint(&self.ctx, &mut self.context);
1309            if let Err(error) = Rc::clone(&self.job_executor)
1310                .run_jobs_with_budget(&mut self.context, self.job_budget.remaining())
1311            {
1312                report_js_error(&self.diagnostics, description, &error);
1313            }
1314            let mutations_delivered = self.deliver_mutations();
1315            let toggles_delivered =
1316                crate::dom::element::deliver_details_toggles(&self.ctx, &mut self.context);
1317            #[cfg(feature = "shadow-dom")]
1318            let slots_delivered =
1319                crate::dom::shadow::deliver_slot_changes(&self.ctx, &mut self.context);
1320            #[cfg(not(feature = "shadow-dom"))]
1321            let slots_delivered = false;
1322            if !mutations_delivered && !slots_delivered && !toggles_delivered {
1323                return delivered
1324                    || self.jobs_remain()
1325                    || self.job_executor.executed_job_count() != before;
1326            }
1327            delivered = true;
1328        }
1329        delivered || self.jobs_remain() || self.job_executor.executed_job_count() != before
1330    }
1331
1332    /// Hand recorded DOM changes to the prelude's `MutationObserver`. Returns
1333    /// whether there were any.
1334    fn deliver_mutations(&mut self) -> bool {
1335        let records = {
1336            let mut doc = self.ctx.doc.borrow_mut();
1337            if !doc.is_recording_mutations() {
1338                return false;
1339            }
1340            doc.take_mutations()
1341        };
1342        if records.is_empty() {
1343            return false;
1344        }
1345        let deliver = match self
1346            .context
1347            .global_object()
1348            .get(js_string!("__blitzDeliverMutations"), &mut self.context)
1349        {
1350            Ok(value) => value,
1351            Err(_) => return false,
1352        };
1353        let Some(deliver) = deliver.as_callable() else {
1354            return false;
1355        };
1356        let values: Vec<JsValue> = records
1357            .into_iter()
1358            .filter_map(|record| mutation_record_value(&self.ctx, record, &mut self.context))
1359            .collect();
1360        let list = boa_engine::object::builtins::JsArray::from_iter(values, &mut self.context);
1361        if let Err(error) = deliver.call(&JsValue::undefined(), &[list.into()], &mut self.context) {
1362            report_js_error(&self.diagnostics, "MutationObserver delivery", &error);
1363        }
1364        true
1365    }
1366
1367    /// The deadline of the soonest pending DOM or Boa timer (if any).
1368    pub fn next_timer_deadline(&self) -> Option<Instant> {
1369        let dom = self.ctx.state.borrow().timers.next_deadline();
1370        let boa = self
1371            .job_executor
1372            .next_job_timeout(&self.context)
1373            .map(|delay| Instant::now() + delay);
1374        match (dom, boa) {
1375            (Some(dom), Some(boa)) => Some(dom.min(boa)),
1376            (dom, boa) => dom.or(boa),
1377        }
1378    }
1379
1380    /// Free detached nodes whose wrappers script no longer holds.
1381    ///
1382    /// Removal cannot judge this at the time: the wrapper is alive at the
1383    /// instant of the call that removed the node, because that call went
1384    /// through it. Deciding later, once the collector has had its say, is what
1385    /// stops a document growing by one abandoned subtree per removed row.
1386    pub fn sweep_detached_nodes(&mut self) {
1387        crate::dom::sweep_detached_nodes(&self.ctx);
1388    }
1389
1390    /// Run one due timer and its budgeted microtask checkpoint.
1391    ///
1392    /// Unstarted timers remain cancellable and the host regains control before
1393    /// another callback starts. A yielded checkpoint finishes on later polls
1394    /// before the next timer is allowed to run.
1395    pub fn run_due_timers(&mut self, profiling: bool) -> bool {
1396        if self.job_budget.exhausted() || self.jobs_remain() {
1397            return false;
1398        }
1399        let timers_started = profiling.then(std::time::Instant::now);
1400        let due = self.ctx.state.borrow_mut().timers.take_due(Instant::now());
1401        if due.is_empty() {
1402            return false;
1403        }
1404        // All frame callbacks in this snapshot share one start time on the
1405        // performance.now() clock. Newly registered callbacks stay queued.
1406        let frame_args = [JsValue::new(TIME_ORIGIN.elapsed().as_secs_f64() * 1000.0)];
1407        for timer in due {
1408            let args = if timer.animation_frame {
1409                if !self
1410                    .ctx
1411                    .state
1412                    .borrow_mut()
1413                    .timers
1414                    .begin_animation_frame_callback(timer.id)
1415                {
1416                    continue;
1417                }
1418                &frame_args[..]
1419            } else {
1420                &timer.args[..]
1421            };
1422            if let Err(error) = timer
1423                .callback
1424                .call(&JsValue::undefined(), args, &mut self.context)
1425            {
1426                report_js_error(&self.diagnostics, "timer callback", &error);
1427            }
1428        }
1429        self.run_jobs("timer microtasks");
1430        if let Some(started) = timers_started {
1431            crate::script_stats::record_work("timers", started.elapsed());
1432        }
1433        true
1434    }
1435
1436    /// Dispatch a Blitz DOM event to JavaScript event listeners registered on
1437    /// the nodes in `chain` (which is ordered target-first).
1438    ///
1439    /// Returns `true` if any listener was invoked.
1440    pub fn dispatch_dom_event(
1441        &mut self,
1442        chain: &[NodeId],
1443        event: &DomEvent,
1444        event_state: &mut EventState,
1445        profiling: bool,
1446    ) -> bool {
1447        // Attributed by event name. A poll costing 16ms says nothing about what
1448        // to fix; "scroll cost 14ms of it" names the handler.
1449        let dispatch_started = profiling.then(std::time::Instant::now);
1450        let ran = self.dispatch_dom_event_timed(chain, event, event_state);
1451        if let Some(started) = dispatch_started {
1452            crate::script_stats::record_work(
1453                &format!("event:{}", event.data.name()),
1454                started.elapsed(),
1455            );
1456        }
1457        ran
1458    }
1459
1460    fn dispatch_dom_event_timed(
1461        &mut self,
1462        chain: &[NodeId],
1463        event: &DomEvent,
1464        event_state: &mut EventState,
1465    ) -> bool {
1466        let pointer_id = match &event.data {
1467            DomEventData::PointerMove(pointer)
1468            | DomEventData::PointerDown(pointer)
1469            | DomEventData::PointerUp(pointer)
1470            | DomEventData::PointerCancel(pointer) => Some(match pointer.id {
1471                BlitzPointerId::Mouse => 1,
1472                BlitzPointerId::Pen => 2,
1473                BlitzPointerId::Finger(id) => id.saturating_add(3),
1474            }),
1475            _ => None,
1476        };
1477        let captured_node =
1478            pointer_id.and_then(|id| self.ctx.state.borrow().pointer_capture.get(&id).copied());
1479        let captured_chain = captured_node.map(|target| {
1480            let doc = self.ctx.doc.borrow();
1481            let mut chain = Vec::new();
1482            let mut current = Some(target);
1483            while let Some(node_id) = current {
1484                chain.push(node_id);
1485                current = doc.get_node(node_id).and_then(|node| node.parent);
1486            }
1487            chain
1488        });
1489        let chain = captured_chain.as_deref().unwrap_or(chain);
1490
1491        if chain.is_empty() {
1492            return false;
1493        }
1494        let name = event.name().to_string();
1495        // Touch and movement state must advance even without a listener.
1496        let target: JsValue = node_wrapper(&self.ctx, chain[0], &mut self.context).into();
1497        let event_object = create_event_for_dom_event(
1498            &self.ctx,
1499            &event.data,
1500            event.bubbles,
1501            event.cancelable,
1502            &target,
1503            &mut self.context,
1504        );
1505        let mut any_called = self.dispatch_event_inner(
1506            chain,
1507            &name,
1508            event.bubbles,
1509            |_, _, _| event_object,
1510            event_state,
1511        );
1512
1513        // Browsers fire a `change` event after `input` events on checkbox/radio
1514        // inputs. Blitz only generates `input` events, so synthesise the `change`
1515        // event here.
1516        //
1517        // A text control commits instead: `change` fires when it loses focus
1518        // having been edited, never on each keystroke. Only checkbox and radio
1519        // were covered, so `change` on a text field was dead, and a phone-number
1520        // field bound to it gated a Confirm button that nothing could open.
1521        let synthesise_change = match &event.data {
1522            DomEventData::Input(_) => {
1523                // A select commits on selection, not on blur: the keystroke or
1524                // the press is the commit, exactly as it is for a checkbox.
1525                self.target_is_checkbox_or_radio(event.target)
1526                    || self.target_is_select(event.target)
1527            }
1528            DomEventData::Focus(_) => {
1529                self.focussed_text_value = self
1530                    .text_control_value(event.target)
1531                    .map(|value| (event.target, value));
1532                false
1533            }
1534            DomEventData::Blur(_) => match self.focussed_text_value.take() {
1535                Some((node_id, focussed_value)) if node_id == event.target => self
1536                    .text_control_value(event.target)
1537                    .is_some_and(|current| current != focussed_value),
1538                _ => false,
1539            },
1540            _ => false,
1541        };
1542        if synthesise_change {
1543            let mut change_state = EventState::default();
1544            any_called |= self.dispatch_event_inner(
1545                chain,
1546                "change",
1547                true,
1548                |ctx, target, context| create_event(ctx, "change", true, false, target, context),
1549                &mut change_state,
1550            );
1551            if change_state.redraw_is_requested() {
1552                event_state.request_redraw();
1553            }
1554        }
1555
1556        if any_called {
1557            self.run_jobs("event microtasks");
1558        }
1559
1560        if matches!(
1561            event.data,
1562            DomEventData::PointerUp(_) | DomEventData::PointerCancel(_)
1563        ) && let Some(pointer_id) = pointer_id
1564        {
1565            self.ctx
1566                .state
1567                .borrow_mut()
1568                .pointer_capture
1569                .remove(&pointer_id);
1570        }
1571
1572        any_called
1573    }
1574
1575    /// The current text of a text input or textarea, or `None` for anything
1576    /// that is not one.
1577    fn text_control_value(&self, node_id: NodeId) -> Option<String> {
1578        let doc = self.ctx.doc.borrow();
1579        doc.get_node(node_id)?
1580            .element_data()?
1581            .text_input_data()
1582            .map(|input| input.editor.text().to_string())
1583    }
1584
1585    fn target_is_select(&self, node_id: NodeId) -> bool {
1586        let doc = self.ctx.doc.borrow();
1587        doc.get_node(node_id).is_some_and(|node| {
1588            node.data
1589                .is_element_with_tag_name(&blitz_dom::local_name!("select"))
1590        })
1591    }
1592
1593    fn target_is_checkbox_or_radio(&self, node_id: NodeId) -> bool {
1594        let doc = self.ctx.doc.borrow();
1595        doc.get_node(node_id)
1596            .and_then(|node| node.element_data())
1597            .is_some_and(|element| {
1598                element.name.local == blitz_dom::local_name!("input")
1599                    && matches!(
1600                        element.attr(blitz_dom::local_name!("type")),
1601                        Some("checkbox") | Some("radio")
1602                    )
1603            })
1604    }
1605
1606    /// Dispatch an event named `name` along `chain`, using `make_event` to lazily
1607    /// construct the JS event object. Returns `true` if any listener was invoked.
1608    fn dispatch_event_inner(
1609        &mut self,
1610        chain: &[NodeId],
1611        name: &str,
1612        bubbles: bool,
1613        make_event: impl FnOnce(&DomCtx, &JsValue, &mut Context) -> JsObject,
1614        event_state: &mut EventState,
1615    ) -> bool {
1616        let ctx = self.ctx.clone();
1617        let context = &mut self.context;
1618        crate::dom::inline_handlers::sync(&ctx, context);
1619        let on_name = JsString::from(format!("on{name}"));
1620
1621        #[cfg(feature = "shadow-dom")]
1622        if ctx.doc.borrow().has_shadow_roots() {
1623            let Some(&target_id) = chain.first() else {
1624                return false;
1625            };
1626            let target: JsValue = node_wrapper(&ctx, target_id, context).into();
1627            let event_obj = make_event(&ctx, &target, context);
1628            let composed = name.starts_with("pointer")
1629                || name.starts_with("mouse")
1630                || name.starts_with("touch")
1631                || name.starts_with("key")
1632                || matches!(
1633                    name,
1634                    "click"
1635                        | "dblclick"
1636                        | "contextmenu"
1637                        | "wheel"
1638                        | "input"
1639                        | "focus"
1640                        | "blur"
1641                        | "focusin"
1642                        | "focusout"
1643                );
1644            crate::dom::define_value(&event_obj, "composed", JsValue::from(composed), context);
1645            match crate::dom::shadow_event::dispatch(&ctx, target_id, &event_obj, false, context) {
1646                Ok(result) => {
1647                    if result.prevented {
1648                        event_state.prevent_default();
1649                    }
1650                    if result.stopped {
1651                        event_state.stop_propagation();
1652                    }
1653                    if result.called {
1654                        event_state.request_redraw();
1655                    }
1656                    return result.called;
1657                }
1658                Err(error) => {
1659                    report_js_error(&self.diagnostics, name, &error);
1660                    return false;
1661                }
1662            }
1663        }
1664
1665        // Fast path: bail if no listener of this type could possibly be registered
1666        let window_handler = bubbles
1667            && context
1668                .global_object()
1669                .get(on_name, context)
1670                .is_ok_and(|handler| handler.is_callable());
1671        let may_have_listeners = {
1672            let state = ctx.state.borrow();
1673            let registry_hit = chain.iter().any(|node_id| {
1674                state
1675                    .node_listeners
1676                    .get(node_id)
1677                    .and_then(|map| map.get(name))
1678                    .is_some_and(|listeners| !listeners.is_empty())
1679            }) || state
1680                .window_listeners
1681                .get(name)
1682                .is_some_and(|listeners| !listeners.is_empty());
1683            // Recorded content attributes have already seeded their wrappers.
1684            let wrapper_hit = chain
1685                .iter()
1686                .any(|node_id| state.node_wrappers.contains_key(node_id));
1687            registry_hit || wrapper_hit || window_handler
1688        };
1689        if !may_have_listeners {
1690            return false;
1691        }
1692
1693        let target: JsValue = node_wrapper(&ctx, chain[0], context).into();
1694        let event_obj = make_event(&ctx, &target, context);
1695        let _dispatch = crate::dom::event::begin_dispatch(&event_obj, false, context)
1696            .expect("fresh native event");
1697        crate::dom::define_value(&event_obj, "eventPhase", JsValue::from(2), context);
1698        let mut event_path: Vec<JsObject> = chain
1699            .iter()
1700            .map(|&node_id| node_wrapper(&ctx, node_id, context))
1701            .collect();
1702        event_path.push(context.global_object().clone());
1703        set_event_path(&event_obj, event_path);
1704        let event_ref = |event_obj: &JsObject, f: &dyn Fn(&EventRef) -> bool| -> bool {
1705            event_obj
1706                .downcast_ref::<EventRef>()
1707                .map(|event| f(&event))
1708                .unwrap_or(false)
1709        };
1710
1711        let mut any_called = false;
1712
1713        'chain: for &node_id in chain {
1714            // Gather listeners for this node: `addEventListener` listeners plus
1715            // an `on<event>` property handler (if any)
1716            let mut callbacks: Vec<(JsObject, bool)> = Vec::new();
1717            {
1718                let mut state = ctx.state.borrow_mut();
1719                if let Some(listeners) = state
1720                    .node_listeners
1721                    .get_mut(&node_id)
1722                    .and_then(|map| map.get_mut(name))
1723                {
1724                    callbacks.extend(listeners.iter().filter_map(|listener| {
1725                        listener
1726                            .callback
1727                            .upgrade()
1728                            .map(|callback| (callback, listener.passive))
1729                    }));
1730                    // `once` listeners are removed at dispatch time
1731                    listeners.retain(|l| !l.once);
1732                }
1733            }
1734            crate::dom::node::sync_node_listener_callbacks(&ctx, node_id, context);
1735
1736            let current_target: JsValue = node_wrapper(&ctx, node_id, context).into();
1737            let phase = if node_id == chain[0] { 2 } else { 3 };
1738            crate::dom::define_value(&event_obj, "eventPhase", JsValue::from(phase), context);
1739            crate::dom::define_value(&event_obj, "currentTarget", current_target.clone(), context);
1740
1741            for (callback, passive) in callbacks {
1742                any_called = true;
1743                if let Err(error) = crate::dom::passive::invoke(
1744                    &callback,
1745                    &current_target,
1746                    &event_obj,
1747                    passive,
1748                    context,
1749                ) {
1750                    let what = listener_error_context(
1751                        name,
1752                        &format!("node {node_id}"),
1753                        &callback,
1754                        context,
1755                    );
1756                    report_js_error(&self.diagnostics, &what, &error);
1757                }
1758                if event_ref(&event_obj, &|event| event.stopped_immediate.get()) {
1759                    break 'chain;
1760                }
1761            }
1762
1763            let wrapper = node_wrapper(&ctx, node_id, context);
1764            any_called |=
1765                crate::dom::inline_handlers::invoke(&ctx, &wrapper, &event_obj, name, context);
1766            if event_ref(&event_obj, &|event| event.stopped_immediate.get()) {
1767                break 'chain;
1768            }
1769            if !bubbles || event_ref(&event_obj, &|event| event.stopped.get()) {
1770                break;
1771            }
1772        }
1773
1774        // Window-level listeners
1775        if bubbles && !event_ref(&event_obj, &|event| event.stopped.get()) {
1776            let listeners: Vec<Listener> = {
1777                let mut state = ctx.state.borrow_mut();
1778                match state.window_listeners.get_mut(name) {
1779                    Some(listeners) => {
1780                        let cloned = listeners.clone();
1781                        listeners.retain(|l| !l.once);
1782                        cloned
1783                    }
1784                    None => Vec::new(),
1785                }
1786            };
1787            let window = context.global_object().clone();
1788            let global: JsValue = window.clone().into();
1789            crate::dom::define_value(&event_obj, "eventPhase", JsValue::from(3), context);
1790            crate::dom::define_value(&event_obj, "currentTarget", global.clone(), context);
1791            for listener in listeners {
1792                any_called = true;
1793                if let Err(error) = crate::dom::passive::invoke(
1794                    &listener.callback,
1795                    &global,
1796                    &event_obj,
1797                    listener.passive,
1798                    context,
1799                ) {
1800                    let what = listener_error_context(name, "window", &listener.callback, context);
1801                    report_js_error(&self.diagnostics, &what, &error);
1802                }
1803                if event_ref(&event_obj, &|event| event.stopped_immediate.get()) {
1804                    break;
1805                }
1806            }
1807            if !event_ref(&event_obj, &|event| event.stopped_immediate.get()) {
1808                any_called |=
1809                    crate::dom::inline_handlers::invoke(&ctx, &window, &event_obj, name, context);
1810            }
1811        }
1812
1813        crate::dom::define_value(&event_obj, "currentTarget", JsValue::null(), context);
1814
1815        // Feed `preventDefault` / `stopPropagation` back into Blitz
1816        if event_ref(&event_obj, &|event| event.prevented.get()) {
1817            event_state.prevent_default();
1818        }
1819        if event_ref(&event_obj, &|event| event.stopped.get()) {
1820            event_state.stop_propagation();
1821        }
1822        if any_called {
1823            event_state.request_redraw();
1824        }
1825
1826        any_called
1827    }
1828
1829    pub(crate) fn dispatch_lifecycle_event(&mut self, name: &str) {
1830        let root_id = self.ctx.doc.borrow().root_node().id;
1831        let mut event_state = EventState::default();
1832        let ran = self.dispatch_event_inner(
1833            &[root_id],
1834            name,
1835            false,
1836            |ctx, target, context| create_event(ctx, name, false, false, target, context),
1837            &mut event_state,
1838        );
1839        if ran {
1840            self.run_jobs("lifecycle microtasks");
1841        }
1842    }
1843
1844    /// Dispatch a simple event (e.g. `DOMContentLoaded`) targeting the document node
1845    pub fn dispatch_document_event(&mut self, name: &str) -> bool {
1846        let root_id = self.ctx.doc.borrow().root_node().id;
1847        let mut event_state = EventState::default();
1848        let ran = self.dispatch_event_inner(
1849            &[root_id],
1850            name,
1851            true,
1852            |ctx, target, context| create_event(ctx, name, true, false, target, context),
1853            &mut event_state,
1854        );
1855        if ran {
1856            self.run_jobs("event microtasks");
1857        }
1858        ran
1859    }
1860
1861    /// Dispatch a simple, non-bubbling event at one node.
1862    ///
1863    /// For the resource events a script element reports on itself, `load` and
1864    /// `error`. Non-bubbling because that is what the DOM specifies for them,
1865    /// and a loader listening on the element would otherwise also hear every
1866    /// image on the page finish.
1867    pub fn dispatch_node_event(&mut self, node_id: NodeId, name: &str) -> bool {
1868        let mut event_state = EventState::default();
1869        let ran = self.dispatch_event_inner(
1870            &[node_id],
1871            name,
1872            false,
1873            |ctx, target, context| create_event(ctx, name, false, false, target, context),
1874            &mut event_state,
1875        );
1876        if ran {
1877            self.run_jobs("event microtasks");
1878        }
1879        ran
1880    }
1881
1882    /// Dispatch a simple event (e.g. `load`) targeting the window
1883    pub fn dispatch_window_event(&mut self, name: &str) -> bool {
1884        let ctx = self.ctx.clone();
1885        let context = &mut self.context;
1886
1887        let listeners: Vec<Listener> = {
1888            let mut state = ctx.state.borrow_mut();
1889            match state.window_listeners.get_mut(name) {
1890                Some(listeners) => {
1891                    let cloned = listeners.clone();
1892                    listeners.retain(|l| !l.once);
1893                    cloned
1894                }
1895                None => Vec::new(),
1896            }
1897        };
1898
1899        let global: JsValue = context.global_object().into();
1900        let event_obj = create_event(&ctx, name, false, false, &global, context);
1901        crate::dom::define_value(&event_obj, "currentTarget", global.clone(), context);
1902
1903        let mut any_called = false;
1904        for listener in listeners {
1905            any_called = true;
1906            if let Err(error) = crate::dom::passive::invoke(
1907                &listener.callback,
1908                &global,
1909                &event_obj,
1910                listener.passive,
1911                context,
1912            ) {
1913                let what = listener_error_context(name, "window", &listener.callback, context);
1914                report_js_error(&self.diagnostics, &what, &error);
1915            }
1916        }
1917
1918        if !event_obj
1919            .downcast_ref::<EventRef>()
1920            .is_some_and(|event| event.stopped_immediate.get())
1921        {
1922            let window = context.global_object().clone();
1923            any_called |=
1924                crate::dom::inline_handlers::invoke(&ctx, &window, &event_obj, name, context);
1925        }
1926        crate::dom::define_value(&event_obj, "currentTarget", JsValue::null(), context);
1927
1928        if any_called {
1929            self.run_jobs("event microtasks");
1930        }
1931        any_called
1932    }
1933}
1934
1935/// `getComputedStyle(element)`.
1936///
1937/// Backed by the engine's own computed values rather than a stub returning
1938/// empty strings. That distinction matters: a page reading `display` off a stub
1939/// gets `""`, believes the element is not hidden, and lays itself out wrongly —
1940/// harder to diagnose than the `getComputedStyle is not defined` this replaces,
1941/// which at least stopped loudly. Three sites in a hundred-site corpus died on
1942/// that error.
1943///
1944/// The declaration retains its element and reads live computed values after a
1945/// synchronous layout flush. Longhand serialization belongs to Stylo.
1946fn get_computed_style(_: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult<JsValue> {
1947    crate::domc::get_computed_style(args, context)
1948}
1949
1950/// `__blitzObserveMutations(on)`: the prelude's `MutationObserver` turns the
1951/// document's change log on while any observer is registered and off when the
1952/// last one disconnects, so a page without observers records nothing.
1953fn observe_mutations(_: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult<JsValue> {
1954    let on = args.first().is_some_and(JsValue::to_boolean);
1955    dom_ctx(context)?
1956        .doc
1957        .borrow_mut()
1958        .set_recording_mutations(on);
1959    Ok(JsValue::undefined())
1960}
1961
1962/// One recorded change as the array the prelude's delivery function reads:
1963/// `[type, target, added, removed, previousSibling, nextSibling,
1964/// attributeName, oldValue]`. `None` when the target no longer exists.
1965fn mutation_record_value(
1966    ctx: &DomCtx,
1967    record: blitz_dom::DomMutation,
1968    context: &mut Context,
1969) -> Option<JsValue> {
1970    use blitz_dom::DomMutation;
1971    use boa_engine::object::builtins::JsArray;
1972
1973    let exists = |id: NodeId| ctx.doc.borrow().get_node(id).is_some();
1974    let node = |id: NodeId, context: &mut Context| -> JsValue {
1975        if exists(id) {
1976            node_wrapper(ctx, id, context).into()
1977        } else {
1978            JsValue::null()
1979        }
1980    };
1981    let nodes = |ids: Vec<NodeId>, context: &mut Context| -> JsValue {
1982        let values: Vec<JsValue> = ids
1983            .into_iter()
1984            .filter(|id| exists(*id))
1985            .map(|id| node_wrapper(ctx, id, context).into())
1986            .collect();
1987        JsArray::from_iter(values, context).into()
1988    };
1989    let text = |value: Option<String>| -> JsValue {
1990        value.map_or(JsValue::null(), |value| {
1991            JsString::from(value.as_str()).into()
1992        })
1993    };
1994
1995    let fields: Vec<JsValue> = match record {
1996        DomMutation::ChildList {
1997            target,
1998            added,
1999            removed,
2000            previous_sibling,
2001            next_sibling,
2002        } => {
2003            if !exists(target) {
2004                return None;
2005            }
2006            vec![
2007                js_string!("childList").into(),
2008                node(target, context),
2009                nodes(added, context),
2010                nodes(removed, context),
2011                previous_sibling.map_or(JsValue::null(), |id| node(id, context)),
2012                next_sibling.map_or(JsValue::null(), |id| node(id, context)),
2013                JsValue::null(),
2014                JsValue::null(),
2015            ]
2016        }
2017        DomMutation::Attributes {
2018            target,
2019            name,
2020            old_value,
2021        } => {
2022            if !exists(target) {
2023                return None;
2024            }
2025            vec![
2026                js_string!("attributes").into(),
2027                node(target, context),
2028                nodes(Vec::new(), context),
2029                nodes(Vec::new(), context),
2030                JsValue::null(),
2031                JsValue::null(),
2032                text(Some(name)),
2033                text(old_value),
2034            ]
2035        }
2036        DomMutation::CharacterData { target, old_value } => {
2037            if !exists(target) {
2038                return None;
2039            }
2040            vec![
2041                js_string!("characterData").into(),
2042                node(target, context),
2043                nodes(Vec::new(), context),
2044                nodes(Vec::new(), context),
2045                JsValue::null(),
2046                JsValue::null(),
2047                JsValue::null(),
2048                text(Some(old_value)),
2049            ]
2050        }
2051    };
2052    Some(JsArray::from_iter(fields, context).into())
2053}
2054
2055fn ipc_post_message(_: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult<JsValue> {
2056    let body = args
2057        .first()
2058        .unwrap_or(&JsValue::undefined())
2059        .to_string(context)?
2060        .to_std_string_lossy();
2061    let handler = dom_ctx(context)?.state.borrow().ipc_handler.clone();
2062    if let Some(handler) = handler {
2063        handler(body);
2064    }
2065    Ok(JsValue::undefined())
2066}
2067
2068/// `navigator.clipboard.writeText(text)`.
2069///
2070/// The spec returns a promise, and callers await it, so a plain value would be
2071/// awaited as an already-resolved one and hide a failed write. Resolve on a
2072/// successful shell write and reject otherwise, which is what the API's own
2073/// error path looks like.
2074fn clipboard_write_text(_: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult<JsValue> {
2075    let text = args
2076        .first()
2077        .unwrap_or(&JsValue::undefined())
2078        .to_string(context)?
2079        .to_std_string_lossy();
2080    let wrote = dom_ctx(context)?
2081        .doc
2082        .borrow()
2083        .shell_provider
2084        .set_clipboard_text(text)
2085        .is_ok();
2086    let promise = if wrote {
2087        JsPromise::resolve(JsValue::undefined(), context)
2088    } else {
2089        let error =
2090            crate::dom_exception::error("NotAllowedError", "the clipboard is unavailable", context);
2091        JsPromise::reject(error, context)
2092    };
2093    Ok(JsValue::from(promise?))
2094}
2095
2096/// `navigator.clipboard.readText()`. Rejects when the shell has no clipboard,
2097/// rather than resolving to "" — an empty clipboard and an absent one are
2098/// different answers and callers branch on them differently.
2099fn clipboard_read_text(_: &JsValue, _: &[JsValue], context: &mut Context) -> JsResult<JsValue> {
2100    let read = dom_ctx(context)?
2101        .doc
2102        .borrow()
2103        .shell_provider
2104        .get_clipboard_text();
2105    let promise = match read {
2106        Ok(text) => JsPromise::resolve(JsValue::from(JsString::from(text)), context),
2107        Err(_) => {
2108            let error = crate::dom_exception::error(
2109                "NotAllowedError",
2110                "the clipboard is unavailable",
2111                context,
2112            );
2113            JsPromise::reject(error, context)
2114        }
2115    };
2116    Ok(JsValue::from(promise?))
2117}
2118
2119/// The spec's time origin is document creation. A first-use instant is close
2120/// enough for the elapsed-time measurements callers actually take, and sharing
2121/// one origin across documents keeps their readings comparable.
2122static TIME_ORIGIN: LazyLock<Instant> = LazyLock::new(Instant::now);
2123
2124fn performance_now(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult<JsValue> {
2125    Ok(JsValue::new(TIME_ORIGIN.elapsed().as_secs_f64() * 1000.0))
2126}
2127
2128fn viewport_scroll(_: &JsValue, _: &[JsValue], context: &mut Context) -> JsResult<JsValue> {
2129    let ctx = dom_ctx(context)?;
2130    let scroll = ctx.doc.borrow().viewport_scroll();
2131    Ok(boa_engine::object::builtins::JsArray::from_iter(
2132        [JsValue::new(scroll.x), JsValue::new(scroll.y)],
2133        context,
2134    )
2135    .into())
2136}
2137
2138fn scroll_viewport(_: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult<JsValue> {
2139    let ctx = dom_ctx(context)?;
2140    let x = args
2141        .first()
2142        .unwrap_or(&JsValue::undefined())
2143        .to_number(context)?;
2144    let y = args
2145        .get(1)
2146        .unwrap_or(&JsValue::undefined())
2147        .to_number(context)?;
2148    let x = if x.is_finite() { x } else { 0.0 };
2149    let y = if y.is_finite() { y } else { 0.0 };
2150    ctx.flush_layout();
2151    let mut document = ctx.doc.borrow_mut();
2152    let current = document.viewport_scroll();
2153    let changed = document.scroll_viewport_by_has_changed(current.x - x, current.y - y);
2154    if changed {
2155        document.shell_provider.request_redraw();
2156    }
2157    Ok(JsValue::new(changed))
2158}
2159
2160fn random_u32(_: &JsValue, _: &[JsValue], context: &mut Context) -> JsResult<JsValue> {
2161    getrandom::u32().map(JsValue::new).map_err(|error| {
2162        crate::dom_exception::error(
2163            "OperationError",
2164            &format!("secure random generation failed: {error}"),
2165            context,
2166        )
2167    })
2168}
2169
2170fn register_global(context: &mut Context, name: &str, value: JsValue) {
2171    context
2172        .register_global_property(
2173            JsString::from(name),
2174            value,
2175            Attribute::WRITABLE.union(Attribute::CONFIGURABLE),
2176        )
2177        .expect("failed to register global");
2178}
2179
2180fn register_global_fn(
2181    context: &mut Context,
2182    name: &str,
2183    length: usize,
2184    body: fn(&JsValue, &[JsValue], &mut Context) -> JsResult<JsValue>,
2185) {
2186    context
2187        .register_global_callable(
2188            JsString::from(name),
2189            length,
2190            NativeFunction::from_fn_ptr(body),
2191        )
2192        .expect("failed to register global function");
2193}
2194
2195fn build_location(base_url: Option<&Url>, context: &mut Context) -> JsValue {
2196    let (href, protocol, host, pathname, search, hash) = match base_url {
2197        Some(url) => (
2198            url.to_string(),
2199            format!("{}:", url.scheme()),
2200            url.host_str().unwrap_or_default().to_string(),
2201            url.path().to_string(),
2202            url.query().map(|q| format!("?{q}")).unwrap_or_default(),
2203            url.fragment().map(|f| format!("#{f}")).unwrap_or_default(),
2204        ),
2205        None => (
2206            "about:blank".to_string(),
2207            "about:".to_string(),
2208            String::new(),
2209            "blank".to_string(),
2210            String::new(),
2211            String::new(),
2212        ),
2213    };
2214    ObjectInitializer::new(context)
2215        .property(js_string!("href"), JsString::from(href), Attribute::all())
2216        .property(
2217            js_string!("protocol"),
2218            JsString::from(protocol),
2219            Attribute::all(),
2220        )
2221        .property(
2222            js_string!("host"),
2223            JsString::from(host.clone()),
2224            Attribute::all(),
2225        )
2226        .property(
2227            js_string!("hostname"),
2228            JsString::from(host),
2229            Attribute::all(),
2230        )
2231        .property(
2232            js_string!("pathname"),
2233            JsString::from(pathname),
2234            Attribute::all(),
2235        )
2236        .property(
2237            js_string!("search"),
2238            JsString::from(search),
2239            Attribute::all(),
2240        )
2241        .property(js_string!("hash"), JsString::from(hash), Attribute::all())
2242        // `assign`, `replace` and `reload`. Without them `location` was a plain
2243        // data object, so a page calling any of the three threw a TypeError out
2244        // of whatever ran it. All three go through the document's navigation
2245        // provider, which is where a link click goes.
2246        .function(
2247            NativeFunction::from_fn_ptr(location_assign),
2248            js_string!("assign"),
2249            1,
2250        )
2251        .function(
2252            NativeFunction::from_fn_ptr(location_assign),
2253            js_string!("replace"),
2254            1,
2255        )
2256        .function(
2257            NativeFunction::from_fn_ptr(location_reload),
2258            js_string!("reload"),
2259            0,
2260        )
2261        .function(
2262            NativeFunction::from_fn_ptr(location_to_string),
2263            js_string!("toString"),
2264            0,
2265        )
2266        .build()
2267        .into()
2268}
2269
2270/// Serves both `assign` and `replace`. They differ only in whether the current
2271/// entry is kept in the session history, and this runtime's history is a
2272/// script-level shim that a real navigation replaces wholesale either way.
2273fn location_assign(_: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult<JsValue> {
2274    let ctx = dom_ctx(context)?;
2275    let url = crate::dom::to_rust_string(args.first().unwrap_or(&JsValue::undefined()), context)?;
2276    if !ctx.doc.borrow().navigate_to_url(&url) {
2277        return Err(crate::dom_exception::error(
2278            "SyntaxError",
2279            &format!("{url} is not a valid URL"),
2280            context,
2281        ));
2282    }
2283    Ok(JsValue::undefined())
2284}
2285
2286fn location_reload(_: &JsValue, _: &[JsValue], context: &mut Context) -> JsResult<JsValue> {
2287    let ctx = dom_ctx(context)?;
2288    let doc = ctx.doc.borrow();
2289    let current = doc.current_url();
2290    doc.navigate_to_url(&current);
2291    Ok(JsValue::undefined())
2292}
2293
2294/// `Location`'s stringifier. `String(location)` and `new URL(input, location)`
2295/// convert the object with ToString, and the result is the current href.
2296/// The default object stringifier returns "[object Object]", which the URL
2297/// parser rejects as a relative base.
2298fn location_to_string(this: &JsValue, _: &[JsValue], context: &mut Context) -> JsResult<JsValue> {
2299    match this.as_object() {
2300        Some(object) => object.get(js_string!("href"), context),
2301        None => Ok(js_string!("").into()),
2302    }
2303}
2304
2305fn window_inner_width(_: &JsValue, _: &[JsValue], context: &mut Context) -> JsResult<JsValue> {
2306    let ctx = dom_ctx(context)?;
2307    let viewport = ctx.doc.borrow().get_viewport();
2308    Ok(JsValue::from(
2309        viewport.window_size.0 as f64 / viewport.scale_f64(),
2310    ))
2311}
2312
2313fn window_inner_height(_: &JsValue, _: &[JsValue], context: &mut Context) -> JsResult<JsValue> {
2314    let ctx = dom_ctx(context)?;
2315    let viewport = ctx.doc.borrow().get_viewport();
2316    Ok(JsValue::from(
2317        viewport.window_size.1 as f64 / viewport.scale_f64(),
2318    ))
2319}
2320
2321fn window_device_pixel_ratio(
2322    _: &JsValue,
2323    _: &[JsValue],
2324    context: &mut Context,
2325) -> JsResult<JsValue> {
2326    let ctx = dom_ctx(context)?;
2327    Ok(JsValue::from(ctx.doc.borrow().get_viewport().scale_f64()))
2328}
2329
2330// === Timer + window listener native functions ===
2331
2332fn timer_args(
2333    args: &[JsValue],
2334    context: &mut Context,
2335) -> JsResult<Option<(JsObject, Duration, Vec<JsValue>)>> {
2336    let Some(callback) = args
2337        .first()
2338        .and_then(|value| value.as_object())
2339        .filter(|obj| obj.is_callable())
2340    else {
2341        return Ok(None);
2342    };
2343    let delay_ms = match args.get(1) {
2344        Some(value) => value.to_number(context)?,
2345        None => 0.0,
2346    };
2347    let delay_ms = if delay_ms.is_finite() && delay_ms > 0.0 {
2348        delay_ms
2349    } else {
2350        0.0
2351    };
2352    let rest: Vec<JsValue> = args.iter().skip(2).cloned().collect();
2353    Ok(Some((
2354        callback,
2355        Duration::from_secs_f64(delay_ms / 1000.0),
2356        rest,
2357    )))
2358}
2359
2360fn set_timeout(_: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult<JsValue> {
2361    let ctx = dom_ctx(context)?;
2362    let Some((callback, delay, rest)) = timer_args(args, context)? else {
2363        return Ok(JsValue::from(0));
2364    };
2365    let id = ctx
2366        .state
2367        .borrow_mut()
2368        .timers
2369        .add(delay, None, callback, rest);
2370    Ok(JsValue::from(id as f64))
2371}
2372
2373fn set_interval(_: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult<JsValue> {
2374    let ctx = dom_ctx(context)?;
2375    let Some((callback, delay, rest)) = timer_args(args, context)? else {
2376        return Ok(JsValue::from(0));
2377    };
2378    let id = ctx
2379        .state
2380        .borrow_mut()
2381        .timers
2382        .add(delay, Some(delay), callback, rest);
2383    Ok(JsValue::from(id as f64))
2384}
2385
2386fn request_animation_frame(
2387    _: &JsValue,
2388    args: &[JsValue],
2389    context: &mut Context,
2390) -> JsResult<JsValue> {
2391    let ctx = dom_ctx(context)?;
2392    let Some(callback) = args
2393        .first()
2394        .and_then(|value| value.as_object())
2395        .filter(|obj| obj.is_callable())
2396    else {
2397        return Ok(JsValue::from(0));
2398    };
2399    let id = ctx.state.borrow_mut().timers.add_animation_frame(callback);
2400    Ok(JsValue::from(id as f64))
2401}
2402
2403fn clear_timer(_: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult<JsValue> {
2404    let ctx = dom_ctx(context)?;
2405    let id = match args.first() {
2406        Some(value) => value.to_number(context)?,
2407        None => return Ok(JsValue::undefined()),
2408    };
2409    if id.is_finite() && id >= 0.0 {
2410        ctx.state.borrow_mut().timers.remove(id as u64);
2411    }
2412    Ok(JsValue::undefined())
2413}
2414
2415/// `window.dispatchEvent`, which the window did not have.
2416///
2417/// `Node.prototype.dispatchEvent` walks a node chain and never reaches the
2418/// window listeners, so there was no way at all to raise a window-targeted
2419/// event, from a page or from the runtime's own `history` shim.
2420pub(crate) fn window_dispatch_event(
2421    _: &JsValue,
2422    args: &[JsValue],
2423    context: &mut Context,
2424) -> JsResult<JsValue> {
2425    dispatch_window_supplied_event(args, true, context)
2426}
2427
2428fn window_dispatch_history_event(
2429    _: &JsValue,
2430    args: &[JsValue],
2431    context: &mut Context,
2432) -> JsResult<JsValue> {
2433    let event = args
2434        .first()
2435        .and_then(JsValue::as_object)
2436        .filter(|event| event.downcast_ref::<EventRef>().is_some())
2437        .ok_or_else(|| JsNativeError::typ().with_message("History dispatch requires an Event"))?;
2438    crate::dom::define_value(&event, "isTrusted", JsValue::from(true), context);
2439    dispatch_window_supplied_event(args, false, context)
2440}
2441
2442fn dispatch_window_supplied_event(
2443    args: &[JsValue],
2444    scripted: bool,
2445    context: &mut Context,
2446) -> JsResult<JsValue> {
2447    let ctx = dom_ctx(context)?;
2448    let event = args
2449        .first()
2450        .and_then(JsValue::as_object)
2451        .filter(|event| event.downcast_ref::<EventRef>().is_some())
2452        .ok_or_else(|| JsNativeError::typ().with_message("dispatchEvent requires an Event"))?;
2453    let _dispatch = crate::dom::event::begin_dispatch(&event, scripted, context)?;
2454    let event_type = crate::dom::to_rust_string(&event.get(js_string!("type"), context)?, context)?;
2455
2456    set_event_path(&event, vec![context.global_object().clone()]);
2457    let global: JsValue = context.global_object().into();
2458    crate::dom::define_value(&event, "target", global.clone(), context);
2459    crate::dom::define_value(&event, "srcElement", global.clone(), context);
2460    crate::dom::define_value(&event, "currentTarget", global.clone(), context);
2461    crate::dom::define_value(&event, "eventPhase", JsValue::from(2), context);
2462
2463    let listeners: Vec<Listener> = {
2464        let mut state = ctx.state.borrow_mut();
2465        match state.window_listeners.get_mut(&event_type) {
2466            Some(listeners) => {
2467                let cloned = listeners.clone();
2468                listeners.retain(|listener| !listener.once);
2469                cloned
2470            }
2471            None => Vec::new(),
2472        }
2473    };
2474    for listener in listeners {
2475        crate::dom::passive::invoke(
2476            &listener.callback,
2477            &global,
2478            &event,
2479            listener.passive,
2480            context,
2481        )?;
2482        if event
2483            .downcast_ref::<EventRef>()
2484            .is_some_and(|event| event.stopped_immediate.get())
2485        {
2486            break;
2487        }
2488    }
2489
2490    // The `window.on<event>` form, including lazy body/frameset handlers.
2491    if !event
2492        .downcast_ref::<EventRef>()
2493        .is_some_and(|event| event.stopped_immediate.get())
2494    {
2495        let window = context.global_object().clone();
2496        crate::dom::inline_handlers::invoke(&ctx, &window, &event, &event_type, context);
2497    }
2498
2499    crate::dom::define_value(&event, "currentTarget", JsValue::null(), context);
2500    let prevented = event
2501        .downcast_ref::<EventRef>()
2502        .is_some_and(|event| event.prevented.get());
2503    Ok(JsValue::from(!prevented))
2504}
2505
2506pub(crate) fn window_add_event_listener(
2507    _: &JsValue,
2508    args: &[JsValue],
2509    context: &mut Context,
2510) -> JsResult<JsValue> {
2511    let ctx = dom_ctx(context)?;
2512    let event_type =
2513        crate::dom::to_rust_string(args.first().unwrap_or(&JsValue::undefined()), context)?;
2514    let Some(callback) = args
2515        .get(1)
2516        .and_then(|value| value.as_object())
2517        .filter(|obj| obj.is_callable())
2518    else {
2519        return Ok(JsValue::undefined());
2520    };
2521
2522    let window: JsValue = context.global_object().into();
2523    let passive = crate::dom::passive::resolve(&window, args, &event_type, context)?;
2524    let mut state = ctx.state.borrow_mut();
2525    let listeners = state.window_listeners.entry(event_type).or_default();
2526    if !listeners
2527        .iter()
2528        .any(|l| JsObject::equals(&l.callback, &callback))
2529    {
2530        listeners.push(Listener {
2531            callback,
2532            once: false,
2533            passive,
2534        });
2535    }
2536    Ok(JsValue::undefined())
2537}
2538
2539pub(crate) fn window_remove_event_listener(
2540    _: &JsValue,
2541    args: &[JsValue],
2542    context: &mut Context,
2543) -> JsResult<JsValue> {
2544    let ctx = dom_ctx(context)?;
2545    let event_type =
2546        crate::dom::to_rust_string(args.first().unwrap_or(&JsValue::undefined()), context)?;
2547    let Some(callback) = args.get(1).and_then(|value| value.as_object()) else {
2548        return Ok(JsValue::undefined());
2549    };
2550
2551    let mut state = ctx.state.borrow_mut();
2552    if let Some(listeners) = state.window_listeners.get_mut(&event_type) {
2553        listeners.retain(|l| !JsObject::equals(&l.callback, &callback));
2554    }
2555    Ok(JsValue::undefined())
2556}