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