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    /// Work queued for the next host tick: due timers and frame callbacks,
175    /// plus one while any promise job is runnable.
176    pub fn pending_task_count(&self) -> u64 {
177        self.runtime.pending_task_count()
178    }
179
180    /// Drive scripts and runnable event-loop work to quiescence.
181    ///
182    /// Starts document scripts if necessary, then polls until parsing,
183    /// deferred scripts, microtasks, observer delivery and timers due now
184    /// have drained. Completion depends on pending work, not a fixed number
185    /// of polls. Each poll retains the configured job budget.
186    ///
187    /// External scripts use the synchronous document fetcher. Imported module
188    /// loads remain pending until their result futures complete, including
189    /// dynamic imports from classic scripts. Pending loads are polled every
190    /// five milliseconds when there is no runnable work. Suspended module
191    /// evaluations also wait for their next timer or a later host poll.
192    /// Unrelated future timers remain scheduled, and non-script resources
193    /// are not awaited.
194    ///
195    /// Returns an error if module loading or evaluation remains pending after
196    /// sixty seconds, or an unfinished document input stream cannot make
197    /// progress. Synchronous fetchers must enforce their own I/O timeouts.
198    /// A page that continuously produces runnable work without pending modules
199    /// may never become idle.
200    ///
201    /// Call on the document's owning thread, outside poll hooks and
202    /// JavaScript callbacks. This is a blocking entry point for tests and
203    /// hosts that need completed script startup before inspecting the DOM.
204    pub fn run_until_idle(&mut self) -> Result<(), &'static str> {
205        const MODULE_TIMEOUT: Duration = Duration::from_secs(60);
206        const LOAD_POLL_INTERVAL: Duration = Duration::from_millis(5);
207        let module_deadline = Instant::now() + MODULE_TIMEOUT;
208
209        loop {
210            let progressed = self.poll(None);
211            let now = Instant::now();
212            let next_timer = self.runtime.next_timer_deadline();
213            let loads_pending = self.runtime.has_pending_module_loads();
214            let evaluations_pending = self.runtime.has_pending_module_evaluations();
215
216            // Check before the progress branch so continuing module work
217            // cannot bypass the deadline by keeping each poll busy.
218            if now >= module_deadline {
219                if loads_pending {
220                    return Err("module loading did not complete within sixty seconds");
221                }
222                if evaluations_pending {
223                    return Err("module evaluation did not complete within sixty seconds");
224                }
225            }
226
227            if self.runtime.jobs_remain()
228                || next_timer.is_some_and(|deadline| deadline <= now)
229                || progressed
230            {
231                continue;
232            }
233
234            if loads_pending || evaluations_pending {
235                // A timer is not required for a loader completion. While a
236                // load is outstanding, keep polling even if the next timer
237                // belongs to unrelated page work far in the future.
238                let wait = if loads_pending {
239                    LOAD_POLL_INTERVAL
240                } else {
241                    next_timer.map_or(LOAD_POLL_INTERVAL, |deadline| {
242                        deadline.saturating_duration_since(now)
243                    })
244                };
245                let wait = next_timer.map_or(wait, |deadline| {
246                    wait.min(deadline.saturating_duration_since(now))
247                });
248                std::thread::sleep(wait.min(module_deadline.saturating_duration_since(now)));
249                continue;
250            }
251
252            if crate::docwrite::active(&self.runtime.context)
253                || self.startup_events_pending
254                || self.collect_scripts().iter().any(|script| {
255                    !self.executed_scripts.contains(&script.node_id)
256                        && !crate::docwrite::was_executed(&self.runtime.context, script.node_id)
257                })
258            {
259                return Err("script startup cannot make progress");
260            }
261
262            return Ok(());
263        }
264    }
265
266    /// When the soonest page timer is due, so a host can sleep exactly until
267    /// then instead of on a fixed tick (a `setTimeout(0)` continuation should
268    /// not wait for the host's next frame).
269    pub fn next_timer_deadline(&self) -> Option<std::time::Instant> {
270        self.runtime.next_timer_deadline()
271    }
272
273    /// Override the [`ScriptFetcher`] used to load external (`src="..."`) scripts.
274    /// The default fetcher supports `file:` and `data:` URLs.
275    pub fn with_fetcher(self, fetcher: impl ScriptFetcher) -> Self {
276        *self.fetcher.borrow_mut() = Rc::new(fetcher);
277        self
278    }
279
280    /// Install the host callback invoked by `window.ipc.postMessage(body)`.
281    ///
282    /// The callback may forward work to another thread, but JavaScript and DOM state remain on
283    /// the document's owning thread. Replacing the callback is supported before script startup.
284    pub fn set_ipc_handler(&mut self, handler: impl Fn(String) + 'static) {
285        self.runtime.ctx.state.borrow_mut().ipc_handler = Some(Rc::new(handler));
286    }
287
288    /// Install work that an embedder needs to run on the document thread during polling.
289    ///
290    /// The hook runs after document scripts and due timers. Returning `true` requests a redraw.
291    pub fn set_poll_hook(
292        &mut self,
293        hook: impl for<'a> FnMut(&mut ScriptDocument, Option<&TaskContext<'a>>) -> bool + 'static,
294    ) {
295        self.poll_hook = Some(Box::new(hook));
296    }
297
298    /// Append document-thread work without discarding an embedder's existing
299    /// poll lifecycle.
300    pub fn add_poll_hook(
301        &mut self,
302        mut hook: impl for<'a> FnMut(&mut ScriptDocument, Option<&TaskContext<'a>>) -> bool + 'static,
303    ) {
304        let Some(mut existing) = self.poll_hook.take() else {
305            self.poll_hook = Some(Box::new(hook));
306            return;
307        };
308        self.poll_hook = Some(Box::new(move |document, task_context| {
309            let ran_existing = existing(document, task_context);
310            let ran_added = hook(document, task_context);
311            ran_existing | ran_added
312        }));
313    }
314
315    /// Execute the document's `<script>` elements in document order, then fire
316    /// the `DOMContentLoaded` and `load` events.
317    ///
318    /// Does nothing if scripts have already been executed.
319    pub fn execute_scripts(&mut self) {
320        let _profiling = self.runtime.ctx.enter_profiling_boundary();
321        let _slice = self.runtime.begin_job_slice();
322        if self.scripts_executed {
323            return;
324        }
325        self.scripts_executed = true;
326        self.startup_events_pending = true;
327
328        crate::docwrite::begin(&mut self.runtime.context);
329        loop {
330            let progressed = self.run_pending_scripts();
331            if !progressed
332                || self.runtime.jobs_remain()
333                || !crate::docwrite::active(&self.runtime.context)
334            {
335                break;
336            }
337        }
338        self.finish_startup_events();
339
340        self.request_redraw();
341        self.arm_timer_thread();
342    }
343
344    /// The resolved URLs of the document's external (`<script src="...">`) scripts,
345    /// in document order.
346    ///
347    /// The [`ScriptFetcher`] API is synchronous, so embedders with asynchronous
348    /// networking can use this to prefetch script sources before calling
349    /// [`execute_scripts`](Self::execute_scripts), and then serve them from memory
350    /// via a custom fetcher (see [`with_fetcher`](Self::with_fetcher)).
351    pub fn external_script_urls(&self) -> Vec<Url> {
352        if let Some(original) = crate::docwrite::original(&self.runtime.context) {
353            // The navigation DOM stops at its first boundary. Discover later
354            // source scripts without creating native nodes or loading resources.
355            return blitz_html::scan_script_tags(&original)
356                .into_iter()
357                .filter(|script| script.is_javascript())
358                .filter_map(|script| script.src)
359                .filter_map(|src| self.resolve_script_url(src.as_ref()))
360                .collect();
361        }
362        self.collect_scripts()
363            .iter()
364            // An import map is inline-only per the HTML spec, so a `src` on one
365            // names nothing an embedder should prefetch.
366            .filter(|script| script.kind != ScriptKind::ImportMap)
367            .filter_map(|script| script.src.as_deref())
368            .filter_map(|src| self.resolve_script_url(src))
369            .collect()
370    }
371
372    /// Resolve a script `src` attribute against the document's base URL
373    fn resolve_script_url(&self, src: &str) -> Option<Url> {
374        match &self.base_url {
375            Some(base) => base.join(src).ok(),
376            None => Url::parse(src).ok(),
377        }
378    }
379
380    /// Extend the native JavaScript surface on the document's owning thread.
381    ///
382    /// The callback does not execute a microtask checkpoint or request a
383    /// redraw. Poll hooks can therefore deliver native values and report work
384    /// only when an event actually reaches the page.
385    pub fn with_js_context<R>(
386        &mut self,
387        callback: impl FnOnce(&mut boa_engine::Context) -> R,
388    ) -> R {
389        callback(&mut self.runtime.context)
390    }
391
392    /// Evaluate arbitrary JavaScript code in the document's script context
393    pub fn eval(&mut self, code: &str) {
394        let _profiling = self.runtime.ctx.enter_profiling_boundary();
395        self.runtime.eval(code, "<eval>");
396        self.request_redraw();
397        self.arm_timer_thread();
398    }
399
400    /// Evaluate an embedder prelude under a name, which becomes its source path
401    /// in stack traces and profiles (for example `<chuzz-webgpu>`).
402    pub fn eval_named(&mut self, code: &str, name: &str) {
403        let _profiling = self.runtime.ctx.enter_profiling_boundary();
404        self.runtime.eval_internal(code, name);
405        self.runtime.run_jobs(name);
406        self.request_redraw();
407        self.arm_timer_thread();
408    }
409
410    /// Evaluate JavaScript and convert its result to JSON.
411    ///
412    /// Embedders use this for APIs that return an evaluation result, such as Tauri's
413    /// `eval_script_with_callback`. JavaScript exceptions are recorded in the runtime diagnostics
414    /// and returned as an error.
415    pub fn eval_json(&mut self, code: &str) -> Result<serde_json::Value, String> {
416        let _profiling = self.runtime.ctx.enter_profiling_boundary();
417        let result = self.runtime.eval_json(code, "<eval with result>");
418        self.request_redraw();
419        self.arm_timer_thread();
420        result
421    }
422
423    #[cfg(feature = "debug-control")]
424    pub(crate) fn console_entries_after(
425        &self,
426        sequence: u64,
427    ) -> Vec<crate::runtime::DiagnosticEntry> {
428        self.runtime.console_entries_after(sequence)
429    }
430
431    #[cfg(feature = "debug-control")]
432    pub(crate) fn runtime_errors_after(
433        &self,
434        sequence: u64,
435    ) -> Vec<crate::runtime::DiagnosticEntry> {
436        self.runtime.runtime_errors_after(sequence)
437    }
438
439    /// Current document URL for automation and diagnostics.
440    pub fn current_url(&self) -> Option<&Url> {
441        self.base_url.as_ref()
442    }
443
444    /// Serialize the current document tree as HTML.
445    pub fn page_source(&self) -> String {
446        self.inner.borrow().root_element().outer_html()
447    }
448
449    /// Dispatch a synthetic DOM event (e.g. a click created with
450    /// [`Node::synthetic_click_event`](blitz_dom::Node::synthetic_click_event))
451    /// through the document's event driver. The event is exposed to JavaScript
452    /// event listeners, and Blitz's default actions run unless prevented.
453    pub fn dispatch_dom_event(&mut self, event: DomEvent) {
454        let profiling_boundary = self.runtime.ctx.enter_profiling_boundary();
455        let profiling = profiling_boundary.enabled();
456        let handler = ScriptEventHandler {
457            runtime: &mut self.runtime,
458            profiling,
459        };
460        let mut driver = EventDriver::new(&mut self.inner, handler);
461        driver.handle_dom_event(event);
462
463        self.request_redraw();
464        self.arm_timer_thread();
465    }
466
467    /// Run every `<script>` that has appeared since the last pass.
468    ///
469    /// Called on each poll as well as at startup, because script elements are
470    /// not only a parser product: a page can build one and append it, and that
471    /// is how most real loaders bring in their bundle. Running only the markup
472    /// the parser produced meant those were fetched by nobody and the page
473    /// simply never started.
474    ///
475    /// A `src` script reports `load` when it runs and `error` when it does not.
476    /// Loaders wait on those before continuing — nofilter.io keeps the body
477    /// hidden until the bundle's `load` arrives — so a script that ran silently
478    /// would still leave the page blank.
479    fn run_pending_scripts(&mut self) -> bool {
480        let _slice = self.runtime.begin_job_slice();
481        if self.runtime.jobs_remain() {
482            return false;
483        }
484        if crate::docwrite::take_restart(&self.runtime.context) {
485            self.executed_scripts.clear();
486            self.startup_events_pending = true;
487            self.load_event_pending = false;
488        }
489        let parsed = crate::docwrite::advance(&mut self.runtime.context);
490        let generation = crate::docwrite::generation(&self.runtime.context);
491        if parsed {
492            self.runtime.ctx.mark_layout_dirty();
493        }
494        // Collect first, then run: executing a script can append more, and the
495        // borrow on the document has to be released before any of them runs.
496        let pending: Vec<PendingScript> = self
497            .collect_scripts()
498            .into_iter()
499            .filter(|script| !self.executed_scripts.contains(&script.node_id))
500            .filter(|script| {
501                !crate::docwrite::was_executed(&self.runtime.context, script.node_id)
502                    && (!script.deferred || !crate::docwrite::active(&self.runtime.context))
503            })
504            .collect();
505        let ran = parsed || !pending.is_empty();
506
507        // Import maps first, across the whole batch. A page is free to write
508        // its map after the module that needs it, and a map installed too late
509        // is the same as no map at all: the module has already failed to
510        // resolve its first bare specifier.
511        for script in &pending {
512            if script.kind == ScriptKind::ImportMap && !script.inline_text.trim().is_empty() {
513                self.runtime
514                    .set_import_map(&script.inline_text, self.base_url.as_ref());
515            }
516        }
517
518        // Two passes, because a module is a deferred script and a browser runs
519        // every non-deferred classic script before any of them. Measured
520        // against Chromium on a page mixing all five forms: the parser-blocking
521        // classics run first in document order, then modules and `defer`
522        // scripts together, also in document order.
523        //
524        // Document order alone was right until modules existed. It is now
525        // wrong in a way that bites: an inline classic script writing
526        // `window.__CONFIG__` after a module tag runs *before* that module in
527        // a browser, and the module reads the config it expects.
528        let (deferred, immediate): (Vec<_>, Vec<_>) =
529            pending.into_iter().partition(|script| script.deferred);
530
531        for script in immediate.into_iter().chain(deferred) {
532            if generation != crate::docwrite::generation(&self.runtime.context) {
533                break;
534            }
535            if self.startup_events_pending && script.deferred {
536                self.runtime.set_document_ready_state("interactive");
537                if self.runtime.jobs_remain() {
538                    break;
539                }
540            }
541            // Marked before running, not after: a script that appends another
542            // copy of itself, or that throws, must not be retried on every
543            // poll for the life of the page.
544            self.executed_scripts.insert(script.node_id);
545            if script.kind == ScriptKind::ImportMap {
546                continue;
547            }
548            match script.src {
549                Some(src) => {
550                    let Some(url) = self.resolve_script_url(&src) else {
551                        eprintln!("blitz-script: could not resolve script URL {src:?}");
552                        self.runtime.dispatch_node_event(script.node_id, "error");
553                        if self.runtime.jobs_remain() {
554                            break;
555                        }
556                        continue;
557                    };
558                    let fetcher = Rc::clone(&self.fetcher.borrow());
559                    match fetcher.fetch(&url) {
560                        Ok(code) => {
561                            if script.kind == ScriptKind::Module {
562                                self.runtime.eval_module(&code, Some(&url), url.as_str());
563                            } else {
564                                crate::domc::set_current_script(
565                                    &self.runtime.context,
566                                    Some(script.node_id),
567                                );
568                                self.runtime.eval_at(&code, Some(&url), url.as_str());
569                            }
570                            self.runtime.dispatch_node_event(script.node_id, "load");
571                        }
572                        Err(error) => {
573                            eprintln!("blitz-script: failed to fetch script {url}: {error}");
574                            self.runtime.dispatch_node_event(script.node_id, "error");
575                        }
576                    }
577                }
578                None => {
579                    if !script.inline_text.trim().is_empty() {
580                        // An inline script has no URL of its own, so its
581                        // relative imports and `import.meta.url` resolve
582                        // against the document, exactly as in a browser.
583                        let base_url = self.base_url.clone();
584                        if script.kind == ScriptKind::Module {
585                            self.runtime.eval_module(
586                                &script.inline_text,
587                                base_url.as_ref(),
588                                "<inline module>",
589                            );
590                        } else {
591                            crate::domc::set_current_script(
592                                &self.runtime.context,
593                                Some(script.node_id),
594                            );
595                            self.runtime.eval_at(
596                                &script.inline_text,
597                                base_url.as_ref(),
598                                "<inline script>",
599                            );
600                        }
601                    }
602                }
603            }
604            // Preserve the checkpoint before the next script, even when it
605            // takes several host ticks to finish the queued promise jobs.
606            if self.runtime.jobs_remain() {
607                break;
608            }
609        }
610        ran
611    }
612
613    fn finish_startup_events(&mut self) -> bool {
614        if !(self.startup_events_pending || self.load_event_pending)
615            || self.runtime.jobs_remain()
616            || crate::docwrite::active(&self.runtime.context)
617        {
618            return false;
619        }
620        if self.startup_events_pending
621            && (self.runtime.has_pending_module_evaluations()
622                || self.collect_scripts().iter().any(|script| {
623                    !self.executed_scripts.contains(&script.node_id)
624                        && !crate::docwrite::was_executed(&self.runtime.context, script.node_id)
625                }))
626        {
627            return false;
628        }
629
630        let mut ran = false;
631        if self.startup_events_pending {
632            ran |= self.runtime.set_document_ready_state("interactive");
633            if self.runtime.jobs_remain() {
634                return ran;
635            }
636            self.startup_events_pending = false;
637            self.load_event_pending = true;
638            self.runtime.dispatch_document_event("DOMContentLoaded");
639            ran = true;
640        }
641        let resources_pending = {
642            let doc = self.inner.borrow();
643            doc.has_pending_critical_resources() || doc.pending_image_count() != 0
644        };
645        if self.load_event_pending
646            && !self.runtime.jobs_remain()
647            && !resources_pending
648            && !self.runtime.has_pending_module_evaluations()
649        {
650            ran |= self.runtime.set_document_ready_state("complete");
651            if !self.runtime.jobs_remain() {
652                self.load_event_pending = false;
653                self.runtime.dispatch_window_event("load");
654                ran = true;
655            }
656        }
657        ran
658    }
659
660    /// Find `<script>` elements in document order
661    fn collect_scripts(&self) -> Vec<PendingScript> {
662        let doc = self.inner.borrow();
663        let mut scripts = Vec::new();
664        let mut stack = vec![doc.root_node().id];
665
666        while let Some(node_id) = stack.pop() {
667            let Some(node) = doc.get_node(node_id) else {
668                continue;
669            };
670
671            if let Some(element) = node.element_data() {
672                if element.name.local == blitz_dom::local_name!("script") {
673                    if node
674                        .flags
675                        .contains(blitz_dom::node::NodeFlags::IS_PARSER_INERT_SCRIPT)
676                    {
677                        continue;
678                    }
679                    // Skip non-JavaScript script types (e.g. JSON data blocks).
680                    let script_type = element
681                        .attr(blitz_dom::local_name!("type"))
682                        .unwrap_or("")
683                        .trim()
684                        .to_ascii_lowercase();
685                    let kind = match script_type.as_str() {
686                        "module" => Some(ScriptKind::Module),
687                        "importmap" => Some(ScriptKind::ImportMap),
688                        "" | "text/javascript" | "application/javascript" => {
689                            // `nomodule` marks the classic fallback a page
690                            // ships for engines without module support. Now
691                            // that modules run, taking the fallback as well
692                            // would mount the same application twice.
693                            if element.attr(blitz_dom::local_name!("nomodule")).is_some() {
694                                None
695                            } else {
696                                Some(ScriptKind::Classic)
697                            }
698                        }
699                        _ => None,
700                    };
701
702                    if let Some(kind) = kind {
703                        let src = element
704                            .attr(blitz_dom::local_name!("src"))
705                            .map(str::to_string);
706                        let is_async = element.attr(blitz_dom::local_name!("async")).is_some();
707
708                        // `defer` is only meaningful on an external classic
709                        // script; the spec ignores it on an inline one. A
710                        // module is deferred whether or not it says so.
711                        let deferred = !is_async
712                            && match kind {
713                                ScriptKind::Module => true,
714                                ScriptKind::Classic => {
715                                    src.is_some()
716                                        && element.attr(blitz_dom::local_name!("defer")).is_some()
717                                }
718                                ScriptKind::ImportMap => false,
719                            };
720
721                        scripts.push(PendingScript {
722                            node_id,
723                            src,
724                            inline_text: node.text_content(),
725                            kind,
726                            deferred,
727                        });
728                    }
729                    continue;
730                }
731            }
732
733            stack.extend(node.children.iter().rev().copied());
734        }
735
736        scripts
737    }
738
739    fn request_redraw(&self) {
740        self.inner.borrow().shell_provider.request_redraw();
741    }
742
743    /// Ensure the timer thread is armed to wake the event loop when the next
744    /// JS timer is due.
745    fn arm_timer_thread(&mut self) {
746        let Some(deadline) = self.runtime.next_timer_deadline() else {
747            return;
748        };
749
750        let sender = self.timer_thread.get_or_insert_with(|| {
751            let (tx, rx) = channel::<Instant>();
752            let waker = Arc::clone(&self.waker);
753            std::thread::Builder::new()
754                .name("blitz-script-timers".to_string())
755                .spawn(move || timer_thread_main(rx, waker))
756                .expect("failed to spawn timer thread");
757            tx
758        });
759
760        // If the thread has exited (channel disconnected) drop the sender so a
761        // new thread is spawned next time.
762        if sender.send(deadline).is_err() {
763            self.timer_thread = None;
764        }
765    }
766}
767
768/// Background thread which wakes the event loop when JS timers are due
769fn timer_thread_main(rx: Receiver<Instant>, waker: Arc<Mutex<Option<Waker>>>) {
770    let mut deadline: Option<Instant> = None;
771
772    loop {
773        match deadline {
774            None => match rx.recv() {
775                Ok(new_deadline) => deadline = Some(new_deadline),
776                Err(_) => return,
777            },
778            Some(current) => {
779                let now = Instant::now();
780                if current <= now {
781                    if let Some(waker) = waker.lock().unwrap().as_ref() {
782                        waker.wake_by_ref();
783                    }
784                    deadline = None;
785                    continue;
786                }
787                match rx.recv_timeout(current - now) {
788                    Ok(new_deadline) => deadline = Some(new_deadline.min(current)),
789                    Err(RecvTimeoutError::Timeout) => {
790                        if let Some(waker) = waker.lock().unwrap().as_ref() {
791                            waker.wake_by_ref();
792                        }
793                        deadline = None;
794                    }
795                    Err(RecvTimeoutError::Disconnected) => return,
796                }
797            }
798        }
799    }
800}
801
802impl Document for ScriptDocument {
803    fn inner(&self) -> DocGuard<'_> {
804        DocGuard::RefCell(self.inner.borrow())
805    }
806
807    fn inner_mut(&mut self) -> DocGuardMut<'_> {
808        DocGuardMut::RefCell(self.inner.borrow_mut())
809    }
810
811    fn handle_ui_event(&mut self, event: UiEvent) {
812        let profiling_boundary = self.runtime.ctx.enter_profiling_boundary();
813        let profiling = profiling_boundary.enabled();
814        let handler = ScriptEventHandler {
815            runtime: &mut self.runtime,
816            profiling,
817        };
818        let mut driver = EventDriver::new(&mut self.inner, handler);
819        driver.handle_ui_event(event);
820
821        // JS may have mutated the DOM or scheduled timers
822        self.request_redraw();
823        self.arm_timer_thread();
824    }
825
826    fn poll(&mut self, task_context: Option<TaskContext>) -> bool {
827        self.poll_with_timer_policy(task_context, None)
828    }
829
830    fn poll_for_settle(&mut self, task_context: Option<TaskContext>, run_timers: bool) -> bool {
831        self.poll_with_timer_policy(task_context, Some(run_timers))
832    }
833}
834
835impl ScriptDocument {
836    /// `None` preserves normal polling; `Some` selects a settle pass's policy.
837    fn poll_with_timer_policy(
838        &mut self,
839        task_context: Option<TaskContext>,
840        settle_timers: Option<bool>,
841    ) -> bool {
842        let profiling_boundary = self.runtime.ctx.enter_profiling_boundary();
843        let profiling = profiling_boundary.enabled();
844        let poll_started = profiling.then(std::time::Instant::now);
845        let _slice = self.runtime.begin_job_slice();
846        let ran = self.poll_inner(task_context, profiling, settle_timers);
847        if let Some(started) = poll_started {
848            crate::script_stats::record_poll(started.elapsed(), ran);
849        }
850        ran
851    }
852
853    /// Move a semantic automation pointer to the exact resolved DOM node.
854    ///
855    /// Normal window input remains coordinate hit-tested through
856    /// [`Document::handle_ui_event`]. Debug-control callers already selected a
857    /// node by id, so routing those coordinates through hit testing again can
858    /// silently select an overlapping descendant or retained overlay.
859    pub fn handle_pointer_move_to_node(&mut self, event: BlitzPointerEvent, node_id: NodeId) {
860        let profiling_boundary = self.runtime.ctx.enter_profiling_boundary();
861        let profiling = profiling_boundary.enabled();
862        let handler = ScriptEventHandler {
863            runtime: &mut self.runtime,
864            profiling,
865        };
866        let mut driver = EventDriver::new(&mut self.inner, handler);
867        driver.handle_pointer_move_to_node(&event, node_id);
868
869        self.request_redraw();
870        self.arm_timer_thread();
871    }
872
873    /// Deliver one automation input event to the exact semantic node selected
874    /// by the caller, while retaining the normal pointer event ordering,
875    /// compatibility mouse events, activation state and click synthesis.
876    pub fn handle_ui_event_to_node(&mut self, event: UiEvent, node_id: NodeId) {
877        let profiling_boundary = self.runtime.ctx.enter_profiling_boundary();
878        let profiling = profiling_boundary.enabled();
879        let handler = ScriptEventHandler {
880            runtime: &mut self.runtime,
881            profiling,
882        };
883        let mut driver = EventDriver::new(&mut self.inner, handler);
884        driver.handle_ui_event_to_node(event, node_id);
885
886        self.request_redraw();
887        self.arm_timer_thread();
888    }
889
890    /// The real poll. Split out so every exit path is timed by the wrapper
891    /// above rather than by a stopwatch threaded through each early return.
892    fn poll_inner(
893        &mut self,
894        task_context: Option<TaskContext>,
895        profiling: bool,
896        settle_timers: Option<bool>,
897    ) -> bool {
898        // Store the waker so the timer thread can wake the event loop
899        if let Some(cx) = &task_context {
900            let mut waker = self.waker.lock().unwrap();
901            let stale = waker
902                .as_ref()
903                .map(|old| !old.will_wake(cx.waker()))
904                .unwrap_or(true);
905            if stale {
906                *waker = Some(cx.waker().clone());
907            }
908        }
909
910        // A scripted document may itself be an embedder. Chuzz's Solid chrome
911        // is one: its `<web-view>` elements own the page documents. Poll those
912        // children at the same outer boundary so their timers, resource
913        // completions, and script work continue to make progress.
914        let subdocument_changes = {
915            let mut inner = self.inner.borrow_mut();
916            let waker = task_context.as_ref().map(TaskContext::waker);
917            match settle_timers {
918                Some(run_timers) => inner.poll_subdocuments_for_settle(waker, run_timers),
919                None => inner.poll_subdocuments(waker),
920            }
921        };
922
923        let mut ran = subdocument_changes;
924        ran |= self.runtime.poll_domc();
925        // Every poll resumes a yielded checkpoint, including a timer-free
926        // settle pass. Jobs do not need a timer or a dummy eval to advance.
927        ran |= self.runtime.run_jobs_with_progress("poll microtasks");
928
929        // Execute scripts on first poll if they haven't been run explicitly
930        if !self.scripts_executed {
931            // One-time: parsing and running the application bundle. Separated
932            // because it is startup cost, and folding it into the steady-state
933            // numbers made every per-poll average meaningless.
934            let started = profiling.then(std::time::Instant::now);
935            self.execute_scripts();
936            if let Some(started) = started {
937                crate::script_stats::record_work("startup:execute_scripts", started.elapsed());
938            }
939            ran = true;
940        } else {
941            // Steady state: pick up any script the page appended since the last
942            // turn. Cheap when there are none, and it is the only path by which
943            // a runtime-injected bundle ever runs.
944            ran |= self.run_pending_scripts();
945        }
946        ran |= self.finish_startup_events();
947
948        if settle_timers.unwrap_or(true) {
949            ran |= self.runtime.run_due_timers(profiling);
950        }
951
952        // A module that opened with a top-level `await` may have settled since
953        // the last turn. Checking here is what turns a silent half-mounted page
954        // into a reported rejection.
955        self.runtime.poll_module_evaluations();
956
957        if let Some(mut hook) = self.poll_hook.take() {
958            // The embedder's per-poll work. For a Solid application this is
959            // where reactive updates and DOM mutation actually happen, so it is
960            // the bucket that matters once startup is excluded.
961            let started = profiling.then(std::time::Instant::now);
962            ran |= hook(self, task_context.as_ref());
963            if let Some(started) = started {
964                crate::script_stats::record_work("poll_hook", started.elapsed());
965            }
966            self.poll_hook = Some(hook);
967        }
968
969        // Reclaim what removal could not judge at the time. A node is detached
970        // while script may still hold its wrapper, and the answer only becomes
971        // knowable once the collector has run; this is the point where asking
972        // is cheap and the answer is current.
973        self.runtime.sweep_detached_nodes();
974
975        if self.runtime.jobs_remain() {
976            ran = true;
977            self.request_redraw();
978            if let Some(cx) = &task_context {
979                cx.waker().wake_by_ref();
980            }
981        }
982        self.arm_timer_thread();
983        ran
984    }
985}