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 job_slice_exhausted(&self) -> bool {
1256        self.job_budget.exhausted()
1257    }
1258
1259    pub(crate) fn jobs_remain(&self) -> bool {
1260        self.job_executor.has_pending_jobs(&self.context)
1261            || self.ctx.doc.borrow().has_pending_mutations()
1262    }
1263
1264    /// Run pending promise jobs (microtasks) within the current slice.
1265    pub fn run_jobs(&mut self, description: &str) {
1266        self.run_jobs_with_progress(description);
1267    }
1268
1269    /// Run a slice and report jobs executed, queued work, or mutation delivery.
1270    ///
1271    /// Every drain in a poll shares one deadline. Observer delivery uses the
1272    /// remaining budget. When it expires, unconsumed records remain in the
1273    /// document for the next poll, alongside queued promise jobs.
1274    /// Observer callbacks retain the existing delivery cap.
1275    pub(crate) fn run_jobs_with_progress(&mut self, description: &str) -> bool {
1276        let _slice = self.begin_job_slice();
1277        let before = self.job_executor.executed_job_count();
1278        const MUTATION_DELIVERY_ROUNDS: usize = 32;
1279        let mut delivered = false;
1280        for _ in 0..MUTATION_DELIVERY_ROUNDS {
1281            crate::dom::inline_handlers::sync(&self.ctx, &mut self.context);
1282            crate::dom::custom_elements::checkpoint(&self.ctx, &mut self.context);
1283            if let Err(error) = Rc::clone(&self.job_executor)
1284                .run_jobs_with_budget(&mut self.context, self.job_budget.remaining())
1285            {
1286                report_js_error(&self.diagnostics, description, &error);
1287            }
1288            let mutations_delivered = self.deliver_mutations();
1289            let toggles_delivered =
1290                crate::dom::element::deliver_details_toggles(&self.ctx, &mut self.context);
1291            #[cfg(feature = "shadow-dom")]
1292            let slots_delivered =
1293                crate::dom::shadow::deliver_slot_changes(&self.ctx, &mut self.context);
1294            #[cfg(not(feature = "shadow-dom"))]
1295            let slots_delivered = false;
1296            if !mutations_delivered && !slots_delivered && !toggles_delivered {
1297                return delivered
1298                    || self.jobs_remain()
1299                    || self.job_executor.executed_job_count() != before;
1300            }
1301            delivered = true;
1302        }
1303        delivered || self.jobs_remain() || self.job_executor.executed_job_count() != before
1304    }
1305
1306    /// Hand recorded DOM changes to the prelude's `MutationObserver`. Returns
1307    /// whether there were any.
1308    fn deliver_mutations(&mut self) -> bool {
1309        let records = {
1310            let mut doc = self.ctx.doc.borrow_mut();
1311            if !doc.is_recording_mutations() {
1312                return false;
1313            }
1314            doc.take_mutations()
1315        };
1316        if records.is_empty() {
1317            return false;
1318        }
1319        let deliver = match self
1320            .context
1321            .global_object()
1322            .get(js_string!("__blitzDeliverMutations"), &mut self.context)
1323        {
1324            Ok(value) => value,
1325            Err(_) => return false,
1326        };
1327        let Some(deliver) = deliver.as_callable() else {
1328            return false;
1329        };
1330        let values: Vec<JsValue> = records
1331            .into_iter()
1332            .filter_map(|record| mutation_record_value(&self.ctx, record, &mut self.context))
1333            .collect();
1334        let list = boa_engine::object::builtins::JsArray::from_iter(values, &mut self.context);
1335        if let Err(error) = deliver.call(&JsValue::undefined(), &[list.into()], &mut self.context) {
1336            report_js_error(&self.diagnostics, "MutationObserver delivery", &error);
1337        }
1338        true
1339    }
1340
1341    /// The deadline of the soonest pending DOM or Boa timer (if any).
1342    pub fn next_timer_deadline(&self) -> Option<Instant> {
1343        let dom = self.ctx.state.borrow().timers.next_deadline();
1344        let boa = self
1345            .job_executor
1346            .next_job_timeout(&self.context)
1347            .map(|delay| Instant::now() + delay);
1348        match (dom, boa) {
1349            (Some(dom), Some(boa)) => Some(dom.min(boa)),
1350            (dom, boa) => dom.or(boa),
1351        }
1352    }
1353
1354    /// Free detached nodes whose wrappers script no longer holds.
1355    ///
1356    /// Removal cannot judge this at the time: the wrapper is alive at the
1357    /// instant of the call that removed the node, because that call went
1358    /// through it. Deciding later, once the collector has had its say, is what
1359    /// stops a document growing by one abandoned subtree per removed row.
1360    pub fn sweep_detached_nodes(&mut self) {
1361        crate::dom::sweep_detached_nodes(&self.ctx);
1362    }
1363
1364    /// Run one due timer and its budgeted microtask checkpoint.
1365    ///
1366    /// Unstarted timers remain cancellable and the host regains control before
1367    /// another callback starts. A yielded checkpoint finishes on later polls
1368    /// before the next timer is allowed to run.
1369    pub fn run_due_timers(&mut self, profiling: bool) -> bool {
1370        if self.job_budget.exhausted() || self.jobs_remain() {
1371            return false;
1372        }
1373        let timers_started = profiling.then(std::time::Instant::now);
1374        let due = self.ctx.state.borrow_mut().timers.take_due(Instant::now());
1375        if due.is_empty() {
1376            return false;
1377        }
1378        // All frame callbacks in this snapshot share one start time on the
1379        // performance.now() clock. Newly registered callbacks stay queued.
1380        let frame_args = [JsValue::new(TIME_ORIGIN.elapsed().as_secs_f64() * 1000.0)];
1381        for timer in due {
1382            let args = if timer.animation_frame {
1383                if !self
1384                    .ctx
1385                    .state
1386                    .borrow_mut()
1387                    .timers
1388                    .begin_animation_frame_callback(timer.id)
1389                {
1390                    continue;
1391                }
1392                &frame_args[..]
1393            } else {
1394                &timer.args[..]
1395            };
1396            if let Err(error) = timer
1397                .callback
1398                .call(&JsValue::undefined(), args, &mut self.context)
1399            {
1400                report_js_error(&self.diagnostics, "timer callback", &error);
1401            }
1402        }
1403        self.run_jobs("timer microtasks");
1404        if let Some(started) = timers_started {
1405            crate::script_stats::record_work("timers", started.elapsed());
1406        }
1407        true
1408    }
1409
1410    /// Dispatch a Blitz DOM event to JavaScript event listeners registered on
1411    /// the nodes in `chain` (which is ordered target-first).
1412    ///
1413    /// Returns `true` if any listener was invoked.
1414    pub fn dispatch_dom_event(
1415        &mut self,
1416        chain: &[NodeId],
1417        event: &DomEvent,
1418        event_state: &mut EventState,
1419        profiling: bool,
1420    ) -> bool {
1421        // Attributed by event name. A poll costing 16ms says nothing about what
1422        // to fix; "scroll cost 14ms of it" names the handler.
1423        let dispatch_started = profiling.then(std::time::Instant::now);
1424        let ran = self.dispatch_dom_event_timed(chain, event, event_state);
1425        if let Some(started) = dispatch_started {
1426            crate::script_stats::record_work(
1427                &format!("event:{}", event.data.name()),
1428                started.elapsed(),
1429            );
1430        }
1431        ran
1432    }
1433
1434    fn dispatch_dom_event_timed(
1435        &mut self,
1436        chain: &[NodeId],
1437        event: &DomEvent,
1438        event_state: &mut EventState,
1439    ) -> bool {
1440        let pointer_id = match &event.data {
1441            DomEventData::PointerMove(pointer)
1442            | DomEventData::PointerDown(pointer)
1443            | DomEventData::PointerUp(pointer)
1444            | DomEventData::PointerCancel(pointer) => Some(match pointer.id {
1445                BlitzPointerId::Mouse => 1,
1446                BlitzPointerId::Pen => 2,
1447                BlitzPointerId::Finger(id) => id.saturating_add(3),
1448            }),
1449            _ => None,
1450        };
1451        let captured_node =
1452            pointer_id.and_then(|id| self.ctx.state.borrow().pointer_capture.get(&id).copied());
1453        let captured_chain = captured_node.map(|target| {
1454            let doc = self.ctx.doc.borrow();
1455            let mut chain = Vec::new();
1456            let mut current = Some(target);
1457            while let Some(node_id) = current {
1458                chain.push(node_id);
1459                current = doc.get_node(node_id).and_then(|node| node.parent);
1460            }
1461            chain
1462        });
1463        let chain = captured_chain.as_deref().unwrap_or(chain);
1464
1465        if chain.is_empty() {
1466            return false;
1467        }
1468        let name = event.name().to_string();
1469        // Touch and movement state must advance even without a listener.
1470        let target: JsValue = node_wrapper(&self.ctx, chain[0], &mut self.context).into();
1471        let event_object = create_event_for_dom_event(
1472            &self.ctx,
1473            &event.data,
1474            event.bubbles,
1475            event.cancelable,
1476            &target,
1477            &mut self.context,
1478        );
1479        let mut any_called = self.dispatch_event_inner(
1480            chain,
1481            &name,
1482            event.bubbles,
1483            |_, _, _| event_object,
1484            event_state,
1485        );
1486
1487        // Browsers fire a `change` event after `input` events on checkbox/radio
1488        // inputs. Blitz only generates `input` events, so synthesise the `change`
1489        // event here.
1490        //
1491        // A text control commits instead: `change` fires when it loses focus
1492        // having been edited, never on each keystroke. Only checkbox and radio
1493        // were covered, so `change` on a text field was dead, and a phone-number
1494        // field bound to it gated a Confirm button that nothing could open.
1495        let synthesise_change = match &event.data {
1496            DomEventData::Input(_) => {
1497                // A select commits on selection, not on blur: the keystroke or
1498                // the press is the commit, exactly as it is for a checkbox.
1499                self.target_is_checkbox_or_radio(event.target)
1500                    || self.target_is_select(event.target)
1501            }
1502            DomEventData::Focus(_) => {
1503                self.focussed_text_value = self
1504                    .text_control_value(event.target)
1505                    .map(|value| (event.target, value));
1506                false
1507            }
1508            DomEventData::Blur(_) => match self.focussed_text_value.take() {
1509                Some((node_id, focussed_value)) if node_id == event.target => self
1510                    .text_control_value(event.target)
1511                    .is_some_and(|current| current != focussed_value),
1512                _ => false,
1513            },
1514            _ => false,
1515        };
1516        if synthesise_change {
1517            let mut change_state = EventState::default();
1518            any_called |= self.dispatch_event_inner(
1519                chain,
1520                "change",
1521                true,
1522                |ctx, target, context| create_event(ctx, "change", true, false, target, context),
1523                &mut change_state,
1524            );
1525            if change_state.redraw_is_requested() {
1526                event_state.request_redraw();
1527            }
1528        }
1529
1530        if any_called {
1531            self.run_jobs("event microtasks");
1532        }
1533
1534        if matches!(
1535            event.data,
1536            DomEventData::PointerUp(_) | DomEventData::PointerCancel(_)
1537        ) && let Some(pointer_id) = pointer_id
1538        {
1539            self.ctx
1540                .state
1541                .borrow_mut()
1542                .pointer_capture
1543                .remove(&pointer_id);
1544        }
1545
1546        any_called
1547    }
1548
1549    /// The current text of a text input or textarea, or `None` for anything
1550    /// that is not one.
1551    fn text_control_value(&self, node_id: NodeId) -> Option<String> {
1552        let doc = self.ctx.doc.borrow();
1553        doc.get_node(node_id)?
1554            .element_data()?
1555            .text_input_data()
1556            .map(|input| input.editor.text().to_string())
1557    }
1558
1559    fn target_is_select(&self, node_id: NodeId) -> bool {
1560        let doc = self.ctx.doc.borrow();
1561        doc.get_node(node_id).is_some_and(|node| {
1562            node.data
1563                .is_element_with_tag_name(&blitz_dom::local_name!("select"))
1564        })
1565    }
1566
1567    fn target_is_checkbox_or_radio(&self, node_id: NodeId) -> bool {
1568        let doc = self.ctx.doc.borrow();
1569        doc.get_node(node_id)
1570            .and_then(|node| node.element_data())
1571            .is_some_and(|element| {
1572                element.name.local == blitz_dom::local_name!("input")
1573                    && matches!(
1574                        element.attr(blitz_dom::local_name!("type")),
1575                        Some("checkbox") | Some("radio")
1576                    )
1577            })
1578    }
1579
1580    /// Dispatch an event named `name` along `chain`, using `make_event` to lazily
1581    /// construct the JS event object. Returns `true` if any listener was invoked.
1582    fn dispatch_event_inner(
1583        &mut self,
1584        chain: &[NodeId],
1585        name: &str,
1586        bubbles: bool,
1587        make_event: impl FnOnce(&DomCtx, &JsValue, &mut Context) -> JsObject,
1588        event_state: &mut EventState,
1589    ) -> bool {
1590        let ctx = self.ctx.clone();
1591        let context = &mut self.context;
1592        crate::dom::inline_handlers::sync(&ctx, context);
1593        let on_name = JsString::from(format!("on{name}"));
1594
1595        #[cfg(feature = "shadow-dom")]
1596        if ctx.doc.borrow().has_shadow_roots() {
1597            let Some(&target_id) = chain.first() else {
1598                return false;
1599            };
1600            let target: JsValue = node_wrapper(&ctx, target_id, context).into();
1601            let event_obj = make_event(&ctx, &target, context);
1602            let composed = name.starts_with("pointer")
1603                || name.starts_with("mouse")
1604                || name.starts_with("touch")
1605                || name.starts_with("key")
1606                || matches!(
1607                    name,
1608                    "click"
1609                        | "dblclick"
1610                        | "contextmenu"
1611                        | "wheel"
1612                        | "input"
1613                        | "focus"
1614                        | "blur"
1615                        | "focusin"
1616                        | "focusout"
1617                );
1618            crate::dom::define_value(&event_obj, "composed", JsValue::from(composed), context);
1619            match crate::dom::shadow_event::dispatch(&ctx, target_id, &event_obj, false, context) {
1620                Ok(result) => {
1621                    if result.prevented {
1622                        event_state.prevent_default();
1623                    }
1624                    if result.stopped {
1625                        event_state.stop_propagation();
1626                    }
1627                    if result.called {
1628                        event_state.request_redraw();
1629                    }
1630                    return result.called;
1631                }
1632                Err(error) => {
1633                    report_js_error(&self.diagnostics, name, &error);
1634                    return false;
1635                }
1636            }
1637        }
1638
1639        // Fast path: bail if no listener of this type could possibly be registered
1640        let window_handler = bubbles
1641            && context
1642                .global_object()
1643                .get(on_name, context)
1644                .is_ok_and(|handler| handler.is_callable());
1645        let may_have_listeners = {
1646            let state = ctx.state.borrow();
1647            let registry_hit = chain.iter().any(|node_id| {
1648                state
1649                    .node_listeners
1650                    .get(node_id)
1651                    .and_then(|map| map.get(name))
1652                    .is_some_and(|listeners| !listeners.is_empty())
1653            }) || state
1654                .window_listeners
1655                .get(name)
1656                .is_some_and(|listeners| !listeners.is_empty());
1657            // Recorded content attributes have already seeded their wrappers.
1658            let wrapper_hit = chain
1659                .iter()
1660                .any(|node_id| state.node_wrappers.contains_key(node_id));
1661            registry_hit || wrapper_hit || window_handler
1662        };
1663        if !may_have_listeners {
1664            return false;
1665        }
1666
1667        let target: JsValue = node_wrapper(&ctx, chain[0], context).into();
1668        let event_obj = make_event(&ctx, &target, context);
1669        let _dispatch = crate::dom::event::begin_dispatch(&event_obj, false, context)
1670            .expect("fresh native event");
1671        crate::dom::define_value(&event_obj, "eventPhase", JsValue::from(2), context);
1672        let mut event_path: Vec<JsObject> = chain
1673            .iter()
1674            .map(|&node_id| node_wrapper(&ctx, node_id, context))
1675            .collect();
1676        event_path.push(context.global_object().clone());
1677        set_event_path(&event_obj, event_path);
1678        let event_ref = |event_obj: &JsObject, f: &dyn Fn(&EventRef) -> bool| -> bool {
1679            event_obj
1680                .downcast_ref::<EventRef>()
1681                .map(|event| f(&event))
1682                .unwrap_or(false)
1683        };
1684
1685        let mut any_called = false;
1686
1687        'chain: for &node_id in chain {
1688            // Gather listeners for this node: `addEventListener` listeners plus
1689            // an `on<event>` property handler (if any)
1690            let mut callbacks: Vec<(JsObject, bool)> = Vec::new();
1691            {
1692                let mut state = ctx.state.borrow_mut();
1693                if let Some(listeners) = state
1694                    .node_listeners
1695                    .get_mut(&node_id)
1696                    .and_then(|map| map.get_mut(name))
1697                {
1698                    callbacks.extend(listeners.iter().filter_map(|listener| {
1699                        listener
1700                            .callback
1701                            .upgrade()
1702                            .map(|callback| (callback, listener.passive))
1703                    }));
1704                    // `once` listeners are removed at dispatch time
1705                    listeners.retain(|l| !l.once);
1706                }
1707            }
1708            crate::dom::node::sync_node_listener_callbacks(&ctx, node_id, context);
1709
1710            let current_target: JsValue = node_wrapper(&ctx, node_id, context).into();
1711            let phase = if node_id == chain[0] { 2 } else { 3 };
1712            crate::dom::define_value(&event_obj, "eventPhase", JsValue::from(phase), context);
1713            crate::dom::define_value(&event_obj, "currentTarget", current_target.clone(), context);
1714
1715            for (callback, passive) in callbacks {
1716                any_called = true;
1717                if let Err(error) = crate::dom::passive::invoke(
1718                    &callback,
1719                    &current_target,
1720                    &event_obj,
1721                    passive,
1722                    context,
1723                ) {
1724                    let what = listener_error_context(
1725                        name,
1726                        &format!("node {node_id}"),
1727                        &callback,
1728                        context,
1729                    );
1730                    report_js_error(&self.diagnostics, &what, &error);
1731                }
1732                if event_ref(&event_obj, &|event| event.stopped_immediate.get()) {
1733                    break 'chain;
1734                }
1735            }
1736
1737            let wrapper = node_wrapper(&ctx, node_id, context);
1738            any_called |=
1739                crate::dom::inline_handlers::invoke(&ctx, &wrapper, &event_obj, name, context);
1740            if event_ref(&event_obj, &|event| event.stopped_immediate.get()) {
1741                break 'chain;
1742            }
1743            if !bubbles || event_ref(&event_obj, &|event| event.stopped.get()) {
1744                break;
1745            }
1746        }
1747
1748        // Window-level listeners
1749        if bubbles && !event_ref(&event_obj, &|event| event.stopped.get()) {
1750            let listeners: Vec<Listener> = {
1751                let mut state = ctx.state.borrow_mut();
1752                match state.window_listeners.get_mut(name) {
1753                    Some(listeners) => {
1754                        let cloned = listeners.clone();
1755                        listeners.retain(|l| !l.once);
1756                        cloned
1757                    }
1758                    None => Vec::new(),
1759                }
1760            };
1761            let window = context.global_object().clone();
1762            let global: JsValue = window.clone().into();
1763            crate::dom::define_value(&event_obj, "eventPhase", JsValue::from(3), context);
1764            crate::dom::define_value(&event_obj, "currentTarget", global.clone(), context);
1765            for listener in listeners {
1766                any_called = true;
1767                if let Err(error) = crate::dom::passive::invoke(
1768                    &listener.callback,
1769                    &global,
1770                    &event_obj,
1771                    listener.passive,
1772                    context,
1773                ) {
1774                    let what = listener_error_context(name, "window", &listener.callback, context);
1775                    report_js_error(&self.diagnostics, &what, &error);
1776                }
1777                if event_ref(&event_obj, &|event| event.stopped_immediate.get()) {
1778                    break;
1779                }
1780            }
1781            if !event_ref(&event_obj, &|event| event.stopped_immediate.get()) {
1782                any_called |=
1783                    crate::dom::inline_handlers::invoke(&ctx, &window, &event_obj, name, context);
1784            }
1785        }
1786
1787        crate::dom::define_value(&event_obj, "currentTarget", JsValue::null(), context);
1788
1789        // Feed `preventDefault` / `stopPropagation` back into Blitz
1790        if event_ref(&event_obj, &|event| event.prevented.get()) {
1791            event_state.prevent_default();
1792        }
1793        if event_ref(&event_obj, &|event| event.stopped.get()) {
1794            event_state.stop_propagation();
1795        }
1796        if any_called {
1797            event_state.request_redraw();
1798        }
1799
1800        any_called
1801    }
1802
1803    /// Dispatch a simple event (e.g. `DOMContentLoaded`) targeting the document node
1804    pub fn dispatch_document_event(&mut self, name: &str) -> bool {
1805        let root_id = self.ctx.doc.borrow().root_node().id;
1806        let mut event_state = EventState::default();
1807        let ran = self.dispatch_event_inner(
1808            &[root_id],
1809            name,
1810            true,
1811            |ctx, target, context| create_event(ctx, name, true, false, target, context),
1812            &mut event_state,
1813        );
1814        if ran {
1815            self.run_jobs("event microtasks");
1816        }
1817        ran
1818    }
1819
1820    /// Dispatch a simple, non-bubbling event at one node.
1821    ///
1822    /// For the resource events a script element reports on itself, `load` and
1823    /// `error`. Non-bubbling because that is what the DOM specifies for them,
1824    /// and a loader listening on the element would otherwise also hear every
1825    /// image on the page finish.
1826    pub fn dispatch_node_event(&mut self, node_id: NodeId, name: &str) -> bool {
1827        let mut event_state = EventState::default();
1828        let ran = self.dispatch_event_inner(
1829            &[node_id],
1830            name,
1831            false,
1832            |ctx, target, context| create_event(ctx, name, false, false, target, context),
1833            &mut event_state,
1834        );
1835        if ran {
1836            self.run_jobs("event microtasks");
1837        }
1838        ran
1839    }
1840
1841    /// Dispatch a simple event (e.g. `load`) targeting the window
1842    pub fn dispatch_window_event(&mut self, name: &str) -> bool {
1843        let ctx = self.ctx.clone();
1844        let context = &mut self.context;
1845
1846        let listeners: Vec<Listener> = {
1847            let mut state = ctx.state.borrow_mut();
1848            match state.window_listeners.get_mut(name) {
1849                Some(listeners) => {
1850                    let cloned = listeners.clone();
1851                    listeners.retain(|l| !l.once);
1852                    cloned
1853                }
1854                None => Vec::new(),
1855            }
1856        };
1857
1858        let global: JsValue = context.global_object().into();
1859        let event_obj = create_event(&ctx, name, false, false, &global, context);
1860        crate::dom::define_value(&event_obj, "currentTarget", global.clone(), context);
1861
1862        let mut any_called = false;
1863        for listener in listeners {
1864            any_called = true;
1865            if let Err(error) = crate::dom::passive::invoke(
1866                &listener.callback,
1867                &global,
1868                &event_obj,
1869                listener.passive,
1870                context,
1871            ) {
1872                let what = listener_error_context(name, "window", &listener.callback, context);
1873                report_js_error(&self.diagnostics, &what, &error);
1874            }
1875        }
1876
1877        if !event_obj
1878            .downcast_ref::<EventRef>()
1879            .is_some_and(|event| event.stopped_immediate.get())
1880        {
1881            let window = context.global_object().clone();
1882            any_called |=
1883                crate::dom::inline_handlers::invoke(&ctx, &window, &event_obj, name, context);
1884        }
1885        crate::dom::define_value(&event_obj, "currentTarget", JsValue::null(), context);
1886
1887        if any_called {
1888            self.run_jobs("event microtasks");
1889        }
1890        any_called
1891    }
1892}
1893
1894/// `getComputedStyle(element)`.
1895///
1896/// Backed by the engine's own computed values rather than a stub returning
1897/// empty strings. That distinction matters: a page reading `display` off a stub
1898/// gets `""`, believes the element is not hidden, and lays itself out wrongly —
1899/// harder to diagnose than the `getComputedStyle is not defined` this replaces,
1900/// which at least stopped loudly. Three sites in a hundred-site corpus died on
1901/// that error.
1902///
1903/// The declaration retains its element and reads live computed values after a
1904/// synchronous layout flush. Longhand serialization belongs to Stylo.
1905fn get_computed_style(_: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult<JsValue> {
1906    crate::domc::get_computed_style(args, context)
1907}
1908
1909/// `__blitzObserveMutations(on)`: the prelude's `MutationObserver` turns the
1910/// document's change log on while any observer is registered and off when the
1911/// last one disconnects, so a page without observers records nothing.
1912fn observe_mutations(_: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult<JsValue> {
1913    let on = args.first().is_some_and(JsValue::to_boolean);
1914    dom_ctx(context)?
1915        .doc
1916        .borrow_mut()
1917        .set_recording_mutations(on);
1918    Ok(JsValue::undefined())
1919}
1920
1921/// One recorded change as the array the prelude's delivery function reads:
1922/// `[type, target, added, removed, previousSibling, nextSibling,
1923/// attributeName, oldValue]`. `None` when the target no longer exists.
1924fn mutation_record_value(
1925    ctx: &DomCtx,
1926    record: blitz_dom::DomMutation,
1927    context: &mut Context,
1928) -> Option<JsValue> {
1929    use blitz_dom::DomMutation;
1930    use boa_engine::object::builtins::JsArray;
1931
1932    let exists = |id: NodeId| ctx.doc.borrow().get_node(id).is_some();
1933    let node = |id: NodeId, context: &mut Context| -> JsValue {
1934        if exists(id) {
1935            node_wrapper(ctx, id, context).into()
1936        } else {
1937            JsValue::null()
1938        }
1939    };
1940    let nodes = |ids: Vec<NodeId>, context: &mut Context| -> JsValue {
1941        let values: Vec<JsValue> = ids
1942            .into_iter()
1943            .filter(|id| exists(*id))
1944            .map(|id| node_wrapper(ctx, id, context).into())
1945            .collect();
1946        JsArray::from_iter(values, context).into()
1947    };
1948    let text = |value: Option<String>| -> JsValue {
1949        value.map_or(JsValue::null(), |value| {
1950            JsString::from(value.as_str()).into()
1951        })
1952    };
1953
1954    let fields: Vec<JsValue> = match record {
1955        DomMutation::ChildList {
1956            target,
1957            added,
1958            removed,
1959            previous_sibling,
1960            next_sibling,
1961        } => {
1962            if !exists(target) {
1963                return None;
1964            }
1965            vec![
1966                js_string!("childList").into(),
1967                node(target, context),
1968                nodes(added, context),
1969                nodes(removed, context),
1970                previous_sibling.map_or(JsValue::null(), |id| node(id, context)),
1971                next_sibling.map_or(JsValue::null(), |id| node(id, context)),
1972                JsValue::null(),
1973                JsValue::null(),
1974            ]
1975        }
1976        DomMutation::Attributes {
1977            target,
1978            name,
1979            old_value,
1980        } => {
1981            if !exists(target) {
1982                return None;
1983            }
1984            vec![
1985                js_string!("attributes").into(),
1986                node(target, context),
1987                nodes(Vec::new(), context),
1988                nodes(Vec::new(), context),
1989                JsValue::null(),
1990                JsValue::null(),
1991                text(Some(name)),
1992                text(old_value),
1993            ]
1994        }
1995        DomMutation::CharacterData { target, old_value } => {
1996            if !exists(target) {
1997                return None;
1998            }
1999            vec![
2000                js_string!("characterData").into(),
2001                node(target, context),
2002                nodes(Vec::new(), context),
2003                nodes(Vec::new(), context),
2004                JsValue::null(),
2005                JsValue::null(),
2006                JsValue::null(),
2007                text(Some(old_value)),
2008            ]
2009        }
2010    };
2011    Some(JsArray::from_iter(fields, context).into())
2012}
2013
2014fn ipc_post_message(_: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult<JsValue> {
2015    let body = args
2016        .first()
2017        .unwrap_or(&JsValue::undefined())
2018        .to_string(context)?
2019        .to_std_string_lossy();
2020    let handler = dom_ctx(context)?.state.borrow().ipc_handler.clone();
2021    if let Some(handler) = handler {
2022        handler(body);
2023    }
2024    Ok(JsValue::undefined())
2025}
2026
2027/// `navigator.clipboard.writeText(text)`.
2028///
2029/// The spec returns a promise, and callers await it, so a plain value would be
2030/// awaited as an already-resolved one and hide a failed write. Resolve on a
2031/// successful shell write and reject otherwise, which is what the API's own
2032/// error path looks like.
2033fn clipboard_write_text(_: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult<JsValue> {
2034    let text = args
2035        .first()
2036        .unwrap_or(&JsValue::undefined())
2037        .to_string(context)?
2038        .to_std_string_lossy();
2039    let wrote = dom_ctx(context)?
2040        .doc
2041        .borrow()
2042        .shell_provider
2043        .set_clipboard_text(text)
2044        .is_ok();
2045    let promise = if wrote {
2046        JsPromise::resolve(JsValue::undefined(), context)
2047    } else {
2048        let error =
2049            crate::dom_exception::error("NotAllowedError", "the clipboard is unavailable", context);
2050        JsPromise::reject(error, context)
2051    };
2052    Ok(JsValue::from(promise?))
2053}
2054
2055/// `navigator.clipboard.readText()`. Rejects when the shell has no clipboard,
2056/// rather than resolving to "" — an empty clipboard and an absent one are
2057/// different answers and callers branch on them differently.
2058fn clipboard_read_text(_: &JsValue, _: &[JsValue], context: &mut Context) -> JsResult<JsValue> {
2059    let read = dom_ctx(context)?
2060        .doc
2061        .borrow()
2062        .shell_provider
2063        .get_clipboard_text();
2064    let promise = match read {
2065        Ok(text) => JsPromise::resolve(JsValue::from(JsString::from(text)), context),
2066        Err(_) => {
2067            let error = crate::dom_exception::error(
2068                "NotAllowedError",
2069                "the clipboard is unavailable",
2070                context,
2071            );
2072            JsPromise::reject(error, context)
2073        }
2074    };
2075    Ok(JsValue::from(promise?))
2076}
2077
2078/// The spec's time origin is document creation. A first-use instant is close
2079/// enough for the elapsed-time measurements callers actually take, and sharing
2080/// one origin across documents keeps their readings comparable.
2081static TIME_ORIGIN: LazyLock<Instant> = LazyLock::new(Instant::now);
2082
2083fn performance_now(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult<JsValue> {
2084    Ok(JsValue::new(TIME_ORIGIN.elapsed().as_secs_f64() * 1000.0))
2085}
2086
2087fn viewport_scroll(_: &JsValue, _: &[JsValue], context: &mut Context) -> JsResult<JsValue> {
2088    let ctx = dom_ctx(context)?;
2089    let scroll = ctx.doc.borrow().viewport_scroll();
2090    Ok(boa_engine::object::builtins::JsArray::from_iter(
2091        [JsValue::new(scroll.x), JsValue::new(scroll.y)],
2092        context,
2093    )
2094    .into())
2095}
2096
2097fn scroll_viewport(_: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult<JsValue> {
2098    let ctx = dom_ctx(context)?;
2099    let x = args
2100        .first()
2101        .unwrap_or(&JsValue::undefined())
2102        .to_number(context)?;
2103    let y = args
2104        .get(1)
2105        .unwrap_or(&JsValue::undefined())
2106        .to_number(context)?;
2107    let x = if x.is_finite() { x } else { 0.0 };
2108    let y = if y.is_finite() { y } else { 0.0 };
2109    ctx.flush_layout();
2110    let mut document = ctx.doc.borrow_mut();
2111    let current = document.viewport_scroll();
2112    let changed = document.scroll_viewport_by_has_changed(current.x - x, current.y - y);
2113    if changed {
2114        document.shell_provider.request_redraw();
2115    }
2116    Ok(JsValue::new(changed))
2117}
2118
2119fn random_u32(_: &JsValue, _: &[JsValue], context: &mut Context) -> JsResult<JsValue> {
2120    getrandom::u32().map(JsValue::new).map_err(|error| {
2121        crate::dom_exception::error(
2122            "OperationError",
2123            &format!("secure random generation failed: {error}"),
2124            context,
2125        )
2126    })
2127}
2128
2129fn register_global(context: &mut Context, name: &str, value: JsValue) {
2130    context
2131        .register_global_property(
2132            JsString::from(name),
2133            value,
2134            Attribute::WRITABLE.union(Attribute::CONFIGURABLE),
2135        )
2136        .expect("failed to register global");
2137}
2138
2139fn register_global_fn(
2140    context: &mut Context,
2141    name: &str,
2142    length: usize,
2143    body: fn(&JsValue, &[JsValue], &mut Context) -> JsResult<JsValue>,
2144) {
2145    context
2146        .register_global_callable(
2147            JsString::from(name),
2148            length,
2149            NativeFunction::from_fn_ptr(body),
2150        )
2151        .expect("failed to register global function");
2152}
2153
2154fn build_location(base_url: Option<&Url>, context: &mut Context) -> JsValue {
2155    let (href, protocol, host, pathname, search, hash) = match base_url {
2156        Some(url) => (
2157            url.to_string(),
2158            format!("{}:", url.scheme()),
2159            url.host_str().unwrap_or_default().to_string(),
2160            url.path().to_string(),
2161            url.query().map(|q| format!("?{q}")).unwrap_or_default(),
2162            url.fragment().map(|f| format!("#{f}")).unwrap_or_default(),
2163        ),
2164        None => (
2165            "about:blank".to_string(),
2166            "about:".to_string(),
2167            String::new(),
2168            "blank".to_string(),
2169            String::new(),
2170            String::new(),
2171        ),
2172    };
2173    ObjectInitializer::new(context)
2174        .property(js_string!("href"), JsString::from(href), Attribute::all())
2175        .property(
2176            js_string!("protocol"),
2177            JsString::from(protocol),
2178            Attribute::all(),
2179        )
2180        .property(
2181            js_string!("host"),
2182            JsString::from(host.clone()),
2183            Attribute::all(),
2184        )
2185        .property(
2186            js_string!("hostname"),
2187            JsString::from(host),
2188            Attribute::all(),
2189        )
2190        .property(
2191            js_string!("pathname"),
2192            JsString::from(pathname),
2193            Attribute::all(),
2194        )
2195        .property(
2196            js_string!("search"),
2197            JsString::from(search),
2198            Attribute::all(),
2199        )
2200        .property(js_string!("hash"), JsString::from(hash), Attribute::all())
2201        // `assign`, `replace` and `reload`. Without them `location` was a plain
2202        // data object, so a page calling any of the three threw a TypeError out
2203        // of whatever ran it. All three go through the document's navigation
2204        // provider, which is where a link click goes.
2205        .function(
2206            NativeFunction::from_fn_ptr(location_assign),
2207            js_string!("assign"),
2208            1,
2209        )
2210        .function(
2211            NativeFunction::from_fn_ptr(location_assign),
2212            js_string!("replace"),
2213            1,
2214        )
2215        .function(
2216            NativeFunction::from_fn_ptr(location_reload),
2217            js_string!("reload"),
2218            0,
2219        )
2220        .function(
2221            NativeFunction::from_fn_ptr(location_to_string),
2222            js_string!("toString"),
2223            0,
2224        )
2225        .build()
2226        .into()
2227}
2228
2229/// Serves both `assign` and `replace`. They differ only in whether the current
2230/// entry is kept in the session history, and this runtime's history is a
2231/// script-level shim that a real navigation replaces wholesale either way.
2232fn location_assign(_: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult<JsValue> {
2233    let ctx = dom_ctx(context)?;
2234    let url = crate::dom::to_rust_string(args.first().unwrap_or(&JsValue::undefined()), context)?;
2235    if !ctx.doc.borrow().navigate_to_url(&url) {
2236        return Err(crate::dom_exception::error(
2237            "SyntaxError",
2238            &format!("{url} is not a valid URL"),
2239            context,
2240        ));
2241    }
2242    Ok(JsValue::undefined())
2243}
2244
2245fn location_reload(_: &JsValue, _: &[JsValue], context: &mut Context) -> JsResult<JsValue> {
2246    let ctx = dom_ctx(context)?;
2247    let doc = ctx.doc.borrow();
2248    let current = doc.current_url();
2249    doc.navigate_to_url(&current);
2250    Ok(JsValue::undefined())
2251}
2252
2253/// `Location`'s stringifier. `String(location)` and `new URL(input, location)`
2254/// convert the object with ToString, and the result is the current href.
2255/// The default object stringifier returns "[object Object]", which the URL
2256/// parser rejects as a relative base.
2257fn location_to_string(this: &JsValue, _: &[JsValue], context: &mut Context) -> JsResult<JsValue> {
2258    match this.as_object() {
2259        Some(object) => object.get(js_string!("href"), context),
2260        None => Ok(js_string!("").into()),
2261    }
2262}
2263
2264fn window_inner_width(_: &JsValue, _: &[JsValue], context: &mut Context) -> JsResult<JsValue> {
2265    let ctx = dom_ctx(context)?;
2266    let viewport = ctx.doc.borrow().get_viewport();
2267    Ok(JsValue::from(
2268        viewport.window_size.0 as f64 / viewport.scale_f64(),
2269    ))
2270}
2271
2272fn window_inner_height(_: &JsValue, _: &[JsValue], context: &mut Context) -> JsResult<JsValue> {
2273    let ctx = dom_ctx(context)?;
2274    let viewport = ctx.doc.borrow().get_viewport();
2275    Ok(JsValue::from(
2276        viewport.window_size.1 as f64 / viewport.scale_f64(),
2277    ))
2278}
2279
2280fn window_device_pixel_ratio(
2281    _: &JsValue,
2282    _: &[JsValue],
2283    context: &mut Context,
2284) -> JsResult<JsValue> {
2285    let ctx = dom_ctx(context)?;
2286    Ok(JsValue::from(ctx.doc.borrow().get_viewport().scale_f64()))
2287}
2288
2289// === Timer + window listener native functions ===
2290
2291fn timer_args(
2292    args: &[JsValue],
2293    context: &mut Context,
2294) -> JsResult<Option<(JsObject, Duration, Vec<JsValue>)>> {
2295    let Some(callback) = args
2296        .first()
2297        .and_then(|value| value.as_object())
2298        .filter(|obj| obj.is_callable())
2299    else {
2300        return Ok(None);
2301    };
2302    let delay_ms = match args.get(1) {
2303        Some(value) => value.to_number(context)?,
2304        None => 0.0,
2305    };
2306    let delay_ms = if delay_ms.is_finite() && delay_ms > 0.0 {
2307        delay_ms
2308    } else {
2309        0.0
2310    };
2311    let rest: Vec<JsValue> = args.iter().skip(2).cloned().collect();
2312    Ok(Some((
2313        callback,
2314        Duration::from_secs_f64(delay_ms / 1000.0),
2315        rest,
2316    )))
2317}
2318
2319fn set_timeout(_: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult<JsValue> {
2320    let ctx = dom_ctx(context)?;
2321    let Some((callback, delay, rest)) = timer_args(args, context)? else {
2322        return Ok(JsValue::from(0));
2323    };
2324    let id = ctx
2325        .state
2326        .borrow_mut()
2327        .timers
2328        .add(delay, None, callback, rest);
2329    Ok(JsValue::from(id as f64))
2330}
2331
2332fn set_interval(_: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult<JsValue> {
2333    let ctx = dom_ctx(context)?;
2334    let Some((callback, delay, rest)) = timer_args(args, context)? else {
2335        return Ok(JsValue::from(0));
2336    };
2337    let id = ctx
2338        .state
2339        .borrow_mut()
2340        .timers
2341        .add(delay, Some(delay), callback, rest);
2342    Ok(JsValue::from(id as f64))
2343}
2344
2345fn request_animation_frame(
2346    _: &JsValue,
2347    args: &[JsValue],
2348    context: &mut Context,
2349) -> JsResult<JsValue> {
2350    let ctx = dom_ctx(context)?;
2351    let Some(callback) = args
2352        .first()
2353        .and_then(|value| value.as_object())
2354        .filter(|obj| obj.is_callable())
2355    else {
2356        return Ok(JsValue::from(0));
2357    };
2358    let id = ctx.state.borrow_mut().timers.add_animation_frame(callback);
2359    Ok(JsValue::from(id as f64))
2360}
2361
2362fn clear_timer(_: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult<JsValue> {
2363    let ctx = dom_ctx(context)?;
2364    let id = match args.first() {
2365        Some(value) => value.to_number(context)?,
2366        None => return Ok(JsValue::undefined()),
2367    };
2368    if id.is_finite() && id >= 0.0 {
2369        ctx.state.borrow_mut().timers.remove(id as u64);
2370    }
2371    Ok(JsValue::undefined())
2372}
2373
2374/// `window.dispatchEvent`, which the window did not have.
2375///
2376/// `Node.prototype.dispatchEvent` walks a node chain and never reaches the
2377/// window listeners, so there was no way at all to raise a window-targeted
2378/// event, from a page or from the runtime's own `history` shim.
2379pub(crate) fn window_dispatch_event(
2380    _: &JsValue,
2381    args: &[JsValue],
2382    context: &mut Context,
2383) -> JsResult<JsValue> {
2384    dispatch_window_supplied_event(args, true, context)
2385}
2386
2387fn window_dispatch_history_event(
2388    _: &JsValue,
2389    args: &[JsValue],
2390    context: &mut Context,
2391) -> JsResult<JsValue> {
2392    let event = args
2393        .first()
2394        .and_then(JsValue::as_object)
2395        .filter(|event| event.downcast_ref::<EventRef>().is_some())
2396        .ok_or_else(|| JsNativeError::typ().with_message("History dispatch requires an Event"))?;
2397    crate::dom::define_value(&event, "isTrusted", JsValue::from(true), context);
2398    dispatch_window_supplied_event(args, false, context)
2399}
2400
2401fn dispatch_window_supplied_event(
2402    args: &[JsValue],
2403    scripted: bool,
2404    context: &mut Context,
2405) -> JsResult<JsValue> {
2406    let ctx = dom_ctx(context)?;
2407    let event = args
2408        .first()
2409        .and_then(JsValue::as_object)
2410        .filter(|event| event.downcast_ref::<EventRef>().is_some())
2411        .ok_or_else(|| JsNativeError::typ().with_message("dispatchEvent requires an Event"))?;
2412    let _dispatch = crate::dom::event::begin_dispatch(&event, scripted, context)?;
2413    let event_type = crate::dom::to_rust_string(&event.get(js_string!("type"), context)?, context)?;
2414
2415    set_event_path(&event, vec![context.global_object().clone()]);
2416    let global: JsValue = context.global_object().into();
2417    crate::dom::define_value(&event, "target", global.clone(), context);
2418    crate::dom::define_value(&event, "srcElement", global.clone(), context);
2419    crate::dom::define_value(&event, "currentTarget", global.clone(), context);
2420    crate::dom::define_value(&event, "eventPhase", JsValue::from(2), context);
2421
2422    let listeners: Vec<Listener> = {
2423        let mut state = ctx.state.borrow_mut();
2424        match state.window_listeners.get_mut(&event_type) {
2425            Some(listeners) => {
2426                let cloned = listeners.clone();
2427                listeners.retain(|listener| !listener.once);
2428                cloned
2429            }
2430            None => Vec::new(),
2431        }
2432    };
2433    for listener in listeners {
2434        crate::dom::passive::invoke(
2435            &listener.callback,
2436            &global,
2437            &event,
2438            listener.passive,
2439            context,
2440        )?;
2441        if event
2442            .downcast_ref::<EventRef>()
2443            .is_some_and(|event| event.stopped_immediate.get())
2444        {
2445            break;
2446        }
2447    }
2448
2449    // The `window.on<event>` form, including lazy body/frameset handlers.
2450    if !event
2451        .downcast_ref::<EventRef>()
2452        .is_some_and(|event| event.stopped_immediate.get())
2453    {
2454        let window = context.global_object().clone();
2455        crate::dom::inline_handlers::invoke(&ctx, &window, &event, &event_type, context);
2456    }
2457
2458    crate::dom::define_value(&event, "currentTarget", JsValue::null(), context);
2459    let prevented = event
2460        .downcast_ref::<EventRef>()
2461        .is_some_and(|event| event.prevented.get());
2462    Ok(JsValue::from(!prevented))
2463}
2464
2465pub(crate) fn window_add_event_listener(
2466    _: &JsValue,
2467    args: &[JsValue],
2468    context: &mut Context,
2469) -> JsResult<JsValue> {
2470    let ctx = dom_ctx(context)?;
2471    let event_type =
2472        crate::dom::to_rust_string(args.first().unwrap_or(&JsValue::undefined()), context)?;
2473    let Some(callback) = args
2474        .get(1)
2475        .and_then(|value| value.as_object())
2476        .filter(|obj| obj.is_callable())
2477    else {
2478        return Ok(JsValue::undefined());
2479    };
2480
2481    let window: JsValue = context.global_object().into();
2482    let passive = crate::dom::passive::resolve(&window, args, &event_type, context)?;
2483    let mut state = ctx.state.borrow_mut();
2484    let listeners = state.window_listeners.entry(event_type).or_default();
2485    if !listeners
2486        .iter()
2487        .any(|l| JsObject::equals(&l.callback, &callback))
2488    {
2489        listeners.push(Listener {
2490            callback,
2491            once: false,
2492            passive,
2493        });
2494    }
2495    Ok(JsValue::undefined())
2496}
2497
2498pub(crate) fn window_remove_event_listener(
2499    _: &JsValue,
2500    args: &[JsValue],
2501    context: &mut Context,
2502) -> JsResult<JsValue> {
2503    let ctx = dom_ctx(context)?;
2504    let event_type =
2505        crate::dom::to_rust_string(args.first().unwrap_or(&JsValue::undefined()), context)?;
2506    let Some(callback) = args.get(1).and_then(|value| value.as_object()) else {
2507        return Ok(JsValue::undefined());
2508    };
2509
2510    let mut state = ctx.state.borrow_mut();
2511    if let Some(listeners) = state.window_listeners.get_mut(&event_type) {
2512        listeners.retain(|l| !JsObject::equals(&l.callback, &callback));
2513    }
2514    Ok(JsValue::undefined())
2515}