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 fn diagnostic_batch(
425        &self,
426        console_after: u64,
427        errors_after: u64,
428        limit: usize,
429    ) -> crate::DiagnosticBatch {
430        self.runtime
431            .diagnostic_batch(console_after, errors_after, limit)
432    }
433
434    #[cfg(feature = "debug-control")]
435    pub(crate) fn console_entries_after(
436        &self,
437        sequence: u64,
438    ) -> Vec<crate::runtime::DiagnosticEntry> {
439        self.runtime.console_entries_after(sequence)
440    }
441
442    #[cfg(feature = "debug-control")]
443    pub(crate) fn runtime_errors_after(
444        &self,
445        sequence: u64,
446    ) -> Vec<crate::runtime::DiagnosticEntry> {
447        self.runtime.runtime_errors_after(sequence)
448    }
449
450    /// Current document URL for automation and diagnostics.
451    pub fn current_url(&self) -> Option<&Url> {
452        self.base_url.as_ref()
453    }
454
455    /// Serialize the current document tree as HTML.
456    pub fn page_source(&self) -> String {
457        self.inner.borrow().root_element().outer_html()
458    }
459
460    /// Dispatch a synthetic DOM event (e.g. a click created with
461    /// [`Node::synthetic_click_event`](blitz_dom::Node::synthetic_click_event))
462    /// through the document's event driver. The event is exposed to JavaScript
463    /// event listeners, and Blitz's default actions run unless prevented.
464    pub fn dispatch_dom_event(&mut self, event: DomEvent) {
465        let profiling_boundary = self.runtime.ctx.enter_profiling_boundary();
466        let profiling = profiling_boundary.enabled();
467        let handler = ScriptEventHandler {
468            runtime: &mut self.runtime,
469            profiling,
470        };
471        let mut driver = EventDriver::new(&mut self.inner, handler);
472        driver.handle_dom_event(event);
473
474        self.request_redraw();
475        self.arm_timer_thread();
476    }
477
478    /// Run every `<script>` that has appeared since the last pass.
479    ///
480    /// Called on each poll as well as at startup, because script elements are
481    /// not only a parser product: a page can build one and append it, and that
482    /// is how most real loaders bring in their bundle. Running only the markup
483    /// the parser produced meant those were fetched by nobody and the page
484    /// simply never started.
485    ///
486    /// A `src` script reports `load` when it runs and `error` when it does not.
487    /// Loaders wait on those before continuing — nofilter.io keeps the body
488    /// hidden until the bundle's `load` arrives — so a script that ran silently
489    /// would still leave the page blank.
490    fn run_pending_scripts(&mut self) -> bool {
491        let _slice = self.runtime.begin_job_slice();
492        if self.runtime.jobs_remain() {
493            return false;
494        }
495        if crate::docwrite::take_restart(&self.runtime.context) {
496            self.executed_scripts.clear();
497            self.startup_events_pending = true;
498            self.load_event_pending = false;
499        }
500        let parsed = crate::docwrite::advance(&mut self.runtime.context);
501        let generation = crate::docwrite::generation(&self.runtime.context);
502        if parsed {
503            self.runtime.ctx.mark_layout_dirty();
504        }
505        // Collect first, then run: executing a script can append more, and the
506        // borrow on the document has to be released before any of them runs.
507        let pending: Vec<PendingScript> = self
508            .collect_scripts()
509            .into_iter()
510            .filter(|script| !self.executed_scripts.contains(&script.node_id))
511            .filter(|script| {
512                !crate::docwrite::was_executed(&self.runtime.context, script.node_id)
513                    && (!script.deferred || !crate::docwrite::active(&self.runtime.context))
514            })
515            .collect();
516        let ran = parsed || !pending.is_empty();
517
518        // Import maps first, across the whole batch. A page is free to write
519        // its map after the module that needs it, and a map installed too late
520        // is the same as no map at all: the module has already failed to
521        // resolve its first bare specifier.
522        for script in &pending {
523            if script.kind == ScriptKind::ImportMap && !script.inline_text.trim().is_empty() {
524                self.runtime
525                    .set_import_map(&script.inline_text, self.base_url.as_ref());
526            }
527        }
528
529        // Two passes, because a module is a deferred script and a browser runs
530        // every non-deferred classic script before any of them. Measured
531        // against Chromium on a page mixing all five forms: the parser-blocking
532        // classics run first in document order, then modules and `defer`
533        // scripts together, also in document order.
534        //
535        // Document order alone was right until modules existed. It is now
536        // wrong in a way that bites: an inline classic script writing
537        // `window.__CONFIG__` after a module tag runs *before* that module in
538        // a browser, and the module reads the config it expects.
539        let (deferred, immediate): (Vec<_>, Vec<_>) =
540            pending.into_iter().partition(|script| script.deferred);
541
542        for script in immediate.into_iter().chain(deferred) {
543            if generation != crate::docwrite::generation(&self.runtime.context) {
544                break;
545            }
546            if self.startup_events_pending && script.deferred {
547                self.runtime.set_document_ready_state("interactive");
548                if self.runtime.jobs_remain() {
549                    break;
550                }
551            }
552            // Marked before running, not after: a script that appends another
553            // copy of itself, or that throws, must not be retried on every
554            // poll for the life of the page.
555            self.executed_scripts.insert(script.node_id);
556            if script.kind == ScriptKind::ImportMap {
557                continue;
558            }
559            match script.src {
560                Some(src) => {
561                    let Some(url) = self.resolve_script_url(&src) else {
562                        eprintln!("blitz-script: could not resolve script URL {src:?}");
563                        self.runtime.dispatch_node_event(script.node_id, "error");
564                        if self.runtime.jobs_remain() {
565                            break;
566                        }
567                        continue;
568                    };
569                    let fetcher = Rc::clone(&self.fetcher.borrow());
570                    match fetcher.fetch(&url) {
571                        Ok(code) => {
572                            if script.kind == ScriptKind::Module {
573                                self.runtime.eval_module(&code, Some(&url), url.as_str());
574                            } else {
575                                crate::domc::set_current_script(
576                                    &self.runtime.context,
577                                    Some(script.node_id),
578                                );
579                                self.runtime.eval_at(&code, Some(&url), url.as_str());
580                            }
581                            self.runtime.dispatch_node_event(script.node_id, "load");
582                        }
583                        Err(error) => {
584                            eprintln!("blitz-script: failed to fetch script {url}: {error}");
585                            self.runtime.dispatch_node_event(script.node_id, "error");
586                        }
587                    }
588                }
589                None => {
590                    if !script.inline_text.trim().is_empty() {
591                        // An inline script has no URL of its own, so its
592                        // relative imports and `import.meta.url` resolve
593                        // against the document, exactly as in a browser.
594                        let base_url = self.base_url.clone();
595                        if script.kind == ScriptKind::Module {
596                            self.runtime.eval_module(
597                                &script.inline_text,
598                                base_url.as_ref(),
599                                "<inline module>",
600                            );
601                        } else {
602                            crate::domc::set_current_script(
603                                &self.runtime.context,
604                                Some(script.node_id),
605                            );
606                            self.runtime.eval_at(
607                                &script.inline_text,
608                                base_url.as_ref(),
609                                "<inline script>",
610                            );
611                        }
612                    }
613                }
614            }
615            // Preserve the checkpoint before the next script, even when it
616            // takes several host ticks to finish the queued promise jobs.
617            if self.runtime.jobs_remain() {
618                break;
619            }
620        }
621        ran
622    }
623
624    fn finish_startup_events(&mut self) -> bool {
625        if !(self.startup_events_pending || self.load_event_pending)
626            || self.runtime.jobs_remain()
627            || crate::docwrite::active(&self.runtime.context)
628        {
629            return false;
630        }
631        if self.startup_events_pending
632            && (self.runtime.has_pending_module_evaluations()
633                || self.collect_scripts().iter().any(|script| {
634                    !self.executed_scripts.contains(&script.node_id)
635                        && !crate::docwrite::was_executed(&self.runtime.context, script.node_id)
636                }))
637        {
638            return false;
639        }
640
641        let mut ran = false;
642        if self.startup_events_pending {
643            ran |= self.runtime.set_document_ready_state("interactive");
644            if self.runtime.jobs_remain() {
645                return ran;
646            }
647            self.startup_events_pending = false;
648            self.load_event_pending = true;
649            self.runtime.dispatch_document_event("DOMContentLoaded");
650            ran = true;
651        }
652        let resources_pending = {
653            let doc = self.inner.borrow();
654            doc.has_pending_critical_resources() || doc.pending_image_count() != 0
655        };
656        if self.load_event_pending
657            && !self.runtime.jobs_remain()
658            && !resources_pending
659            && !self.runtime.has_pending_module_evaluations()
660        {
661            ran |= self.runtime.set_document_ready_state("complete");
662            if !self.runtime.jobs_remain() {
663                self.load_event_pending = false;
664                self.runtime.dispatch_window_event("load");
665                ran = true;
666            }
667        }
668        ran
669    }
670
671    /// Find `<script>` elements in document order
672    fn collect_scripts(&self) -> Vec<PendingScript> {
673        let doc = self.inner.borrow();
674        let mut scripts = Vec::new();
675        let mut stack = vec![doc.root_node().id];
676
677        while let Some(node_id) = stack.pop() {
678            let Some(node) = doc.get_node(node_id) else {
679                continue;
680            };
681
682            if let Some(element) = node.element_data() {
683                if element.name.local == blitz_dom::local_name!("script") {
684                    if node
685                        .flags
686                        .contains(blitz_dom::node::NodeFlags::IS_PARSER_INERT_SCRIPT)
687                    {
688                        continue;
689                    }
690                    // Skip non-JavaScript script types (e.g. JSON data blocks).
691                    let script_type = element
692                        .attr(blitz_dom::local_name!("type"))
693                        .unwrap_or("")
694                        .trim()
695                        .to_ascii_lowercase();
696                    let kind = match script_type.as_str() {
697                        "module" => Some(ScriptKind::Module),
698                        "importmap" => Some(ScriptKind::ImportMap),
699                        "" | "text/javascript" | "application/javascript" => {
700                            // `nomodule` marks the classic fallback a page
701                            // ships for engines without module support. Now
702                            // that modules run, taking the fallback as well
703                            // would mount the same application twice.
704                            if element.attr(blitz_dom::local_name!("nomodule")).is_some() {
705                                None
706                            } else {
707                                Some(ScriptKind::Classic)
708                            }
709                        }
710                        _ => None,
711                    };
712
713                    if let Some(kind) = kind {
714                        let src = element
715                            .attr(blitz_dom::local_name!("src"))
716                            .map(str::to_string);
717                        let is_async = element.attr(blitz_dom::local_name!("async")).is_some();
718
719                        // `defer` is only meaningful on an external classic
720                        // script; the spec ignores it on an inline one. A
721                        // module is deferred whether or not it says so.
722                        let deferred = !is_async
723                            && match kind {
724                                ScriptKind::Module => true,
725                                ScriptKind::Classic => {
726                                    src.is_some()
727                                        && element.attr(blitz_dom::local_name!("defer")).is_some()
728                                }
729                                ScriptKind::ImportMap => false,
730                            };
731
732                        scripts.push(PendingScript {
733                            node_id,
734                            src,
735                            inline_text: node.text_content(),
736                            kind,
737                            deferred,
738                        });
739                    }
740                    continue;
741                }
742            }
743
744            stack.extend(node.children.iter().rev().copied());
745        }
746
747        scripts
748    }
749
750    fn request_redraw(&self) {
751        self.inner.borrow().shell_provider.request_redraw();
752    }
753
754    /// Ensure the timer thread is armed to wake the event loop when the next
755    /// JS timer is due.
756    fn arm_timer_thread(&mut self) {
757        let Some(deadline) = self.runtime.next_timer_deadline() else {
758            return;
759        };
760
761        let sender = self.timer_thread.get_or_insert_with(|| {
762            let (tx, rx) = channel::<Instant>();
763            let waker = Arc::clone(&self.waker);
764            std::thread::Builder::new()
765                .name("blitz-script-timers".to_string())
766                .spawn(move || timer_thread_main(rx, waker))
767                .expect("failed to spawn timer thread");
768            tx
769        });
770
771        // If the thread has exited (channel disconnected) drop the sender so a
772        // new thread is spawned next time.
773        if sender.send(deadline).is_err() {
774            self.timer_thread = None;
775        }
776    }
777}
778
779/// Background thread which wakes the event loop when JS timers are due
780fn timer_thread_main(rx: Receiver<Instant>, waker: Arc<Mutex<Option<Waker>>>) {
781    let mut deadline: Option<Instant> = None;
782
783    loop {
784        match deadline {
785            None => match rx.recv() {
786                Ok(new_deadline) => deadline = Some(new_deadline),
787                Err(_) => return,
788            },
789            Some(current) => {
790                let now = Instant::now();
791                if current <= now {
792                    if let Some(waker) = waker.lock().unwrap().as_ref() {
793                        waker.wake_by_ref();
794                    }
795                    deadline = None;
796                    continue;
797                }
798                match rx.recv_timeout(current - now) {
799                    Ok(new_deadline) => deadline = Some(new_deadline.min(current)),
800                    Err(RecvTimeoutError::Timeout) => {
801                        if let Some(waker) = waker.lock().unwrap().as_ref() {
802                            waker.wake_by_ref();
803                        }
804                        deadline = None;
805                    }
806                    Err(RecvTimeoutError::Disconnected) => return,
807                }
808            }
809        }
810    }
811}
812
813impl Document for ScriptDocument {
814    fn inner(&self) -> DocGuard<'_> {
815        DocGuard::RefCell(self.inner.borrow())
816    }
817
818    fn inner_mut(&mut self) -> DocGuardMut<'_> {
819        DocGuardMut::RefCell(self.inner.borrow_mut())
820    }
821
822    fn handle_ui_event(&mut self, event: UiEvent) {
823        let profiling_boundary = self.runtime.ctx.enter_profiling_boundary();
824        let profiling = profiling_boundary.enabled();
825        let handler = ScriptEventHandler {
826            runtime: &mut self.runtime,
827            profiling,
828        };
829        let mut driver = EventDriver::new(&mut self.inner, handler);
830        driver.handle_ui_event(event);
831
832        // JS may have mutated the DOM or scheduled timers
833        self.request_redraw();
834        self.arm_timer_thread();
835    }
836
837    fn poll(&mut self, task_context: Option<TaskContext>) -> bool {
838        self.poll_with_timer_policy(task_context, None)
839    }
840
841    fn poll_for_settle(&mut self, task_context: Option<TaskContext>, run_timers: bool) -> bool {
842        self.poll_with_timer_policy(task_context, Some(run_timers))
843    }
844}
845
846impl ScriptDocument {
847    /// `None` preserves normal polling; `Some` selects a settle pass's policy.
848    fn poll_with_timer_policy(
849        &mut self,
850        task_context: Option<TaskContext>,
851        settle_timers: Option<bool>,
852    ) -> bool {
853        let profiling_boundary = self.runtime.ctx.enter_profiling_boundary();
854        let profiling = profiling_boundary.enabled();
855        let poll_started = profiling.then(std::time::Instant::now);
856        let _slice = self.runtime.begin_job_slice();
857        let ran = self.poll_inner(task_context, profiling, settle_timers);
858        if let Some(started) = poll_started {
859            crate::script_stats::record_poll(started.elapsed(), ran);
860        }
861        ran
862    }
863
864    /// Move a semantic automation pointer to the exact resolved DOM node.
865    ///
866    /// Normal window input remains coordinate hit-tested through
867    /// [`Document::handle_ui_event`]. Debug-control callers already selected a
868    /// node by id, so routing those coordinates through hit testing again can
869    /// silently select an overlapping descendant or retained overlay.
870    pub fn handle_pointer_move_to_node(&mut self, event: BlitzPointerEvent, node_id: NodeId) {
871        let profiling_boundary = self.runtime.ctx.enter_profiling_boundary();
872        let profiling = profiling_boundary.enabled();
873        let handler = ScriptEventHandler {
874            runtime: &mut self.runtime,
875            profiling,
876        };
877        let mut driver = EventDriver::new(&mut self.inner, handler);
878        driver.handle_pointer_move_to_node(&event, node_id);
879
880        self.request_redraw();
881        self.arm_timer_thread();
882    }
883
884    /// Deliver one automation input event to the exact semantic node selected
885    /// by the caller, while retaining the normal pointer event ordering,
886    /// compatibility mouse events, activation state and click synthesis.
887    pub fn handle_ui_event_to_node(&mut self, event: UiEvent, node_id: NodeId) {
888        let profiling_boundary = self.runtime.ctx.enter_profiling_boundary();
889        let profiling = profiling_boundary.enabled();
890        let handler = ScriptEventHandler {
891            runtime: &mut self.runtime,
892            profiling,
893        };
894        let mut driver = EventDriver::new(&mut self.inner, handler);
895        driver.handle_ui_event_to_node(event, node_id);
896
897        self.request_redraw();
898        self.arm_timer_thread();
899    }
900
901    /// The real poll. Split out so every exit path is timed by the wrapper
902    /// above rather than by a stopwatch threaded through each early return.
903    fn poll_inner(
904        &mut self,
905        task_context: Option<TaskContext>,
906        profiling: bool,
907        settle_timers: Option<bool>,
908    ) -> bool {
909        // Store the waker so the timer thread can wake the event loop
910        if let Some(cx) = &task_context {
911            let mut waker = self.waker.lock().unwrap();
912            let stale = waker
913                .as_ref()
914                .map(|old| !old.will_wake(cx.waker()))
915                .unwrap_or(true);
916            if stale {
917                *waker = Some(cx.waker().clone());
918            }
919        }
920
921        // A scripted document may itself be an embedder. Chuzz's Solid chrome
922        // is one: its `<web-view>` elements own the page documents. Poll those
923        // children at the same outer boundary so their timers, resource
924        // completions, and script work continue to make progress.
925        let subdocument_changes = {
926            let mut inner = self.inner.borrow_mut();
927            let waker = task_context.as_ref().map(TaskContext::waker);
928            match settle_timers {
929                Some(run_timers) => inner.poll_subdocuments_for_settle(waker, run_timers),
930                None => inner.poll_subdocuments(waker),
931            }
932        };
933
934        let mut ran = subdocument_changes;
935        ran |= self.runtime.poll_domc();
936        // Every poll resumes a yielded checkpoint, including a timer-free
937        // settle pass. Jobs do not need a timer or a dummy eval to advance.
938        ran |= self.runtime.run_jobs_with_progress("poll microtasks");
939
940        // Execute scripts on first poll if they haven't been run explicitly
941        if !self.scripts_executed {
942            // One-time: parsing and running the application bundle. Separated
943            // because it is startup cost, and folding it into the steady-state
944            // numbers made every per-poll average meaningless.
945            let started = profiling.then(std::time::Instant::now);
946            self.execute_scripts();
947            if let Some(started) = started {
948                crate::script_stats::record_work("startup:execute_scripts", started.elapsed());
949            }
950            ran = true;
951        } else {
952            // Steady state: pick up any script the page appended since the last
953            // turn. Cheap when there are none, and it is the only path by which
954            // a runtime-injected bundle ever runs.
955            ran |= self.run_pending_scripts();
956        }
957        ran |= self.finish_startup_events();
958
959        if settle_timers.unwrap_or(true) {
960            ran |= self.runtime.run_due_timers(profiling);
961        }
962
963        // A module that opened with a top-level `await` may have settled since
964        // the last turn. Checking here is what turns a silent half-mounted page
965        // into a reported rejection.
966        self.runtime.poll_module_evaluations();
967
968        if let Some(mut hook) = self.poll_hook.take() {
969            // The embedder's per-poll work. For a Solid application this is
970            // where reactive updates and DOM mutation actually happen, so it is
971            // the bucket that matters once startup is excluded.
972            let started = profiling.then(std::time::Instant::now);
973            ran |= hook(self, task_context.as_ref());
974            if let Some(started) = started {
975                crate::script_stats::record_work("poll_hook", started.elapsed());
976            }
977            self.poll_hook = Some(hook);
978        }
979
980        // Reclaim what removal could not judge at the time. A node is detached
981        // while script may still hold its wrapper, and the answer only becomes
982        // knowable once the collector has run; this is the point where asking
983        // is cheap and the answer is current.
984        self.runtime.sweep_detached_nodes();
985
986        if self.runtime.jobs_remain() {
987            ran = true;
988            self.request_redraw();
989            if let Some(cx) = &task_context {
990                cx.waker().wake_by_ref();
991            }
992        }
993        self.arm_timer_thread();
994        ran
995    }
996}