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