Skip to main content

blitz_script/
document.rs

1//! [`ScriptDocument`]: a [`Document`] implementation with JavaScript support
2
3use std::cell::RefCell;
4use std::future::Future;
5use std::pin::Pin;
6use std::rc::Rc;
7use std::sync::Arc;
8use std::task::{Context as TaskContext, Poll, Waker};
9
10use blitz_dom::{
11    BaseDocument, DEFAULT_CSS, DocGuard, DocGuardMut, Document, DocumentConfig, EventDriver, NodeId,
12};
13use blitz_html::stream::StreamingParser;
14use blitz_html::{DocumentHtmlParser, HtmlProvider};
15use blitz_traits::events::{BlitzPointerEvent, DomEvent, UiEvent};
16use url::Url;
17use web_time::{Duration, Instant};
18
19use crate::event_handler::ScriptEventHandler;
20use crate::fetch::{DefaultScriptFetcher, ScriptFetcher};
21use crate::module::SharedFetcher;
22use crate::runtime::{BoaScriptEngine, ScriptRuntime};
23
24type PollHook =
25    Box<dyn for<'a> FnMut(&mut ScriptDocument, Option<&TaskContext<'a>>) -> bool + 'static>;
26
27/// What a `<script>` element's `type` says it holds.
28#[derive(Clone, Copy, PartialEq, Eq)]
29enum ScriptKind {
30    Classic,
31    /// `type="module"`. Parsed in module goal and given a loader; a module run
32    /// as a classic script fails on its own first `import` statement.
33    Module,
34    /// `type="importmap"`. Not code: the JSON that gives bare specifiers their
35    /// meaning, and it has to be installed before any module resolves one.
36    ImportMap,
37}
38
39/// A `<script>` element found in the document
40struct PendingScript {
41    node_id: NodeId,
42    src: Option<String>,
43    inline_text: String,
44    kind: ScriptKind,
45    /// Runs after every non-deferred script in the same batch.
46    ///
47    /// True for module scripts, which are deferred by definition, and for
48    /// `<script defer src>`. `async` takes it back off both: an async script
49    /// runs whenever its fetch lands, which here is immediately.
50    deferred: bool,
51}
52
53type BeforeScriptHook = Box<dyn FnMut(&mut BaseDocument)>;
54
55/// A [`Document`] which executes the JavaScript contained in the document's
56/// `<script>` tags, exposing DOM APIs backed by `blitz-dom` to the scripts.
57///
58/// Construct with [`ScriptDocument::from_html`], then call
59/// [`execute_scripts`](ScriptDocument::execute_scripts) (this also happens
60/// automatically on the first [`poll`](Document::poll)). UI events pushed via
61/// [`handle_ui_event`](Document::handle_ui_event) are dispatched to JavaScript
62/// event listeners before Blitz's default actions run.
63pub struct ScriptDocument {
64    inner: Rc<RefCell<BaseDocument>>,
65    runtime: ScriptRuntime,
66    base_url: Option<Url>,
67    /// Shared with the module loader inside the script context.
68    ///
69    /// The loader is fixed at context construction, which happens in
70    /// `from_html`, while an embedder installs its fetcher afterwards through
71    /// [`with_fetcher`](Self::with_fetcher). The cell is what lets the later
72    /// call reach the earlier object.
73    fetcher: SharedFetcher,
74    scripts_executed: bool,
75    startup_events_pending: bool,
76    load_event_pending: bool,
77    /// Which `<script>` nodes have already run.
78    ///
79    /// By node rather than a single "done" flag, because scripts keep arriving
80    /// after the first pass: a loader that appends its bundle, an analytics
81    /// snippet, a lazily inserted chunk. Keyed this way, a rescan can tell a
82    /// script it has run from one it has not without running anything twice.
83    executed_scripts: std::collections::HashSet<NodeId>,
84    poll_hook: Option<PollHook>,
85    teardown_hooks: Vec<Box<dyn FnOnce()>>,
86    before_script_hook: Option<BeforeScriptHook>,
87    _runtime_lease: blitz_dom::release::ReleaseLease,
88
89    // The timer registry owns a clone of the current document waker.
90    waker: Option<Waker>,
91    timer_wake: Option<(Instant, nagoya::time::Sleep)>,
92}
93
94impl ScriptDocument {
95    /// Parse HTML up to its first script boundary into a [`ScriptDocument`].
96    ///
97    /// Embedders install their shims on this live DOM before scripts execute.
98    /// [`execute_scripts`](Self::execute_scripts), or the first `poll`, resumes
99    /// the same parser without replacing any nodes.
100    ///
101    /// HTML without a script boundary is parsed completely before returning.
102    /// XHTML keeps its non-streaming XML parser. No scripts execute here.
103    pub fn from_html(html: &str, mut config: DocumentConfig) -> Self {
104        if let Some(ss) = &mut config.ua_stylesheets {
105            if !ss.iter().any(|s| s == DEFAULT_CSS) {
106                ss.push(String::from(DEFAULT_CSS));
107            }
108        } else {
109            config.ua_stylesheets = Some(vec![String::from(DEFAULT_CSS)]);
110        }
111        if config.html_parser_provider.is_none() {
112            config.html_parser_provider = Some(Arc::new(HtmlProvider));
113        }
114
115        let base_url = config
116            .base_url
117            .as_deref()
118            .and_then(|url| Url::parse(url).ok());
119
120        let is_xml = DocumentHtmlParser::is_xhtml_document(html);
121        let mut doc = BaseDocument::new(config);
122        if is_xml {
123            let mut mutr = doc.mutate();
124            DocumentHtmlParser::parse_into_mutator(&mut mutr, html);
125            drop(mutr);
126        }
127
128        let inner = Rc::new(RefCell::new(doc));
129        let parser = if is_xml {
130            None
131        } else {
132            let mut parser = StreamingParser::new(Rc::clone(&inner), html);
133            if parser.next_script().is_some() {
134                Some(parser)
135            } else {
136                parser.finish();
137                None
138            }
139        };
140        let fetcher: SharedFetcher = Rc::new(RefCell::new(Rc::new(DefaultScriptFetcher)));
141        let runtime = ScriptRuntime::new(BoaScriptEngine::new(
142            Rc::clone(&inner),
143            base_url.as_ref(),
144            Rc::clone(&fetcher),
145        ));
146        crate::docwrite::prepare(&runtime.context, html, Rc::clone(&fetcher), parser);
147
148        let runtime_lease = inner.borrow().teardown.lease();
149        Self {
150            inner,
151            runtime,
152            teardown_hooks: Vec::new(),
153            before_script_hook: None,
154            _runtime_lease: runtime_lease,
155            base_url,
156            fetcher,
157            scripts_executed: false,
158            startup_events_pending: false,
159            load_event_pending: false,
160            executed_scripts: std::collections::HashSet::new(),
161            poll_hook: None,
162            waker: None,
163            timer_wake: None,
164        }
165    }
166
167    pub fn set_before_script_hook(&mut self, hook: impl FnMut(&mut BaseDocument) + 'static) {
168        assert!(!self.scripts_executed, "restore precedes bootstrap");
169        self.before_script_hook = Some(Box::new(hook));
170    }
171
172    fn prepare_native_restore(&mut self) {
173        if let Some(mut hook) = self.before_script_hook.take() {
174            hook(&mut self.inner.borrow_mut());
175            self.before_script_hook = Some(hook);
176        }
177    }
178
179    /// Host cancellation hooks run exactly once on retirement or ordinary drop.
180    pub fn add_teardown_hook(&mut self, hook: impl FnOnce() + 'static) {
181        self.teardown_hooks.push(Box::new(hook));
182    }
183
184    pub fn teardown_receipt(&self) -> Arc<blitz_dom::release::Teardown> {
185        Arc::clone(&self.inner.borrow().teardown)
186    }
187
188    pub fn set_was_discarded(&mut self, discarded: bool) {
189        assert!(
190            !self.scripts_executed,
191            "wasDiscarded must precede page bootstrap"
192        );
193        self.inner.borrow_mut().was_discarded = discarded;
194    }
195
196    fn cancel_host_work(&mut self) {
197        self.timer_wake.take();
198        self.before_script_hook.take();
199        self.waker.take();
200        for hook in std::mem::take(&mut self.teardown_hooks) {
201            hook();
202        }
203        self.poll_hook.take();
204        self.runtime.retire();
205    }
206
207    /// Set the microtask budget shared by all drains in one poll.
208    ///
209    /// Defaults to 16 milliseconds. Unstarted jobs remain queued. Individual
210    /// jobs run to completion unless the context's hard execution limit
211    /// interrupts them. Panics if `duration` is zero.
212    pub fn set_job_budget(&mut self, duration: Duration) {
213        self.runtime.set_job_budget(duration);
214    }
215
216    /// The configured microtask budget.
217    pub fn job_budget(&self) -> Duration {
218        self.runtime.job_budget()
219    }
220
221    /// Whether immediate Boa jobs or observer records remain for a later host tick.
222    ///
223    /// Future timers and suspended module evaluations alone do not count.
224    pub fn has_pending_jobs(&self) -> bool {
225        self.runtime.jobs_remain()
226    }
227
228    /// Work queued for the next host tick: due timers and frame callbacks,
229    /// plus one while any promise job is runnable.
230    pub fn pending_task_count(&self) -> u64 {
231        self.runtime.pending_task_count()
232    }
233
234    /// Drive this document while a renderer-owned completion is pending.
235    #[cfg(feature = "debug-control")]
236    pub(crate) fn wait_with<T>(
237        &mut self,
238        mut wait: impl FnMut(&mut Self, &mut TaskContext<'_>) -> Poll<T>,
239    ) -> T {
240        let host_waker = self.waker.clone();
241        let result = nagoya::block_on(std::future::poll_fn(|cx| {
242            self.poll(Some(TaskContext::from_waker(cx.waker())));
243            wait(self, cx)
244        }));
245        self.waker = host_waker;
246        self.arm_timer_wake();
247        result
248    }
249
250    /// Drive scripts and runnable event-loop work to quiescence.
251    ///
252    /// Starts document scripts if necessary, then polls until parsing,
253    /// deferred scripts, microtasks, observer delivery and timers due now
254    /// have drained. Completion depends on pending work, not a fixed number
255    /// of polls. Each poll retains the configured job budget.
256    ///
257    /// External scripts use the synchronous document fetcher. Imported module
258    /// loads remain pending until their result futures complete, including
259    /// dynamic imports from classic scripts. Runnable result futures drain
260    /// with the microtask queue. Suspended module evaluations wait for a
261    /// document wake or the module deadline.
262    /// Unrelated future timers remain scheduled, and non-script resources
263    /// are not awaited.
264    ///
265    /// Returns an error if module loading or evaluation remains pending after
266    /// sixty seconds, or an unfinished document input stream cannot make
267    /// progress. Synchronous fetchers must enforce their own I/O timeouts.
268    /// A page that continuously produces runnable work without pending modules
269    /// may never become idle.
270    ///
271    /// Call on the document's owning thread, outside poll hooks and
272    /// JavaScript callbacks. This is a blocking entry point for tests and
273    /// hosts that need completed script startup before inspecting the DOM.
274    pub fn run_until_idle(&mut self) -> Result<(), &'static str> {
275        const MODULE_TIMEOUT: Duration = Duration::from_secs(60);
276        let module_deadline = Instant::now() + MODULE_TIMEOUT;
277        let mut module_timeout = nagoya::time::sleep(MODULE_TIMEOUT);
278        let host_waker = self.waker.clone();
279
280        let result = nagoya::block_on(std::future::poll_fn(|cx| {
281            let progressed = self.poll(Some(TaskContext::from_waker(cx.waker())));
282            let now = Instant::now();
283            let next_timer = self.runtime.next_timer_deadline();
284            let loads_pending = self.runtime.has_pending_module_loads();
285            let evaluations_pending = self.runtime.has_pending_module_evaluations();
286
287            // Check before yielding another runnable slice.
288            if now >= module_deadline {
289                if loads_pending {
290                    return Poll::Ready(Err(
291                        "module loading did not complete within sixty seconds",
292                    ));
293                }
294                if evaluations_pending {
295                    return Poll::Ready(Err(
296                        "module evaluation did not complete within sixty seconds",
297                    ));
298                }
299            }
300
301            if self.runtime.jobs_remain()
302                || next_timer.is_some_and(|deadline| deadline <= now)
303                || progressed
304            {
305                cx.waker().wake_by_ref();
306                return Poll::Pending;
307            }
308
309            if loads_pending || evaluations_pending {
310                // Loads use the synchronous fetcher and ready result futures.
311                // Suspended evaluation resumes on a document wake or timeout.
312                let _ = Pin::new(&mut module_timeout).poll(cx);
313                return Poll::Pending;
314            }
315
316            if crate::docwrite::active(&self.runtime.context)
317                || self.startup_events_pending
318                || self.collect_scripts().iter().any(|script| {
319                    !self.executed_scripts.contains(&script.node_id)
320                        && !crate::docwrite::was_executed(&self.runtime.context, script.node_id)
321                })
322            {
323                return Poll::Ready(Err("script startup cannot make progress"));
324            }
325
326            Poll::Ready(Ok(()))
327        }));
328        self.waker = host_waker;
329        self.arm_timer_wake();
330        result
331    }
332
333    /// When the soonest page timer is due, so a host can sleep exactly until
334    /// then instead of on a fixed tick (a `setTimeout(0)` continuation should
335    /// not wait for the host's next frame).
336    pub fn next_timer_deadline(&self) -> Option<std::time::Instant> {
337        self.runtime.next_timer_deadline()
338    }
339
340    /// Retain the context's hard execution deadline across a host operation.
341    ///
342    /// Input, polling and evaluation enter scopes automatically. An embedder
343    /// can retain an outer scope across several input events in one action.
344    pub fn enter_execution_time_limit(&self) -> boa_engine::vm::ExecutionScope {
345        self.runtime.context.enter_execution_time_limit()
346    }
347    /// Override the [`ScriptFetcher`] used to load external (`src="..."`) scripts.
348    /// The default fetcher supports `file:` and `data:` URLs.
349    pub fn with_fetcher(self, fetcher: impl ScriptFetcher) -> Self {
350        *self.fetcher.borrow_mut() = Rc::new(fetcher);
351        self
352    }
353
354    /// Install the host callback invoked by `window.ipc.postMessage(body)`.
355    ///
356    /// The callback may forward work to another thread, but JavaScript and DOM state remain on
357    /// the document's owning thread. Replacing the callback is supported before script startup.
358    pub fn set_ipc_handler(&mut self, handler: impl Fn(String) + 'static) {
359        self.runtime.ctx.state.borrow_mut().ipc_handler = Some(Rc::new(handler));
360    }
361
362    /// Install work that an embedder needs to run on the document thread during polling.
363    ///
364    /// The hook runs after document scripts and due timers. Returning `true` requests a redraw.
365    pub fn set_poll_hook(
366        &mut self,
367        hook: impl for<'a> FnMut(&mut ScriptDocument, Option<&TaskContext<'a>>) -> bool + 'static,
368    ) {
369        self.poll_hook = Some(Box::new(hook));
370    }
371
372    /// Append document-thread work without discarding an embedder's existing
373    /// poll lifecycle.
374    pub fn add_poll_hook(
375        &mut self,
376        mut hook: impl for<'a> FnMut(&mut ScriptDocument, Option<&TaskContext<'a>>) -> bool + 'static,
377    ) {
378        let Some(mut existing) = self.poll_hook.take() else {
379            self.poll_hook = Some(Box::new(hook));
380            return;
381        };
382        self.poll_hook = Some(Box::new(move |document, task_context| {
383            let ran_existing = existing(document, task_context);
384            let ran_added = hook(document, task_context);
385            ran_existing | ran_added
386        }));
387    }
388
389    /// Execute the document's `<script>` elements in document order, then fire
390    /// the `DOMContentLoaded` and `load` events.
391    ///
392    /// Does nothing if scripts have already been executed.
393    pub fn execute_scripts(&mut self) {
394        let _execution = self.enter_execution_time_limit();
395        let _profiling = self.runtime.ctx.enter_profiling_boundary();
396        let _slice = self.runtime.begin_job_slice();
397        if self.scripts_executed {
398            return;
399        }
400        self.scripts_executed = true;
401        self.startup_events_pending = true;
402
403        crate::docwrite::begin(&mut self.runtime.context);
404        self.resume_parser_and_scripts();
405        self.finish_startup_events();
406
407        self.request_redraw();
408        self.arm_timer_wake();
409    }
410
411    /// The resolved URLs of the document's external (`<script src="...">`) scripts,
412    /// in document order.
413    ///
414    /// The [`ScriptFetcher`] API is synchronous, so embedders with asynchronous
415    /// networking can use this to prefetch script sources before calling
416    /// [`execute_scripts`](Self::execute_scripts), and then serve them from memory
417    /// via a custom fetcher (see [`with_fetcher`](Self::with_fetcher)).
418    pub fn external_script_urls(&self) -> Vec<Url> {
419        if let Some(original) = crate::docwrite::original(&self.runtime.context) {
420            // The navigation DOM stops at its first boundary. Discover later
421            // source scripts without creating native nodes or loading resources.
422            return blitz_html::scan_script_tags(&original)
423                .into_iter()
424                .filter(|script| script.is_javascript())
425                .filter_map(|script| script.src)
426                .filter_map(|src| self.resolve_script_url(src.as_ref()))
427                .collect();
428        }
429        self.collect_scripts()
430            .iter()
431            // An import map is inline-only per the HTML spec, so a `src` on one
432            // names nothing an embedder should prefetch.
433            .filter(|script| script.kind != ScriptKind::ImportMap)
434            .filter_map(|script| script.src.as_deref())
435            .filter_map(|src| self.resolve_script_url(src))
436            .collect()
437    }
438
439    /// Resolve a script `src` attribute against the document's base URL
440    fn resolve_script_url(&self, src: &str) -> Option<Url> {
441        match &self.base_url {
442            Some(base) => base.join(src).ok(),
443            None => Url::parse(src).ok(),
444        }
445    }
446
447    /// Extend the native JavaScript surface on the document's owning thread.
448    ///
449    /// The callback does not execute a microtask checkpoint or request a
450    /// redraw. Poll hooks can therefore deliver native values and report work
451    /// only when an event actually reaches the page.
452    pub fn with_js_context<R>(
453        &mut self,
454        callback: impl FnOnce(&mut boa_engine::Context) -> R,
455    ) -> R {
456        let _execution = self.enter_execution_time_limit();
457        callback(&mut self.runtime.context)
458    }
459
460    /// Evaluate arbitrary JavaScript code in the document's script context
461    pub fn eval(&mut self, code: &str) {
462        let _execution = self.enter_execution_time_limit();
463        let _profiling = self.runtime.ctx.enter_profiling_boundary();
464        self.runtime.eval(code, "<eval>");
465        self.request_redraw();
466        self.arm_timer_wake();
467    }
468
469    /// Evaluate an embedder prelude under a name, which becomes its source path
470    /// in stack traces and profiles (for example `<chuzz-webgpu>`).
471    pub fn eval_named(&mut self, code: &str, name: &str) {
472        let _execution = self.enter_execution_time_limit();
473        let _profiling = self.runtime.ctx.enter_profiling_boundary();
474        self.runtime.eval_internal(code, name);
475        self.runtime.run_jobs(name);
476        self.request_redraw();
477        self.arm_timer_wake();
478    }
479
480    /// Evaluate JavaScript and convert its result to JSON.
481    ///
482    /// Embedders use this for APIs that return an evaluation result, such as Tauri's
483    /// `eval_script_with_callback`. JavaScript exceptions are recorded in the runtime diagnostics
484    /// and returned as an error.
485    pub fn eval_json(&mut self, code: &str) -> Result<serde_json::Value, String> {
486        let _execution = self.enter_execution_time_limit();
487        let _profiling = self.runtime.ctx.enter_profiling_boundary();
488        let result = self.runtime.eval_json(code, "<eval with result>");
489        self.request_redraw();
490        self.arm_timer_wake();
491        result
492    }
493
494    #[cfg(feature = "debug-control")]
495    pub fn diagnostic_batch(
496        &self,
497        console_after: u64,
498        errors_after: u64,
499        limit: usize,
500    ) -> crate::DiagnosticBatch {
501        self.runtime
502            .diagnostic_batch(console_after, errors_after, limit)
503    }
504
505    #[cfg(feature = "debug-control")]
506    pub(crate) fn console_entries_after(
507        &self,
508        sequence: u64,
509    ) -> Vec<crate::runtime::DiagnosticEntry> {
510        self.runtime.console_entries_after(sequence)
511    }
512
513    #[cfg(feature = "debug-control")]
514    pub(crate) fn runtime_errors_after(
515        &self,
516        sequence: u64,
517    ) -> Vec<crate::runtime::DiagnosticEntry> {
518        self.runtime.runtime_errors_after(sequence)
519    }
520
521    /// Current document URL for automation and diagnostics.
522    pub fn current_url(&self) -> Option<&Url> {
523        self.base_url.as_ref()
524    }
525
526    /// Serialize the current document tree as HTML.
527    pub fn page_source(&self) -> String {
528        self.inner.borrow().root_element().outer_html()
529    }
530
531    /// Dispatch a synthetic DOM event (e.g. a click created with
532    /// [`Node::synthetic_click_event`](blitz_dom::Node::synthetic_click_event))
533    /// through the document's event driver. The event is exposed to JavaScript
534    /// event listeners, and Blitz's default actions run unless prevented.
535    pub fn dispatch_dom_event(&mut self, event: DomEvent) {
536        if self.inner.borrow().page_frozen
537            || self.inner.borrow().page_retiring
538            || self.inner.borrow().page_input_blocked
539        {
540            return;
541        }
542        let _execution = self.enter_execution_time_limit();
543        let profiling_boundary = self.runtime.ctx.enter_profiling_boundary();
544        let profiling = profiling_boundary.enabled();
545        let handler = ScriptEventHandler {
546            runtime: &mut self.runtime,
547            profiling,
548        };
549        let mut driver = EventDriver::new(&mut self.inner, handler);
550        driver.handle_dom_event(event);
551
552        self.request_redraw();
553        self.arm_timer_wake();
554    }
555
556    /// Resume navigation input through completed script checkpoints.
557    ///
558    /// Startup and subsequent polls must use the same continuation loop.
559    /// After a yielded checkpoint completes, keep feeding the retained input
560    /// through each script boundary until EOF or another checkpoint yields.
561    fn resume_parser_and_scripts(&mut self) -> bool {
562        let _slice = self.runtime.begin_job_slice();
563        let mut ran = false;
564        loop {
565            let progressed = self.run_pending_scripts();
566            ran |= progressed;
567            if !progressed
568                || self.runtime.jobs_remain()
569                || !crate::docwrite::active(&self.runtime.context)
570            {
571                break;
572            }
573        }
574        ran
575    }
576
577    /// Run every `<script>` that has appeared since the last pass.
578    ///
579    /// Called on each poll as well as at startup, because script elements are
580    /// not only a parser product: a page can build one and append it, and that
581    /// is how most real loaders bring in their bundle. Running only the markup
582    /// the parser produced meant those were fetched by nobody and the page
583    /// simply never started.
584    ///
585    /// A `src` script reports `load` when it runs and `error` when it does not.
586    /// Loaders wait on those before continuing , nofilter.io keeps the body
587    /// hidden until the bundle's `load` arrives , so a script that ran silently
588    /// would still leave the page blank.
589    fn run_pending_scripts(&mut self) -> bool {
590        let _slice = self.runtime.begin_job_slice();
591        if self.runtime.jobs_remain() || self.runtime.job_slice_exhausted() {
592            return false;
593        }
594        if crate::docwrite::take_restart(&self.runtime.context) {
595            self.executed_scripts.clear();
596            self.startup_events_pending = true;
597            self.load_event_pending = false;
598        }
599        let parsed = crate::docwrite::advance(&mut self.runtime.context);
600        let generation = crate::docwrite::generation(&self.runtime.context);
601        self.prepare_native_restore();
602        if parsed {
603            self.runtime.ctx.mark_layout_dirty();
604        }
605        // Collect first, then run: executing a script can append more, and the
606        // borrow on the document has to be released before any of them runs.
607        let pending: Vec<PendingScript> = self
608            .collect_scripts()
609            .into_iter()
610            .filter(|script| !self.executed_scripts.contains(&script.node_id))
611            .filter(|script| {
612                !crate::docwrite::was_executed(&self.runtime.context, script.node_id)
613                    && (!script.deferred || !crate::docwrite::active(&self.runtime.context))
614            })
615            .collect();
616        let ran = parsed || !pending.is_empty();
617
618        // Import maps first, across the whole batch. A page is free to write
619        // its map after the module that needs it, and a map installed too late
620        // is the same as no map at all: the module has already failed to
621        // resolve its first bare specifier.
622        for script in &pending {
623            if script.kind == ScriptKind::ImportMap && !script.inline_text.trim().is_empty() {
624                self.runtime
625                    .set_import_map(&script.inline_text, self.base_url.as_ref());
626            }
627        }
628
629        // Two passes, because a module is a deferred script and a browser runs
630        // every non-deferred classic script before any of them. Measured
631        // against Chromium on a page mixing all five forms: the parser-blocking
632        // classics run first in document order, then modules and `defer`
633        // scripts together, also in document order.
634        //
635        // Document order alone was right until modules existed. It is now
636        // wrong in a way that bites: an inline classic script writing
637        // `window.__CONFIG__` after a module tag runs *before* that module in
638        // a browser, and the module reads the config it expects.
639        let (deferred, immediate): (Vec<_>, Vec<_>) =
640            pending.into_iter().partition(|script| script.deferred);
641
642        for script in immediate.into_iter().chain(deferred) {
643            if self.runtime.job_slice_exhausted() {
644                break;
645            }
646            if generation != crate::docwrite::generation(&self.runtime.context) {
647                break;
648            }
649            if self.startup_events_pending && script.deferred {
650                self.runtime.set_document_ready_state("interactive");
651                if self.runtime.jobs_remain() {
652                    break;
653                }
654            }
655            // Marked before running, not after: a script that appends another
656            // copy of itself, or that throws, must not be retried on every
657            // poll for the life of the page.
658            self.prepare_native_restore();
659            self.executed_scripts.insert(script.node_id);
660            if script.kind == ScriptKind::ImportMap {
661                continue;
662            }
663            match script.src {
664                Some(src) => {
665                    let Some(url) = self.resolve_script_url(&src) else {
666                        eprintln!("blitz-script: could not resolve script URL {src:?}");
667                        self.runtime.dispatch_node_event(script.node_id, "error");
668                        if self.runtime.jobs_remain() {
669                            break;
670                        }
671                        continue;
672                    };
673                    let fetcher = Rc::clone(&self.fetcher.borrow());
674                    match fetcher.fetch(&url) {
675                        Ok(code) => {
676                            if script.kind == ScriptKind::Module {
677                                self.runtime.eval_module(&code, Some(&url), url.as_str());
678                            } else {
679                                crate::domc::set_current_script(
680                                    &self.runtime.context,
681                                    Some(script.node_id),
682                                );
683                                self.runtime.eval_at(&code, Some(&url), url.as_str());
684                            }
685                            self.runtime.dispatch_node_event(script.node_id, "load");
686                        }
687                        Err(error) => {
688                            eprintln!("blitz-script: failed to fetch script {url}: {error}");
689                            self.runtime.dispatch_node_event(script.node_id, "error");
690                        }
691                    }
692                }
693                None => {
694                    if !script.inline_text.trim().is_empty() {
695                        // An inline script has no URL of its own, so its
696                        // relative imports and `import.meta.url` resolve
697                        // against the document, exactly as in a browser.
698                        let base_url = self.base_url.clone();
699                        if script.kind == ScriptKind::Module {
700                            self.runtime.eval_module(
701                                &script.inline_text,
702                                base_url.as_ref(),
703                                "<inline module>",
704                            );
705                        } else {
706                            crate::domc::set_current_script(
707                                &self.runtime.context,
708                                Some(script.node_id),
709                            );
710                            self.runtime.eval_at(
711                                &script.inline_text,
712                                base_url.as_ref(),
713                                "<inline script>",
714                            );
715                        }
716                    }
717                }
718            }
719            // Preserve the checkpoint before the next script, even when it
720            // takes several host ticks to finish the queued promise jobs.
721            if self.runtime.jobs_remain() {
722                break;
723            }
724        }
725        ran
726    }
727
728    fn finish_startup_events(&mut self) -> bool {
729        if !(self.startup_events_pending || self.load_event_pending)
730            || self.runtime.jobs_remain()
731            || crate::docwrite::active(&self.runtime.context)
732            || self.runtime.job_slice_exhausted()
733        {
734            return false;
735        }
736        if self.startup_events_pending
737            && (self.runtime.has_pending_module_evaluations()
738                || self.collect_scripts().iter().any(|script| {
739                    !self.executed_scripts.contains(&script.node_id)
740                        && !crate::docwrite::was_executed(&self.runtime.context, script.node_id)
741                }))
742        {
743            return false;
744        }
745
746        let mut ran = false;
747        if self.startup_events_pending {
748            ran |= self.runtime.set_document_ready_state("interactive");
749            if self.runtime.jobs_remain() {
750                return ran;
751            }
752            self.startup_events_pending = false;
753            self.load_event_pending = true;
754            self.runtime.dispatch_document_event("DOMContentLoaded");
755            ran = true;
756        }
757        let resources_pending = {
758            let doc = self.inner.borrow();
759            doc.has_pending_critical_resources() || doc.pending_image_count() != 0
760        };
761        if self.load_event_pending
762            && !self.runtime.job_slice_exhausted()
763            && !self.runtime.jobs_remain()
764            && !resources_pending
765            && !self.runtime.has_pending_module_evaluations()
766        {
767            ran |= self.runtime.set_document_ready_state("complete");
768            if !self.runtime.jobs_remain() {
769                self.load_event_pending = false;
770                self.runtime.dispatch_window_event("load");
771                ran = true;
772            }
773        }
774        ran
775    }
776
777    /// Find `<script>` elements in document order
778    fn collect_scripts(&self) -> Vec<PendingScript> {
779        let doc = self.inner.borrow();
780        let mut scripts = Vec::new();
781        let mut stack = vec![doc.root_node().id];
782
783        while let Some(node_id) = stack.pop() {
784            let Some(node) = doc.get_node(node_id) else {
785                continue;
786            };
787
788            if let Some(element) = node.element_data() {
789                if element.name.local == blitz_dom::local_name!("script") {
790                    if node
791                        .flags
792                        .contains(blitz_dom::node::NodeFlags::IS_PARSER_INERT_SCRIPT)
793                    {
794                        continue;
795                    }
796                    // Skip non-JavaScript script types (e.g. JSON data blocks).
797                    let script_type = element
798                        .attr(blitz_dom::local_name!("type"))
799                        .unwrap_or("")
800                        .trim()
801                        .to_ascii_lowercase();
802                    let kind = match script_type.as_str() {
803                        "module" => Some(ScriptKind::Module),
804                        "importmap" => Some(ScriptKind::ImportMap),
805                        "" | "text/javascript" | "application/javascript" => {
806                            // `nomodule` marks the classic fallback a page
807                            // ships for engines without module support. Now
808                            // that modules run, taking the fallback as well
809                            // would mount the same application twice.
810                            if element.attr(blitz_dom::local_name!("nomodule")).is_some() {
811                                None
812                            } else {
813                                Some(ScriptKind::Classic)
814                            }
815                        }
816                        _ => None,
817                    };
818
819                    if let Some(kind) = kind {
820                        let src = element
821                            .attr(blitz_dom::local_name!("src"))
822                            .map(str::to_string);
823                        let is_async = element.attr(blitz_dom::local_name!("async")).is_some();
824
825                        // `defer` is only meaningful on an external classic
826                        // script; the spec ignores it on an inline one. A
827                        // module is deferred whether or not it says so.
828                        let deferred = !is_async
829                            && match kind {
830                                ScriptKind::Module => true,
831                                ScriptKind::Classic => {
832                                    src.is_some()
833                                        && element.attr(blitz_dom::local_name!("defer")).is_some()
834                                }
835                                ScriptKind::ImportMap => false,
836                            };
837
838                        scripts.push(PendingScript {
839                            node_id,
840                            src,
841                            inline_text: node.text_content(),
842                            kind,
843                            deferred,
844                        });
845                    }
846                    continue;
847                }
848            }
849
850            stack.extend(node.children.iter().rev().copied());
851        }
852
853        scripts
854    }
855
856    fn request_redraw(&self) {
857        self.inner.borrow().shell_provider.request_redraw();
858    }
859
860    /// Register the next JS deadline with the document's current host waker.
861    fn arm_timer_wake(&mut self) {
862        let Some(deadline) = self.runtime.next_timer_deadline() else {
863            self.timer_wake = None;
864            return;
865        };
866        let Some(waker) = &self.waker else {
867            self.timer_wake = None;
868            return;
869        };
870        if self
871            .timer_wake
872            .as_ref()
873            .is_none_or(|(armed, _)| *armed != deadline)
874        {
875            self.timer_wake = Some((
876                deadline,
877                nagoya::time::sleep(deadline.saturating_duration_since(Instant::now())),
878            ));
879        }
880        let (_, timer) = self.timer_wake.as_mut().unwrap();
881        let mut cx = TaskContext::from_waker(waker);
882        if Pin::new(timer).poll(&mut cx).is_ready() {
883            waker.wake_by_ref();
884        }
885    }
886}
887
888impl Drop for ScriptDocument {
889    fn drop(&mut self) {
890        self.cancel_host_work();
891        self.inner.borrow_mut().prepare_teardown();
892    }
893}
894
895impl Document for ScriptDocument {
896    fn prepare_teardown(&mut self) {
897        self.cancel_host_work();
898        self.inner.borrow_mut().prepare_teardown();
899    }
900
901    fn dispose(mut self: Box<Self>) -> Arc<blitz_dom::release::Teardown> {
902        self.prepare_teardown();
903        let receipt = self.teardown_receipt();
904        drop(self);
905        // Context drop removes its roots; collect wrappers which held Rc<DOM>.
906        boa_gc::force_collect();
907        receipt
908    }
909
910    fn set_page_visible(&mut self, visible: bool) {
911        let changed = self.inner.borrow().page_visible != visible;
912        self.inner.borrow_mut().page_visible = visible;
913        if changed {
914            self.runtime.dispatch_document_event("visibilitychange");
915        }
916    }
917
918    fn freeze(&mut self) {
919        if self.inner.borrow().page_frozen {
920            return;
921        }
922        self.runtime
923            .context
924            .set_execution_time_limit(Some(std::time::Duration::from_millis(50)));
925        let _execution = self.enter_execution_time_limit();
926        self.runtime.dispatch_lifecycle_event("freeze");
927        self.runtime.run_jobs_with_progress("freeze microtasks");
928        self.inner.borrow_mut().page_frozen = true;
929        self.timer_wake.take();
930    }
931
932    fn finish_restore(&mut self) {
933        self.before_script_hook.take();
934        self.inner.borrow_mut().page_input_blocked = false;
935    }
936
937    fn resume(&mut self) {
938        if !self.inner.borrow().page_frozen || self.inner.borrow().page_retiring {
939            return;
940        }
941        self.inner.borrow_mut().page_frozen = false;
942        self.runtime.dispatch_lifecycle_event("resume");
943        self.arm_timer_wake();
944        self.request_redraw();
945    }
946
947    fn inner(&self) -> DocGuard<'_> {
948        DocGuard::RefCell(self.inner.borrow())
949    }
950
951    fn inner_mut(&mut self) -> DocGuardMut<'_> {
952        DocGuardMut::RefCell(self.inner.borrow_mut())
953    }
954
955    fn handle_ui_event(&mut self, event: UiEvent) {
956        if self.inner.borrow().page_frozen
957            || self.inner.borrow().page_retiring
958            || self.inner.borrow().page_input_blocked
959        {
960            return;
961        }
962        let _execution = self.enter_execution_time_limit();
963        let profiling_boundary = self.runtime.ctx.enter_profiling_boundary();
964        let profiling = profiling_boundary.enabled();
965        let handler = ScriptEventHandler {
966            runtime: &mut self.runtime,
967            profiling,
968        };
969        let mut driver = EventDriver::new(&mut self.inner, handler);
970        driver.handle_ui_event(event);
971
972        // JS may have mutated the DOM or scheduled timers
973        self.request_redraw();
974        self.arm_timer_wake();
975    }
976
977    fn poll(&mut self, task_context: Option<TaskContext>) -> bool {
978        self.poll_with_timer_policy(task_context, None)
979    }
980
981    fn poll_for_settle(&mut self, task_context: Option<TaskContext>, run_timers: bool) -> bool {
982        self.poll_with_timer_policy(task_context, Some(run_timers))
983    }
984}
985
986impl ScriptDocument {
987    /// `None` preserves normal polling; `Some` selects a settle pass's policy.
988    fn poll_with_timer_policy(
989        &mut self,
990        task_context: Option<TaskContext>,
991        settle_timers: Option<bool>,
992    ) -> bool {
993        if self.inner.borrow().page_frozen || self.inner.borrow().page_retiring {
994            return false;
995        }
996        let _execution = self.enter_execution_time_limit();
997        let profiling_boundary = self.runtime.ctx.enter_profiling_boundary();
998        let profiling = profiling_boundary.enabled();
999        let poll_started = profiling.then(std::time::Instant::now);
1000        let _slice = self.runtime.begin_job_slice();
1001        let ran = self.poll_inner(task_context, profiling, settle_timers);
1002        if let Some(started) = poll_started {
1003            crate::script_stats::record_poll(started.elapsed(), ran);
1004        }
1005        ran
1006    }
1007
1008    /// Move a semantic automation pointer to the exact resolved DOM node.
1009    ///
1010    /// Normal window input remains coordinate hit-tested through
1011    /// [`Document::handle_ui_event`]. Debug-control callers already selected a
1012    /// node by id, so routing those coordinates through hit testing again can
1013    /// silently select an overlapping descendant or retained overlay.
1014    pub fn handle_pointer_move_to_node(&mut self, event: BlitzPointerEvent, node_id: NodeId) {
1015        if self.inner.borrow().page_frozen
1016            || self.inner.borrow().page_retiring
1017            || self.inner.borrow().page_input_blocked
1018        {
1019            return;
1020        }
1021        let _execution = self.enter_execution_time_limit();
1022        let profiling_boundary = self.runtime.ctx.enter_profiling_boundary();
1023        let profiling = profiling_boundary.enabled();
1024        let handler = ScriptEventHandler {
1025            runtime: &mut self.runtime,
1026            profiling,
1027        };
1028        let mut driver = EventDriver::new(&mut self.inner, handler);
1029        driver.handle_pointer_move_to_node(&event, node_id);
1030
1031        self.request_redraw();
1032        self.arm_timer_wake();
1033    }
1034
1035    /// Deliver one automation input event to the exact semantic node selected
1036    /// by the caller, while retaining the normal pointer event ordering,
1037    /// compatibility mouse events, activation state and click synthesis.
1038    pub fn handle_ui_event_to_node(&mut self, event: UiEvent, node_id: NodeId) {
1039        if self.inner.borrow().page_frozen
1040            || self.inner.borrow().page_retiring
1041            || self.inner.borrow().page_input_blocked
1042        {
1043            return;
1044        }
1045        let _execution = self.enter_execution_time_limit();
1046        let profiling_boundary = self.runtime.ctx.enter_profiling_boundary();
1047        let profiling = profiling_boundary.enabled();
1048        let handler = ScriptEventHandler {
1049            runtime: &mut self.runtime,
1050            profiling,
1051        };
1052        let mut driver = EventDriver::new(&mut self.inner, handler);
1053        driver.handle_ui_event_to_node(event, node_id);
1054
1055        self.request_redraw();
1056        self.arm_timer_wake();
1057    }
1058
1059    /// The real poll. Split out so every exit path is timed by the wrapper
1060    /// above rather than by a stopwatch threaded through each early return.
1061    fn poll_inner(
1062        &mut self,
1063        task_context: Option<TaskContext>,
1064        profiling: bool,
1065        settle_timers: Option<bool>,
1066    ) -> bool {
1067        // Re-registering the Sleep below also updates its captured waker.
1068        if let Some(cx) = &task_context {
1069            if self
1070                .waker
1071                .as_ref()
1072                .is_none_or(|old| !old.will_wake(cx.waker()))
1073            {
1074                self.waker = Some(cx.waker().clone());
1075            }
1076        }
1077
1078        // A scripted document may itself be an embedder. Chuzz's Solid chrome
1079        // is one: its `<web-view>` elements own the page documents. Poll those
1080        // children at the same outer boundary so their timers, resource
1081        // completions, and script work continue to make progress.
1082        let subdocument_changes = {
1083            let mut inner = self.inner.borrow_mut();
1084            let waker = task_context.as_ref().map(TaskContext::waker);
1085            match settle_timers {
1086                Some(run_timers) => inner.poll_subdocuments_for_settle(waker, run_timers),
1087                None => inner.poll_subdocuments(waker),
1088            }
1089        };
1090
1091        let mut ran = subdocument_changes;
1092        ran |= self.runtime.poll_domc();
1093        // Every poll resumes a yielded checkpoint, including a timer-free
1094        // settle pass. Jobs do not need a timer or a dummy eval to advance.
1095        ran |= self.runtime.run_jobs_with_progress("poll microtasks");
1096
1097        // Execute scripts on first poll if they haven't been run explicitly
1098        if !self.scripts_executed {
1099            // One-time: parsing and running the application bundle. Separated
1100            // because it is startup cost, and folding it into the steady-state
1101            // numbers made every per-poll average meaningless.
1102            let started = profiling.then(std::time::Instant::now);
1103            self.execute_scripts();
1104            if let Some(started) = started {
1105                crate::script_stats::record_work("startup:execute_scripts", started.elapsed());
1106            }
1107            ran = true;
1108        } else {
1109            // Resume navigation after a yielded checkpoint using the same
1110            // continuation loop as startup. A completed script boundary must
1111            // not leave the remaining input waiting for another host poll.
1112            // After EOF, this still picks up scripts appended by the page.
1113            ran |= self.resume_parser_and_scripts();
1114        }
1115        ran |= self.finish_startup_events();
1116
1117        if settle_timers.unwrap_or(true) {
1118            ran |= self.runtime.run_due_timers(profiling);
1119        }
1120
1121        // A module that opened with a top-level `await` may have settled since
1122        // the last turn. Checking here is what turns a silent half-mounted page
1123        // into a reported rejection.
1124        self.runtime.poll_module_evaluations();
1125
1126        if let Some(mut hook) = self.poll_hook.take() {
1127            // The embedder's per-poll work. For a Solid application this is
1128            // where reactive updates and DOM mutation actually happen, so it is
1129            // the bucket that matters once startup is excluded.
1130            let started = profiling.then(std::time::Instant::now);
1131            ran |= hook(self, task_context.as_ref());
1132            if let Some(started) = started {
1133                crate::script_stats::record_work("poll_hook", started.elapsed());
1134            }
1135            self.poll_hook = Some(hook);
1136        }
1137
1138        // Reclaim what removal could not judge at the time. A node is detached
1139        // while script may still hold its wrapper, and the answer only becomes
1140        // knowable once the collector has run; this is the point where asking
1141        // is cheap and the answer is current.
1142        self.runtime.sweep_detached_nodes();
1143
1144        if self.runtime.jobs_remain() {
1145            ran = true;
1146            self.request_redraw();
1147            if let Some(cx) = &task_context {
1148                cx.waker().wake_by_ref();
1149            }
1150        }
1151        self.arm_timer_wake();
1152        ran
1153    }
1154}