Skip to main content

orinium_browser/browser/core/webview/
mod.rs

1//! ブラウザのwebview機能。タスクとレンダリング情報の管理を行う。
2
3use std::cell::RefCell;
4use std::collections::HashMap;
5use std::rc::{Rc, Weak};
6use std::sync::{Arc, mpsc};
7
8use crate::engine::image_decoder::ImageDecoder;
9use crate::engine::layouter::types::{ColorScheme, TextFlowStyle};
10use crate::engine::{
11    css::{
12        self,
13        matcher::ElementChain,
14        parser::{CssNode, CssNodeType, Parser as CssParser},
15        values::CssValue,
16    },
17    html::HtmlNodeType,
18    html::parser::{
19        ClassicScriptExecution, ClassicScriptSource, DomTree, Parser as HtmlParser, ScriptingMode,
20    },
21    js::{
22        JsDevToolsRequest, JsDynamicImageRequest, JsDynamicScriptRequest, JsDynamicScriptSource,
23        JsDynamicStyleRequest, JsFetchRequest, JsFetchResponse, JsIframeFetchRequest,
24        JsLayoutMetrics, JsProcessor, JsTask, JsTaskResult,
25    },
26    layouter::{
27        self, InheritedCss, LayoutResult, NodeId,
28        dom_snapshot::DomSnapshot,
29        types::{InfoNode, NodeKind},
30    },
31    origin::Origin,
32    renderer_model::Image,
33    tree::{NodeRef, TreeNode},
34};
35use crate::platform::{locale, renderer::text_measurer::PlatformTextMeasurer};
36use crate::{perf_scope, profile_log};
37use ui_layout::{LayoutChild, LayoutNode};
38use url::Url;
39
40const USER_AGENT_CSS: &str = include_str!("../../../../resource/user-agent.css");
41
42pub enum WebViewTask {
43    AskTabHtml,
44    Fetch {
45        url: Url,
46        kind: FetchKind,
47    },
48    /// A page asked the DevTools bridge to inspect rendered state.
49    DevToolsRequest {
50        id: u64,
51        method: String,
52        params: String,
53    },
54}
55
56mod inspector;
57
58#[derive(Debug, Clone, PartialEq)]
59pub enum FetchKind {
60    Html,
61    Css,
62    Script {
63        index: usize,
64    },
65    DynamicScript {
66        node_id: u64,
67    },
68    DynamicCss {
69        node_id: u64,
70    },
71    Image {
72        source: String,
73    },
74    Audio {
75        source: String,
76    },
77    JavaScript {
78        request_id: u64,
79        method: String,
80        headers: Vec<(String, String)>,
81        body: Vec<u8>,
82    },
83    Iframe {
84        dom_id: u64,
85    },
86}
87
88/// CSS application strategy.
89///
90/// - `Batch`: wait for all external CSS to be fetched, then process everything
91///   at once on a background thread and apply the result.
92/// - `Incremental`: process each CSS file on a background thread as it arrives,
93///   applying results progressively.
94#[derive(Debug, Clone, Copy, PartialEq)]
95pub enum CssApplicationStrategy {
96    Batch,
97    Incremental,
98}
99
100/// JavaScript execution policy for a page.
101///
102/// This drives both the HTML parser's scripting mode (so `<noscript>` fallbacks
103/// are either hidden or shown) and whether the WebView executes scripts.
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
105pub enum JsPolicy {
106    /// Scripts run and `<noscript>` contents are kept as raw text.
107    #[default]
108    Enabled,
109    /// Scripts are never executed and `<noscript>` fallbacks are shown.
110    Disabled,
111}
112
113impl From<JsPolicy> for ScriptingMode {
114    fn from(value: JsPolicy) -> ScriptingMode {
115        match value {
116            JsPolicy::Enabled => ScriptingMode::Enabled,
117            JsPolicy::Disabled => ScriptingMode::Disabled,
118        }
119    }
120}
121
122impl From<ScriptingMode> for JsPolicy {
123    fn from(value: ScriptingMode) -> JsPolicy {
124        match value {
125            ScriptingMode::Enabled => JsPolicy::Enabled,
126            ScriptingMode::Disabled => JsPolicy::Disabled,
127        }
128    }
129}
130
131#[derive(Debug, Clone, PartialEq)]
132enum PagePhase {
133    Init,
134    BeforeHtmlParsing,
135    HtmlParsed,
136    CssPending,
137    CssProcessing,
138    CssApplied,
139    ScriptApplied,
140}
141
142pub struct WebView {
143    phase: PagePhase,
144
145    docment_info: Option<DocumentInfo>,
146
147    pending_css_urls: Vec<Url>,
148    pending_images: Vec<(String, Url)>,
149    pending_audio: Vec<(String, Url)>,
150    loaded_css: Vec<String>,
151    linked_css: Vec<String>,
152    images: HashMap<String, Image>,
153    image_decoder: ImageDecoder,
154    audio: HashMap<String, Arc<[u8]>>,
155
156    resolved_styles: Arc<layouter::css_resolver::ResolvedStyles>,
157    /// Monotonic version of `resolved_styles`, bumped on every in-place or
158    /// wholesale mutation so the layout processor can detect stale rule sets.
159    resolved_styles_version: u64,
160    layout_and_info: Option<(LayoutNode, InfoNode)>,
161
162    needs_redraw: bool,
163
164    text_measurer: Option<Arc<PlatformTextMeasurer>>,
165
166    system_color_scheme: ColorScheme,
167    viewport: (f32, f32),
168
169    css_processor: css::processor::CssProcessor,
170    css_strategy: CssApplicationStrategy,
171    css_results_expected: usize,
172    css_results_received: usize,
173
174    /// Policy controlling whether page scripts are executed and how
175    /// `<noscript>` contents are parsed.
176    js_policy: JsPolicy,
177
178    layout_processor: layouter::LayoutProcessor,
179    layout_pending: bool,
180    layout_requested_version: u64,
181    layout_applied_version: u64,
182    /// The `(layout version, viewport)` the current tree was last positioned
183    /// for. Applied background results start out unpositioned; hit-testing
184    /// must never observe them before [`WebView::position_layout_if_needed`]
185    /// has run, so this memo gate runs the positioning pass eagerly.
186    positioned_layout: Option<(u64, (f32, f32))>,
187    /// Live DOM references for the latest snapshot, used to apply write-backs.
188    layout_dom_refs: Vec<Weak<RefCell<TreeNode<HtmlNodeType>>>>,
189    /// Cached DOM snapshot, reused while the tree's mutation version is
190    /// unchanged so that CSS/image-driven relayouts skip the full clone.
191    snapshot_cache: Option<SnapshotCache>,
192    /// The most recent serialized content documents of any `<iframe>`s, keyed
193    /// by the iframe's JS-facing dom id. Used by layout to render them nested.
194    iframe_content: HashMap<u64, DomSnapshot>,
195    /// Channel on which text inputs report value write-backs (received here).
196    write_back_tx: mpsc::Sender<(u32, String)>,
197    write_back_rx: mpsc::Receiver<(u32, String)>,
198    /// JS runtime on a background thread, sharing a mirror of the current
199    /// document's DOM. Results are applied in [`WebView::try_apply_js_results`].
200    js_processor: Option<JsProcessor>,
201    /// JS-facing dom id per live node address of the committed tree.
202    ///
203    /// Rebuilt whenever a JS result is committed, so hit-tested layout
204    /// nodes and write-back serialization can be translated to JS dom ids.
205    js_dom_ids: HashMap<usize, u64>,
206    /// Ordered JS tasks sent but not yet applied. Write-backs are only synced
207    /// to the JS thread once this reaches zero, so the mirror and the real tree
208    /// cannot diverge mid-task.
209    pending_js_tasks: usize,
210    /// The real DOM diverged from the JS thread's mirror and needs syncing.
211    js_dom_dirty: bool,
212    /// Version of the newest `RunTimers` poke still in flight. Write-backs are
213    /// not synced while one is pending: a timer callback can mutate the mirror,
214    /// and a snapshot produced behind the sync would clobber it.
215    in_flight_timer_version: Option<u64>,
216    /// Whether the window `load` event has been dispatched for the current page.
217    window_load_dispatched: bool,
218    /// `fetch()` requests collected from applied JS results.
219    pending_js_fetches: Vec<JsFetchRequest>,
220    /// DevTools inspection requests collected from applied JS results.
221    pending_devtools_requests: Vec<JsDevToolsRequest>,
222    /// Stable DOM ids for the inspector, assigned lazily over the live tree.
223    inspector_ids: RefCell<inspector::DomIdRegistry>,
224    /// Dynamically inserted scripts collected from applied JS results.
225    pending_dynamic_scripts: Vec<JsDynamicScriptRequest>,
226    /// Dynamically inserted stylesheet links collected from JS results.
227    pending_dynamic_styles: Vec<JsDynamicStyleRequest>,
228    /// Images created or populated by scripts, awaiting network scheduling.
229    pending_dynamic_images: Vec<JsDynamicImageRequest>,
230    /// `<iframe src="...">` requests queued by JS results, awaiting fetch.
231    pending_iframe_fetches: Vec<JsIframeFetchRequest>,
232    /// Classic scripts in document order. Execution starts after CSS is applied.
233    classic_scripts: Vec<ClassicScript>,
234    next_script_index: usize,
235    pending_script_fetches: HashMap<usize, ClassicScriptExecution>,
236    non_blocking_scripts_scheduled: bool,
237    deferred_script_results: HashMap<usize, Option<String>>,
238    next_deferred_script_index: usize,
239    /// Fragment to reveal once the document has a completed layout.
240    pending_fragment_scroll: Option<String>,
241    /// Layout generation that contains every initially linked stylesheet.
242    fragment_ready_version: Option<u64>,
243}
244
245#[derive(Debug, Clone, PartialEq)]
246enum ClassicScript {
247    Inline(String),
248    External {
249        url: Url,
250        execution: ClassicScriptExecution,
251    },
252}
253
254/// A DOM snapshot paired with the tree mutation version it was built from.
255///
256/// The snapshot and its live references stay valid as long as the DOM has not
257/// mutated (`Tree::version()` unchanged). They are shared with layout tasks via
258/// `Arc` instead of being cloned per task.
259struct SnapshotCache {
260    dom_version: u64,
261    snapshot: Arc<DomSnapshot>,
262    dom_refs: Vec<Weak<RefCell<TreeNode<HtmlNodeType>>>>,
263}
264
265fn js_snapshot_from_tree(dom: &DomTree) -> (DomSnapshot, HashMap<usize, u64>) {
266    let mut dom_ids = HashMap::new();
267    let mut next_id = 1u64;
268    dom.traverse(|node| {
269        dom_ids.insert(Rc::as_ptr(node) as usize, next_id);
270        next_id += 1;
271    });
272    (DomSnapshot::from_mirror(&dom.root, &dom_ids), dom_ids)
273}
274
275/// Grafts each committed iframe's content document into its host `<iframe>`
276/// node so the normal layout/paint pipeline renders the content nested inside
277/// the host box. The JS domain keeps iframe documents in a separate tree, so we
278/// splice their `<html>` subtree under the matching host node here.
279fn graft_iframe_documents(
280    dom: &Rc<DomTree>,
281    js_dom_ids: &HashMap<usize, u64>,
282    iframes: &HashMap<u64, DomSnapshot>,
283) {
284    if iframes.is_empty() {
285        return;
286    }
287    // Build a reverse map: js_dom_id -> live node, so host lookups are O(1)
288    // per iframe instead of O(n) DOM traversals.
289    let mut node_by_dom_id: HashMap<u64, NodeRef<HtmlNodeType>> = HashMap::new();
290    dom.traverse(|node| {
291        if let Some(&dom_id) = js_dom_ids.get(&(Rc::as_ptr(node) as usize)) {
292            node_by_dom_id.insert(dom_id, Rc::clone(node));
293        }
294    });
295    for (iframe_dom_id, content) in iframes {
296        let (content_tree, _ids) = content.into_tree();
297        let Some(html) = content_tree.query_selector("html") else {
298            continue;
299        };
300        let Some(host) = node_by_dom_id.get(iframe_dom_id) else {
301            continue;
302        };
303        if host.borrow().value.tag_name() != Some("iframe") {
304            continue;
305        }
306        TreeNode::add_child(host, html);
307    }
308}
309
310impl std::fmt::Debug for SnapshotCache {
311    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
312        f.debug_struct("SnapshotCache")
313            .field("dom_version", &self.dom_version)
314            .field("snapshot", &self.snapshot)
315            .finish()
316    }
317}
318
319/// DocumentInfo holds basic information about the HTML document.
320/// It includes the document URL, base URL, title, and DOM tree.
321///
322/// - document_url: The URL of the document.
323/// - base_url: The base URL for resolving relative URLs.
324/// - origin: The origin of the document.
325/// - title: The title of the document.
326/// - dom: The DOM tree of the document.
327#[derive(Debug)]
328pub struct DocumentInfo {
329    document_url: Url,
330    base_url: Url,
331    pub origin: crate::engine::origin::Origin,
332    title: String,
333    pub dom: Rc<DomTree>,
334}
335
336/// ParsedDocument holds the result of parsing an HTML document.
337/// It includes the document URL, base URL, DOM tree, title, style links, and inline styles.
338///
339/// - document_url: The URL of the document.
340/// - base_url: The base URL for resolving relative URLs.
341/// - dom: The DOM tree of the document.
342/// - title: The title of the document.
343/// - style_links: A list of URLs for linked stylesheets.
344/// - inline_styles: A list of inline CSS styles.
345/// - scripts: A list of inline script sources.
346struct ParsedDocument {
347    document_url: Url,
348    base_url: Url,
349    dom: Rc<DomTree>,
350    title: String,
351    style_links: Vec<Url>,
352    inline_styles: Vec<String>,
353    image_sources: Vec<(String, Url)>,
354    audio_sources: Vec<(String, Url)>,
355    scripts: Vec<ClassicScript>,
356}
357
358impl Default for WebView {
359    fn default() -> Self {
360        Self::new(ColorScheme::default(), JsPolicy::default())
361    }
362}
363
364impl WebView {
365    pub fn new(system_color_scheme: ColorScheme, js_policy: JsPolicy) -> Self {
366        let (write_back_tx, write_back_rx) = mpsc::channel();
367        Self {
368            phase: PagePhase::Init,
369
370            docment_info: None,
371
372            pending_css_urls: Vec::new(),
373            pending_images: Vec::new(),
374            pending_audio: Vec::new(),
375            loaded_css: Vec::new(),
376            linked_css: Vec::new(),
377            images: HashMap::new(),
378            image_decoder: ImageDecoder::new(),
379            audio: HashMap::new(),
380
381            resolved_styles: Arc::new(layouter::css_resolver::ResolvedStyles::default()),
382            resolved_styles_version: 0,
383            layout_and_info: None,
384
385            needs_redraw: false,
386
387            text_measurer: None,
388
389            system_color_scheme,
390            viewport: (800.0, 600.0),
391
392            css_processor: css::processor::CssProcessor::new(),
393            css_strategy: CssApplicationStrategy::Incremental,
394            css_results_expected: 0,
395            css_results_received: 0,
396
397            js_policy,
398
399            layout_processor: layouter::LayoutProcessor::new(),
400            layout_pending: false,
401            layout_requested_version: 0,
402            layout_applied_version: 0,
403            positioned_layout: None,
404            layout_dom_refs: Vec::new(),
405            snapshot_cache: None,
406            iframe_content: HashMap::new(),
407            write_back_tx,
408            write_back_rx,
409            js_processor: None,
410            js_dom_ids: HashMap::new(),
411            pending_js_tasks: 0,
412            js_dom_dirty: false,
413            in_flight_timer_version: None,
414            window_load_dispatched: false,
415            pending_js_fetches: Vec::new(),
416            pending_devtools_requests: Vec::new(),
417            inspector_ids: RefCell::new(inspector::DomIdRegistry::default()),
418            pending_dynamic_scripts: Vec::new(),
419            pending_dynamic_styles: Vec::new(),
420            pending_dynamic_images: Vec::new(),
421            pending_iframe_fetches: Vec::new(),
422            classic_scripts: Vec::new(),
423            next_script_index: 0,
424            pending_script_fetches: HashMap::new(),
425            non_blocking_scripts_scheduled: false,
426            deferred_script_results: HashMap::new(),
427            next_deferred_script_index: 0,
428            pending_fragment_scroll: None,
429            fragment_ready_version: None,
430        }
431    }
432
433    /// Set the CSS application strategy.
434    ///
435    /// Default is `Incremental`.
436    pub fn set_css_strategy(&mut self, strategy: CssApplicationStrategy) {
437        self.css_strategy = strategy;
438    }
439
440    /// Sets the JavaScript execution policy.
441    ///
442    /// The policy is applied to `<noscript>` parsing on the next document load
443    /// and takes effect immediately for execution: disabling it drops the JS
444    /// runtime, cancels pending script work, and stops running scripts.
445    pub fn set_js_policy(&mut self, policy: JsPolicy) {
446        if self.js_policy == policy {
447            return;
448        }
449        self.js_policy = policy;
450
451        if policy == JsPolicy::Disabled {
452            self.teardown_script_execution();
453        } else if self.js_processor.is_none()
454            && let Some(dom) = self.docment_info.as_ref().map(|info| Rc::clone(&info.dom))
455        {
456            // Re-enabling: install a processor so DOM APIs work again, without
457            // replaying scripts that were skipped while disabled.
458            let document_url = self
459                .docment_info
460                .as_ref()
461                .map(|info| info.document_url.to_string())
462                .unwrap_or_default();
463            let origin = self
464                .docment_info
465                .as_ref()
466                .map(|info| info.origin.ascii_serialization())
467                .unwrap_or_else(|| "null".to_string());
468            let (snapshot, dom_ids) = js_snapshot_from_tree(&dom);
469            let processor = JsProcessor::new(snapshot);
470            processor.send(JsTask::SetDocumentUrl { url: document_url });
471            processor.send(JsTask::SetOrigin { origin });
472            processor.send(JsTask::SetViewport {
473                width: self.viewport.0,
474                height: self.viewport.1,
475            });
476            processor.send(JsTask::SetLanguage {
477                language: locale::preferred_language(),
478            });
479            self.js_processor = Some(processor);
480            self.js_dom_ids = dom_ids;
481            self.pending_js_tasks = 4;
482        }
483    }
484
485    /// Returns the current JavaScript execution policy.
486    pub fn js_policy(&self) -> JsPolicy {
487        self.js_policy
488    }
489
490    /// Stops script execution immediately, dropping the runtime and any
491    /// pending script work.
492    fn teardown_script_execution(&mut self) {
493        self.js_processor = None;
494        self.pending_js_tasks = 0;
495        self.js_dom_dirty = false;
496        self.in_flight_timer_version = None;
497        self.pending_js_fetches.clear();
498        self.pending_devtools_requests.clear();
499        self.inspector_ids.borrow_mut().clear();
500        self.pending_dynamic_scripts.clear();
501        self.pending_dynamic_styles.clear();
502        self.pending_dynamic_images.clear();
503        self.pending_iframe_fetches.clear();
504        self.js_dom_ids.clear();
505        self.classic_scripts.clear();
506        self.next_script_index = 0;
507        self.pending_script_fetches.clear();
508        self.non_blocking_scripts_scheduled = false;
509        self.deferred_script_results.clear();
510        self.next_deferred_script_index = 0;
511
512        // Nothing will advance past CssApplied anymore.
513        if self.phase == PagePhase::CssApplied {
514            self.phase = PagePhase::ScriptApplied;
515        }
516    }
517
518    pub fn tick(&mut self) -> Vec<WebViewTask> {
519        let mut tasks = Vec::new();
520
521        match self.phase {
522            PagePhase::Init => {
523                let ua_styles = layouter::css_resolver::CssResolver::resolve_with_origin(
524                    &CssParser::new(USER_AGENT_CSS).parse().unwrap(),
525                    layouter::css_resolver::StyleOrigin::UserAgent,
526                );
527                layouter::css_resolver::append_resolved_styles(
528                    Arc::make_mut(&mut self.resolved_styles),
529                    ua_styles,
530                );
531                self.resolved_styles_version += 1;
532
533                tasks.push(WebViewTask::AskTabHtml);
534
535                self.phase = PagePhase::BeforeHtmlParsing;
536            }
537
538            PagePhase::BeforeHtmlParsing => {}
539
540            PagePhase::HtmlParsed => {
541                // Phase 1: UA.css only layout
542                self.ensure_text_measurer();
543                self.update_layout();
544
545                for (source, url) in std::mem::take(&mut self.pending_images) {
546                    log::info!("Image fetch requested in WebView: url={}", url);
547                    tasks.push(WebViewTask::Fetch {
548                        url,
549                        kind: FetchKind::Image { source },
550                    });
551                }
552
553                for (source, url) in std::mem::take(&mut self.pending_audio) {
554                    log::info!("Audio fetch requested in WebView: url={}", url);
555                    tasks.push(WebViewTask::Fetch {
556                        url,
557                        kind: FetchKind::Audio { source },
558                    });
559                }
560
561                // CSS fetch を要求
562                if self.pending_css_urls.is_empty() {
563                    self.fragment_ready_version = Some(self.layout_requested_version);
564                    self.phase = PagePhase::CssApplied;
565                } else {
566                    for url in &self.pending_css_urls {
567                        log::info!("Fetch requested in WebView: url={}", url);
568                        tasks.push(WebViewTask::Fetch {
569                            url: url.clone(),
570                            kind: FetchKind::Css,
571                        });
572                    }
573
574                    self.phase = PagePhase::CssPending;
575                }
576            }
577
578            PagePhase::CssPending => {
579                // Poll for CSS processor results (Incremental strategy)
580                self.try_apply_css_results();
581            }
582
583            PagePhase::CssProcessing => {
584                // Poll for the single batch result (Batch strategy)
585                self.try_apply_batch_result();
586            }
587
588            PagePhase::CssApplied => {
589                self.advance_classic_scripts(&mut tasks);
590            }
591
592            PagePhase::ScriptApplied => {
593                // 安定状態
594            }
595        }
596
597        self.try_apply_js_results();
598        self.schedule_js_fetches(&mut tasks);
599        self.schedule_iframe_fetches(&mut tasks);
600        self.schedule_dynamic_scripts(&mut tasks);
601        for request in std::mem::take(&mut self.pending_devtools_requests) {
602            tasks.push(WebViewTask::DevToolsRequest {
603                id: request.id,
604                method: request.method,
605                params: request.params,
606            });
607        }
608        self.schedule_dynamic_styles(&mut tasks);
609        self.schedule_dynamic_images(&mut tasks);
610        self.schedule_pending_images(&mut tasks);
611        self.try_apply_decoded_images();
612        self.run_due_js_timers();
613        self.try_apply_layout_results();
614        self.drain_write_backs();
615        self.sync_dom_to_worker();
616
617        // Window `load` fires once the page is stable: after DOMContentLoaded
618        // (phase `ScriptApplied`), with no JS-visible subresource work still in
619        // flight. Reaching `ScriptApplied` guarantees the page scripts and
620        // DOMContentLoaded listeners have already been applied, so the `onload`
621        // handler is in place before we dispatch.
622        if !self.window_load_dispatched
623            && self.phase == PagePhase::ScriptApplied
624            && self.pending_js_tasks == 0
625            && !self.has_pending_subresource_work()
626        {
627            self.dispatch_window_load();
628        }
629
630        tasks
631    }
632
633    pub fn on_html_fetched(&mut self, html: String, document_url: Url) {
634        log::info!("Fetched HTML: {}", document_url);
635        self.pending_fragment_scroll = document_url.fragment().map(str::to_string);
636        self.fragment_ready_version = None;
637        perf_scope!(html_parse);
638        let parsed = parse_html(&html, document_url, self.js_policy.into());
639        #[cfg(any(feature = "profile", debug_assertions))]
640        let html_parse_time = html_parse.elapsed();
641
642        self.pending_css_urls = parsed.style_links;
643        self.pending_images = parsed.image_sources;
644        self.pending_audio = parsed.audio_sources;
645        self.classic_scripts = parsed.scripts;
646        self.next_script_index = 0;
647        self.pending_script_fetches.clear();
648        self.non_blocking_scripts_scheduled = false;
649        self.deferred_script_results.clear();
650        self.next_deferred_script_index = 0;
651        self.css_results_expected = self.pending_css_urls.len();
652
653        let mut initial_js_tasks = 0;
654        let mut initial_js_dom_ids = HashMap::new();
655        #[cfg(any(feature = "profile", debug_assertions))]
656        let mut js_snapshot_time = std::time::Duration::ZERO;
657        self.js_processor = if self.js_policy == JsPolicy::Enabled {
658            perf_scope!(js_snapshot);
659            let (snapshot, dom_ids) = js_snapshot_from_tree(&parsed.dom);
660            #[cfg(any(feature = "profile", debug_assertions))]
661            {
662                js_snapshot_time = js_snapshot.elapsed();
663            }
664            initial_js_dom_ids = dom_ids;
665            let processor = JsProcessor::new(snapshot);
666            processor.send(JsTask::SetDocumentUrl {
667                url: parsed.document_url.to_string(),
668            });
669            processor.send(JsTask::SetOrigin {
670                origin: Origin::from_url(&parsed.document_url).ascii_serialization(),
671            });
672            processor.send(JsTask::SetViewport {
673                width: self.viewport.0,
674                height: self.viewport.1,
675            });
676            processor.send(JsTask::SetLanguage {
677                language: locale::preferred_language(),
678            });
679            initial_js_tasks = 4;
680            Some(processor)
681        } else {
682            None
683        };
684        self.pending_js_tasks = initial_js_tasks;
685        self.js_dom_ids = initial_js_dom_ids;
686        self.js_dom_dirty = false;
687        self.in_flight_timer_version = None;
688        self.window_load_dispatched = false;
689        self.pending_js_fetches.clear();
690        self.pending_devtools_requests.clear();
691        self.inspector_ids.borrow_mut().clear();
692        self.pending_dynamic_scripts.clear();
693        self.pending_dynamic_styles.clear();
694        self.pending_dynamic_images.clear();
695        self.pending_iframe_fetches.clear();
696        self.iframe_content.clear();
697
698        let css_base_url = parsed.base_url.clone();
699        let docment_info = DocumentInfo {
700            origin: Origin::from_url(&parsed.document_url),
701            document_url: parsed.document_url,
702            base_url: parsed.base_url,
703            dom: parsed.dom,
704            title: parsed.title,
705        };
706        self.docment_info = Some(docment_info);
707        self.snapshot_cache = None;
708
709        for inline_css in &parsed.inline_styles {
710            self.queue_css_images(inline_css, &css_base_url);
711            let sheet = CssParser::new(inline_css).parse_lossy();
712            layouter::css_resolver::append_resolved_styles(
713                Arc::make_mut(&mut self.resolved_styles),
714                layouter::css_resolver::CssResolver::resolve(&sheet),
715            );
716            self.resolved_styles_version += 1;
717        }
718        self.phase = PagePhase::HtmlParsed;
719        profile_log!(
720            target: "PageLoad",
721            log::Level::Info,
722            "[HtmlParse] html_parse: {:?} | js_snapshot: {:?}",
723            html_parse_time,
724            js_snapshot_time,
725        );
726    }
727
728    pub fn on_css_fetched(&mut self, css: String) {
729        let base_url = self
730            .docment_info
731            .as_ref()
732            .map(|info| info.base_url.clone())
733            .unwrap_or_else(|| Url::parse("about:blank").expect("valid fallback URL"));
734        self.on_css_fetched_from(css, &base_url);
735    }
736
737    pub fn on_css_fetched_from(&mut self, css: String, stylesheet_url: &Url) {
738        self.queue_css_images(&css, stylesheet_url);
739        self.linked_css.push(css.clone());
740        match self.css_strategy {
741            CssApplicationStrategy::Batch => {
742                self.loaded_css.push(css);
743
744                if self.loaded_css.len() == self.pending_css_urls.len() {
745                    let all_css = std::mem::take(&mut self.loaded_css);
746                    self.css_results_expected = 1;
747                    self.css_results_received = 0;
748                    self.css_processor.process(all_css);
749                    self.phase = PagePhase::CssProcessing;
750                }
751            }
752            CssApplicationStrategy::Incremental => {
753                self.css_processor.process(vec![css]);
754            }
755        }
756    }
757
758    /// Decodes a fetched image and rebuilds layout using its intrinsic size.
759    pub fn on_image_fetched(&mut self, source: String, bytes: &[u8]) -> anyhow::Result<()> {
760        self.image_decoder.decode(source, bytes.to_vec());
761        Ok(())
762    }
763
764    /// Stores fetched audio bytes for the matching `<audio>` control.
765    pub fn on_audio_fetched(&mut self, source: String, bytes: &[u8]) {
766        self.audio.insert(source, Arc::from(bytes));
767        self.update_layout();
768    }
769
770    /// Executes or queues a fetched external classic script by scheduling mode.
771    pub fn on_script_fetched(&mut self, index: usize, source: String) {
772        let Some(execution) = self.pending_script_fetches.get(&index).copied() else {
773            log::warn!("Ignoring unexpected classic script response at index {index}");
774            return;
775        };
776
777        if execution == ClassicScriptExecution::Default && self.next_script_index != index {
778            log::warn!("Ignoring out-of-order blocking script response at index {index}");
779            return;
780        }
781        self.pending_script_fetches.remove(&index);
782
783        match execution {
784            ClassicScriptExecution::Default => {
785                self.next_script_index += 1;
786                self.send_script(&source);
787            }
788            ClassicScriptExecution::Defer => {
789                self.deferred_script_results.insert(index, Some(source));
790            }
791            ClassicScriptExecution::Async => self.send_script(&source),
792        }
793    }
794
795    /// Records a failed external classic script without aborting page loading.
796    pub fn on_script_fetch_failed(&mut self, index: usize) {
797        let Some(execution) = self.pending_script_fetches.get(&index).copied() else {
798            log::warn!("Ignoring unexpected classic script failure at index {index}");
799            return;
800        };
801
802        if execution == ClassicScriptExecution::Default && self.next_script_index != index {
803            log::warn!("Ignoring out-of-order blocking script failure at index {index}");
804            return;
805        }
806        self.pending_script_fetches.remove(&index);
807
808        match execution {
809            ClassicScriptExecution::Default => self.next_script_index += 1,
810            ClassicScriptExecution::Defer => {
811                self.deferred_script_results.insert(index, None);
812            }
813            ClassicScriptExecution::Async => {}
814        }
815    }
816
817    /// Executes a fetched dynamically inserted script and dispatches `load`.
818    pub fn on_dynamic_script_fetched(&mut self, node_id: u64, source: String) {
819        self.send_script(&source);
820        self.dispatch_js_element_event(node_id, "load");
821    }
822
823    /// Dispatches `error` for a dynamically inserted script that failed to load.
824    pub fn on_dynamic_script_fetch_failed(&mut self, node_id: u64) {
825        self.dispatch_js_element_event(node_id, "error");
826    }
827
828    pub fn on_dynamic_style_fetched(&mut self, node_id: u64, source: String) {
829        // TODO: Preserve the final stylesheet URL so relative url() and @import resolve correctly.
830        self.linked_css.push(source);
831        self.rebuild_styles_and_layout();
832        self.needs_redraw = true;
833        self.dispatch_js_element_event(node_id, "load");
834    }
835
836    pub fn on_dynamic_style_fetch_failed(&mut self, node_id: u64) {
837        self.dispatch_js_element_event(node_id, "error");
838    }
839
840    /// Resolves a JavaScript `fetch()` request with a network response.
841    pub fn on_js_fetch_succeeded(&mut self, request_id: u64, response: JsFetchResponse) {
842        if let Some(processor) = self.js_processor.as_ref() {
843            processor.send(JsTask::ResolveFetch {
844                id: request_id,
845                response,
846            });
847            self.pending_js_tasks += 1;
848        }
849    }
850
851    /// Rejects a JavaScript `fetch()` request after a network failure.
852    pub fn on_js_fetch_failed(&mut self, request_id: u64, reason: String) {
853        if let Some(processor) = self.js_processor.as_ref() {
854            processor.send(JsTask::RejectFetch {
855                id: request_id,
856                reason,
857            });
858            self.pending_js_tasks += 1;
859        }
860    }
861
862    /// Installs parsed iframe HTML as the host element's `contentDocument` and
863    /// fires its `load` event.
864    pub fn on_iframe_fetched(&mut self, dom_id: u64, html: String) {
865        if let Some(processor) = self.js_processor.as_ref() {
866            processor.send(JsTask::ResolveIframe { dom_id, html });
867            self.pending_js_tasks += 1;
868        }
869    }
870
871    /// Marks an iframe load as failed so later `contentDocument` accesses do
872    /// not keep re-queuing a fetch.
873    pub fn on_iframe_fetch_failed(&mut self, dom_id: u64) {
874        if let Some(processor) = self.js_processor.as_ref() {
875            processor.send(JsTask::RejectIframe { dom_id });
876            self.pending_js_tasks += 1;
877        }
878    }
879
880    /// Settles a DevTools inspection request with its JSON envelope.
881    pub fn on_devtools_response(&mut self, id: u64, result: String) {
882        if let Some(processor) = self.js_processor.as_ref() {
883            processor.send(JsTask::ResolveDevTools { id, result });
884            self.pending_js_tasks += 1;
885        }
886    }
887
888    /// Answers a DevTools inspection query against this page's live state.
889    pub(crate) fn inspect(
890        &mut self,
891        method: &str,
892        params: &str,
893    ) -> Result<serde_json::Value, String> {
894        inspector::handle(self, method, params)
895    }
896
897    /// Update page (e.g. DOM changed)
898    ///
899    /// This is a stub method for now.
900    pub fn update_page(&mut self) {
901        self.ensure_text_measurer();
902        // The caller mutated the DOM (e.g. TreeNode::replace_child), which does
903        // not bump Tree::version on its own. Mark the tree dirty so the cached
904        // snapshot is rebuilt instead of reused stale.
905        if let Some(doc_info) = self.docment_info.as_mut() {
906            doc_info.dom.mark_dirty();
907        }
908        self.update_layout();
909        // The JS thread's mirror must reflect the external DOM mutation too.
910        self.js_dom_dirty = true;
911    }
912
913    fn apply_resolved_styles_and_relayout(
914        &mut self,
915        resolved: layouter::css_resolver::ResolvedStyles,
916    ) {
917        layouter::css_resolver::append_resolved_styles(
918            Arc::make_mut(&mut self.resolved_styles),
919            resolved,
920        );
921        self.resolved_styles_version += 1;
922        self.update_layout();
923    }
924
925    fn rebuild_styles_and_layout(&mut self) {
926        let Some(document) = self.docment_info.as_ref() else {
927            self.update_layout();
928            return;
929        };
930        perf_scope!(resolve_styles);
931        let mut resolved = layouter::css_resolver::CssResolver::resolve_with_origin(
932            &CssParser::new(USER_AGENT_CSS).parse().unwrap(),
933            layouter::css_resolver::StyleOrigin::UserAgent,
934        );
935        let mut stylesheet_count = 1;
936        for source in &self.linked_css {
937            let sheet = CssParser::new(source).parse_lossy();
938            layouter::css_resolver::append_resolved_styles(
939                &mut resolved,
940                layouter::css_resolver::CssResolver::resolve(&sheet),
941            );
942            stylesheet_count += 1;
943        }
944        for source in document.dom.collect_text_by_tag("style") {
945            let sheet = CssParser::new(&source).parse_lossy();
946            layouter::css_resolver::append_resolved_styles(
947                &mut resolved,
948                layouter::css_resolver::CssResolver::resolve(&sheet),
949            );
950            stylesheet_count += 1;
951        }
952        #[cfg(any(feature = "profile", debug_assertions))]
953        let resolve_styles_time = resolve_styles.elapsed();
954        profile_log!(
955            target: "PageLoad",
956            log::Level::Info,
957            "[StyleResolve] stylesheet_resolve: {:?} (sheets: {})",
958            resolve_styles_time,
959            stylesheet_count,
960        );
961        self.resolved_styles = Arc::new(resolved);
962        self.resolved_styles_version += 1;
963        self.update_layout();
964    }
965
966    fn try_apply_css_results(&mut self) {
967        while let Some(resolved) = self.css_processor.try_receive() {
968            self.css_results_received += 1;
969            self.apply_resolved_styles_and_relayout(resolved);
970            self.needs_redraw = true;
971
972            if self.css_results_received >= self.css_results_expected {
973                self.fragment_ready_version = Some(self.layout_requested_version);
974                self.phase = PagePhase::CssApplied;
975            }
976        }
977    }
978
979    fn try_apply_batch_result(&mut self) {
980        if let Some(resolved) = self.css_processor.try_receive() {
981            self.css_results_received += 1;
982            self.apply_resolved_styles_and_relayout(resolved);
983            self.needs_redraw = true;
984            self.fragment_ready_version = Some(self.layout_requested_version);
985            self.phase = PagePhase::CssApplied;
986        }
987    }
988
989    fn advance_classic_scripts(&mut self, tasks: &mut Vec<WebViewTask>) {
990        if self.js_policy == JsPolicy::Disabled {
991            self.phase = PagePhase::ScriptApplied;
992            return;
993        }
994
995        self.schedule_non_blocking_scripts(tasks);
996
997        while self.next_script_index < self.classic_scripts.len() {
998            match self.classic_scripts[self.next_script_index].clone() {
999                ClassicScript::Inline(source) => {
1000                    self.next_script_index += 1;
1001                    self.send_script(&source);
1002                }
1003                ClassicScript::External { url, execution } => {
1004                    if execution != ClassicScriptExecution::Default {
1005                        self.next_script_index += 1;
1006                        continue;
1007                    }
1008
1009                    let index = self.next_script_index;
1010                    if let std::collections::hash_map::Entry::Vacant(entry) =
1011                        self.pending_script_fetches.entry(index)
1012                    {
1013                        entry.insert(ClassicScriptExecution::Default);
1014                        tasks.push(WebViewTask::Fetch {
1015                            url,
1016                            kind: FetchKind::Script { index },
1017                        });
1018                    }
1019                    return;
1020                }
1021            }
1022        }
1023
1024        self.advance_deferred_scripts();
1025    }
1026
1027    fn schedule_non_blocking_scripts(&mut self, tasks: &mut Vec<WebViewTask>) {
1028        if self.non_blocking_scripts_scheduled {
1029            return;
1030        }
1031        self.non_blocking_scripts_scheduled = true;
1032
1033        for (index, script) in self.classic_scripts.iter().enumerate() {
1034            let ClassicScript::External { url, execution } = script else {
1035                continue;
1036            };
1037            if *execution == ClassicScriptExecution::Default {
1038                continue;
1039            }
1040
1041            self.pending_script_fetches.insert(index, *execution);
1042            tasks.push(WebViewTask::Fetch {
1043                url: url.clone(),
1044                kind: FetchKind::Script { index },
1045            });
1046        }
1047    }
1048
1049    fn advance_deferred_scripts(&mut self) {
1050        loop {
1051            let Some(index) =
1052                (self.next_deferred_script_index..self.classic_scripts.len()).find(|&index| {
1053                    matches!(
1054                        self.classic_scripts.get(index),
1055                        Some(ClassicScript::External {
1056                            execution: ClassicScriptExecution::Defer,
1057                            ..
1058                        })
1059                    )
1060                })
1061            else {
1062                self.dispatch_dom_content_loaded();
1063                self.phase = PagePhase::ScriptApplied;
1064                return;
1065            };
1066
1067            let Some(source) = self.deferred_script_results.remove(&index) else {
1068                return;
1069            };
1070            self.next_deferred_script_index = index + 1;
1071            if let Some(source) = source {
1072                self.send_script(&source);
1073            }
1074        }
1075    }
1076
1077    /// Sends a script to the JS thread for ordered execution.
1078    fn send_script(&mut self, source: &str) {
1079        if let Some(processor) = self.js_processor.as_ref() {
1080            processor.send(JsTask::RunScript {
1081                source: source.to_string(),
1082            });
1083            self.pending_js_tasks += 1;
1084        }
1085    }
1086
1087    fn dispatch_js_element_event(&mut self, dom_id: u64, event_type: &str) {
1088        if let Some(processor) = self.js_processor.as_ref() {
1089            processor.send(JsTask::DispatchElementEvent {
1090                dom_id,
1091                event_type: event_type.to_string(),
1092            });
1093            self.pending_js_tasks += 1;
1094        }
1095    }
1096
1097    fn dispatch_dom_content_loaded(&mut self) {
1098        if let Some(processor) = self.js_processor.as_ref() {
1099            processor.send(JsTask::DispatchDomContentLoaded);
1100            self.pending_js_tasks += 1;
1101        }
1102    }
1103
1104    fn dispatch_window_load(&mut self) {
1105        if let Some(processor) = self.js_processor.as_ref() {
1106            processor.send(JsTask::DispatchWindowLoad);
1107            self.pending_js_tasks += 1;
1108            self.window_load_dispatched = true;
1109        }
1110    }
1111
1112    /// Whether any JS-visible subresource work still awaits its round trip
1113    /// (classic/dynamic scripts, stylesheets, images, or `fetch()` requests).
1114    ///
1115    /// When this returns `false` the JS thread is idle, so its `mirror` DOM
1116    /// matches the committed tree and the page can be considered fully loaded.
1117    fn has_pending_subresource_work(&self) -> bool {
1118        !self.pending_script_fetches.is_empty()
1119            || !self.pending_js_fetches.is_empty()
1120            || !self.pending_iframe_fetches.is_empty()
1121            || !self.pending_dynamic_scripts.is_empty()
1122            || !self.pending_dynamic_styles.is_empty()
1123            || !self.pending_dynamic_images.is_empty()
1124    }
1125
1126    fn run_due_js_timers(&mut self) {
1127        if self.js_dom_dirty {
1128            // A write-back sync is owed. Pausing pokes keeps a timer snapshot
1129            // from racing the pending sync; timers resume once it completes.
1130            return;
1131        }
1132        if let Some(processor) = self.js_processor.as_ref() {
1133            // Timer pokes are coalescable: the JS thread skips this one when a
1134            // newer task has already been queued. Track the newest poke so the
1135            // write-back sync waits until its result has been applied.
1136            let version = processor.send(JsTask::RunTimers);
1137            self.in_flight_timer_version = Some(version);
1138        }
1139    }
1140
1141    fn schedule_js_fetches(&mut self, tasks: &mut Vec<WebViewTask>) {
1142        let requests = std::mem::take(&mut self.pending_js_fetches);
1143
1144        for request in requests {
1145            match self.resolve_url(&request.url) {
1146                Ok(url) => tasks.push(WebViewTask::Fetch {
1147                    url,
1148                    kind: FetchKind::JavaScript {
1149                        request_id: request.id,
1150                        method: request.method,
1151                        headers: request.headers,
1152                        body: request.body,
1153                    },
1154                }),
1155                Err(error) => self
1156                    .on_js_fetch_failed(request.id, format!("Failed to parse fetch URL: {error}")),
1157            }
1158        }
1159    }
1160
1161    fn schedule_iframe_fetches(&mut self, tasks: &mut Vec<WebViewTask>) {
1162        let requests = std::mem::take(&mut self.pending_iframe_fetches);
1163
1164        for request in requests {
1165            match Url::parse(&request.url) {
1166                Ok(url) => {
1167                    log::info!("Iframe fetch requested in WebView: url={}", url);
1168                    tasks.push(WebViewTask::Fetch {
1169                        url,
1170                        kind: FetchKind::Iframe {
1171                            dom_id: request.dom_id,
1172                        },
1173                    })
1174                }
1175                Err(error) => {
1176                    log::warn!("Failed to parse iframe URL: {error}");
1177                    self.on_iframe_fetch_failed(request.dom_id);
1178                }
1179            }
1180        }
1181    }
1182
1183    fn schedule_dynamic_scripts(&mut self, tasks: &mut Vec<WebViewTask>) {
1184        let requests = std::mem::take(&mut self.pending_dynamic_scripts);
1185
1186        for request in requests {
1187            match request.source {
1188                JsDynamicScriptSource::Inline(source) => {
1189                    self.send_script(&source);
1190                    self.dispatch_js_element_event(request.node_id, "load");
1191                }
1192                JsDynamicScriptSource::External(source) => match self.resolve_url(&source) {
1193                    Ok(url) => tasks.push(WebViewTask::Fetch {
1194                        url,
1195                        kind: FetchKind::DynamicScript {
1196                            node_id: request.node_id,
1197                        },
1198                    }),
1199                    Err(error) => {
1200                        log::warn!("Failed to resolve dynamic script URL: {error}");
1201                        self.on_dynamic_script_fetch_failed(request.node_id);
1202                    }
1203                },
1204            }
1205        }
1206    }
1207
1208    fn schedule_dynamic_styles(&mut self, tasks: &mut Vec<WebViewTask>) {
1209        let requests = std::mem::take(&mut self.pending_dynamic_styles);
1210
1211        for request in requests {
1212            match self.resolve_url(&request.url) {
1213                Ok(url) => tasks.push(WebViewTask::Fetch {
1214                    url,
1215                    kind: FetchKind::DynamicCss {
1216                        node_id: request.node_id,
1217                    },
1218                }),
1219                Err(error) => {
1220                    log::warn!("Failed to resolve dynamic stylesheet URL: {error}");
1221                    self.on_dynamic_style_fetch_failed(request.node_id);
1222                }
1223            }
1224        }
1225    }
1226
1227    fn schedule_dynamic_images(&mut self, tasks: &mut Vec<WebViewTask>) {
1228        let requests = std::mem::take(&mut self.pending_dynamic_images);
1229
1230        for request in requests {
1231            match self.resolve_url(&request.source) {
1232                Ok(url) => tasks.push(WebViewTask::Fetch {
1233                    url,
1234                    kind: FetchKind::Image {
1235                        source: request.source,
1236                    },
1237                }),
1238                Err(error) => log::warn!("Failed to resolve dynamic image URL: {error}"),
1239            }
1240        }
1241    }
1242
1243    fn queue_css_images(&mut self, css: &str, base_url: &Url) {
1244        for source in collect_css_image_sources(css) {
1245            if self.images.contains_key(&source)
1246                || self
1247                    .pending_images
1248                    .iter()
1249                    .any(|(pending, _)| pending == &source)
1250            {
1251                continue;
1252            }
1253            if let Ok(url) = resolve_url(base_url, &source) {
1254                self.pending_images.push((source, url));
1255            }
1256        }
1257    }
1258
1259    fn schedule_pending_images(&mut self, tasks: &mut Vec<WebViewTask>) {
1260        for (source, url) in std::mem::take(&mut self.pending_images) {
1261            tasks.push(WebViewTask::Fetch {
1262                url,
1263                kind: FetchKind::Image { source },
1264            });
1265        }
1266    }
1267
1268    /// Dispatches a click on the given DOM snapshot node id to the page's JS.
1269    ///
1270    /// Resolves the live DOM node behind the snapshot id, translates it to the
1271    /// JS-facing dom id and hands the click to the JS thread. Returns whether
1272    /// a redraw is needed; the JS result triggers the relayout once applied.
1273    pub fn on_js_click(&mut self, dom_id: u32) -> bool {
1274        let Some(processor) = self.js_processor.as_ref() else {
1275            return false;
1276        };
1277        let Some(node) = self
1278            .layout_dom_refs
1279            .get(dom_id as usize)
1280            .and_then(|weak| weak.upgrade())
1281        else {
1282            return false;
1283        };
1284        let Some(js_dom_id) = self.js_dom_ids.get(&(Rc::as_ptr(&node) as usize)) else {
1285            return false;
1286        };
1287        processor.send(JsTask::Click { dom_id: *js_dom_id });
1288        self.pending_js_tasks += 1;
1289        false
1290    }
1291
1292    /// Dispatches a `scroll` event on the given DOM snapshot node id to the
1293    /// page's JS.
1294    ///
1295    /// Resolves the live DOM node behind the snapshot id, translates it to the
1296    /// JS-facing dom id and hands the scroll to the JS thread. Returns whether
1297    /// a redraw is needed; the JS result triggers the relayout once applied.
1298    pub fn on_js_scroll(&mut self, dom_id: u32) -> bool {
1299        let Some(processor) = self.js_processor.as_ref() else {
1300            return false;
1301        };
1302        let Some(node) = self
1303            .layout_dom_refs
1304            .get(dom_id as usize)
1305            .and_then(|weak| weak.upgrade())
1306        else {
1307            return false;
1308        };
1309        let Some(js_dom_id) = self.js_dom_ids.get(&(Rc::as_ptr(&node) as usize)) else {
1310            return false;
1311        };
1312        processor.send(JsTask::Scroll { dom_id: *js_dom_id });
1313        self.pending_js_tasks += 1;
1314        false
1315    }
1316
1317    fn ensure_text_measurer(&mut self) {
1318        if self.text_measurer.is_none() {
1319            self.text_measurer = Some(Arc::new(PlatformTextMeasurer::new().unwrap()));
1320        }
1321    }
1322
1323    /// Builds a snapshot and hands the heavy tree construction to the background.
1324    fn update_layout(&mut self) {
1325        if self.docment_info.is_none() {
1326            return;
1327        }
1328        self.ensure_text_measurer();
1329
1330        let doc_info = self.docment_info.as_ref().unwrap();
1331        let dom_version = doc_info.dom.version();
1332        // Snapshot construction happens at function scope so the profile log
1333        // below can read accumulators regardless of which branch ran.
1334        #[cfg(any(feature = "profile", debug_assertions))]
1335        let mut snapshot_build_time = std::time::Duration::ZERO;
1336        #[cfg(any(feature = "profile", debug_assertions))]
1337        let mut snapshot_cached = false;
1338
1339        let (snapshot, dom_refs) = if let Some(cache) = &self.snapshot_cache
1340            // The DOM is unchanged since the last snapshot: reuse it instead of
1341            // re-cloning the whole tree (CSS/image relayouts dominate).
1342            && cache.dom_version == dom_version
1343        {
1344            #[cfg(any(feature = "profile", debug_assertions))]
1345            {
1346                snapshot_cached = true;
1347            }
1348            (Arc::clone(&cache.snapshot), cache.dom_refs.clone())
1349        } else {
1350            perf_scope!(snapshot_build);
1351            let (snapshot, dom_refs) = DomSnapshot::from_tree(&doc_info.dom.root);
1352            #[cfg(any(feature = "profile", debug_assertions))]
1353            {
1354                snapshot_build_time = snapshot_build.elapsed();
1355            }
1356            let snapshot = Arc::new(snapshot);
1357            self.snapshot_cache = Some(SnapshotCache {
1358                dom_version,
1359                snapshot: Arc::clone(&snapshot),
1360                dom_refs: dom_refs.clone(),
1361            });
1362            (snapshot, dom_refs)
1363        };
1364        profile_log!(
1365            target: "PageLoad",
1366            log::Level::Info,
1367            "[DomSnapshot] build: {:?} (cache hit: {})",
1368            snapshot_build_time,
1369            snapshot_cached,
1370        );
1371        let root = snapshot.roots()[0];
1372
1373        let media_environment =
1374            layouter::css_resolver::MediaEnvironment::new(self.viewport, self.system_color_scheme);
1375        let task = layouter::LayoutTask {
1376            snapshot,
1377            root,
1378            resolved_styles: Arc::clone(&self.resolved_styles),
1379            media_environment,
1380            measurer: self.text_measurer.clone().unwrap(),
1381            system_color_scheme: self.system_color_scheme,
1382            scripting_mode: self.js_policy.into(),
1383            images: self.images.clone(),
1384            audio: self.audio.clone(),
1385            parent: InheritedCss {
1386                text_flow_style: TextFlowStyle {
1387                    font_size: 16.0,
1388                    ..Default::default()
1389                },
1390                ..Default::default()
1391            },
1392            chain: ElementChain::default(),
1393            write_back_sender: Some(self.write_back_tx.clone()),
1394            styles_version: self.resolved_styles_version,
1395            version: 0,
1396        };
1397        self.layout_dom_refs = dom_refs;
1398        self.layout_requested_version = self.layout_processor.send(task);
1399        self.layout_pending = true;
1400    }
1401
1402    /// Takes decoded images from the background thread and triggers a relayout.
1403    fn try_apply_decoded_images(&mut self) {
1404        while let Some((source, result)) = self.image_decoder.try_receive() {
1405            match result {
1406                Ok(image) => {
1407                    self.images.insert(source, image);
1408                    self.update_layout();
1409                }
1410                Err(error) => {
1411                    log::warn!("Image decode failed: {error:#}");
1412                }
1413            }
1414        }
1415    }
1416
1417    /// Takes completed layout results from the thread and makes them drawable.
1418    fn try_apply_layout_results(&mut self) {
1419        while let Some(result) = self.layout_processor.try_receive() {
1420            let LayoutResult {
1421                layout,
1422                mut info,
1423                version,
1424            } = result;
1425            if version < self.layout_requested_version {
1426                continue;
1427            }
1428            // The builder initializes every node's scroll offset to 0, so a
1429            // rebuilt tree would otherwise drop the scroll position (e.g. the
1430            // viewport change on a window resize). Re-apply the offsets of the
1431            // previous tree before swapping the new one in.
1432            if let Some((_, old_info)) = self.layout_and_info.as_ref() {
1433                let mut scroll_offsets = HashMap::new();
1434                capture_scroll_offsets(old_info, &mut scroll_offsets);
1435                apply_scroll_offsets(&mut info, &scroll_offsets);
1436            }
1437
1438            self.layout_and_info = Some((layout, info));
1439            self.layout_applied_version = version;
1440            self.layout_pending = false;
1441            self.needs_redraw = true;
1442            // The fresh tree has no geometry yet (positioning normally happens
1443            // during draws). Position it right away so input events arriving
1444            // before the next redraw still hit-test against real boxes.
1445            self.position_layout_if_needed();
1446        }
1447    }
1448
1449    /// Takes completed JS results from the thread and commits them.
1450    ///
1451    /// A result that mutated the DOM carries a snapshot of the thread's mirror;
1452    /// committing it replaces the authoritative tree, re-registers the JS dom
1453    /// id map and triggers a relayout.
1454    fn try_apply_js_results(&mut self) {
1455        let results: Vec<JsTaskResult> = {
1456            let Some(processor) = self.js_processor.as_ref() else {
1457                return;
1458            };
1459            let mut results = Vec::new();
1460            while let Some(result) = processor.try_receive() {
1461                results.push(result);
1462            }
1463            results
1464        };
1465        for result in results {
1466            self.pending_js_tasks = self.pending_js_tasks.saturating_sub(1);
1467            self.pending_js_fetches.extend(result.fetch_requests);
1468            self.pending_devtools_requests
1469                .extend(result.devtools_requests);
1470            self.pending_dynamic_scripts
1471                .extend(result.dynamic_script_requests);
1472            self.pending_dynamic_styles
1473                .extend(result.dynamic_style_requests);
1474            self.pending_dynamic_images
1475                .extend(result.dynamic_image_requests);
1476            self.pending_iframe_fetches
1477                .extend(result.iframe_fetch_requests);
1478
1479            if let Some(in_flight) = self.in_flight_timer_version
1480                && result.version >= in_flight
1481            {
1482                // The newest timer poke has been processed (run or superseded),
1483                // so its snapshot can no longer race the write-back sync.
1484                self.in_flight_timer_version = None;
1485            }
1486
1487            let Some(snapshot) = result.dom else {
1488                continue;
1489            };
1490            let Some(info) = self.docment_info.as_mut() else {
1491                continue;
1492            };
1493            // Commit the thread's mirror as the new authoritative tree. The
1494            // rebuilt tree starts with a fresh version, so the cached snapshot
1495            // and live layout references are stale and must be dropped.
1496            let (tree, dom_ids) = snapshot.into_tree();
1497            // Retain only iframe content for iframes still present; stale
1498            // entries from removed iframes are dropped on the next nav anyway.
1499            self.iframe_content.clear();
1500            for iframe_doc in result.iframe_documents {
1501                self.iframe_content
1502                    .insert(iframe_doc.iframe_dom_id, iframe_doc.content);
1503            }
1504            info.dom = Rc::new(tree);
1505            self.js_dom_ids = dom_ids;
1506            // Splice committed iframe content under the host <iframe> nodes so
1507            // layout/paint render it nested.
1508            graft_iframe_documents(&info.dom, &self.js_dom_ids, &self.iframe_content);
1509            self.snapshot_cache = None;
1510            self.layout_dom_refs.clear();
1511
1512            self.rebuild_styles_and_layout();
1513            self.needs_redraw = true;
1514        }
1515    }
1516
1517    /// Syncs UI-side DOM mutations (write-backs, `update_page`) to the JS thread.
1518    ///
1519    /// Only runs when no JS task is in flight: mid-task the thread's mirror
1520    /// legitimately diverges from the real tree, and committing an in-flight
1521    /// snapshot after this sync would clobber the thread's newer mutations.
1522    /// An in-flight `RunTimers` poke counts as in flight for the same reason:
1523    /// its callback may mutate the mirror and commit a stale snapshot.
1524    fn sync_dom_to_worker(&mut self) {
1525        if !self.js_dom_dirty
1526            || self.pending_js_tasks != 0
1527            || self.in_flight_timer_version.is_some()
1528        {
1529            return;
1530        }
1531        let Some(processor) = self.js_processor.as_ref() else {
1532            return;
1533        };
1534        let Some(doc_info) = self.docment_info.as_ref() else {
1535            return;
1536        };
1537        let snapshot = DomSnapshot::from_mirror(&doc_info.dom.root, &self.js_dom_ids);
1538        processor.send(JsTask::UpdateDom { snapshot });
1539        self.pending_js_tasks += 1;
1540        self.js_dom_dirty = false;
1541    }
1542
1543    /// Applies value write-backs reported by text inputs to the live DOM.
1544    fn drain_write_backs(&mut self) {
1545        let mut applied = false;
1546        while let Ok((node_id, value)) = self.write_back_rx.try_recv() {
1547            if let Some(weak) = self.layout_dom_refs.get(node_id as usize)
1548                && let Some(node) = weak.upgrade()
1549            {
1550                node.borrow_mut().value.set_attr("value", value);
1551                applied = true;
1552            }
1553            self.needs_redraw = true;
1554        }
1555
1556        // The DOM mutated, so the cached snapshot is stale and must be rebuilt
1557        // on the next relayout. Future JS mutations must also bump the version.
1558        if applied && let Some(doc_info) = self.docment_info.as_mut() {
1559            doc_info.dom.mark_dirty();
1560        }
1561        // The JS thread's mirror must reflect the new input value; synced once
1562        // the in-flight JS tasks have all been applied.
1563        if applied {
1564            self.js_dom_dirty = true;
1565        }
1566    }
1567
1568    pub fn navigate(&mut self) {
1569        self.reset_for_navigation();
1570    }
1571
1572    fn reset_for_navigation(&mut self) {
1573        if self.phase != PagePhase::Init {
1574            self.phase = PagePhase::BeforeHtmlParsing;
1575        }
1576
1577        self.docment_info = None;
1578        self.pending_css_urls.clear();
1579        self.pending_images.clear();
1580        self.pending_audio.clear();
1581        self.loaded_css.clear();
1582        self.linked_css.clear();
1583        self.images.clear();
1584        self.audio.clear();
1585        Arc::make_mut(&mut self.resolved_styles).clear();
1586        self.layout_and_info = None;
1587
1588        self.needs_redraw = false;
1589
1590        self.css_processor = css::processor::CssProcessor::new();
1591        self.css_results_expected = 0;
1592        self.css_results_received = 0;
1593
1594        self.layout_processor = layouter::LayoutProcessor::new();
1595        self.layout_pending = false;
1596        self.layout_requested_version = 0;
1597        self.layout_applied_version = 0;
1598        self.positioned_layout = None;
1599        self.layout_dom_refs.clear();
1600        self.snapshot_cache = None;
1601        self.js_processor = None;
1602        self.js_dom_ids.clear();
1603        self.pending_js_tasks = 0;
1604        self.js_dom_dirty = false;
1605        self.in_flight_timer_version = None;
1606        self.pending_js_fetches.clear();
1607        self.pending_devtools_requests.clear();
1608        self.inspector_ids.borrow_mut().clear();
1609        self.pending_dynamic_scripts.clear();
1610        self.pending_dynamic_styles.clear();
1611        self.pending_dynamic_images.clear();
1612        self.pending_iframe_fetches.clear();
1613        self.iframe_content.clear();
1614        self.classic_scripts.clear();
1615        self.next_script_index = 0;
1616        self.pending_script_fetches.clear();
1617        self.non_blocking_scripts_scheduled = false;
1618        self.deferred_script_results.clear();
1619        self.next_deferred_script_index = 0;
1620        self.pending_fragment_scroll = None;
1621        self.fragment_ready_version = None;
1622        let (write_back_tx, write_back_rx) = mpsc::channel();
1623        self.write_back_tx = write_back_tx;
1624        self.write_back_rx = write_back_rx;
1625    }
1626
1627    pub fn set_system_color_scheme(&mut self, scheme: ColorScheme) {
1628        if self.system_color_scheme == scheme {
1629            return;
1630        }
1631        self.system_color_scheme = scheme;
1632        self.update_layout();
1633    }
1634
1635    pub fn title(&self) -> Option<&String> {
1636        self.docment_info.as_ref().map(|d| &d.title)
1637    }
1638
1639    /// Runs the box-positioning pass over the current layout tree unless it
1640    /// has already been positioned for the current version + viewport.
1641    ///
1642    /// Background layout results arrive unpositioned (geometry is computed on
1643    /// the main thread), so this must run before the tree is used for anything
1644    /// geometry-sensitive — drawing, but crucially also hit-testing. Without
1645    /// the eager call in [`WebView::try_apply_layout_results`], a click landing
1646    /// between a result being applied and the next draw would walk boxes with
1647    /// no geometry and find nothing.
1648    fn position_layout_if_needed(&mut self) {
1649        let viewport = self.viewport;
1650        if self.positioned_layout == Some((self.layout_applied_version, viewport)) {
1651            return;
1652        }
1653        let Some((layout, info)) = self.layout_and_info.as_mut() else {
1654            return;
1655        };
1656
1657        ui_layout::LayoutEngine::layout(layout, viewport.0, viewport.1);
1658        if layouter::constrain_auto_grid_track_items(layout) {
1659            ui_layout::LayoutEngine::layout(layout, viewport.0, viewport.1);
1660        }
1661        layouter::correct_atomic_inline_spacing_with_info(layout, info);
1662        layouter::align_table_columns(layout, info);
1663        layouter::refresh_missing_text_layout_results(layout, info, viewport);
1664
1665        self.positioned_layout = Some((self.layout_applied_version, viewport));
1666    }
1667
1668    pub fn relayout(&mut self, viewport: (f32, f32)) {
1669        if self.viewport != viewport {
1670            self.viewport = viewport;
1671            if let Some(processor) = self.js_processor.as_ref() {
1672                processor.send(JsTask::SetViewport {
1673                    width: viewport.0,
1674                    height: viewport.1,
1675                });
1676                self.pending_js_tasks += 1;
1677            }
1678            self.update_layout();
1679        }
1680        self.position_layout_if_needed();
1681
1682        let fragment_target =
1683            if fragment_layout_is_ready(self.fragment_ready_version, self.layout_applied_version) {
1684                self.pending_fragment_scroll
1685                    .as_deref()
1686                    .and_then(|fragment| {
1687                        find_fragment_target_dom_id(&self.layout_dom_refs, fragment)
1688                    })
1689            } else {
1690                None
1691            };
1692
1693        let Some((layout, info)) = self.layout_and_info.as_mut() else {
1694            return;
1695        };
1696
1697        if fragment_target
1698            .is_some_and(|target| apply_fragment_scroll(layout, info, target, viewport.1))
1699        {
1700            self.pending_fragment_scroll = None;
1701            self.needs_redraw = true;
1702        }
1703
1704        let layout_metrics =
1705            collect_js_layout_metrics(layout, info, &self.layout_dom_refs, &self.js_dom_ids);
1706        if let Some(processor) = self.js_processor.as_ref() {
1707            processor.send(JsTask::SetLayoutMetrics {
1708                metrics: layout_metrics,
1709            });
1710            self.pending_js_tasks += 1;
1711        }
1712    }
1713
1714    /// 現在描画可能な Layout / Info を返す(なければ None)
1715    pub fn layout_and_info(&self) -> Option<(&LayoutNode, &InfoNode)> {
1716        self.layout_and_info.as_ref().map(|(l, i)| (l, i))
1717    }
1718
1719    pub fn layout_and_info_mut(&mut self) -> Option<(&LayoutNode, &mut InfoNode)> {
1720        self.layout_and_info.as_mut().map(|(l, i)| (&*l, i))
1721    }
1722
1723    /// Returns document info
1724    pub fn document_info(&self) -> Option<&DocumentInfo> {
1725        self.docment_info.as_ref()
1726    }
1727
1728    pub fn document_url(&self) -> Option<&Url> {
1729        self.docment_info.as_ref().map(|info| &info.document_url)
1730    }
1731
1732    pub fn base_url(&self) -> Option<&Url> {
1733        self.docment_info.as_ref().map(|info| &info.base_url)
1734    }
1735
1736    pub fn needs_redraw(&self) -> bool {
1737        self.needs_redraw
1738            || self
1739                .layout_and_info
1740                .as_ref()
1741                .is_some_and(|(_, info)| crate::engine::input::any_custom_node_needs_repaint(info))
1742    }
1743
1744    pub fn clear_redraw_flag(&mut self) {
1745        self.needs_redraw = false;
1746    }
1747
1748    fn resolve_url(&self, url: &str) -> Result<Url, url::ParseError> {
1749        let base = self
1750            .docment_info
1751            .as_ref()
1752            .map(|info| &info.base_url)
1753            .ok_or(url::ParseError::RelativeUrlWithoutBase)?;
1754
1755        Url::parse(url).or_else(|_| base.join(url))
1756    }
1757}
1758
1759/// Builds the geometry snapshot used by DOM measurement APIs from the same
1760/// layout boxes and scroll offsets consumed by painting and hit testing.
1761fn collect_js_layout_metrics(
1762    layout: &LayoutNode,
1763    info: &InfoNode,
1764    dom_refs: &[Weak<RefCell<TreeNode<HtmlNodeType>>>],
1765    js_dom_ids: &HashMap<usize, u64>,
1766) -> HashMap<u64, JsLayoutMetrics> {
1767    let mut metrics = HashMap::new();
1768    collect_js_layout_metrics_inner(
1769        layout,
1770        info,
1771        dom_refs,
1772        js_dom_ids,
1773        (0.0, 0.0),
1774        (0.0, 0.0),
1775        &mut metrics,
1776    );
1777    metrics
1778}
1779
1780fn collect_js_layout_metrics_inner(
1781    layout: &LayoutNode,
1782    info: &InfoNode,
1783    dom_refs: &[Weak<RefCell<TreeNode<HtmlNodeType>>>],
1784    js_dom_ids: &HashMap<usize, u64>,
1785    parent_content_origin: (f32, f32),
1786    inherited_scroll: (f32, f32),
1787    metrics: &mut HashMap<u64, JsLayoutMetrics>,
1788) {
1789    let is_fixed = layout.style.position.kind == ui_layout::Position::Fixed;
1790    let effective_scroll = if is_fixed {
1791        (0.0, 0.0)
1792    } else {
1793        inherited_scroll
1794    };
1795    let own_scroll = info.kind.scroll_offsets();
1796    let child_scroll = if is_fixed {
1797        own_scroll
1798    } else {
1799        (
1800            inherited_scroll.0 + own_scroll.0,
1801            inherited_scroll.1 + own_scroll.1,
1802        )
1803    };
1804
1805    let boxes: Vec<_> = layout.layout_box.iter().collect();
1806    if let Some(first) = boxes.first() {
1807        let mut page_left = parent_content_origin.0 + first.border_box.x;
1808        let mut page_top = parent_content_origin.1 + first.border_box.y;
1809        let mut page_right = page_left + first.border_box.width;
1810        let mut page_bottom = page_top + first.border_box.height;
1811        for model in boxes.iter().skip(1) {
1812            let left = parent_content_origin.0 + model.border_box.x;
1813            let top = parent_content_origin.1 + model.border_box.y;
1814            page_left = page_left.min(left);
1815            page_top = page_top.min(top);
1816            page_right = page_right.max(left + model.border_box.width);
1817            page_bottom = page_bottom.max(top + model.border_box.height);
1818        }
1819
1820        if let Some(node) = info
1821            .dom_id
1822            .and_then(|id| dom_refs.get(id as usize))
1823            .and_then(Weak::upgrade)
1824        {
1825            let node_key = Rc::as_ptr(&node) as usize;
1826            if let Some(dom_id) = js_dom_ids.get(&node_key).copied() {
1827                metrics.insert(
1828                    dom_id,
1829                    JsLayoutMetrics {
1830                        offset_left: first.border_box.x as f64,
1831                        offset_top: first.border_box.y as f64,
1832                        offset_width: (page_right - page_left) as f64,
1833                        offset_height: (page_bottom - page_top) as f64,
1834                        client_width: first.padding_box.width as f64,
1835                        client_height: first.padding_box.height as f64,
1836                        rect_left: (page_left - effective_scroll.0) as f64,
1837                        rect_top: (page_top - effective_scroll.1) as f64,
1838                        rect_width: (page_right - page_left) as f64,
1839                        rect_height: (page_bottom - page_top) as f64,
1840                    },
1841                );
1842            }
1843        }
1844
1845        // TODO: Apply CSS transforms and sticky-position paint offsets to DOMRect geometry.
1846        let child_origin = (
1847            parent_content_origin.0 + first.content_box.x,
1848            parent_content_origin.1 + first.content_box.y,
1849        );
1850        for (child_layout, child_info) in layout.children.iter().zip(&info.children) {
1851            if let Some(child_layout) = child_layout.node() {
1852                collect_js_layout_metrics_inner(
1853                    child_layout,
1854                    child_info,
1855                    dom_refs,
1856                    js_dom_ids,
1857                    child_origin,
1858                    child_scroll,
1859                    metrics,
1860                );
1861            }
1862        }
1863    }
1864}
1865
1866/// Records the nonzero scroll offsets of every scrollable node in `info`,
1867/// keyed by the node's DOM snapshot id.
1868///
1869/// The layout builder initializes each node's `scroll_offset` to 0, so a
1870/// rebuild would otherwise drop the scroll position (e.g. after a window
1871/// resize). `dom_id` stays stable across rebuilds while the DOM is unchanged,
1872/// which makes it a reliable key for restoring state onto the new tree.
1873fn capture_scroll_offsets(info: &InfoNode, offsets: &mut HashMap<NodeId, (f32, f32)>) {
1874    let (x, y) = info.kind.scroll_offsets();
1875    if (x != 0.0 || y != 0.0)
1876        && let Some(dom_id) = info.dom_id
1877    {
1878        offsets.insert(dom_id, (x, y));
1879    }
1880    for child in &info.children {
1881        capture_scroll_offsets(child, offsets);
1882    }
1883}
1884
1885/// Restores scroll offsets captured by [`capture_scroll_offsets`] onto a newly
1886/// built tree.
1887///
1888/// Offsets are copied verbatim for every matching node regardless of its
1889/// `scroll_x`/`scroll_y` flags: the flags describe whether an axis *can*
1890/// scroll, not whether a scroll position was captured, so gating on them here
1891/// would drop positions (e.g. the viewport/page scroll carried by the root).
1892fn apply_scroll_offsets(info: &mut InfoNode, offsets: &HashMap<NodeId, (f32, f32)>) {
1893    if let Some((x, y)) = info.dom_id.and_then(|id| offsets.get(&id)) {
1894        match &mut info.kind {
1895            NodeKind::Container {
1896                scroll_offset_x,
1897                scroll_offset_y,
1898                ..
1899            }
1900            | NodeKind::Custom {
1901                scroll_offset_x,
1902                scroll_offset_y,
1903                ..
1904            } => {
1905                *scroll_offset_x = *x;
1906                *scroll_offset_y = *y;
1907            }
1908            _ => {}
1909        }
1910    }
1911    for child in &mut info.children {
1912        apply_scroll_offsets(child, offsets);
1913    }
1914}
1915
1916fn fragment_layout_is_ready(ready_version: Option<u64>, applied_version: u64) -> bool {
1917    ready_version.is_some_and(|ready_version| applied_version >= ready_version)
1918}
1919
1920fn find_fragment_target_dom_id(
1921    dom_refs: &[Weak<RefCell<TreeNode<HtmlNodeType>>>],
1922    fragment: &str,
1923) -> Option<NodeId> {
1924    dom_refs.iter().enumerate().find_map(|(index, node)| {
1925        let node = node.upgrade()?;
1926        (node.borrow().value.get_attr("id") == Some(fragment)).then_some(index as NodeId)
1927    })
1928}
1929
1930fn apply_fragment_scroll(
1931    layout: &LayoutNode,
1932    info: &mut InfoNode,
1933    target: NodeId,
1934    viewport_height: f32,
1935) -> bool {
1936    let Some(target_y) = fragment_target_y(layout, info, target, 0.0) else {
1937        return false;
1938    };
1939    set_first_vertical_scroll_offset(layout, info, target_y, viewport_height)
1940}
1941
1942fn fragment_target_y(
1943    layout: &LayoutNode,
1944    info: &InfoNode,
1945    target: NodeId,
1946    parent_content_y: f32,
1947) -> Option<f32> {
1948    let model = layout.layout_box.iter().next();
1949    if info.dom_id == Some(target) {
1950        return model.map(|model| parent_content_y + model.border_box.y);
1951    }
1952    let child_content_y =
1953        parent_content_y + model.as_ref().map_or(0.0, |model| model.content_box.y);
1954    layout
1955        .children
1956        .iter()
1957        .zip(&info.children)
1958        .find_map(|(layout_child, info_child)| {
1959            let LayoutChild::Node(layout_child) = layout_child else {
1960                return None;
1961            };
1962            fragment_target_y(layout_child, info_child, target, child_content_y)
1963        })
1964}
1965
1966fn set_first_vertical_scroll_offset(
1967    layout: &LayoutNode,
1968    info: &mut InfoNode,
1969    target_y: f32,
1970    viewport_height: f32,
1971) -> bool {
1972    if let Some(model) = layout.layout_box.iter().next() {
1973        let offset = match &mut info.kind {
1974            NodeKind::Container {
1975                scroll_y: true,
1976                scroll_offset_y,
1977                ..
1978            }
1979            | NodeKind::Custom {
1980                scroll_y: true,
1981                scroll_offset_y,
1982                ..
1983            } => Some(scroll_offset_y),
1984            _ => None,
1985        };
1986        if let Some(offset) = offset {
1987            let max_scroll = (model.children_box.height
1988                - model.content_box.height.min(viewport_height))
1989            .max(0.0);
1990            *offset = target_y.clamp(0.0, max_scroll);
1991            return true;
1992        }
1993    }
1994
1995    layout
1996        .children
1997        .iter()
1998        .zip(&mut info.children)
1999        .any(|(layout_child, info_child)| {
2000            let LayoutChild::Node(layout_child) = layout_child else {
2001                return false;
2002            };
2003            set_first_vertical_scroll_offset(layout_child, info_child, target_y, viewport_height)
2004        })
2005}
2006
2007fn collect_css_image_sources(css: &str) -> Vec<String> {
2008    fn collect_value(value: &CssValue, sources: &mut Vec<String>) {
2009        match value {
2010            CssValue::Function(name, arguments) if name.eq_ignore_ascii_case("url") => {
2011                if let Some(source) = arguments
2012                    .iter()
2013                    .flatten()
2014                    .find_map(|argument| match argument {
2015                        CssValue::String(source) => Some(source.clone()),
2016                        CssValue::Keyword(source) => Some(source.to_string()),
2017                        _ => None,
2018                    })
2019                    && !source.is_empty()
2020                    && !sources.contains(&source)
2021                {
2022                    sources.push(source);
2023                }
2024            }
2025            CssValue::Function(_, arguments) => {
2026                for argument in arguments.iter().flatten() {
2027                    collect_value(argument, sources);
2028                }
2029            }
2030            CssValue::List(arguments) => {
2031                for argument in arguments {
2032                    collect_value(argument, sources);
2033                }
2034            }
2035            _ => {}
2036        }
2037    }
2038
2039    fn visit(node: &CssNode, sources: &mut Vec<String>) {
2040        if let CssNodeType::Declaration { name, value } = node.node()
2041            && matches!(
2042                name.to_ascii_lowercase().as_str(),
2043                "background" | "background-image"
2044            )
2045        {
2046            collect_value(value, sources);
2047        }
2048        for child in node.children() {
2049            visit(child, sources);
2050        }
2051    }
2052
2053    let stylesheet = CssParser::new(css).parse_lossy();
2054    let mut sources = Vec::new();
2055    visit(&stylesheet, &mut sources);
2056    sources
2057}
2058
2059fn parse_html(html: &str, document_url: Url, scripting_mode: ScriptingMode) -> ParsedDocument {
2060    // --- DOM パース ---
2061    let mut parser = HtmlParser::new(html).with_scripting_mode(scripting_mode);
2062    let dom = Rc::new(parser.parse());
2063
2064    // --- base_url ---
2065    let base_url = dom
2066        .find_all(|n| n.tag_name() == Some("base"))
2067        .iter()
2068        .filter_map(|node_ref| {
2069            let html_node = &node_ref.borrow().value;
2070            let href = html_node.get_attr("href")?;
2071            document_url.join(href).ok()
2072        })
2073        .next()
2074        .unwrap_or_else(|| document_url.clone());
2075
2076    // --- title 抽出 ---
2077    let title = dom
2078        .collect_text_by_tag("title")
2079        .first()
2080        .cloned()
2081        .unwrap_or("".into());
2082
2083    // --- Style links ---
2084    // <link rel="stylesheet" href="...">
2085    let link_nodes = dom.find_all(|n| n.tag_name() == Some("link"));
2086    let mut style_links = Vec::new();
2087
2088    for node in link_nodes {
2089        let (rel, href) = {
2090            let node_ref = node.borrow();
2091            let html_node = &node_ref.value;
2092
2093            let rel = html_node.get_attr("rel").map(|s| s.to_string());
2094            let href = html_node.get_attr("href").map(|s| s.to_string());
2095            (rel, href)
2096        };
2097
2098        if let (Some(rel), Some(href)) = (rel, href)
2099            && rel
2100                .split_ascii_whitespace()
2101                .any(|token| token.eq_ignore_ascii_case("stylesheet"))
2102        {
2103            let css_url = match resolve_url(&base_url, &href) {
2104                Ok(url) => url,
2105                Err(_) => continue,
2106            };
2107            style_links.push(css_url);
2108        }
2109    }
2110
2111    // --- Inline styles ---
2112    let inline_styles = dom.collect_text_by_tag("style");
2113
2114    // --- Classic scripts ---
2115    let scripts = dom
2116        .collect_classic_script_descriptors()
2117        .into_iter()
2118        .filter_map(|script| match script.source {
2119            ClassicScriptSource::Inline(source) => Some(ClassicScript::Inline(source)),
2120            ClassicScriptSource::External(source) => {
2121                resolve_url(&base_url, &source)
2122                    .ok()
2123                    .map(|url| ClassicScript::External {
2124                        url,
2125                        execution: script.execution,
2126                    })
2127            }
2128        })
2129        .collect();
2130
2131    let image_sources = dom
2132        .get_elements_by_tag_name("img")
2133        .into_iter()
2134        .filter_map(|node| {
2135            let source = node.borrow().value.get_attr("src")?.to_string();
2136            let url = resolve_url(&base_url, &source).ok()?;
2137            Some((source, url))
2138        })
2139        .collect();
2140
2141    let audio_sources = dom
2142        .get_elements_by_tag_name("audio")
2143        .into_iter()
2144        .filter_map(|node| {
2145            let source = {
2146                let audio = node.borrow();
2147                audio.value.get_attr("src").map(str::to_string).or_else(|| {
2148                    audio.children().iter().find_map(|child| {
2149                        let child = child.borrow();
2150                        (child.value.tag_name() == Some("source"))
2151                            .then(|| child.value.get_attr("src").map(str::to_string))
2152                            .flatten()
2153                    })
2154                })
2155            }?;
2156            let url = resolve_url(&base_url, &source).ok()?;
2157            Some((source, url))
2158        })
2159        .collect();
2160
2161    ParsedDocument {
2162        document_url,
2163        base_url,
2164        dom,
2165        title,
2166        style_links,
2167        inline_styles,
2168        image_sources,
2169        audio_sources,
2170        scripts,
2171    }
2172}
2173
2174pub fn resolve_url(base_url: &Url, path: &str) -> Result<Url, url::ParseError> {
2175    // absolute URL(scheme を持つ)
2176    if let Ok(url) = Url::parse(path) {
2177        return Ok(url);
2178    }
2179
2180    // relative URL
2181    base_url.join(path)
2182}
2183
2184#[cfg(test)]
2185mod tests {
2186    use super::*;
2187    use crate::engine::layouter::types::{ContainerRole, ContainerStyle};
2188    use serde_json::{Value, json};
2189    use std::time::{Duration, Instant};
2190    use ui_layout::{Length, LengthOrAuto};
2191
2192    /// Drives `tick()` until `done` holds, or panics after a timeout.
2193    ///
2194    /// JS runs on a background thread, so effects (DOM commits, relayouts) are
2195    /// observed a few ticks after the task that produced them was sent.
2196    fn pump_until(webview: &mut WebView, mut done: impl FnMut(&mut WebView) -> bool, why: &str) {
2197        let deadline = Instant::now() + Duration::from_secs(5);
2198        while !done(webview) {
2199            assert!(Instant::now() < deadline, "timed out waiting for {why}");
2200            webview.tick();
2201            std::thread::sleep(Duration::from_millis(1));
2202        }
2203    }
2204
2205    /// Drives `tick()` collecting tasks until `done` accepts one, then returns it.
2206    fn pump_for_task(
2207        webview: &mut WebView,
2208        mut done: impl FnMut(&WebViewTask) -> bool,
2209        why: &str,
2210    ) -> WebViewTask {
2211        let deadline = Instant::now() + Duration::from_secs(5);
2212        loop {
2213            let tasks = webview.tick();
2214            if let Some(task) = tasks.into_iter().find(&mut done) {
2215                return task;
2216            }
2217            assert!(Instant::now() < deadline, "timed out waiting for {why}");
2218            std::thread::sleep(Duration::from_millis(1));
2219        }
2220    }
2221
2222    fn find_json_by_attribute<'a>(value: &'a Value, name: &str, wanted: &str) -> Option<&'a Value> {
2223        if value
2224            .get("attributes")
2225            .and_then(Value::as_array)
2226            .is_some_and(|attrs| {
2227                attrs
2228                    .iter()
2229                    .any(|attr| attr[0] == *name && attr[1] == *wanted)
2230            })
2231        {
2232            return Some(value);
2233        }
2234        value
2235            .get("children")
2236            .and_then(Value::as_array)
2237            .and_then(|children| {
2238                children
2239                    .iter()
2240                    .find_map(|child| find_json_by_attribute(child, name, wanted))
2241            })
2242    }
2243
2244    fn styles_webview() -> WebView {
2245        let mut webview = WebView::default();
2246        // The Init phase injects the user-agent stylesheet into resolved_styles.
2247        webview.tick();
2248        webview.on_html_fetched(
2249            r#"<html><head><style>
2250                    p { color: red; }
2251                    .box { color: blue; }
2252               </style></head>
2253               <body><p id="t" class="box" style="margin-top: 7px">x</p></body></html>"#
2254                .to_string(),
2255            Url::parse("https://example.test/").unwrap(),
2256        );
2257        // Resolve the inline <style> block and rebuild snapshot + layout inputs.
2258        webview.rebuild_styles_and_layout();
2259        webview
2260    }
2261
2262    fn box_model_webview() -> WebView {
2263        let mut webview = WebView::default();
2264        webview.tick();
2265        webview.on_html_fetched(
2266            r#"<html><head><style>
2267                    #box {
2268                        width: 100px;
2269                        height: 50px;
2270                        padding: 10px;
2271                        border: 2px solid red;
2272                        margin-top: 7px;
2273                    }
2274               </style></head>
2275               <body><div id="box">x</div></body></html>"#
2276                .to_string(),
2277            Url::parse("https://example.test/box").unwrap(),
2278        );
2279        webview.rebuild_styles_and_layout();
2280        // The heavy tree build runs on the background thread; wait for it.
2281        pump_until(
2282            &mut webview,
2283            |webview| webview.layout_and_info.is_some(),
2284            "the first background layout build",
2285        );
2286        webview
2287    }
2288
2289    fn dom_id_for_attribute(webview: &mut WebView, name: &str, wanted: &str) -> u64 {
2290        let document = webview.inspect("getDocument", "{}").expect("document");
2291        find_json_by_attribute(&document, name, wanted).map_or_else(
2292            || panic!("no element with {name}={wanted}"),
2293            |node| node["id"].as_u64().unwrap(),
2294        )
2295    }
2296
2297    #[test]
2298    fn box_model_reports_rings_from_laid_out_geometry() {
2299        let mut webview = box_model_webview();
2300        let dom_id = dom_id_for_attribute(&mut webview, "id", "box");
2301        let params = format!(r#"{{"domId":{dom_id}}}"#);
2302
2303        let model = webview.inspect("getBoxModel", &params).expect("box model")["model"].clone();
2304
2305        // Declared margins come through as text, auto stays readable.
2306        assert_eq!(model["margin"][0], "7");
2307        assert_eq!(
2308            model["padding"],
2309            json!([10.0, 10.0, 10.0, 10.0]),
2310            "padding ring derives from padding vs content boxes"
2311        );
2312        assert_eq!(model["border"], json!([2.0, 2.0, 2.0, 2.0]));
2313        // Default box-sizing is content-box: content keeps the declared size.
2314        assert_eq!(model["content"], json!([100.0, 50.0]));
2315        assert_eq!(model["size"], json!([124.0, 74.0]));
2316
2317        let info = webview
2318            .inspect("getLayoutInfo", &params)
2319            .expect("layout info")["info"]
2320            .clone();
2321        assert_eq!(info["width"], "100");
2322        assert_eq!(info["height"], "50");
2323        assert_eq!(info["scroll"], json!([0.0, 0.0]));
2324    }
2325
2326    #[test]
2327    fn box_model_rejects_ids_outside_the_current_layout() {
2328        let mut webview = box_model_webview();
2329        let error = webview
2330            .inspect("getBoxModel", r#"{"domId":99999}"#)
2331            .expect_err("unknown id must fail");
2332        assert!(error.contains("unknown domId"), "unexpected error: {error}");
2333    }
2334
2335    #[test]
2336    fn applied_layout_results_are_positioned_before_any_draw() {
2337        // Regression: background layout results replaced the tree unpositioned
2338        // (geometry was only computed during draws), so a click landing between
2339        // an application and the next redraw hit-tested against boxes without
2340        // geometry and found nothing.
2341        let webview = box_model_webview();
2342        let (layout, info) = webview.layout_and_info().expect("layout applied");
2343
2344        let path = crate::engine::input::hit_test(layout, info, 50.0, 25.0);
2345        assert!(
2346            crate::engine::input::hit_dom_id(&path).is_some(),
2347            "boxes must carry geometry as soon as a background result lands"
2348        );
2349    }
2350
2351    #[test]
2352    fn matched_rules_report_winners_overrides_and_inline_styles() {
2353        let mut webview = styles_webview();
2354
2355        let document = webview.inspect("getDocument", "{}").expect("document");
2356        let paragraph = find_json_by_attribute(&document, "id", "t").expect("<p id=t>");
2357        let dom_id = paragraph["id"].as_u64().unwrap();
2358
2359        let rules = webview
2360            .inspect("getMatchedRules", &format!(r#"{{"domId":{dom_id}}}"#))
2361            .expect("matched rules");
2362        let rules = rules["rules"].as_array().unwrap();
2363
2364        let inline = rules
2365            .iter()
2366            .find(|rule| rule["inline"] == Value::Bool(true))
2367            .expect("inline entry");
2368        assert_eq!(inline["selector"], "element.style");
2369        assert_eq!(inline["declarations"][0]["name"], "margin-top");
2370        assert_eq!(inline["declarations"][0]["value"], "7px");
2371        assert_eq!(inline["declarations"][0]["applied"], true);
2372
2373        let class_rule = rules
2374            .iter()
2375            .find(|rule| rule["selector"] == ".box")
2376            .expect(".box rule");
2377        assert_eq!(class_rule["origin"], "author");
2378        assert_eq!(class_rule["declarations"][0]["applied"], true);
2379
2380        // The user-agent sheet also styles `p`; pick the author rule and
2381        // check its color declaration specifically.
2382        let tag_rule = rules
2383            .iter()
2384            .find(|rule| rule["selector"] == "p" && rule["origin"] == "author")
2385            .expect("author p rule");
2386        let color = tag_rule["declarations"]
2387            .as_array()
2388            .unwrap()
2389            .iter()
2390            .find(|declaration| declaration["name"] == "color")
2391            .unwrap();
2392        assert_eq!(color["applied"], false, ".box must override the p color");
2393
2394        // User-agent rules participate in the report too.
2395        assert!(
2396            rules.iter().any(|rule| rule["origin"] == "user-agent"),
2397            "user-agent origin rules must be reported"
2398        );
2399    }
2400
2401    #[test]
2402    fn computed_style_lists_winning_declarations_sorted_by_name() {
2403        let mut webview = styles_webview();
2404
2405        let document = webview.inspect("getDocument", "{}").expect("document");
2406        let paragraph = find_json_by_attribute(&document, "id", "t").expect("<p id=t>");
2407        let dom_id = paragraph["id"].as_u64().unwrap();
2408
2409        let computed = webview
2410            .inspect("getComputedStyle", &format!(r#"{{"domId":{dom_id}}}"#))
2411            .expect("computed style");
2412        let properties = computed["properties"].as_array().unwrap();
2413
2414        let names: Vec<&str> = properties
2415            .iter()
2416            .map(|property| property["name"].as_str().unwrap())
2417            .collect();
2418        assert_eq!(names, {
2419            let mut sorted = names.clone();
2420            sorted.sort();
2421            sorted
2422        });
2423
2424        let winner = |name: &str| {
2425            properties
2426                .iter()
2427                .find(|property| property["name"] == *name)
2428                .map(|property| property["value"].as_str().unwrap().to_string())
2429        };
2430        assert_eq!(winner("color").as_deref(), Some("blue"));
2431        assert_eq!(winner("margin-top").as_deref(), Some("7px"));
2432    }
2433
2434    #[test]
2435    fn collects_background_images_without_treating_fonts_as_page_images() {
2436        let sources = collect_css_image_sources(
2437            r#"
2438            @font-face { src: url("/fonts/scratch.woff2"); }
2439            .logo { background: white url('/images/logo.svg') no-repeat center; }
2440            .hero { background-image: url("../images/hero.png"); }
2441            "#,
2442        );
2443        assert_eq!(
2444            sources,
2445            vec![
2446                "/images/logo.svg".to_string(),
2447                "../images/hero.png".to_string()
2448            ]
2449        );
2450    }
2451
2452    fn scrollable_info(dom_id: Option<NodeId>, scroll_y: bool, offset_y: f32) -> InfoNode {
2453        InfoNode {
2454            kind: NodeKind::Container {
2455                scroll_x: false,
2456                scroll_y,
2457                scroll_offset_x: 0.0,
2458                scroll_offset_y: offset_y,
2459                style: ContainerStyle::default(),
2460                role: ContainerRole::Normal,
2461            },
2462            children: Vec::new(),
2463            dom_id,
2464        }
2465    }
2466
2467    fn scroll_offsets(info: &InfoNode) -> (f32, f32) {
2468        info.kind.scroll_offsets()
2469    }
2470
2471    fn set_root_scroll_offset(info: &mut InfoNode, offset: f32) {
2472        match &mut info.kind {
2473            NodeKind::Container {
2474                scroll_offset_y, ..
2475            }
2476            | NodeKind::Custom {
2477                scroll_offset_y, ..
2478            } => *scroll_offset_y = offset,
2479            _ => panic!("expected a container root"),
2480        }
2481    }
2482
2483    fn root_scroll_offset(info: &InfoNode) -> Option<f32> {
2484        match &info.kind {
2485            NodeKind::Container {
2486                scroll_offset_y, ..
2487            }
2488            | NodeKind::Custom {
2489                scroll_offset_y, ..
2490            } => Some(*scroll_offset_y),
2491            _ => None,
2492        }
2493    }
2494
2495    fn find_scrollable_offset(info: &InfoNode) -> Option<f32> {
2496        if let NodeKind::Container {
2497            scroll_y: true,
2498            scroll_offset_y,
2499            ..
2500        } = &info.kind
2501        {
2502            return Some(*scroll_offset_y);
2503        }
2504        info.children.iter().find_map(find_scrollable_offset)
2505    }
2506
2507    fn set_first_scrollable_offset(info: &mut InfoNode, offset: f32) -> bool {
2508        if let NodeKind::Container {
2509            scroll_y: true,
2510            scroll_offset_y,
2511            ..
2512        } = &mut info.kind
2513        {
2514            *scroll_offset_y = offset;
2515            return true;
2516        }
2517        info.children
2518            .iter_mut()
2519            .any(|c| set_first_scrollable_offset(c, offset))
2520    }
2521
2522    fn layout_box(y: f32, height: f32, children_height: f32) -> ui_layout::LayoutBox {
2523        let rect = |y, height| ui_layout::Rect {
2524            x: 0.0,
2525            y,
2526            width: 800.0,
2527            height,
2528        };
2529        ui_layout::LayoutBox::BlockBox(ui_layout::BoxModel {
2530            sticky_edges: None,
2531            border_box: rect(y, height),
2532            padding_box: rect(y, height),
2533            content_box: rect(y, height),
2534            children_box: rect(y, children_height),
2535        })
2536    }
2537
2538    #[test]
2539    fn dom_layout_metrics_follow_box_geometry_and_scroll_offsets() {
2540        let mut parser = HtmlParser::new(r#"<div id="target"></div>"#);
2541        let dom = Rc::new(parser.parse());
2542        let target = dom.get_element_by_id("target").unwrap();
2543        let dom_refs = vec![Rc::downgrade(&target)];
2544
2545        let mut child = LayoutNode::new(ui_layout::Style::default());
2546        child.layout_box = ui_layout::LayoutBox::BlockBox(ui_layout::BoxModel {
2547            sticky_edges: None,
2548            border_box: ui_layout::Rect {
2549                x: 30.0,
2550                y: 40.0,
2551                width: 120.0,
2552                height: 80.0,
2553            },
2554            padding_box: ui_layout::Rect {
2555                x: 32.0,
2556                y: 42.0,
2557                width: 116.0,
2558                height: 76.0,
2559            },
2560            content_box: ui_layout::Rect {
2561                x: 36.0,
2562                y: 46.0,
2563                width: 108.0,
2564                height: 68.0,
2565            },
2566            children_box: ui_layout::Rect::default(),
2567        });
2568        let mut root = LayoutNode::with_children(ui_layout::Style::default(), [child]);
2569        root.layout_box = ui_layout::LayoutBox::BlockBox(ui_layout::BoxModel {
2570            sticky_edges: None,
2571            border_box: ui_layout::Rect {
2572                width: 800.0,
2573                height: 600.0,
2574                ..Default::default()
2575            },
2576            padding_box: ui_layout::Rect {
2577                width: 800.0,
2578                height: 600.0,
2579                ..Default::default()
2580            },
2581            content_box: ui_layout::Rect {
2582                x: 10.0,
2583                y: 20.0,
2584                width: 780.0,
2585                height: 580.0,
2586            },
2587            children_box: ui_layout::Rect::default(),
2588        });
2589
2590        let mut root_info = scrollable_info(None, true, 7.0);
2591        if let NodeKind::Container {
2592            scroll_offset_x, ..
2593        } = &mut root_info.kind
2594        {
2595            *scroll_offset_x = 5.0;
2596        }
2597        root_info
2598            .children
2599            .push(scrollable_info(Some(0), false, 0.0));
2600
2601        let js_dom_ids = HashMap::from([(Rc::as_ptr(&target) as usize, 42)]);
2602        let measurements = collect_js_layout_metrics(&root, &root_info, &dom_refs, &js_dom_ids);
2603        assert_eq!(
2604            measurements.get(&42),
2605            Some(&JsLayoutMetrics {
2606                offset_left: 30.0,
2607                offset_top: 40.0,
2608                offset_width: 120.0,
2609                offset_height: 80.0,
2610                client_width: 116.0,
2611                client_height: 76.0,
2612                rect_left: 35.0,
2613                rect_top: 53.0,
2614                rect_width: 120.0,
2615                rect_height: 80.0,
2616            })
2617        );
2618    }
2619
2620    #[test]
2621    fn fragment_waits_for_the_styled_layout_generation() {
2622        assert!(!fragment_layout_is_ready(None, 5));
2623        assert!(!fragment_layout_is_ready(Some(5), 4));
2624        assert!(fragment_layout_is_ready(Some(5), 5));
2625        assert!(fragment_layout_is_ready(Some(5), 6));
2626    }
2627
2628    #[test]
2629    fn fragment_target_scrolls_the_page_to_its_border_box() {
2630        let mut target_layout = LayoutNode::new(ui_layout::Style::default());
2631        target_layout.layout_box = layout_box(900.0, 100.0, 100.0);
2632        let mut root_layout =
2633            LayoutNode::with_children(ui_layout::Style::default(), [target_layout]);
2634        root_layout.layout_box = layout_box(0.0, 600.0, 2000.0);
2635
2636        let mut root_info = scrollable_info(Some(1), true, 0.0);
2637        root_info
2638            .children
2639            .push(scrollable_info(Some(7), false, 0.0));
2640
2641        assert!(apply_fragment_scroll(
2642            &root_layout,
2643            &mut root_info,
2644            7,
2645            600.0
2646        ));
2647        assert_eq!(root_scroll_offset(&root_info), Some(900.0));
2648    }
2649
2650    #[test]
2651    fn resize_preserves_scroll_offset() {
2652        // A scroll container needs a constrained height so its content_box
2653        // stays smaller than children_box (auto-height boxes stretch to their
2654        // content in this engine and are never scrollable).
2655        let html = r#"<html><body><div style="height: 300px; overflow-y: auto;"><div style="height: 3000px;"></div></div></body></html>"#;
2656        let mut wv = WebView::new(ColorScheme::Light, JsPolicy::default());
2657        wv.tick();
2658        wv.on_html_fetched(
2659            html.to_string(),
2660            Url::parse("https://example.test/").unwrap(),
2661        );
2662
2663        for _ in 0..500 {
2664            wv.tick();
2665            if !wv.layout_pending && wv.layout_and_info().is_some() {
2666                break;
2667            }
2668            std::thread::sleep(std::time::Duration::from_millis(2));
2669        }
2670        {
2671            let (_, info) = wv.layout_and_info_mut().expect("layout not ready");
2672            assert!(
2673                set_first_scrollable_offset(info, 500.0),
2674                "expected a scrollable container"
2675            );
2676        }
2677
2678        wv.relayout((1000.0, 700.0));
2679
2680        for _ in 0..500 {
2681            wv.tick();
2682            if !wv.layout_pending {
2683                break;
2684            }
2685            std::thread::sleep(std::time::Duration::from_millis(2));
2686        }
2687
2688        let (_, info) = wv.layout_and_info().expect("layout not ready after resize");
2689        assert_eq!(find_scrollable_offset(info), Some(500.0));
2690    }
2691
2692    #[test]
2693    fn resize_preserves_page_scroll_on_root() {
2694        // A plain page without overflow rules: the wheel handler stores the
2695        // page scroll on the root InfoNode, which has no scroll flags set.
2696        let html = r#"<html><body><div style="height: 3000px;"></div></body></html>"#;
2697        let mut wv = WebView::new(ColorScheme::Light, JsPolicy::default());
2698        wv.tick();
2699        wv.on_html_fetched(
2700            html.to_string(),
2701            Url::parse("https://example.test/").unwrap(),
2702        );
2703
2704        for _ in 0..500 {
2705            wv.tick();
2706            if !wv.layout_pending && wv.layout_and_info().is_some() {
2707                break;
2708            }
2709            std::thread::sleep(std::time::Duration::from_millis(2));
2710        }
2711        {
2712            let (_, info) = wv.layout_and_info_mut().expect("layout not ready");
2713            set_root_scroll_offset(info, 500.0);
2714        }
2715
2716        wv.relayout((1000.0, 700.0));
2717
2718        for _ in 0..500 {
2719            wv.tick();
2720            if !wv.layout_pending {
2721                break;
2722            }
2723            std::thread::sleep(std::time::Duration::from_millis(2));
2724        }
2725
2726        let (_, info) = wv.layout_and_info().expect("layout not ready after resize");
2727        assert_eq!(root_scroll_offset(info), Some(500.0));
2728    }
2729
2730    #[test]
2731    fn captures_and_restores_scroll_offsets_across_rebuild() {
2732        // Old tree: parent scrolled to 100, child to 50.
2733        let mut old_info = scrollable_info(Some(1), true, 100.0);
2734        old_info.children.push(scrollable_info(Some(2), true, 50.0));
2735
2736        let mut offsets = HashMap::new();
2737        capture_scroll_offsets(&old_info, &mut offsets);
2738        assert_eq!(
2739            offsets,
2740            HashMap::from([(1u32, (0.0, 100.0)), (2u32, (0.0, 50.0))])
2741        );
2742
2743        // New tree: same DOM, offsets reset to 0 by the builder.
2744        let mut new_info = scrollable_info(Some(1), true, 0.0);
2745        new_info.children.push(scrollable_info(Some(2), true, 0.0));
2746
2747        apply_scroll_offsets(&mut new_info, &offsets);
2748
2749        assert_eq!(scroll_offsets(&new_info), (0.0, 100.0));
2750        assert_eq!(scroll_offsets(&new_info.children[0]), (0.0, 50.0));
2751    }
2752
2753    #[test]
2754    fn offsets_are_restored_verbatim_even_on_non_scrollable_axes() {
2755        // Old tree: the x axis was scrollable and scrolled to 50.
2756        let old_info = InfoNode {
2757            kind: NodeKind::Container {
2758                scroll_x: true,
2759                scroll_y: false,
2760                scroll_offset_x: 50.0,
2761                scroll_offset_y: 0.0,
2762                style: ContainerStyle::default(),
2763                role: ContainerRole::Normal,
2764            },
2765            children: Vec::new(),
2766            dom_id: Some(3),
2767        };
2768
2769        let mut offsets = HashMap::new();
2770        capture_scroll_offsets(&old_info, &mut offsets);
2771        assert_eq!(offsets, HashMap::from([(3u32, (50.0, 0.0))]));
2772
2773        // New tree: the x axis no longer scrolls. apply restores the captured
2774        // position verbatim; range enforcement is clamp's job, so the flag
2775        // change alone must not drop the offset.
2776        let mut new_info = InfoNode {
2777            kind: NodeKind::Container {
2778                scroll_x: false,
2779                scroll_y: false,
2780                scroll_offset_x: 0.0,
2781                scroll_offset_y: 0.0,
2782                style: ContainerStyle::default(),
2783                role: ContainerRole::Normal,
2784            },
2785            children: Vec::new(),
2786            dom_id: Some(3),
2787        };
2788
2789        apply_scroll_offsets(&mut new_info, &offsets);
2790
2791        assert_eq!(scroll_offsets(&new_info), (50.0, 0.0));
2792    }
2793
2794    #[test]
2795    fn inline_styles_recover_after_an_unsupported_rule() {
2796        let mut webview = WebView::default();
2797        webview.on_html_fetched(
2798            r#"<style>@media { @broken } .valid { color: green; }</style><div class="valid">ok</div>"#
2799                .to_string(),
2800            Url::parse("https://example.test/").unwrap(),
2801        );
2802
2803        assert!(webview.resolved_styles.iter().any(|declaration| {
2804            declaration.name == "color"
2805                && declaration
2806                    .selector
2807                    .parts
2808                    .iter()
2809                    .any(|part| part.selector.classes.iter().any(|class| class == "valid"))
2810        }));
2811    }
2812
2813    #[test]
2814    fn javascript_inserted_style_elements_are_resolved() {
2815        let mut webview = WebView::default();
2816        webview.on_html_fetched(
2817            r#"<html><body><div class="dynamic">ok</div></body></html>"#.to_string(),
2818            Url::parse("https://example.test/").unwrap(),
2819        );
2820        webview.send_script(
2821            r#"
2822            const style = document.createElement("style");
2823            style.textContent = ".dynamic { color: red; }";
2824            document.documentElement.appendChild(style);
2825            "#,
2826        );
2827
2828        pump_until(
2829            &mut webview,
2830            |wv| {
2831                wv.resolved_styles.iter().any(|declaration| {
2832                    declaration.name == "color"
2833                        && declaration.selector.parts.iter().any(|part| {
2834                            part.selector.classes.iter().any(|class| class == "dynamic")
2835                        })
2836                })
2837            },
2838            "JS-inserted style element to be resolved",
2839        );
2840    }
2841
2842    /// Concatenates every text node under an info subtree.
2843    fn collect_text(info: &InfoNode) -> String {
2844        let mut text = match &info.kind {
2845            NodeKind::Text { text, .. } => text.clone(),
2846            _ => String::new(),
2847        };
2848        for child in &info.children {
2849            text.push_str(&collect_text(child));
2850        }
2851        text
2852    }
2853
2854    /// Whether any box in the layout is sized 300×150 — the content-box size
2855    /// the builder gives an `<iframe>` by default (the border box is larger
2856    /// because the UA stylesheet adds a 2px border).
2857    fn has_300x150_box(node: &LayoutNode) -> bool {
2858        let sized = matches!(node.style.size.width, LengthOrAuto::Length(Length::Px(w)) if (w - 300.0).abs() < 0.001)
2859            && matches!(node.style.size.height, LengthOrAuto::Length(Length::Px(h)) if (h - 150.0).abs() < 0.001);
2860        sized
2861            || node
2862                .children
2863                .iter()
2864                .filter_map(LayoutChild::node)
2865                .any(has_300x150_box)
2866    }
2867
2868    /// Whether some dual-axis scroll container (the `<iframe>` box, or the
2869    /// nested `<html>` document root grafted into it) holds only the given
2870    /// nested text and none of the host page's text.
2871    fn iframe_holds_grafted_content(info: &InfoNode) -> bool {
2872        if matches!(
2873            info.kind,
2874            NodeKind::Container {
2875                scroll_x: true,
2876                scroll_y: true,
2877                ..
2878            }
2879        ) {
2880            let text = collect_text(info);
2881            if text.contains("grafted inner paragraph") && !text.contains("host paragraph") {
2882                return true;
2883            }
2884        }
2885        info.children.iter().any(iframe_holds_grafted_content)
2886    }
2887
2888    /// Runs the committed layout through draw-command generation and returns
2889    /// the whitespace-stripped text payload of every `DrawText` command — i.e.
2890    /// the text that would actually be rasterized on screen.
2891    fn paint_text(webview: &WebView) -> String {
2892        let Some((layout, info)) = webview.layout_and_info() else {
2893            return String::new();
2894        };
2895        let mut commands = Vec::new();
2896        crate::engine::renderer_model::generate_draw_commands(
2897            &mut commands,
2898            layout,
2899            info,
2900            (800.0, 600.0),
2901        );
2902        let mut text = String::new();
2903        for command in &commands {
2904            if let crate::engine::renderer_model::DrawCommand::DrawText { text: run, .. } = command
2905            {
2906                text.push_str(run);
2907            }
2908        }
2909        text.chars().filter(|c| !c.is_whitespace()).collect()
2910    }
2911
2912    #[test]
2913    fn iframe_content_documents_are_fetched_grafted_and_laid_out() {
2914        let mut webview = WebView::default();
2915        webview.tick();
2916        webview.on_html_fetched(
2917            r#"<html><body><p>host paragraph</p></body></html>"#.to_string(),
2918            Url::parse("https://example.test/inside-host.html").unwrap(),
2919        );
2920        // Create the iframe from JS so the src attribute is set on a live node
2921        // (the same path the acid3 harness exercises).
2922        webview.send_script(
2923            r#"
2924            const frame = document.createElement("iframe");
2925            document.body.appendChild(frame);
2926            frame.src = "https://example.test/inside.html";
2927            "#,
2928        );
2929
2930        // The JS thread reports the iframe's src as a fetch request.
2931        let task = pump_for_task(
2932            &mut webview,
2933            |task| {
2934                matches!(
2935                    task,
2936                    WebViewTask::Fetch {
2937                        kind: FetchKind::Iframe { .. },
2938                        ..
2939                    }
2940                )
2941            },
2942            "iframe fetch request",
2943        );
2944        let (url, dom_id) = match task {
2945            WebViewTask::Fetch {
2946                url,
2947                kind: FetchKind::Iframe { dom_id },
2948            } => (url, dom_id),
2949            _ => unreachable!("pump_for_task only returns matching tasks"),
2950        };
2951        assert_eq!(url.as_str(), "https://example.test/inside.html");
2952
2953        // The fetched HTML is parsed on the JS thread and installed as the
2954        // iframe's content document; the committed result must carry it back so
2955        // the browser can graft it under the host <iframe> node.
2956        webview.on_iframe_fetched(
2957            dom_id,
2958            r#"<html><body><p>grafted inner paragraph</p></body></html>"#.to_string(),
2959        );
2960
2961        pump_until(
2962            &mut webview,
2963            |wv| {
2964                let Some((layout, info)) = wv.layout_and_info() else {
2965                    return false;
2966                };
2967                iframe_holds_grafted_content(info)
2968                    && collect_text(info).contains("host paragraph")
2969                    && has_300x150_box(layout)
2970            },
2971            "grafted iframe content to reach the layout",
2972        );
2973
2974        // The nested content must be reachable by the paint pass, not just the
2975        // layout tree.
2976        let painted = paint_text(&webview);
2977        assert!(
2978            painted.contains("hostparagraph"),
2979            "host page text must be painted"
2980        );
2981        assert!(
2982            painted.contains("graftedinnerparagraph"),
2983            "iframe content text must be painted"
2984        );
2985    }
2986
2987    #[test]
2988    fn markup_declared_iframes_load_content_without_javascript() {
2989        let mut webview = WebView::default();
2990        webview.tick();
2991        // A plain `<iframe src>` in the parsed HTML must load like any other
2992        // subresource: real pages declare frames in markup, and nothing sets
2993        // their `src` property from JavaScript.
2994        webview.on_html_fetched(
2995            r#"<html><body><p>host paragraph</p><iframe src="https://example.test/inside.html"></iframe></body></html>"#.to_string(),
2996            Url::parse("https://example.test/inside-host.html").unwrap(),
2997        );
2998
2999        let task = pump_for_task(
3000            &mut webview,
3001            |task| {
3002                matches!(
3003                    task,
3004                    WebViewTask::Fetch {
3005                        kind: FetchKind::Iframe { .. },
3006                        ..
3007                    }
3008                )
3009            },
3010            "fetch request for a markup-declared iframe",
3011        );
3012        let (url, dom_id) = match task {
3013            WebViewTask::Fetch {
3014                url,
3015                kind: FetchKind::Iframe { dom_id },
3016            } => (url, dom_id),
3017            _ => unreachable!("pump_for_task only returns matching tasks"),
3018        };
3019        assert_eq!(url.as_str(), "https://example.test/inside.html");
3020
3021        webview.on_iframe_fetched(
3022            dom_id,
3023            r#"<html><body><p>grafted inner paragraph</p></body></html>"#.to_string(),
3024        );
3025        pump_until(
3026            &mut webview,
3027            |wv| {
3028                let Some((layout, info)) = wv.layout_and_info() else {
3029                    return false;
3030                };
3031                iframe_holds_grafted_content(info)
3032                    && collect_text(info).contains("host paragraph")
3033                    && has_300x150_box(layout)
3034            },
3035            "markup-declared iframe content to reach the layout",
3036        );
3037
3038        let painted = paint_text(&webview);
3039        assert!(
3040            painted.contains("hostparagraph"),
3041            "host page text must be painted"
3042        );
3043        assert!(
3044            painted.contains("graftedinnerparagraph"),
3045            "iframe content text must be painted"
3046        );
3047    }
3048
3049    #[test]
3050    fn parse_html_resolves_image_sources_against_base_url() {
3051        let parsed = parse_html(
3052            r#"<base href="https://cdn.example/assets/"><img src="logo.png"><img>"#,
3053            Url::parse("https://example.test/page/index.html").unwrap(),
3054            ScriptingMode::Enabled,
3055        );
3056
3057        assert_eq!(parsed.image_sources.len(), 1);
3058        assert_eq!(parsed.image_sources[0].0, "logo.png");
3059        assert_eq!(
3060            parsed.image_sources[0].1.as_str(),
3061            "https://cdn.example/assets/logo.png"
3062        );
3063    }
3064
3065    #[test]
3066    fn parse_html_resolves_audio_and_child_source_urls() {
3067        let parsed = parse_html(
3068            r#"<base href="https://cdn.example/media/"><audio src="one.mp3"></audio><audio><source src="two.ogg"></audio>"#,
3069            Url::parse("https://example.test/index.html").unwrap(),
3070            ScriptingMode::Enabled,
3071        );
3072
3073        assert_eq!(
3074            parsed.audio_sources,
3075            [
3076                (
3077                    "one.mp3".to_string(),
3078                    Url::parse("https://cdn.example/media/one.mp3").unwrap()
3079                ),
3080                (
3081                    "two.ogg".to_string(),
3082                    Url::parse("https://cdn.example/media/two.ogg").unwrap()
3083                ),
3084            ]
3085        );
3086    }
3087
3088    #[test]
3089    fn parse_html_resolves_external_classic_scripts_in_document_order() {
3090        let parsed = parse_html(
3091            r#"<base href="https://cdn.example/js/"><script>let a = 1;</script><script src="one.js"></script><script src="/two.js"></script>"#,
3092            Url::parse("https://example.test/page/index.html").unwrap(),
3093            ScriptingMode::Enabled,
3094        );
3095
3096        assert_eq!(
3097            parsed.scripts,
3098            [
3099                ClassicScript::Inline("let a = 1;".to_string()),
3100                ClassicScript::External {
3101                    url: Url::parse("https://cdn.example/js/one.js").unwrap(),
3102                    execution: ClassicScriptExecution::Default,
3103                },
3104                ClassicScript::External {
3105                    url: Url::parse("https://cdn.example/two.js").unwrap(),
3106                    execution: ClassicScriptExecution::Default,
3107                },
3108            ]
3109        );
3110    }
3111
3112    #[test]
3113    fn external_classic_scripts_fetch_and_execute_in_document_order() {
3114        let mut webview = WebView::default();
3115        webview.on_html_fetched(
3116            r#"
3117                <div id="result"></div>
3118                <script>let order = "a";</script>
3119                <script src="one.js"></script>
3120                <script>order = order + "c";</script>
3121                <script src="two.js"></script>
3122            "#
3123            .to_string(),
3124            Url::parse("https://example.test/path/index.html").unwrap(),
3125        );
3126
3127        assert!(webview.tick().is_empty());
3128        let first_tasks = webview.tick();
3129        assert_eq!(first_tasks.len(), 1);
3130        match &first_tasks[0] {
3131            WebViewTask::Fetch {
3132                url,
3133                kind: FetchKind::Script { index },
3134            } => {
3135                assert_eq!(*index, 1);
3136                assert_eq!(url.as_str(), "https://example.test/path/one.js");
3137            }
3138            _ => panic!("expected first external classic script fetch"),
3139        }
3140
3141        webview.on_script_fetched(1, r#"order = order + "b";"#.to_string());
3142        let second_tasks = webview.tick();
3143        assert_eq!(second_tasks.len(), 1);
3144        match &second_tasks[0] {
3145            WebViewTask::Fetch {
3146                url,
3147                kind: FetchKind::Script { index },
3148            } => {
3149                assert_eq!(*index, 3);
3150                assert_eq!(url.as_str(), "https://example.test/path/two.js");
3151            }
3152            _ => panic!("expected second external classic script fetch"),
3153        }
3154
3155        let result = webview
3156            .document_info()
3157            .unwrap()
3158            .dom
3159            .get_element_by_id("result")
3160            .unwrap();
3161        assert_eq!(result.borrow().value.get_attr("data-order"), None);
3162
3163        webview.on_script_fetched(
3164            3,
3165            r#"document.getElementById("result").setAttribute("data-order", order + "d");"#
3166                .to_string(),
3167        );
3168        pump_until(
3169            &mut webview,
3170            |wv| {
3171                wv.document_info()
3172                    .unwrap()
3173                    .dom
3174                    .get_element_by_id("result")
3175                    .is_some_and(|node| node.borrow().value.get_attr("data-order").is_some())
3176            },
3177            "final classic script result to be committed",
3178        );
3179        let result = webview
3180            .document_info()
3181            .unwrap()
3182            .dom
3183            .get_element_by_id("result")
3184            .unwrap();
3185        assert_eq!(result.borrow().value.get_attr("data-order"), Some("abcd"));
3186        assert_eq!(webview.phase, PagePhase::ScriptApplied);
3187    }
3188
3189    #[test]
3190    fn failed_external_classic_script_does_not_block_later_scripts() {
3191        let mut webview = WebView::default();
3192        webview.on_html_fetched(
3193            r#"
3194                <div id="result"></div>
3195                <script src="missing.js"></script>
3196                <script>document.getElementById("result").setAttribute("data-ran", "yes");</script>
3197            "#
3198            .to_string(),
3199            Url::parse("https://example.test/index.html").unwrap(),
3200        );
3201
3202        assert!(webview.tick().is_empty());
3203        let tasks = webview.tick();
3204        assert!(matches!(
3205            tasks.as_slice(),
3206            [WebViewTask::Fetch {
3207                kind: FetchKind::Script { index: 0 },
3208                ..
3209            }]
3210        ));
3211
3212        webview.on_script_fetch_failed(0);
3213        pump_until(
3214            &mut webview,
3215            |wv| {
3216                wv.document_info()
3217                    .unwrap()
3218                    .dom
3219                    .get_element_by_id("result")
3220                    .is_some_and(|node| node.borrow().value.get_attr("data-ran").is_some())
3221            },
3222            "inline script after a failed external script",
3223        );
3224        let result = webview
3225            .document_info()
3226            .unwrap()
3227            .dom
3228            .get_element_by_id("result")
3229            .unwrap();
3230        assert_eq!(result.borrow().value.get_attr("data-ran"), Some("yes"));
3231    }
3232
3233    #[test]
3234    fn dom_content_loaded_fires_after_external_classic_scripts_finish() {
3235        let mut webview = WebView::default();
3236        webview.on_html_fetched(
3237            r#"
3238                <div id="result"></div>
3239                <script>
3240                    document.addEventListener("DOMContentLoaded", function () {
3241                        const result = document.getElementById("result");
3242                        result.setAttribute("data-ready", result.getAttribute("data-external"));
3243                    });
3244                </script>
3245                <script src="setup.js"></script>
3246            "#
3247            .to_string(),
3248            Url::parse("https://example.test/index.html").unwrap(),
3249        );
3250
3251        assert!(webview.tick().is_empty());
3252        let tasks = webview.tick();
3253        assert!(matches!(
3254            tasks.as_slice(),
3255            [WebViewTask::Fetch {
3256                kind: FetchKind::Script { index: 1 },
3257                ..
3258            }]
3259        ));
3260
3261        let result = webview
3262            .document_info()
3263            .unwrap()
3264            .dom
3265            .get_element_by_id("result")
3266            .unwrap();
3267        assert_eq!(result.borrow().value.get_attr("data-ready"), None);
3268
3269        webview.on_script_fetched(
3270            1,
3271            r#"document.getElementById("result").setAttribute("data-external", "yes");"#
3272                .to_string(),
3273        );
3274        assert_eq!(result.borrow().value.get_attr("data-ready"), None);
3275
3276        pump_until(
3277            &mut webview,
3278            |wv| {
3279                wv.document_info()
3280                    .unwrap()
3281                    .dom
3282                    .get_element_by_id("result")
3283                    .is_some_and(|node| node.borrow().value.get_attr("data-ready").is_some())
3284            },
3285            "DOMContentLoaded listener to run",
3286        );
3287        let result = webview
3288            .document_info()
3289            .unwrap()
3290            .dom
3291            .get_element_by_id("result")
3292            .unwrap();
3293        assert_eq!(result.borrow().value.get_attr("data-ready"), Some("yes"));
3294        assert_eq!(webview.phase, PagePhase::ScriptApplied);
3295    }
3296
3297    #[test]
3298    fn window_onload_fires_after_the_page_stabilizes() {
3299        let mut webview = WebView::default();
3300        webview.on_html_fetched(
3301            r#"
3302                <div id="result"></div>
3303                <script>
3304                    window.onload = function () {
3305                        document.getElementById("result").setAttribute("data-loaded", "yes");
3306                    };
3307                </script>
3308            "#
3309            .to_string(),
3310            Url::parse("https://example.test/index.html").unwrap(),
3311        );
3312
3313        pump_until(
3314            &mut webview,
3315            |wv| {
3316                wv.document_info()
3317                    .unwrap()
3318                    .dom
3319                    .get_element_by_id("result")
3320                    .is_some_and(|node| node.borrow().value.get_attr("data-loaded").is_some())
3321            },
3322            "window.onload to run",
3323        );
3324        let result = webview
3325            .document_info()
3326            .unwrap()
3327            .dom
3328            .get_element_by_id("result")
3329            .unwrap();
3330        assert_eq!(result.borrow().value.get_attr("data-loaded"), Some("yes"));
3331        assert_eq!(webview.phase, PagePhase::ScriptApplied);
3332    }
3333
3334    #[test]
3335    fn deferred_scripts_fetch_in_parallel_and_execute_in_document_order() {
3336        let mut webview = WebView::default();
3337        webview.on_html_fetched(
3338            r#"
3339                <div id="result"></div>
3340                <script>let order = "inline";</script>
3341                <script defer src="first.js"></script>
3342                <script defer src="second.js"></script>
3343                <script>
3344                    document.addEventListener("DOMContentLoaded", function () {
3345                        document.getElementById("result").setAttribute("data-order", order);
3346                    });
3347                </script>
3348            "#
3349            .to_string(),
3350            Url::parse("https://example.test/index.html").unwrap(),
3351        );
3352
3353        assert!(webview.tick().is_empty());
3354        let tasks = webview.tick();
3355        assert_eq!(tasks.len(), 2);
3356        assert!(matches!(
3357            tasks[0],
3358            WebViewTask::Fetch {
3359                kind: FetchKind::Script { index: 1 },
3360                ..
3361            }
3362        ));
3363        assert!(matches!(
3364            tasks[1],
3365            WebViewTask::Fetch {
3366                kind: FetchKind::Script { index: 2 },
3367                ..
3368            }
3369        ));
3370
3371        webview.on_script_fetched(2, r#"order = order + " > second";"#.to_string());
3372        assert!(webview.tick().is_empty());
3373        assert_ne!(webview.phase, PagePhase::ScriptApplied);
3374
3375        webview.on_script_fetched(1, r#"order = order + " > first";"#.to_string());
3376        pump_until(
3377            &mut webview,
3378            |wv| {
3379                wv.document_info()
3380                    .unwrap()
3381                    .dom
3382                    .get_element_by_id("result")
3383                    .is_some_and(|node| node.borrow().value.get_attr("data-order").is_some())
3384            },
3385            "deferred scripts and DOMContentLoaded to run",
3386        );
3387        let result = webview
3388            .document_info()
3389            .unwrap()
3390            .dom
3391            .get_element_by_id("result")
3392            .unwrap();
3393        assert_eq!(
3394            result.borrow().value.get_attr("data-order"),
3395            Some("inline > first > second")
3396        );
3397        assert_eq!(webview.phase, PagePhase::ScriptApplied);
3398    }
3399
3400    #[test]
3401    fn async_script_executes_on_arrival_without_blocking_dom_content_loaded() {
3402        let mut webview = WebView::default();
3403        webview.on_html_fetched(
3404            r#"
3405                <div id="result"></div>
3406                <script async src="async.js"></script>
3407                <script>
3408                    document.addEventListener("DOMContentLoaded", function () {
3409                        document.getElementById("result").setAttribute("data-ready", "yes");
3410                    });
3411                </script>
3412            "#
3413            .to_string(),
3414            Url::parse("https://example.test/index.html").unwrap(),
3415        );
3416
3417        assert!(webview.tick().is_empty());
3418        let tasks = webview.tick();
3419        assert!(matches!(
3420            tasks.as_slice(),
3421            [WebViewTask::Fetch {
3422                kind: FetchKind::Script { index: 0 },
3423                ..
3424            }]
3425        ));
3426
3427        pump_until(
3428            &mut webview,
3429            |wv| {
3430                wv.document_info()
3431                    .unwrap()
3432                    .dom
3433                    .get_element_by_id("result")
3434                    .is_some_and(|node| node.borrow().value.get_attr("data-ready").is_some())
3435            },
3436            "DOMContentLoaded before the async script arrives",
3437        );
3438        let result = webview
3439            .document_info()
3440            .unwrap()
3441            .dom
3442            .get_element_by_id("result")
3443            .unwrap();
3444        assert_eq!(result.borrow().value.get_attr("data-ready"), Some("yes"));
3445        assert_eq!(result.borrow().value.get_attr("data-async"), None);
3446        assert_eq!(webview.phase, PagePhase::ScriptApplied);
3447
3448        webview.on_script_fetched(
3449            0,
3450            r#"document.getElementById("result").setAttribute("data-async", "yes");"#.to_string(),
3451        );
3452        pump_until(
3453            &mut webview,
3454            |wv| {
3455                wv.document_info()
3456                    .unwrap()
3457                    .dom
3458                    .get_element_by_id("result")
3459                    .is_some_and(|node| node.borrow().value.get_attr("data-async").is_some())
3460            },
3461            "async script to run after DOMContentLoaded",
3462        );
3463        let result = webview
3464            .document_info()
3465            .unwrap()
3466            .dom
3467            .get_element_by_id("result")
3468            .unwrap();
3469        assert_eq!(result.borrow().value.get_attr("data-async"), Some("yes"));
3470    }
3471
3472    #[test]
3473    fn javascript_fetch_uses_document_url_and_resolves_response() {
3474        let mut webview = WebView::default();
3475        webview.on_html_fetched(
3476            r#"
3477                <div id="result"></div>
3478                <script>
3479                    fetch("../message.txt")
3480                        .then(response => response.text())
3481                        .then(text => {
3482                            document.getElementById("result").setAttribute("data-text", text);
3483                        });
3484                </script>
3485            "#
3486            .to_string(),
3487            Url::parse("https://example.test/path/index.html").unwrap(),
3488        );
3489
3490        let fetch_task = pump_for_task(
3491            &mut webview,
3492            |task| {
3493                matches!(
3494                    task,
3495                    WebViewTask::Fetch {
3496                        kind: FetchKind::JavaScript { .. },
3497                        ..
3498                    }
3499                )
3500            },
3501            "the page's fetch() to be dispatched",
3502        );
3503        let request_id = match fetch_task {
3504            WebViewTask::Fetch {
3505                url,
3506                kind: FetchKind::JavaScript { request_id, .. },
3507            } => {
3508                assert_eq!(url.as_str(), "https://example.test/message.txt");
3509                request_id
3510            }
3511            _ => unreachable!("guarded by pump_for_task"),
3512        };
3513
3514        webview.on_js_fetch_succeeded(
3515            request_id,
3516            JsFetchResponse {
3517                url: "https://example.test/message.txt".to_string(),
3518                status: 200,
3519                status_text: "OK".to_string(),
3520                redirected: false,
3521                body: b"hello from fetch".to_vec(),
3522                headers: Vec::new(),
3523            },
3524        );
3525
3526        pump_until(
3527            &mut webview,
3528            |wv| {
3529                wv.document_info()
3530                    .unwrap()
3531                    .dom
3532                    .get_element_by_id("result")
3533                    .is_some_and(|node| node.borrow().value.get_attr("data-text").is_some())
3534            },
3535            "the fetch response microtask to run",
3536        );
3537        let result = webview
3538            .document_info()
3539            .unwrap()
3540            .dom
3541            .get_element_by_id("result")
3542            .unwrap();
3543        assert_eq!(
3544            result.borrow().value.get_attr("data-text"),
3545            Some("hello from fetch")
3546        );
3547    }
3548
3549    #[test]
3550    fn failed_javascript_fetch_rejects_without_navigating() {
3551        let mut webview = WebView::default();
3552        webview.on_html_fetched(
3553            r#"
3554                <div id="result"></div>
3555                <script>
3556                    fetch("missing.txt").catch(error => {
3557                        document.getElementById("result").setAttribute("data-error", error);
3558                    });
3559                </script>
3560            "#
3561            .to_string(),
3562            Url::parse("https://example.test/index.html").unwrap(),
3563        );
3564
3565        let fetch_task = pump_for_task(
3566            &mut webview,
3567            |task| {
3568                matches!(
3569                    task,
3570                    WebViewTask::Fetch {
3571                        kind: FetchKind::JavaScript { .. },
3572                        ..
3573                    }
3574                )
3575            },
3576            "the page's fetch() to be dispatched",
3577        );
3578        let request_id = match fetch_task {
3579            WebViewTask::Fetch {
3580                kind: FetchKind::JavaScript { request_id, .. },
3581                ..
3582            } => request_id,
3583            _ => unreachable!("guarded by pump_for_task"),
3584        };
3585
3586        webview.on_js_fetch_failed(request_id, "network error".to_string());
3587
3588        pump_until(
3589            &mut webview,
3590            |wv| {
3591                wv.document_info()
3592                    .unwrap()
3593                    .dom
3594                    .get_element_by_id("result")
3595                    .is_some_and(|node| node.borrow().value.get_attr("data-error").is_some())
3596            },
3597            "the fetch rejection to run",
3598        );
3599        let result = webview
3600            .document_info()
3601            .unwrap()
3602            .dom
3603            .get_element_by_id("result")
3604            .unwrap();
3605        assert_eq!(
3606            result.borrow().value.get_attr("data-error"),
3607            Some("network error")
3608        );
3609        assert_eq!(
3610            webview.document_info().unwrap().base_url.as_str(),
3611            "https://example.test/index.html"
3612        );
3613    }
3614
3615    #[test]
3616    fn document_origin_is_exposed_to_page_scripts() {
3617        let mut webview = WebView::default();
3618        webview.on_html_fetched(
3619            r#"
3620                <div id="result"></div>
3621                <script>
3622                    document.getElementById("result").setAttribute(
3623                        "data-origin",
3624                        location.origin + ":" + window.origin + ":" + document.origin
3625                    );
3626                </script>
3627            "#
3628            .to_string(),
3629            Url::parse("https://example.test/path/index.html").unwrap(),
3630        );
3631
3632        pump_until(
3633            &mut webview,
3634            |wv| {
3635                wv.document_info()
3636                    .unwrap()
3637                    .dom
3638                    .get_element_by_id("result")
3639                    .is_some_and(|node| node.borrow().value.get_attr("data-origin").is_some())
3640            },
3641            "the origin-handling script to run",
3642        );
3643        let result = webview
3644            .document_info()
3645            .unwrap()
3646            .dom
3647            .get_element_by_id("result")
3648            .unwrap();
3649        assert_eq!(
3650            result.borrow().value.get_attr("data-origin"),
3651            Some("https://example.test:https://example.test:https://example.test")
3652        );
3653    }
3654
3655    #[test]
3656    fn internal_document_reports_null_origin() {
3657        let mut webview = WebView::default();
3658        webview.on_html_fetched(
3659            r#"
3660                <div id="result"></div>
3661                <script>
3662                    document.getElementById("result").setAttribute(
3663                        "data-origin",
3664                        location.origin + ":" + window.origin + ":" + document.origin
3665                    );
3666                </script>
3667            "#
3668            .to_string(),
3669            Url::parse("resource:///devtools/index.html").unwrap(),
3670        );
3671
3672        pump_until(
3673            &mut webview,
3674            |wv| {
3675                wv.document_info()
3676                    .unwrap()
3677                    .dom
3678                    .get_element_by_id("result")
3679                    .is_some_and(|node| node.borrow().value.get_attr("data-origin").is_some())
3680            },
3681            "the origin-handling script to run",
3682        );
3683        let result = webview
3684            .document_info()
3685            .unwrap()
3686            .dom
3687            .get_element_by_id("result")
3688            .unwrap();
3689        assert_eq!(
3690            result.borrow().value.get_attr("data-origin"),
3691            Some("null:null:null")
3692        );
3693    }
3694
3695    #[test]
3696    fn zero_delay_timer_runs_from_webview_tick_and_updates_dom() {
3697        let mut webview = WebView::default();
3698        webview.on_html_fetched(
3699            r##"
3700                <div id="result"></div>
3701                <script>
3702                    setTimeout(function () {
3703                        document.querySelector("#result").setAttribute("data-timer", "ran");
3704                    }, 0);
3705                </script>
3706            "##
3707            .to_string(),
3708            Url::parse("https://example.test/index.html").unwrap(),
3709        );
3710
3711        assert!(webview.tick().is_empty());
3712        assert!(webview.tick().is_empty());
3713        pump_until(
3714            &mut webview,
3715            |wv| {
3716                wv.document_info()
3717                    .unwrap()
3718                    .dom
3719                    .get_element_by_id("result")
3720                    .is_some_and(|node| node.borrow().value.get_attr("data-timer").is_some())
3721            },
3722            "the zero-delay timer callback to run",
3723        );
3724        let result = webview
3725            .document_info()
3726            .unwrap()
3727            .dom
3728            .get_element_by_id("result")
3729            .unwrap();
3730        assert_eq!(result.borrow().value.get_attr("data-timer"), Some("ran"));
3731        assert!(webview.needs_redraw());
3732    }
3733
3734    #[test]
3735    fn devtools_request_round_trips_through_inspection_and_back() {
3736        let mut webview = WebView::default();
3737        webview.on_html_fetched(
3738            r##"
3739                <div id="probe"></div>
3740                <script>
3741                    __orinium_devtools("getVersion").then(function (json) {
3742                        const envelope = JSON.parse(json);
3743                        document.getElementById("probe")
3744                            .setAttribute("data-ok", envelope.ok ? "yes" : "no");
3745                    });
3746                </script>
3747            "##
3748            .to_string(),
3749            Url::parse("https://example.test/index.html").unwrap(),
3750        );
3751
3752        let task = pump_for_task(
3753            &mut webview,
3754            |task| matches!(task, WebViewTask::DevToolsRequest { .. }),
3755            "the page's DevTools inspection request",
3756        );
3757        let WebViewTask::DevToolsRequest { id, method, params } = task else {
3758            unreachable!("pump_for_task matched this variant");
3759        };
3760        assert_eq!(method, "getVersion");
3761        assert_eq!(params, "{}");
3762
3763        let data = webview
3764            .inspect(&method, &params)
3765            .expect("inspection answer");
3766        webview.on_devtools_response(
3767            id,
3768            serde_json::json!({ "ok": true, "data": data }).to_string(),
3769        );
3770
3771        pump_until(
3772            &mut webview,
3773            |wv| {
3774                wv.document_info()
3775                    .unwrap()
3776                    .dom
3777                    .get_element_by_id("probe")
3778                    .is_some_and(|node| node.borrow().value.get_attr("data-ok").is_some())
3779            },
3780            "the resolved promise callback to mark the probe element",
3781        );
3782        let probe = webview
3783            .document_info()
3784            .unwrap()
3785            .dom
3786            .get_element_by_id("probe")
3787            .unwrap();
3788        assert_eq!(probe.borrow().value.get_attr("data-ok"), Some("yes"));
3789    }
3790}