Skip to main content

blitz_script/
document.rs

1//! [`ScriptDocument`]: a [`Document`] implementation with JavaScript support
2
3use std::cell::RefCell;
4use std::rc::Rc;
5use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel};
6use std::sync::{Arc, Mutex};
7use std::task::{Context as TaskContext, Waker};
8
9use blitz_dom::{
10    BaseDocument, DEFAULT_CSS, DocGuard, DocGuardMut, Document, DocumentConfig, EventDriver, NodeId,
11};
12use blitz_html::stream::StreamingParser;
13use blitz_html::{DocumentHtmlParser, HtmlProvider};
14use blitz_traits::events::{BlitzPointerEvent, DomEvent, UiEvent};
15use url::Url;
16use web_time::{Duration, Instant};
17
18use crate::event_handler::ScriptEventHandler;
19use crate::fetch::{DefaultScriptFetcher, ScriptFetcher};
20use crate::module::SharedFetcher;
21use crate::runtime::ScriptRuntime;
22
23type PollHook =
24    Box<dyn for<'a> FnMut(&mut ScriptDocument, Option<&TaskContext<'a>>) -> bool + 'static>;
25
26/// What a `<script>` element's `type` says it holds.
27#[derive(Clone, Copy, PartialEq, Eq)]
28enum ScriptKind {
29    Classic,
30    /// `type="module"`. Parsed in module goal and given a loader; a module run
31    /// as a classic script fails on its own first `import` statement.
32    Module,
33    /// `type="importmap"`. Not code: the JSON that gives bare specifiers their
34    /// meaning, and it has to be installed before any module resolves one.
35    ImportMap,
36}
37
38/// A `<script>` element found in the document
39struct PendingScript {
40    node_id: NodeId,
41    src: Option<String>,
42    inline_text: String,
43    kind: ScriptKind,
44    /// Runs after every non-deferred script in the same batch.
45    ///
46    /// True for module scripts, which are deferred by definition, and for
47    /// `<script defer src>`. `async` takes it back off both: an async script
48    /// runs whenever its fetch lands, which here is immediately.
49    deferred: bool,
50}
51
52/// A [`Document`] which executes the JavaScript contained in the document's
53/// `<script>` tags, exposing DOM APIs backed by `blitz-dom` to the scripts.
54///
55/// Construct with [`ScriptDocument::from_html`], then call
56/// [`execute_scripts`](ScriptDocument::execute_scripts) (this also happens
57/// automatically on the first [`poll`](Document::poll)). UI events pushed via
58/// [`handle_ui_event`](Document::handle_ui_event) are dispatched to JavaScript
59/// event listeners before Blitz's default actions run.
60pub struct ScriptDocument {
61    inner: Rc<RefCell<BaseDocument>>,
62    runtime: ScriptRuntime,
63    base_url: Option<Url>,
64    /// Shared with the module loader inside the script context.
65    ///
66    /// The loader is fixed at context construction, which happens in
67    /// `from_html`, while an embedder installs its fetcher afterwards through
68    /// [`with_fetcher`](Self::with_fetcher). The cell is what lets the later
69    /// call reach the earlier object.
70    fetcher: SharedFetcher,
71    scripts_executed: bool,
72    startup_events_pending: bool,
73    load_event_pending: bool,
74    /// Which `<script>` nodes have already run.
75    ///
76    /// By node rather than a single "done" flag, because scripts keep arriving
77    /// after the first pass: a loader that appends its bundle, an analytics
78    /// snippet, a lazily inserted chunk. Keyed this way, a rescan can tell a
79    /// script it has run from one it has not without running anything twice.
80    executed_scripts: std::collections::HashSet<NodeId>,
81    poll_hook: Option<PollHook>,
82
83    // Timer wakeups: a background thread which wakes the event loop (via the
84    // `Waker` passed to `poll`) when the next JS timer is due.
85    waker: Arc<Mutex<Option<Waker>>>,
86    timer_thread: Option<Sender<Instant>>,
87}
88
89impl ScriptDocument {
90    /// Parse HTML up to its first script boundary into a [`ScriptDocument`].
91    ///
92    /// Embedders install their shims on this live DOM before scripts execute.
93    /// [`execute_scripts`](Self::execute_scripts), or the first `poll`, resumes
94    /// the same parser without replacing any nodes.
95    ///
96    /// HTML without a script boundary is parsed completely before returning.
97    /// XHTML keeps its non-streaming XML parser. No scripts execute here.
98    pub fn from_html(html: &str, mut config: DocumentConfig) -> Self {
99        if let Some(ss) = &mut config.ua_stylesheets {
100            if !ss.iter().any(|s| s == DEFAULT_CSS) {
101                ss.push(String::from(DEFAULT_CSS));
102            }
103        } else {
104            config.ua_stylesheets = Some(vec![String::from(DEFAULT_CSS)]);
105        }
106        if config.html_parser_provider.is_none() {
107            config.html_parser_provider = Some(Arc::new(HtmlProvider));
108        }
109
110        let base_url = config
111            .base_url
112            .as_deref()
113            .and_then(|url| Url::parse(url).ok());
114
115        let is_xml = DocumentHtmlParser::is_xhtml_document(html);
116        let mut doc = BaseDocument::new(config);
117        if is_xml {
118            let mut mutr = doc.mutate();
119            DocumentHtmlParser::parse_into_mutator(&mut mutr, html);
120            drop(mutr);
121        }
122
123        let inner = Rc::new(RefCell::new(doc));
124        let parser = if is_xml {
125            None
126        } else {
127            let mut parser = StreamingParser::new(Rc::clone(&inner), html);
128            if parser.next_script().is_some() {
129                Some(parser)
130            } else {
131                parser.finish();
132                None
133            }
134        };
135        let fetcher: SharedFetcher = Rc::new(RefCell::new(Rc::new(DefaultScriptFetcher)));
136        let runtime = ScriptRuntime::new(Rc::clone(&inner), base_url.as_ref(), Rc::clone(&fetcher));
137        crate::docwrite::prepare(&runtime.context, html, Rc::clone(&fetcher), parser);
138
139        Self {
140            inner,
141            runtime,
142            base_url,
143            fetcher,
144            scripts_executed: false,
145            startup_events_pending: false,
146            load_event_pending: false,
147            executed_scripts: std::collections::HashSet::new(),
148            poll_hook: None,
149            waker: Arc::new(Mutex::new(None)),
150            timer_thread: None,
151        }
152    }
153
154    /// Set the microtask budget shared by all drains in one poll.
155    ///
156    /// Defaults to 16 milliseconds. Individual jobs run to completion and
157    /// cannot be preempted. Panics if `duration` is zero.
158    pub fn set_job_budget(&mut self, duration: Duration) {
159        self.runtime.set_job_budget(duration);
160    }
161
162    /// The configured microtask budget.
163    pub fn job_budget(&self) -> Duration {
164        self.runtime.job_budget()
165    }
166
167    /// Whether immediate Boa jobs remain for a later host tick.
168    ///
169    /// Future timers and suspended module evaluations alone do not count.
170    pub fn has_pending_jobs(&self) -> bool {
171        self.runtime.jobs_remain()
172    }
173
174    /// When the soonest page timer is due, so a host can sleep exactly until
175    /// then instead of on a fixed tick (a `setTimeout(0)` continuation should
176    /// not wait for the host's next frame).
177    pub fn next_timer_deadline(&self) -> Option<std::time::Instant> {
178        self.runtime.next_timer_deadline()
179    }
180
181    /// Override the [`ScriptFetcher`] used to load external (`src="..."`) scripts.
182    /// The default fetcher supports `file:` and `data:` URLs.
183    pub fn with_fetcher(self, fetcher: impl ScriptFetcher) -> Self {
184        *self.fetcher.borrow_mut() = Rc::new(fetcher);
185        self
186    }
187
188    /// Install the host callback invoked by `window.ipc.postMessage(body)`.
189    ///
190    /// The callback may forward work to another thread, but JavaScript and DOM state remain on
191    /// the document's owning thread. Replacing the callback is supported before script startup.
192    pub fn set_ipc_handler(&mut self, handler: impl Fn(String) + 'static) {
193        self.runtime.ctx.state.borrow_mut().ipc_handler = Some(Rc::new(handler));
194    }
195
196    /// Install work that an embedder needs to run on the document thread during polling.
197    ///
198    /// The hook runs after document scripts and due timers. Returning `true` requests a redraw.
199    pub fn set_poll_hook(
200        &mut self,
201        hook: impl for<'a> FnMut(&mut ScriptDocument, Option<&TaskContext<'a>>) -> bool + 'static,
202    ) {
203        self.poll_hook = Some(Box::new(hook));
204    }
205
206    /// Append document-thread work without discarding an embedder's existing
207    /// poll lifecycle.
208    pub fn add_poll_hook(
209        &mut self,
210        mut hook: impl for<'a> FnMut(&mut ScriptDocument, Option<&TaskContext<'a>>) -> bool + 'static,
211    ) {
212        let Some(mut existing) = self.poll_hook.take() else {
213            self.poll_hook = Some(Box::new(hook));
214            return;
215        };
216        self.poll_hook = Some(Box::new(move |document, task_context| {
217            let ran_existing = existing(document, task_context);
218            let ran_added = hook(document, task_context);
219            ran_existing | ran_added
220        }));
221    }
222
223    /// Execute the document's `<script>` elements in document order, then fire
224    /// the `DOMContentLoaded` and `load` events.
225    ///
226    /// Does nothing if scripts have already been executed.
227    pub fn execute_scripts(&mut self) {
228        let _profiling = self.runtime.ctx.enter_profiling_boundary();
229        let _slice = self.runtime.begin_job_slice();
230        if self.scripts_executed {
231            return;
232        }
233        self.scripts_executed = true;
234        self.startup_events_pending = true;
235
236        crate::docwrite::begin(&mut self.runtime.context);
237        loop {
238            let progressed = self.run_pending_scripts();
239            if !progressed
240                || self.runtime.jobs_remain()
241                || !crate::docwrite::active(&self.runtime.context)
242            {
243                break;
244            }
245        }
246        self.finish_startup_events();
247
248        self.request_redraw();
249        self.arm_timer_thread();
250    }
251
252    /// The resolved URLs of the document's external (`<script src="...">`) scripts,
253    /// in document order.
254    ///
255    /// The [`ScriptFetcher`] API is synchronous, so embedders with asynchronous
256    /// networking can use this to prefetch script sources before calling
257    /// [`execute_scripts`](Self::execute_scripts), and then serve them from memory
258    /// via a custom fetcher (see [`with_fetcher`](Self::with_fetcher)).
259    pub fn external_script_urls(&self) -> Vec<Url> {
260        if let Some(original) = crate::docwrite::original(&self.runtime.context) {
261            // The navigation DOM stops at its first boundary. Discover later
262            // source scripts without creating native nodes or loading resources.
263            return blitz_html::scan_script_tags(&original)
264                .into_iter()
265                .filter(|script| script.is_javascript())
266                .filter_map(|script| script.src)
267                .filter_map(|src| self.resolve_script_url(src.as_ref()))
268                .collect();
269        }
270        self.collect_scripts()
271            .iter()
272            // An import map is inline-only per the HTML spec, so a `src` on one
273            // names nothing an embedder should prefetch.
274            .filter(|script| script.kind != ScriptKind::ImportMap)
275            .filter_map(|script| script.src.as_deref())
276            .filter_map(|src| self.resolve_script_url(src))
277            .collect()
278    }
279
280    /// Resolve a script `src` attribute against the document's base URL
281    fn resolve_script_url(&self, src: &str) -> Option<Url> {
282        match &self.base_url {
283            Some(base) => base.join(src).ok(),
284            None => Url::parse(src).ok(),
285        }
286    }
287
288    /// Extend the native JavaScript surface on the document's owning thread.
289    ///
290    /// The callback does not execute a microtask checkpoint or request a
291    /// redraw. Poll hooks can therefore deliver native values and report work
292    /// only when an event actually reaches the page.
293    pub fn with_js_context<R>(
294        &mut self,
295        callback: impl FnOnce(&mut boa_engine::Context) -> R,
296    ) -> R {
297        callback(&mut self.runtime.context)
298    }
299
300    /// Evaluate arbitrary JavaScript code in the document's script context
301    pub fn eval(&mut self, code: &str) {
302        let _profiling = self.runtime.ctx.enter_profiling_boundary();
303        self.runtime.eval(code, "<eval>");
304        self.request_redraw();
305        self.arm_timer_thread();
306    }
307
308    /// Evaluate an embedder prelude under a name, which becomes its source path
309    /// in stack traces and profiles (for example `<chuzz-webgpu>`).
310    pub fn eval_named(&mut self, code: &str, name: &str) {
311        let _profiling = self.runtime.ctx.enter_profiling_boundary();
312        self.runtime.eval_internal(code, name);
313        self.runtime.run_jobs(name);
314        self.request_redraw();
315        self.arm_timer_thread();
316    }
317
318    /// Evaluate JavaScript and convert its result to JSON.
319    ///
320    /// Embedders use this for APIs that return an evaluation result, such as Tauri's
321    /// `eval_script_with_callback`. JavaScript exceptions are recorded in the runtime diagnostics
322    /// and returned as an error.
323    pub fn eval_json(&mut self, code: &str) -> Result<serde_json::Value, String> {
324        let _profiling = self.runtime.ctx.enter_profiling_boundary();
325        let result = self.runtime.eval_json(code, "<eval with result>");
326        self.request_redraw();
327        self.arm_timer_thread();
328        result
329    }
330
331    #[cfg(feature = "debug-control")]
332    pub(crate) fn console_entries_after(
333        &self,
334        sequence: u64,
335    ) -> Vec<crate::runtime::DiagnosticEntry> {
336        self.runtime.console_entries_after(sequence)
337    }
338
339    #[cfg(feature = "debug-control")]
340    pub(crate) fn runtime_errors_after(
341        &self,
342        sequence: u64,
343    ) -> Vec<crate::runtime::DiagnosticEntry> {
344        self.runtime.runtime_errors_after(sequence)
345    }
346
347    /// Current document URL for automation and diagnostics.
348    pub fn current_url(&self) -> Option<&Url> {
349        self.base_url.as_ref()
350    }
351
352    /// Serialize the current document tree as HTML.
353    pub fn page_source(&self) -> String {
354        self.inner.borrow().root_element().outer_html()
355    }
356
357    /// Dispatch a synthetic DOM event (e.g. a click created with
358    /// [`Node::synthetic_click_event`](blitz_dom::Node::synthetic_click_event))
359    /// through the document's event driver. The event is exposed to JavaScript
360    /// event listeners, and Blitz's default actions run unless prevented.
361    pub fn dispatch_dom_event(&mut self, event: DomEvent) {
362        let profiling_boundary = self.runtime.ctx.enter_profiling_boundary();
363        let profiling = profiling_boundary.enabled();
364        let handler = ScriptEventHandler {
365            runtime: &mut self.runtime,
366            profiling,
367        };
368        let mut driver = EventDriver::new(&mut self.inner, handler);
369        driver.handle_dom_event(event);
370
371        self.request_redraw();
372        self.arm_timer_thread();
373    }
374
375    /// Run every `<script>` that has appeared since the last pass.
376    ///
377    /// Called on each poll as well as at startup, because script elements are
378    /// not only a parser product: a page can build one and append it, and that
379    /// is how most real loaders bring in their bundle. Running only the markup
380    /// the parser produced meant those were fetched by nobody and the page
381    /// simply never started.
382    ///
383    /// A `src` script reports `load` when it runs and `error` when it does not.
384    /// Loaders wait on those before continuing — nofilter.io keeps the body
385    /// hidden until the bundle's `load` arrives — so a script that ran silently
386    /// would still leave the page blank.
387    fn run_pending_scripts(&mut self) -> bool {
388        let _slice = self.runtime.begin_job_slice();
389        if self.runtime.jobs_remain() {
390            return false;
391        }
392        if crate::docwrite::take_restart(&self.runtime.context) {
393            self.executed_scripts.clear();
394            self.startup_events_pending = true;
395            self.load_event_pending = false;
396        }
397        let parsed = crate::docwrite::advance(&mut self.runtime.context);
398        let generation = crate::docwrite::generation(&self.runtime.context);
399        if parsed {
400            self.runtime.ctx.mark_layout_dirty();
401        }
402        // Collect first, then run: executing a script can append more, and the
403        // borrow on the document has to be released before any of them runs.
404        let pending: Vec<PendingScript> = self
405            .collect_scripts()
406            .into_iter()
407            .filter(|script| !self.executed_scripts.contains(&script.node_id))
408            .filter(|script| {
409                !crate::docwrite::was_executed(&self.runtime.context, script.node_id)
410                    && (!script.deferred || !crate::docwrite::active(&self.runtime.context))
411            })
412            .collect();
413        let ran = parsed || !pending.is_empty();
414
415        // Import maps first, across the whole batch. A page is free to write
416        // its map after the module that needs it, and a map installed too late
417        // is the same as no map at all: the module has already failed to
418        // resolve its first bare specifier.
419        for script in &pending {
420            if script.kind == ScriptKind::ImportMap && !script.inline_text.trim().is_empty() {
421                self.runtime
422                    .set_import_map(&script.inline_text, self.base_url.as_ref());
423            }
424        }
425
426        // Two passes, because a module is a deferred script and a browser runs
427        // every non-deferred classic script before any of them. Measured
428        // against Chromium on a page mixing all five forms: the parser-blocking
429        // classics run first in document order, then modules and `defer`
430        // scripts together, also in document order.
431        //
432        // Document order alone was right until modules existed. It is now
433        // wrong in a way that bites: an inline classic script writing
434        // `window.__CONFIG__` after a module tag runs *before* that module in
435        // a browser, and the module reads the config it expects.
436        let (deferred, immediate): (Vec<_>, Vec<_>) =
437            pending.into_iter().partition(|script| script.deferred);
438
439        for script in immediate.into_iter().chain(deferred) {
440            if generation != crate::docwrite::generation(&self.runtime.context) {
441                break;
442            }
443            if self.startup_events_pending && script.deferred {
444                self.runtime.set_document_ready_state("interactive");
445                if self.runtime.jobs_remain() {
446                    break;
447                }
448            }
449            // Marked before running, not after: a script that appends another
450            // copy of itself, or that throws, must not be retried on every
451            // poll for the life of the page.
452            self.executed_scripts.insert(script.node_id);
453            if script.kind == ScriptKind::ImportMap {
454                continue;
455            }
456            match script.src {
457                Some(src) => {
458                    let Some(url) = self.resolve_script_url(&src) else {
459                        eprintln!("blitz-script: could not resolve script URL {src:?}");
460                        self.runtime.dispatch_node_event(script.node_id, "error");
461                        if self.runtime.jobs_remain() {
462                            break;
463                        }
464                        continue;
465                    };
466                    let fetcher = Rc::clone(&self.fetcher.borrow());
467                    match fetcher.fetch(&url) {
468                        Ok(code) => {
469                            if script.kind == ScriptKind::Module {
470                                self.runtime.eval_module(&code, Some(&url), url.as_str());
471                            } else {
472                                crate::domc::set_current_script(
473                                    &self.runtime.context,
474                                    Some(script.node_id),
475                                );
476                                self.runtime.eval_at(&code, Some(&url), url.as_str());
477                            }
478                            self.runtime.dispatch_node_event(script.node_id, "load");
479                        }
480                        Err(error) => {
481                            eprintln!("blitz-script: failed to fetch script {url}: {error}");
482                            self.runtime.dispatch_node_event(script.node_id, "error");
483                        }
484                    }
485                }
486                None => {
487                    if !script.inline_text.trim().is_empty() {
488                        // An inline script has no URL of its own, so its
489                        // relative imports and `import.meta.url` resolve
490                        // against the document, exactly as in a browser.
491                        let base_url = self.base_url.clone();
492                        if script.kind == ScriptKind::Module {
493                            self.runtime.eval_module(
494                                &script.inline_text,
495                                base_url.as_ref(),
496                                "<inline module>",
497                            );
498                        } else {
499                            crate::domc::set_current_script(
500                                &self.runtime.context,
501                                Some(script.node_id),
502                            );
503                            self.runtime.eval_at(
504                                &script.inline_text,
505                                base_url.as_ref(),
506                                "<inline script>",
507                            );
508                        }
509                    }
510                }
511            }
512            // Preserve the checkpoint before the next script, even when it
513            // takes several host ticks to finish the queued promise jobs.
514            if self.runtime.jobs_remain() {
515                break;
516            }
517        }
518        ran
519    }
520
521    fn finish_startup_events(&mut self) -> bool {
522        if !(self.startup_events_pending || self.load_event_pending)
523            || self.runtime.jobs_remain()
524            || crate::docwrite::active(&self.runtime.context)
525        {
526            return false;
527        }
528        if self.startup_events_pending
529            && self.collect_scripts().iter().any(|script| {
530                !self.executed_scripts.contains(&script.node_id)
531                    && !crate::docwrite::was_executed(&self.runtime.context, script.node_id)
532            })
533        {
534            return false;
535        }
536
537        let mut ran = false;
538        if self.startup_events_pending {
539            ran |= self.runtime.set_document_ready_state("interactive");
540            if self.runtime.jobs_remain() {
541                return ran;
542            }
543            self.startup_events_pending = false;
544            self.load_event_pending = true;
545            self.runtime.dispatch_document_event("DOMContentLoaded");
546            ran = true;
547        }
548        let resources_pending = {
549            let doc = self.inner.borrow();
550            doc.has_pending_critical_resources() || doc.pending_image_count() != 0
551        };
552        if self.load_event_pending
553            && !self.runtime.jobs_remain()
554            && !resources_pending
555            && !self.runtime.has_pending_module_evaluations()
556        {
557            ran |= self.runtime.set_document_ready_state("complete");
558            if !self.runtime.jobs_remain() {
559                self.load_event_pending = false;
560                self.runtime.dispatch_window_event("load");
561                ran = true;
562            }
563        }
564        ran
565    }
566
567    /// Find `<script>` elements in document order
568    fn collect_scripts(&self) -> Vec<PendingScript> {
569        let doc = self.inner.borrow();
570        let mut scripts = Vec::new();
571        let mut stack = vec![doc.root_node().id];
572
573        while let Some(node_id) = stack.pop() {
574            let Some(node) = doc.get_node(node_id) else {
575                continue;
576            };
577
578            if let Some(element) = node.element_data() {
579                if element.name.local == blitz_dom::local_name!("script") {
580                    if node
581                        .flags
582                        .contains(blitz_dom::node::NodeFlags::IS_PARSER_INERT_SCRIPT)
583                    {
584                        continue;
585                    }
586                    // Skip non-JavaScript script types (e.g. JSON data blocks).
587                    let script_type = element
588                        .attr(blitz_dom::local_name!("type"))
589                        .unwrap_or("")
590                        .trim()
591                        .to_ascii_lowercase();
592                    let kind = match script_type.as_str() {
593                        "module" => Some(ScriptKind::Module),
594                        "importmap" => Some(ScriptKind::ImportMap),
595                        "" | "text/javascript" | "application/javascript" => {
596                            // `nomodule` marks the classic fallback a page
597                            // ships for engines without module support. Now
598                            // that modules run, taking the fallback as well
599                            // would mount the same application twice.
600                            if element.attr(blitz_dom::local_name!("nomodule")).is_some() {
601                                None
602                            } else {
603                                Some(ScriptKind::Classic)
604                            }
605                        }
606                        _ => None,
607                    };
608
609                    if let Some(kind) = kind {
610                        let src = element
611                            .attr(blitz_dom::local_name!("src"))
612                            .map(str::to_string);
613                        let is_async = element.attr(blitz_dom::local_name!("async")).is_some();
614
615                        // `defer` is only meaningful on an external classic
616                        // script; the spec ignores it on an inline one. A
617                        // module is deferred whether or not it says so.
618                        let deferred = !is_async
619                            && match kind {
620                                ScriptKind::Module => true,
621                                ScriptKind::Classic => {
622                                    src.is_some()
623                                        && element.attr(blitz_dom::local_name!("defer")).is_some()
624                                }
625                                ScriptKind::ImportMap => false,
626                            };
627
628                        scripts.push(PendingScript {
629                            node_id,
630                            src,
631                            inline_text: node.text_content(),
632                            kind,
633                            deferred,
634                        });
635                    }
636                    continue;
637                }
638            }
639
640            stack.extend(node.children.iter().rev().copied());
641        }
642
643        scripts
644    }
645
646    fn request_redraw(&self) {
647        self.inner.borrow().shell_provider.request_redraw();
648    }
649
650    /// Ensure the timer thread is armed to wake the event loop when the next
651    /// JS timer is due.
652    fn arm_timer_thread(&mut self) {
653        let Some(deadline) = self.runtime.next_timer_deadline() else {
654            return;
655        };
656
657        let sender = self.timer_thread.get_or_insert_with(|| {
658            let (tx, rx) = channel::<Instant>();
659            let waker = Arc::clone(&self.waker);
660            std::thread::Builder::new()
661                .name("blitz-script-timers".to_string())
662                .spawn(move || timer_thread_main(rx, waker))
663                .expect("failed to spawn timer thread");
664            tx
665        });
666
667        // If the thread has exited (channel disconnected) drop the sender so a
668        // new thread is spawned next time.
669        if sender.send(deadline).is_err() {
670            self.timer_thread = None;
671        }
672    }
673}
674
675/// Background thread which wakes the event loop when JS timers are due
676fn timer_thread_main(rx: Receiver<Instant>, waker: Arc<Mutex<Option<Waker>>>) {
677    let mut deadline: Option<Instant> = None;
678
679    loop {
680        match deadline {
681            None => match rx.recv() {
682                Ok(new_deadline) => deadline = Some(new_deadline),
683                Err(_) => return,
684            },
685            Some(current) => {
686                let now = Instant::now();
687                if current <= now {
688                    if let Some(waker) = waker.lock().unwrap().as_ref() {
689                        waker.wake_by_ref();
690                    }
691                    deadline = None;
692                    continue;
693                }
694                match rx.recv_timeout(current - now) {
695                    Ok(new_deadline) => deadline = Some(new_deadline.min(current)),
696                    Err(RecvTimeoutError::Timeout) => {
697                        if let Some(waker) = waker.lock().unwrap().as_ref() {
698                            waker.wake_by_ref();
699                        }
700                        deadline = None;
701                    }
702                    Err(RecvTimeoutError::Disconnected) => return,
703                }
704            }
705        }
706    }
707}
708
709impl Document for ScriptDocument {
710    fn inner(&self) -> DocGuard<'_> {
711        DocGuard::RefCell(self.inner.borrow())
712    }
713
714    fn inner_mut(&mut self) -> DocGuardMut<'_> {
715        DocGuardMut::RefCell(self.inner.borrow_mut())
716    }
717
718    fn handle_ui_event(&mut self, event: UiEvent) {
719        let profiling_boundary = self.runtime.ctx.enter_profiling_boundary();
720        let profiling = profiling_boundary.enabled();
721        let handler = ScriptEventHandler {
722            runtime: &mut self.runtime,
723            profiling,
724        };
725        let mut driver = EventDriver::new(&mut self.inner, handler);
726        driver.handle_ui_event(event);
727
728        // JS may have mutated the DOM or scheduled timers
729        self.request_redraw();
730        self.arm_timer_thread();
731    }
732
733    fn poll(&mut self, task_context: Option<TaskContext>) -> bool {
734        self.poll_with_timer_policy(task_context, None)
735    }
736
737    fn poll_for_settle(&mut self, task_context: Option<TaskContext>, run_timers: bool) -> bool {
738        self.poll_with_timer_policy(task_context, Some(run_timers))
739    }
740}
741
742impl ScriptDocument {
743    /// `None` preserves normal polling; `Some` selects a settle pass's policy.
744    fn poll_with_timer_policy(
745        &mut self,
746        task_context: Option<TaskContext>,
747        settle_timers: Option<bool>,
748    ) -> bool {
749        let profiling_boundary = self.runtime.ctx.enter_profiling_boundary();
750        let profiling = profiling_boundary.enabled();
751        let poll_started = profiling.then(std::time::Instant::now);
752        let _slice = self.runtime.begin_job_slice();
753        let ran = self.poll_inner(task_context, profiling, settle_timers);
754        if let Some(started) = poll_started {
755            crate::script_stats::record_poll(started.elapsed(), ran);
756        }
757        ran
758    }
759
760    /// Move a semantic automation pointer to the exact resolved DOM node.
761    ///
762    /// Normal window input remains coordinate hit-tested through
763    /// [`Document::handle_ui_event`]. Debug-control callers already selected a
764    /// node by id, so routing those coordinates through hit testing again can
765    /// silently select an overlapping descendant or retained overlay.
766    pub fn handle_pointer_move_to_node(&mut self, event: BlitzPointerEvent, node_id: NodeId) {
767        let profiling_boundary = self.runtime.ctx.enter_profiling_boundary();
768        let profiling = profiling_boundary.enabled();
769        let handler = ScriptEventHandler {
770            runtime: &mut self.runtime,
771            profiling,
772        };
773        let mut driver = EventDriver::new(&mut self.inner, handler);
774        driver.handle_pointer_move_to_node(&event, node_id);
775
776        self.request_redraw();
777        self.arm_timer_thread();
778    }
779
780    /// Deliver one automation input event to the exact semantic node selected
781    /// by the caller, while retaining the normal pointer event ordering,
782    /// compatibility mouse events, activation state and click synthesis.
783    pub fn handle_ui_event_to_node(&mut self, event: UiEvent, node_id: NodeId) {
784        let profiling_boundary = self.runtime.ctx.enter_profiling_boundary();
785        let profiling = profiling_boundary.enabled();
786        let handler = ScriptEventHandler {
787            runtime: &mut self.runtime,
788            profiling,
789        };
790        let mut driver = EventDriver::new(&mut self.inner, handler);
791        driver.handle_ui_event_to_node(event, node_id);
792
793        self.request_redraw();
794        self.arm_timer_thread();
795    }
796
797    /// The real poll. Split out so every exit path is timed by the wrapper
798    /// above rather than by a stopwatch threaded through each early return.
799    fn poll_inner(
800        &mut self,
801        task_context: Option<TaskContext>,
802        profiling: bool,
803        settle_timers: Option<bool>,
804    ) -> bool {
805        // Store the waker so the timer thread can wake the event loop
806        if let Some(cx) = &task_context {
807            let mut waker = self.waker.lock().unwrap();
808            let stale = waker
809                .as_ref()
810                .map(|old| !old.will_wake(cx.waker()))
811                .unwrap_or(true);
812            if stale {
813                *waker = Some(cx.waker().clone());
814            }
815        }
816
817        // A scripted document may itself be an embedder. Chuzz's Solid chrome
818        // is one: its `<web-view>` elements own the page documents. Poll those
819        // children at the same outer boundary so their timers, resource
820        // completions, and script work continue to make progress.
821        let subdocument_changes = {
822            let mut inner = self.inner.borrow_mut();
823            let waker = task_context.as_ref().map(TaskContext::waker);
824            match settle_timers {
825                Some(run_timers) => inner.poll_subdocuments_for_settle(waker, run_timers),
826                None => inner.poll_subdocuments(waker),
827            }
828        };
829
830        let mut ran = subdocument_changes;
831        ran |= self.runtime.poll_domc();
832        // Every poll resumes a yielded checkpoint, including a timer-free
833        // settle pass. Jobs do not need a timer or a dummy eval to advance.
834        ran |= self.runtime.run_jobs_with_progress("poll microtasks");
835
836        // Execute scripts on first poll if they haven't been run explicitly
837        if !self.scripts_executed {
838            // One-time: parsing and running the application bundle. Separated
839            // because it is startup cost, and folding it into the steady-state
840            // numbers made every per-poll average meaningless.
841            let started = profiling.then(std::time::Instant::now);
842            self.execute_scripts();
843            if let Some(started) = started {
844                crate::script_stats::record_work("startup:execute_scripts", started.elapsed());
845            }
846            ran = true;
847        } else {
848            // Steady state: pick up any script the page appended since the last
849            // turn. Cheap when there are none, and it is the only path by which
850            // a runtime-injected bundle ever runs.
851            ran |= self.run_pending_scripts();
852        }
853        ran |= self.finish_startup_events();
854
855        if settle_timers.unwrap_or(true) {
856            ran |= self.runtime.run_due_timers(profiling);
857        }
858
859        // A module that opened with a top-level `await` may have settled since
860        // the last turn. Checking here is what turns a silent half-mounted page
861        // into a reported rejection.
862        self.runtime.poll_module_evaluations();
863
864        if let Some(mut hook) = self.poll_hook.take() {
865            // The embedder's per-poll work. For a Solid application this is
866            // where reactive updates and DOM mutation actually happen, so it is
867            // the bucket that matters once startup is excluded.
868            let started = profiling.then(std::time::Instant::now);
869            ran |= hook(self, task_context.as_ref());
870            if let Some(started) = started {
871                crate::script_stats::record_work("poll_hook", started.elapsed());
872            }
873            self.poll_hook = Some(hook);
874        }
875
876        // Reclaim what removal could not judge at the time. A node is detached
877        // while script may still hold its wrapper, and the answer only becomes
878        // knowable once the collector has run; this is the point where asking
879        // is cheap and the answer is current.
880        self.runtime.sweep_detached_nodes();
881
882        if self.runtime.jobs_remain() {
883            ran = true;
884            self.request_redraw();
885            if let Some(cx) = &task_context {
886                cx.waker().wake_by_ref();
887            }
888        }
889        self.arm_timer_thread();
890        ran
891    }
892}