1use 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 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#[derive(Debug, Clone, Copy, PartialEq)]
95pub enum CssApplicationStrategy {
96 Batch,
97 Incremental,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
105pub enum JsPolicy {
106 #[default]
108 Enabled,
109 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 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 js_policy: JsPolicy,
177
178 layout_processor: layouter::LayoutProcessor,
179 layout_pending: bool,
180 layout_requested_version: u64,
181 layout_applied_version: u64,
182 positioned_layout: Option<(u64, (f32, f32))>,
187 layout_dom_refs: Vec<Weak<RefCell<TreeNode<HtmlNodeType>>>>,
189 snapshot_cache: Option<SnapshotCache>,
192 iframe_content: HashMap<u64, DomSnapshot>,
195 write_back_tx: mpsc::Sender<(u32, String)>,
197 write_back_rx: mpsc::Receiver<(u32, String)>,
198 js_processor: Option<JsProcessor>,
201 js_dom_ids: HashMap<usize, u64>,
206 pending_js_tasks: usize,
210 js_dom_dirty: bool,
212 in_flight_timer_version: Option<u64>,
216 window_load_dispatched: bool,
218 pending_js_fetches: Vec<JsFetchRequest>,
220 pending_devtools_requests: Vec<JsDevToolsRequest>,
222 inspector_ids: RefCell<inspector::DomIdRegistry>,
224 pending_dynamic_scripts: Vec<JsDynamicScriptRequest>,
226 pending_dynamic_styles: Vec<JsDynamicStyleRequest>,
228 pending_dynamic_images: Vec<JsDynamicImageRequest>,
230 pending_iframe_fetches: Vec<JsIframeFetchRequest>,
232 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 pending_fragment_scroll: Option<String>,
241 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
254struct 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
275fn 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 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#[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
336struct 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 pub fn set_css_strategy(&mut self, strategy: CssApplicationStrategy) {
437 self.css_strategy = strategy;
438 }
439
440 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 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 pub fn js_policy(&self) -> JsPolicy {
487 self.js_policy
488 }
489
490 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 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 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 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 self.try_apply_css_results();
581 }
582
583 PagePhase::CssProcessing => {
584 self.try_apply_batch_result();
586 }
587
588 PagePhase::CssApplied => {
589 self.advance_classic_scripts(&mut tasks);
590 }
591
592 PagePhase::ScriptApplied => {
593 }
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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 pub fn update_page(&mut self) {
901 self.ensure_text_measurer();
902 if let Some(doc_info) = self.docment_info.as_mut() {
906 doc_info.dom.mark_dirty();
907 }
908 self.update_layout();
909 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 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 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 return;
1131 }
1132 if let Some(processor) = self.js_processor.as_ref() {
1133 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 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 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 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 #[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 && 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 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 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 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 self.position_layout_if_needed();
1446 }
1447 }
1448
1449 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 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 let (tree, dom_ids) = snapshot.into_tree();
1497 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 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 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 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 if applied && let Some(doc_info) = self.docment_info.as_mut() {
1559 doc_info.dom.mark_dirty();
1560 }
1561 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 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 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 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
1759fn 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 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
1866fn 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
1885fn 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 let mut parser = HtmlParser::new(html).with_scripting_mode(scripting_mode);
2062 let dom = Rc::new(parser.parse());
2063
2064 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 let title = dom
2078 .collect_text_by_tag("title")
2079 .first()
2080 .cloned()
2081 .unwrap_or("".into());
2082
2083 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 let inline_styles = dom.collect_text_by_tag("style");
2113
2114 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 if let Ok(url) = Url::parse(path) {
2177 return Ok(url);
2178 }
2179
2180 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 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 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 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 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 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", ¶ms).expect("box model")["model"].clone();
2304
2305 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 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", ¶ms)
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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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, ¶ms)
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}