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