diff --git a/crates/obscura-browser/src/lib.rs b/crates/obscura-browser/src/lib.rs index 53c5725..0575f31 100644 --- a/crates/obscura-browser/src/lib.rs +++ b/crates/obscura-browser/src/lib.rs @@ -9,7 +9,10 @@ pub use context::BrowserContext; pub use lifecycle::{LifecycleState, WaitUntil}; pub use obscura_js::HTML_TO_MARKDOWN_JS; #[cfg(feature = "render")] -pub use obscura_js::{validate_capture_region, CaptureError, CaptureRegion}; +pub use obscura_js::{ + validate_capture_region, AnimationSample, AnimationSampleMode, AnimationSampleTime, + CaptureError, CaptureRegion, +}; pub use page::{NetworkEvent, Page, PageError}; #[cfg(feature = "render")] pub use pdf::{RasterPdfError, RasterPdfOptions, RasterPdfPageRange}; diff --git a/crates/obscura-browser/src/page.rs b/crates/obscura-browser/src/page.rs index 65fb9ed..41b0ba9 100644 --- a/crates/obscura-browser/src/page.rs +++ b/crates/obscura-browser/src/page.rs @@ -233,6 +233,10 @@ pub struct Page { /// Exposed to JS as `document.characterSet` and used for the URL query /// encoding override on ``/`` hrefs in legacy-charset documents. pub encoding: String, + /// Monotonic origin for the current document's CSS animation timeline. + /// It is reset once author styles are installed, so stylesheet download + /// latency does not incorrectly advance newly-created animations. + document_timeline_origin: std::time::Instant, /// Navigation history for Page.getNavigationHistory / navigateToHistoryEntry. /// Entries are URLs in visit order; `history_index` is the current position. /// Pushed on every successful navigation; truncated on goBack -> new nav. @@ -724,6 +728,7 @@ impl Page { viewport: (1280.0, 720.0), device_scale_factor: 1.0, encoding: "UTF-8".to_string(), + document_timeline_origin: std::time::Instant::now(), history: Vec::new(), history_index: 0, network_events: Vec::new(), @@ -1852,6 +1857,11 @@ impl Page { let mut idle_count = 0u32; loop { let now = tokio::time::Instant::now(); + // `has_pending_dynamic_scripts` includes both inserted script + // elements and loader-owned ES-module graph activity. A lazy + // import may be the work which removes an SSR loading shell; + // do not take the 500ms fast exit while that graph is fetching. + // Unrelated fetch/XHR activity remains on the fast path. if now >= deadline && (now >= dynamic_deadline || !js.has_pending_dynamic_scripts()) { break; @@ -2274,6 +2284,10 @@ impl Page { } } } + self.document_timeline_origin = std::time::Instant::now(); + if let Some(js) = &self.js { + js.reset_animation_timeline(); + } if let Some(js) = &mut self.js { let _ = js.execute_script("", "(function() { var iframes = document.querySelectorAll('iframe[src]'); for (var i = 0; i < iframes.length; i++) { var src = iframes[i].getAttribute('src'); if (src && src !== 'about:blank') iframes[i]._loadIframeSrc(src); } })()"); @@ -2410,6 +2424,7 @@ impl Page { )); self.title = String::new(); self.lifecycle = LifecycleState::Loaded; + self.document_timeline_origin = std::time::Instant::now(); } pub fn url_string(&self) -> String { @@ -2445,10 +2460,10 @@ impl Page { let mut candidates = std::collections::BTreeMap::new(); if let Some(js) = &self.js { - for raw in js.pending_render_image_urls() { + for (raw, profile) in js.pending_render_image_urls() { if let Ok(mut url) = url::Url::parse(&raw) { url.set_fragment(None); - candidates.insert(url.to_string(), ResourceType::Image); + candidates.insert((url.to_string(), Some(profile)), ResourceType::Image); } } let css_sources = js @@ -2484,15 +2499,20 @@ impl Page { .unwrap_or_default(); for css in css_sources { for raw in css_resource_urls(&css, &base_url) { - if let Ok(url) = url::Url::parse(&raw) { - candidates.insert(raw, render_resource_type(&url)); + if let Ok(mut url) = url::Url::parse(&raw) { + let kind = render_resource_type(&url); + url.set_fragment(None); + candidates.insert((url.to_string(), None), kind); } } } - candidates.retain(|url, _| !js.render_resource_is_known(url)); + candidates.retain(|(url, profile), _| match profile { + Some(profile) => !js.render_image_resource_is_known(url, *profile), + None => !js.render_resource_is_known(url), + }); } - candidates.retain(|url, _| { + candidates.retain(|(url, _), _| { subresource_allowed(Some(&document_url), url) && !self.should_block_url(url) }); if candidates.len() > 128 { @@ -2502,14 +2522,18 @@ impl Page { return 0; } - let requested: Vec<(String, ResourceType)> = candidates.into_iter().collect(); + let requested: Vec<(String, Option, ResourceType)> = + candidates + .into_iter() + .map(|((url, profile), kind)| (url, profile, kind)) + .collect(); let client = self.http_client.clone(); #[cfg(feature = "stealth")] let stealth_client = self.stealth_client.clone(); let callbacks = self.callbacks.clone(); let initiator = document_url.clone(); use futures::StreamExt as _; - let requests = futures::stream::iter(requested.clone().into_iter().map(|(raw, kind)| { + let requests = futures::stream::iter(requested.into_iter().map(|(raw, profile, kind)| { let client = client.clone(); #[cfg(feature = "stealth")] let stealth_client = stealth_client.clone(); @@ -2517,7 +2541,18 @@ impl Page { let initiator = initiator.clone(); async move { let parsed = url::Url::parse(&raw).expect("validated render resource URL"); - let request = ResourceRequest::subresource(kind, &initiator); + let mut request = ResourceRequest::subresource(kind, &initiator); + match profile { + Some(obscura_js::ImageRequestProfile::CorsSameOrigin) => { + request.mode = obscura_net::RequestMode::Cors; + request.credentials = obscura_net::RequestCredentials::SameOrigin; + } + Some(obscura_js::ImageRequestProfile::CorsInclude) => { + request.mode = obscura_net::RequestMode::Cors; + request.credentials = obscura_net::RequestCredentials::Include; + } + _ => {} + } #[cfg(feature = "stealth")] let result = if let Some(stealth_client) = stealth_client { stealth_client @@ -2532,57 +2567,54 @@ impl Page { let result = client .fetch_resource_with_callbacks(&parsed, request, Some(&callbacks)) .await; - (raw, kind, result) + (raw, profile, kind, result) } })) .buffer_unordered(16); futures::pin_mut!(requests); let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(max_ms); - let mut outcomes = std::collections::HashMap::new(); + let mut loaded = 0usize; loop { match tokio::time::timeout_at(deadline, requests.next()).await { - Ok(Some((raw, kind, result))) => { - outcomes.insert(raw, (kind, result)); + Ok(Some((raw, profile, kind, result))) => { + let outcome = match result { + Ok(response) => { + self.record_network_event_with_body( + response.url.as_str(), + "GET", + match kind { + ResourceType::Font => "Font", + _ => "Image", + }, + response.status, + &response.headers, + &response.body, + true, + ); + if (200..300).contains(&response.status) { + loaded += 1; + Some(response.body) + } else { + None + } + } + Err(_) => None, + }; + if let Some(js) = &mut self.js { + match profile { + Some(profile) => { + js.seed_render_image_resource(raw, profile, outcome) + } + None => js.seed_render_resource(raw, outcome), + } + } } Ok(None) | Err(_) => break, } } + // A deadline drops unfinished futures without negative-caching them, + // so a later warmup can retry slow resources. drop(requests); - - let mut loaded = 0usize; - for (raw, kind) in requested { - let outcome = match outcomes.remove(&raw) { - Some((_, Ok(response))) => { - self.record_network_event_with_body( - response.url.as_str(), - "GET", - match kind { - ResourceType::Font => "Font", - _ => "Image", - }, - response.status, - &response.headers, - &response.body, - true, - ); - if (200..300).contains(&response.status) { - loaded += 1; - Some(response.body) - } else { - None - } - } - Some((_, Err(_))) => None, - // The shared deadline cancelled this request before it had a - // transport outcome. Keep it unknown so the final capture can - // retry instead of suppressing a valid slow image/font behind - // the renderer's negative-cache TTL. - None => continue, - }; - if let Some(js) = &mut self.js { - js.seed_render_resource(raw, outcome); - } - } tracing::debug!( loaded, elapsed_ms = started.elapsed().as_millis(), @@ -2596,11 +2628,41 @@ impl Page { /// viewport is zero-sized. #[cfg(feature = "render")] pub fn screenshot(&self, viewport: (f32, f32)) -> Option> { + self.screenshot_with_animation_sample(viewport, self.live_animation_sample()) + } + + /// Rasterize every CSS animation at one explicit local time. This mirrors + /// Web Animations `currentTime` and is intended for deterministic parity + /// capture; ordinary screenshots use each live instance's start epoch. + #[cfg(feature = "render")] + pub fn screenshot_at_animation_time( + &self, + viewport: (f32, f32), + animation_sample_time: obscura_js::AnimationSampleTime, + ) -> Option> { + self.screenshot_with_animation_sample( + viewport, + obscura_js::AnimationSample { + time: animation_sample_time, + mode: obscura_js::AnimationSampleMode::LocalOverride, + }, + ) + } + + #[cfg(feature = "render")] + pub fn screenshot_with_animation_sample( + &self, + viewport: (f32, f32), + animation_sample: obscura_js::AnimationSample, + ) -> Option> { // Needed to resolve the relative image URLs ("logo.svg") that make up // the overwhelming majority of real markup. let base_url = self.resolve_base_url(); let base_url = base_url.as_ref().map(|u| u.as_str()); if let Some(js) = &self.js { + if !js.set_animation_sample(animation_sample) { + return None; + } if let Some(png) = js.screenshot_prepared(viewport, base_url) { return Some(png); } @@ -2612,7 +2674,15 @@ impl Page { .as_ref() .map(|js| js.scroll_offset()) .unwrap_or((0.0, 0.0)); - self.with_dom(|dom| obscura_js::screenshot_png_scrolled(dom, viewport, base_url, scroll)) + self.with_dom(|dom| { + obscura_js::screenshot_png_scrolled_at_animation_time( + dom, + viewport, + base_url, + scroll, + animation_sample.time, + ) + }) .flatten() } @@ -2624,10 +2694,38 @@ impl Page { &self, region: obscura_js::CaptureRegion, ) -> Result, obscura_js::CaptureError> { - self.js + self.screenshot_region_with_animation_sample(region, self.live_animation_sample()) + } + + #[cfg(feature = "render")] + pub fn screenshot_region_at_animation_time( + &self, + region: obscura_js::CaptureRegion, + animation_sample_time: obscura_js::AnimationSampleTime, + ) -> Result, obscura_js::CaptureError> { + self.screenshot_region_with_animation_sample( + region, + obscura_js::AnimationSample { + time: animation_sample_time, + mode: obscura_js::AnimationSampleMode::LocalOverride, + }, + ) + } + + #[cfg(feature = "render")] + pub fn screenshot_region_with_animation_sample( + &self, + region: obscura_js::CaptureRegion, + animation_sample: obscura_js::AnimationSample, + ) -> Result, obscura_js::CaptureError> { + let js = self + .js .as_ref() - .ok_or(obscura_js::CaptureError::PaintFailed)? - .screenshot_prepared_region(region) + .ok_or(obscura_js::CaptureError::PaintFailed)?; + if !js.set_animation_sample(animation_sample) { + return Err(obscura_js::CaptureError::PaintFailed); + } + js.screenshot_prepared_region(region) } /// Scrollable document dimensions from the retained render layout. Unlike @@ -2635,7 +2733,50 @@ impl Page { /// monkey-patched by the document being captured. #[cfg(feature = "render")] pub fn prepared_content_size(&self) -> Option<(f32, f32)> { - self.js.as_ref()?.prepared_content_size() + self.prepared_content_size_with_animation_sample(self.live_animation_sample()) + } + + #[cfg(feature = "render")] + pub fn prepared_content_size_at_animation_time( + &self, + animation_sample_time: obscura_js::AnimationSampleTime, + ) -> Option<(f32, f32)> { + self.prepared_content_size_with_animation_sample(obscura_js::AnimationSample { + time: animation_sample_time, + mode: obscura_js::AnimationSampleMode::LocalOverride, + }) + } + + #[cfg(feature = "render")] + pub fn prepared_content_size_with_animation_sample( + &self, + animation_sample: obscura_js::AnimationSample, + ) -> Option<(f32, f32)> { + let js = self.js.as_ref()?; + js.set_animation_sample(animation_sample) + .then(|| js.prepared_content_size()) + .flatten() + } + + #[cfg(feature = "render")] + pub fn live_animation_sample(&self) -> obscura_js::AnimationSample { + if let Some(js) = &self.js { + return js.live_animation_sample(); + } + let milliseconds = self.document_timeline_origin.elapsed().as_secs_f64() * 1_000.0; + obscura_js::AnimationSample { + time: obscura_js::AnimationSampleTime { + milliseconds: milliseconds.min(f64::from(f32::MAX)) as f32, + }, + mode: obscura_js::AnimationSampleMode::DocumentTime, + } + } + + #[cfg(feature = "render")] + pub fn prepared_has_active_css_animations(&self) -> bool { + self.js + .as_ref() + .is_some_and(|js| js.prepared_has_active_css_animations()) } /// Renderer-owned root scroll offset for document-space capture routing. @@ -4026,6 +4167,128 @@ mod tests { ); } + #[tokio::test(flavor = "current_thread")] + async fn lazy_module_graph_keeps_post_script_settle_alive() { + use std::io::{Read as _, Write as _}; + + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + std::thread::spawn(move || { + for _ in 0..2 { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = [0u8; 2048]; + let length = stream.read(&mut request).unwrap(); + let request_text = String::from_utf8_lossy(&request[..length]); + let path = request_text + .lines() + .next() + .and_then(|line| line.split_ascii_whitespace().nth(1)) + .unwrap_or("/"); + let body = match path { + "/app/lazy.js" => { + "import { ready } from './lazy-child.js'; export { ready };" + } + "/app/lazy-child.js" => { + // Cross the lifecycle's 500ms fast-settle floor on a + // descendant edge. deno_core must propagate the lazy + // graph marker beyond its root for this to stay alive. + std::thread::sleep(std::time::Duration::from_millis(700)); + "export const ready = 'lazy-ready';" + } + unexpected => panic!("unexpected module request: {unexpected}"), + }; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/javascript\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len(), + ); + stream.write_all(response.as_bytes()).unwrap(); + } + }); + + let base = format!("http://{address}"); + let mut page = import_map_test_page( + "lazy-module-readiness", + &base, + r#""#, + ); + page.execute_scripts().await; + + assert_eq!( + page.js + .as_mut() + .unwrap() + .evaluate("document.body.getAttribute('data-lazy-state')") + .unwrap(), + serde_json::json!("lazy-ready"), + "post-script readiness must include an in-flight lazy module graph", + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn ordinary_fetch_does_not_extend_dynamic_module_settle() { + use std::io::{Read as _, Write as _}; + + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let (accepted_tx, accepted_rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let _ = accepted_tx.send(()); + let mut request = [0u8; 2048]; + let length = stream.read(&mut request).unwrap(); + assert!(String::from_utf8_lossy(&request[..length]) + .starts_with("GET /app/analytics ")); + std::thread::sleep(std::time::Duration::from_secs(2)); + let body = "{}"; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len(), + ); + let _ = stream.write_all(response.as_bytes()); + }); + + let base = format!("http://{address}"); + let html = format!( + r#""#, + ); + let mut page = import_map_test_page( + "ordinary-fetch-readiness", + &base, + &html, + ); + let started = std::time::Instant::now(); + page.execute_scripts().await; + let elapsed = started.elapsed(); + + assert!( + accepted_rx + .recv_timeout(std::time::Duration::from_millis(100)) + .is_ok(), + "ordinary fetch fixture must actually start its network request", + ); + assert!( + elapsed < std::time::Duration::from_millis(1_500), + "ordinary fetch/XHR must retain the fast settle path; elapsed={elapsed:?}", + ); + assert_eq!( + page.js + .as_mut() + .unwrap() + .evaluate("globalThis.__analyticsStarted") + .unwrap(), + serde_json::json!(true), + ); + } + #[tokio::test(flavor = "current_thread")] async fn dynamic_import_map_uses_live_document_base_at_insertion() { let (base, requests) = spawn_parser_import_map_server(1); @@ -4142,7 +4405,8 @@ mod tests { let mut page = super::Page::new("render-prefetch".to_string(), context); page.set_viewport((100.0, 80.0)); let page_url = format!("http://{address}/page"); - let asset_url = format!("http://{address}/asset.svg"); + let asset_network_url = format!("http://{address}/asset.svg"); + let asset_url = format!("{asset_network_url}#icon"); let dom = parse_html(&format!( r#""# )); @@ -4155,6 +4419,15 @@ mod tests { page.url = Some(url::Url::parse(&page_url).unwrap()); assert_eq!(page.prepare_screenshot_resources(1_000).await, 1); + assert_eq!( + page.js + .as_mut() + .unwrap() + .evaluate("document.querySelector('img').currentSrc") + .unwrap(), + serde_json::json!(asset_url), + "cache/network fragment normalization must not alter currentSrc" + ); page.screenshot(page.viewport).expect("prefetched capture"); assert!(seen_rx .recv_timeout(std::time::Duration::from_secs(1)) diff --git a/crates/obscura-browser/src/pdf.rs b/crates/obscura-browser/src/pdf.rs index b420811..4856441 100644 --- a/crates/obscura-browser/src/pdf.rs +++ b/crates/obscura-browser/src/pdf.rs @@ -278,12 +278,37 @@ impl Page { /// vertical slices become pages. This does not reflow into `@media print` /// or implement CSS paged media, headers, or footers. pub fn raster_pdf(&self, options: RasterPdfOptions) -> Result, RasterPdfError> { + self.raster_pdf_with_animation_sample(options, self.live_animation_sample()) + } + + pub fn raster_pdf_at_animation_time( + &self, + options: RasterPdfOptions, + animation_sample_time: obscura_js::AnimationSampleTime, + ) -> Result, RasterPdfError> { + self.raster_pdf_with_animation_sample( + options, + obscura_js::AnimationSample { + time: animation_sample_time, + mode: obscura_js::AnimationSampleMode::LocalOverride, + }, + ) + } + + pub fn raster_pdf_with_animation_sample( + &self, + options: RasterPdfOptions, + animation_sample: obscura_js::AnimationSample, + ) -> Result, RasterPdfError> { let (page_width, page_height, printable_width, printable_height, left, bottom) = options.page_geometry()?; let js = self .js .as_ref() .ok_or(RasterPdfError::NoRenderableDocument)?; + if !js.set_animation_sample(animation_sample) { + return Err(RasterPdfError::NoRenderableDocument); + } let (content_width, content_height) = js .prepared_content_size() .ok_or(RasterPdfError::NoRenderableDocument)?; diff --git a/crates/obscura-cdp/src/domains/page.rs b/crates/obscura-cdp/src/domains/page.rs index 72d45f7..93da4b1 100644 --- a/crates/obscura-cdp/src/domains/page.rs +++ b/crates/obscura-cdp/src/domains/page.rs @@ -425,6 +425,7 @@ pub(crate) fn queue_screencast_frame( let page = ctx .get_session_page_mut(&attached_session) .ok_or("No page for session")?; + let animation_sample = page.live_animation_sample(); let viewport = page.viewport; let scroll = page .evaluate("[window.scrollX, window.scrollY]") @@ -444,9 +445,12 @@ pub(crate) fn queue_screencast_frame( 1.0, )) .map_err(capture_error_message)?; - let png = page.screenshot(viewport).ok_or_else(|| { - "Page.startScreencast failed: the page has no visible DOM surface to render".to_string() - })?; + let png = page + .screenshot_with_animation_sample(viewport, animation_sample) + .ok_or_else(|| { + "Page.startScreencast failed: the page has no visible DOM surface to render" + .to_string() + })?; let activity_generation = page .js .as_ref() @@ -501,7 +505,7 @@ pub(crate) async fn pump_screencast_frames(ctx: &mut CdpContext) { continue; } let attached_session = Some(cdp_session_id.clone()); - let activity_generation = { + let (activity_generation, css_animation_active) = { let Some(page) = ctx.get_session_page_mut(&attached_session) else { continue; }; @@ -511,14 +515,16 @@ pub(crate) async fn pump_screencast_frames(ctx: &mut CdpContext) { // A V8 watchdog bounds synchronous callbacks as well as the async // timeout, so a hostile timer cannot freeze the CDP connection. let _ = js.run_event_loop_bounded(EVENT_LOOP_SLICE_MS).await; - js.activity_generation() + let generation = js.activity_generation(); + let css_animation_active = page.prepared_has_active_css_animations(); + (generation, css_animation_active) }; let should_attempt = { let Some(state) = ctx.screencasts.get_mut(&cdp_session_id) else { continue; }; - if state.observed_activity_generation != activity_generation { + if state.observed_activity_generation != activity_generation || css_animation_active { state.observed_activity_generation = activity_generation; state.autonomous_frame_pending = true; } @@ -1253,6 +1259,7 @@ pub async fn handle( .get_session_page_mut(session_id) .ok_or("No page for session")?; prepare_capture_resources_if_requested(page).await; + let animation_sample = page.live_animation_sample(); let viewport = page.viewport; let device_scale_factor = f64::from(page.device_scale_factor); let trusted_scroll = page.screenshot_scroll_offset(); @@ -1275,9 +1282,11 @@ pub async fn handle( } Some(chromium_clip_region(clip, device_scale_factor)?) } else if options.capture_beyond_viewport { - let content_size = page.prepared_content_size().ok_or_else(|| { - "Page.captureScreenshot failed: no retained document size".to_string() - })?; + let content_size = page + .prepared_content_size_with_animation_sample(animation_sample) + .ok_or_else(|| { + "Page.captureScreenshot failed: no retained document size".to_string() + })?; Some(obscura_browser::CaptureRegion::new( 0.0, 0.0, @@ -1299,7 +1308,7 @@ pub async fn handle( let png = match region { Some(region) => page - .screenshot_region(region) + .screenshot_region_with_animation_sample(region, animation_sample) .map_err(capture_error_message)?, None => { obscura_browser::validate_capture_region( @@ -1312,10 +1321,11 @@ pub async fn handle( ), ) .map_err(capture_error_message)?; - page.screenshot(viewport).ok_or_else(|| { - "Page.captureScreenshot failed: the page has no DOM to render" - .to_string() - })? + page.screenshot_with_animation_sample(viewport, animation_sample) + .ok_or_else(|| { + "Page.captureScreenshot failed: the page has no DOM to render" + .to_string() + })? } }; @@ -1535,6 +1545,116 @@ mod tests { assert_eq!(frame.params["sessionId"], 1); } + #[cfg(feature = "render")] + #[tokio::test] + async fn css_animation_drives_autonomous_screencast_frames_until_completion() { + let mut ctx = CdpContext::new(); + let page_id = ctx.create_page(); + let session_id = format!("{page_id}-session"); + ctx.sessions.insert(session_id.clone(), page_id); + let session = Some(session_id); + ctx.get_session_page_mut(&session) + .expect("page") + .set_viewport((80.0, 60.0)); + handle( + "navigate", + &json!({ + "url": "data:text/html,
", + "waitUntil": "load", + }), + &mut ctx, + &session, + ) + .await + .expect("navigate animation fixture"); + + ctx.pending_events.clear(); + handle("startScreencast", &json!({}), &mut ctx, &session) + .await + .expect("start screencast"); + let initial = ctx + .pending_events + .iter() + .find(|event| event.method == "Page.screencastFrame") + .expect("initial frame") + .params["data"] + .as_str() + .unwrap() + .to_string(); + let generation = ctx + .get_session_page_mut(&session) + .unwrap() + .js + .as_ref() + .unwrap() + .activity_generation(); + + ctx.pending_events.clear(); + tokio::time::sleep(std::time::Duration::from_millis(30)).await; + pump_screencast_frames(&mut ctx).await; + let (animated, frame_session_id) = { + let event = ctx + .pending_events + .iter() + .find(|event| event.method == "Page.screencastFrame") + .expect("CSS-only animation frame"); + ( + event.params["data"].as_str().unwrap().to_string(), + event.params["sessionId"].as_u64().unwrap(), + ) + }; + assert_ne!(animated, initial); + assert_eq!( + ctx.get_session_page_mut(&session) + .unwrap() + .js + .as_ref() + .unwrap() + .activity_generation(), + generation, + "CSS timeline damage must not depend on DOM or task activity" + ); + + for _ in 0..2 { + handle( + "screencastFrameAck", + &json!({"sessionId": frame_session_id}), + &mut ctx, + &session, + ) + .await + .expect("ack frame"); + } + ctx.pending_events.clear(); + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + pump_screencast_frames(&mut ctx).await; + assert!( + ctx.pending_events + .iter() + .any(|event| event.method == "Page.screencastFrame"), + "the stream must emit the finite animation's final frame" + ); + assert!(!ctx + .get_session_page_mut(&session) + .unwrap() + .prepared_has_active_css_animations()); + + handle( + "screencastFrameAck", + &json!({"sessionId": frame_session_id}), + &mut ctx, + &session, + ) + .await + .expect("ack final frame"); + ctx.pending_events.clear(); + pump_screencast_frames(&mut ctx).await; + assert!( + ctx.pending_events.is_empty(), + "a completed finite animation must not keep rasterizing idle frames" + ); + } + #[cfg(feature = "render")] #[tokio::test] async fn screencast_sampling_backpressure_and_stale_acks_are_bounded() { diff --git a/crates/obscura-cdp/src/domains/pdf.rs b/crates/obscura-cdp/src/domains/pdf.rs index ef416a2..121d273 100644 --- a/crates/obscura-cdp/src/domains/pdf.rs +++ b/crates/obscura-cdp/src/domains/pdf.rs @@ -226,8 +226,9 @@ pub async fn print_to_pdf( .get_session_page_mut(session_id) .ok_or("No page for session")?; crate::domains::page::prepare_capture_resources_if_requested(page).await; + let animation_sample = page.live_animation_sample(); let pdf = page - .raster_pdf(options.raster) + .raster_pdf_with_animation_sample(options.raster, animation_sample) .map_err(|error| error.to_string())?; let mut response = json!({ "obscuraPrintMode": "screen-raster", diff --git a/crates/obscura-cli/src/main.rs b/crates/obscura-cli/src/main.rs index 393da5e..1106f04 100644 --- a/crates/obscura-cli/src/main.rs +++ b/crates/obscura-cli/src/main.rs @@ -902,6 +902,29 @@ async fn run_fetch( // OBSCURA_SHOT_W / OBSCURA_SHOT_H override it (e.g. a tall viewport to // capture below-the-fold content in one shot). let viewport = screenshot_viewport.unwrap_or((1280.0, 720.0)); + // Ordinary screenshots sample the live document timeline. The + // comparison harness can request an exact instant (normally T=0) + // so both engines paint the same animation frame. + let requested_animation_sample = std::env::var("OBSCURA_SHOT_ANIMATION_TIME_MS") + .ok() + .map(|raw| { + let milliseconds = raw.parse::().map_err(|_| { + anyhow::anyhow!( + "OBSCURA_SHOT_ANIMATION_TIME_MS must be a finite non-negative number" + ) + })?; + if !milliseconds.is_finite() || milliseconds < 0.0 { + anyhow::bail!( + "OBSCURA_SHOT_ANIMATION_TIME_MS must be a finite non-negative number" + ); + } + Ok(obscura_browser::AnimationSampleTime { milliseconds }) + }) + .transpose()?; + let capture_screenshot = |page: &Page| match requested_animation_sample { + Some(sample) => page.screenshot_at_animation_time(viewport, sample), + None => page.screenshot(viewport), + }; // The parity harness performs one throwaway paint in both engines // before observing image/font readiness. Obscura resolves retained // render resources during prepare/paint, so sampling first would @@ -910,7 +933,7 @@ async fn run_fetch( let warmup_capture = std::env::var("OBSCURA_SHOT_RESOURCE_WARMUP").is_ok_and(|value| value == "1"); if warmup_capture { - if page.screenshot(viewport).is_none() { + if capture_screenshot(&page).is_none() { anyhow::bail!("resource warm-up screenshot failed: page has no DOM to render"); } // Give completion callbacks one bounded task turn before the @@ -962,7 +985,7 @@ async fn run_fetch( }))()", ) }); - match page.screenshot(viewport) { + match capture_screenshot(&page) { Some(bytes) => std::fs::write(path, &bytes)?, None => anyhow::bail!("screenshot failed: page has no DOM to render"), } diff --git a/crates/obscura-cli/tests/screenshot_after_eval.rs b/crates/obscura-cli/tests/screenshot_after_eval.rs index d76b72e..6c62e82 100644 --- a/crates/obscura-cli/tests/screenshot_after_eval.rs +++ b/crates/obscura-cli/tests/screenshot_after_eval.rs @@ -72,6 +72,57 @@ fn screenshot_is_captured_after_eval_scrolls_the_live_page() { std::fs::remove_dir_all(directory).expect("remove temporary output directory"); } +#[test] +fn screenshots_use_live_animation_time_unless_harness_pins_a_sample() { + let directory = output_directory(); + std::fs::create_dir_all(&directory).expect("temporary output directory"); + let live = directory.join("live.png"); + let initial = directory.join("initial.png"); + let url = concat!( + "data:text/html,", + "
" + ); + + let run = |path: &std::path::Path, animation_time: Option<&str>| { + let mut command = Command::new(env!("CARGO_BIN_EXE_obscura")); + command + .args(["fetch", url, "--screenshot"]) + .arg(path) + .args(["--wait", "1", "--timeout", "5", "--quiet"]) + .env("OBSCURA_SHOT_W", "80") + .env("OBSCURA_SHOT_H", "60") + .env_remove("OBSCURA_SHOT_ANIMATION_TIME_MS"); + if let Some(milliseconds) = animation_time { + command.env("OBSCURA_SHOT_ANIMATION_TIME_MS", milliseconds); + } + command.output().expect("run obscura fetch") + }; + + let live_output = run(&live, None); + assert!( + live_output.status.success(), + "live capture failed: {}", + String::from_utf8_lossy(&live_output.stderr) + ); + let initial_output = run(&initial, Some("0")); + assert!( + initial_output.status.success(), + "explicit capture failed: {}", + String::from_utf8_lossy(&initial_output.stderr) + ); + assert_ne!( + std::fs::read(live).expect("live PNG"), + std::fs::read(initial).expect("initial PNG"), + "live capture must advance past a finite overlay while explicit T=0 preserves it" + ); + + std::fs::remove_dir_all(directory).expect("remove temporary output directory"); +} + #[test] fn paired_capture_reasserts_scroll_after_the_post_eval_settle() { let directory = output_directory(); diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index e6c7976..b7eb400 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -647,7 +647,13 @@ const _scheduleAfter = (delay, fn) => { } // The callback runs only when the embedder pumps the event loop, after the // current microtask checkpoint. - return Deno.core.queueUserTimer(0, false, d, fn); + return Deno.core.queueUserTimer(0, false, d, () => { + // HTML timer/observer/rAF delivery starts a new task. Freeze animation + // time lazily on that task's first style/layout read so a callback that + // waited in the host queue samples its actual delivery instant. + Deno.core.ops.op_begin_render_task?.(); + return fn(); + }); }; // Timers accept a string first arg per the HTML spec (e.g. the Aliyun WAF @@ -4530,7 +4536,8 @@ class HTMLImageElement extends Element { setAttribute(name, value) { const normalized = String(name).toLowerCase(); super.setAttribute(name, value); - if (normalized === "src" || normalized === "srcset" || normalized === "sizes") { + if (normalized === "src" || normalized === "srcset" || normalized === "sizes" + || normalized === "crossorigin") { this._imageSourceChanged(); } else if ((normalized === "onload" || normalized === "onerror") @@ -4540,7 +4547,8 @@ class HTMLImageElement extends Element { removeAttribute(name) { const normalized = String(name).toLowerCase(); super.removeAttribute(name); - if (normalized === "src" || normalized === "srcset" || normalized === "sizes") { + if (normalized === "src" || normalized === "srcset" || normalized === "sizes" + || normalized === "crossorigin") { this._imageSourceChanged(); } } @@ -4581,30 +4589,46 @@ class HTMLImageElement extends Element { this._imageQueued = true; const request = this._imageRequest; setTimeout(() => { - this._imageQueued = false; if (request === this._imageRequest && !this._imageComplete) { this._runImageRequest(request); + } else if (request === this._imageRequest) { + this._imageQueued = false; } }, 1); } _runImageRequest(request) { - let metadata = null; + const finish = (metadata) => { + if (request !== this._imageRequest) return; + this._imageQueued = false; + if (metadata && metadata.state === "stale") { + this._refreshImageFromCache(true); + this._queueImageRequest(); + return; + } + this._applyImageMetadata(metadata, request, true); + }; try { - const op = Deno.core.ops.op_image_metadata; + const op = Deno.core.ops.op_load_image_metadata; if (typeof op === "function") { - metadata = JSON.parse(op(this._nid >>> 0, false)); + Promise.resolve(op(this._nid >>> 0)).then( + raw => { + let metadata = null; + try { metadata = JSON.parse(raw); } + catch (_error) { metadata = { ok: false, currentSrc: this.src }; } + finish(metadata); + }, + () => finish({ ok: false, currentSrc: this.src }), + ); } else { // Non-render builds have no authoritative resource cache. Preserve the // old non-blocking compatibility behavior without issuing a duplicate // network fetch: the request succeeds with unknown intrinsic size. - metadata = { ok: true, currentSrc: this.src, width: 0, height: 0 }; + finish({ ok: true, currentSrc: this.src, width: 0, height: 0 }); } } catch (_error) { - metadata = { ok: false, currentSrc: this.src }; + finish({ ok: false, currentSrc: this.src }); } - if (request !== this._imageRequest) return; - this._applyImageMetadata(metadata, request, true); } _refreshImageFromCache(deferCompletion) { diff --git a/crates/obscura-js/src/lib.rs b/crates/obscura-js/src/lib.rs index f80e9f4..0b5e2db 100644 --- a/crates/obscura-js/src/lib.rs +++ b/crates/obscura-js/src/lib.rs @@ -13,6 +13,8 @@ pub use v8_flags::set_v8_flags; // render feature (which enables obscura-render/paint) is compiled in. #[cfg(feature = "render")] pub use obscura_render::{ - screenshot_png, screenshot_png_scrolled, validate_capture_region, CaptureError, CaptureRegion, + screenshot_png, screenshot_png_scrolled, screenshot_png_scrolled_at_animation_time, + validate_capture_region, AnimationSample, AnimationSampleMode, AnimationSampleTime, + CaptureError, CaptureRegion, ImageRequestProfile, MAX_CAPTURE_DIMENSION, MAX_CAPTURE_PIXELS, }; diff --git a/crates/obscura-js/src/module_loader.rs b/crates/obscura-js/src/module_loader.rs index b2b92e7..69f7daa 100644 --- a/crates/obscura-js/src/module_loader.rs +++ b/crates/obscura-js/src/module_loader.rs @@ -14,6 +14,58 @@ use deno_core::RequestedModuleType; use crate::import_map::ImportMap; use crate::ops::ObscuraState; +/// Observable network activity for ES-module graphs. +/// +/// deno_core keeps dynamic-import state inside its private module map. The +/// browser lifecycle still needs to distinguish a genuinely idle page from a +/// graph whose fetch future is being advanced in short event-loop slices. A +/// loader-owned counter provides that signal without treating unrelated +/// fetch/XHR analytics as render-blocking work. +#[derive(Debug, Default)] +pub(crate) struct ModuleLoadActivity { + pending: std::sync::atomic::AtomicUsize, + last_activity: std::sync::Mutex>, +} + +impl ModuleLoadActivity { + fn begin(self: &Arc) -> ModuleLoadGuard { + self.pending + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + *self + .last_activity + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(std::time::Instant::now()); + ModuleLoadGuard(self.clone()) + } + + pub(crate) fn is_pending_or_recent(&self, grace: std::time::Duration) -> bool { + if self.pending.load(std::sync::atomic::Ordering::Relaxed) != 0 { + return true; + } + self.last_activity + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .is_some_and(|last| last.elapsed() <= grace) + } +} + +struct ModuleLoadGuard(Arc); + +impl Drop for ModuleLoadGuard { + fn drop(&mut self) { + let previous = self + .0 + .pending + .fetch_sub(1, std::sync::atomic::Ordering::Relaxed); + debug_assert!(previous > 0, "module load activity counter underflow"); + *self + .0 + .last_activity + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(std::time::Instant::now()); + } +} + pub struct ObscuraModuleLoader { pub base_url: String, /// Proxy URL threaded through to every dynamic ES-module fetch (#139). @@ -29,6 +81,7 @@ pub struct ObscuraModuleLoader { /// connection pool; they simply have an isolated cookie jar. standalone_client: Option>, import_map: Rc>, + activity: Arc, } impl ObscuraModuleLoader { @@ -56,6 +109,7 @@ impl ObscuraModuleLoader { page_state: None, standalone_client: Some(standalone_client), import_map, + activity: Arc::new(ModuleLoadActivity::default()), } } @@ -71,8 +125,13 @@ impl ObscuraModuleLoader { page_state: Some(Rc::downgrade(page_state)), standalone_client: None, import_map, + activity: Arc::new(ModuleLoadActivity::default()), } } + + pub(crate) fn activity(&self) -> Arc { + self.activity.clone() + } } fn io_err(msg: String) -> ModuleLoaderError { @@ -117,13 +176,19 @@ impl ModuleLoader for ObscuraModuleLoader { &self, module_specifier: &ModuleSpecifier, _maybe_referrer: Option<&ModuleSpecifier>, - _is_dyn_import: bool, + is_dyn_import: bool, _requested_module_type: RequestedModuleType, ) -> ModuleLoadResponse { let url = module_specifier.to_string(); // Capture the loader's proxy here so the async closure below owns a // plain Option rather than borrowing &self across an `await`. let proxy_url = self.proxy_url.clone(); + let activity = self.activity.clone(); + // Register before returning the future. The lifecycle can inspect the + // runtime between deno_core accepting the load and first polling it. + // Keeping the guard inside the future makes cancellation/navigation + // decrement the count through Drop as well as success and failure. + let activity_guard = is_dyn_import.then(|| activity.begin()); let page_network = match self.page_state.as_ref() { Some(weak) => (|| { let state = weak @@ -146,6 +211,10 @@ impl ModuleLoader for ObscuraModuleLoader { }; ModuleLoadResponse::Async(Pin::from(Box::new(async move { + // deno_core propagates `is_dyn_import` to every dependency edge in + // the recursive graph, so this excludes parser-discovered/static + // graphs without losing descendant fetches of a lazy graph. + let _activity_guard = activity_guard; tracing::debug!( "Loading ES module: {} (proxy: {})", url, diff --git a/crates/obscura-js/src/ops.rs b/crates/obscura-js/src/ops.rs index d74b730..c4567ce 100644 --- a/crates/obscura-js/src/ops.rs +++ b/crates/obscura-js/src/ops.rs @@ -8,6 +8,8 @@ use deno_core::op2; use deno_core::Extension; use deno_core::OpState; use obscura_dom::{DomTree, NodeData, NodeId}; +#[cfg(feature = "render")] +use obscura_net::{RequestCredentials, RequestMode, ResourceRequest}; #[cfg(feature = "stealth")] use obscura_net::StealthHttpClient; use obscura_net::{ @@ -74,6 +76,9 @@ pub struct JsNetworkEvent { pub timestamp: f64, } +#[cfg(feature = "render")] +pub use obscura_render::ImageRequestProfile; + pub struct ObscuraState { pub dom: Option, pub url: String, @@ -125,10 +130,28 @@ pub struct ObscuraState { /// The browser settle policy samples this to distinguish useful deferred /// rendering work from unrelated long-lived timers. pub activity_generation: u64, + /// Monotonic identity of the currently installed document. Async resource + /// completions use this to discard bytes and lifecycle results belonging + /// to a navigation that has already been replaced. + pub document_generation: u64, /// Final image/font-aware layout shared by CSSOM geometry and screenshots. /// DOM/style/viewport changes clear this value but retain resource bytes. #[cfg(feature = "render")] pub prepared_render: Option, + /// Explicit document-timeline sample used by the next style/layout flush. + /// Captures set this to either deterministic T=0 or live document time. + #[cfg(feature = "render")] + pub animation_sample: obscura_render::AnimationSample, + #[cfg(feature = "render")] + pub animation_timeline: obscura_render::AnimationTimelineState, + #[cfg(feature = "render")] + pub animation_timeline_origin: std::time::Instant, + /// Host/HTML task epoch for document-timeline sampling. Geometry and + /// computed-style reads within one task share one frozen animation frame. + #[cfg(feature = "render")] + pub animation_task_generation: u64, + #[cfg(feature = "render")] + pub animation_sampled_task_generation: u64, /// Connected mutations awaiting dependency-indexed retained style refresh. /// Tree changes carry stable node/parent ids so a later geometry read can /// coalesce framework DOM churn into one conservative local cascade. @@ -138,6 +161,12 @@ pub struct ObscuraState { /// relayout of the same document reuses it without refetching. #[cfg(feature = "render")] pub render_resources: obscura_render::RenderResourceCache, + /// Waiters sharing an asynchronous HTMLImageElement request. The key keeps + /// navigation identity and request credentials separate so neither stale + /// pages nor incompatible CORS profiles share a completion. + #[cfg(feature = "render")] + pub render_image_in_flight: + HashMap<(u64, String, ImageRequestProfile), Vec>>, /// One exact-key compiled author stylesheet for this document. Connected /// mutations still discard `prepared_render`; the next prepare reuses only /// parsing/indexing when ordered CSS source and viewport remain identical. @@ -199,13 +228,26 @@ impl ObscuraState { js_network_events: Vec::new(), page_in_flight: Arc::new(std::sync::atomic::AtomicU32::new(0)), activity_generation: 0, + document_generation: 0, #[cfg(feature = "render")] prepared_render: None, #[cfg(feature = "render")] + animation_sample: obscura_render::AnimationSample::default(), + #[cfg(feature = "render")] + animation_timeline: obscura_render::AnimationTimelineState::default(), + #[cfg(feature = "render")] + animation_timeline_origin: std::time::Instant::now(), + #[cfg(feature = "render")] + animation_task_generation: 0, + #[cfg(feature = "render")] + animation_sampled_task_generation: 0, + #[cfg(feature = "render")] pending_style_mutations: Vec::new(), #[cfg(feature = "render")] render_resources: obscura_render::RenderResourceCache::default(), #[cfg(feature = "render")] + render_image_in_flight: HashMap::new(), + #[cfg(feature = "render")] stylesheet_cache: obscura_render::StylesheetCache::default(), #[cfg(feature = "render")] dynamic_fonts: Vec::new(), @@ -480,18 +522,12 @@ fn retained_style_mutation( arg2: &str, ) -> Option { let node = NodeId::new(arg1.parse::().ok()?); - let can_retain = |name: &str| { - let name = name.to_ascii_lowercase(); - name == "class" - || name == "id" - || name == "style" - || name.starts_with("data-") - || name.starts_with("aria-") - }; match cmd { "set_attribute" => { let (name, value) = arg2.split_once('\0')?; - if !can_retain(name) { + if obscura_render::dom::retained_attribute_mutation_kind(dom, node, name) + == obscura_render::dom::RetainedAttributeMutationKind::Full + { return None; } let keeps_selector_value = !name.eq_ignore_ascii_case("style"); @@ -510,9 +546,14 @@ fn retained_style_mutation( } .into()) } - "remove_attribute" => can_retain(arg2).then(|| { + "remove_attribute" => { + if obscura_render::dom::retained_attribute_mutation_kind(dom, node, arg2) + == obscura_render::dom::RetainedAttributeMutationKind::Full + { + return None; + } let keeps_selector_value = !arg2.eq_ignore_ascii_case("style"); - obscura_render::AttributeStyleMutation { + Some(obscura_render::AttributeStyleMutation { node, name: arg2.to_string(), old_value: keeps_selector_value @@ -525,8 +566,8 @@ fn retained_style_mutation( .flatten(), new_value: None, } - .into() - }), + .into()) + } "append_child" => { let child = NodeId::new(arg2.parse::().ok()?); dom.get_node(node)?; @@ -559,13 +600,19 @@ fn retained_style_mutation( .into(), ) } - "set_text_content" => Some( - obscura_render::TreeStyleMutation::Text { - node, - parent: dom.get_node(node)?.parent, - } - .into(), - ), + "set_text_content" => match &dom.get_node(node)?.data { + NodeData::Text { .. } => Some( + obscura_render::TreeStyleMutation::Text { + node, + parent: dom.get_node(node)?.parent, + } + .into(), + ), + // Element/fragment textContent replaces a child list. That can + // flip :empty and structural/relational selectors, so the local + // text fast path cannot describe the mutation safely. + _ => None, + }, _ => None, } } @@ -725,11 +772,68 @@ fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> Str .element_scroll_offsets .retain(|node, _| !reset_nodes.contains(node)); state.scroll_generation = state.scroll_generation.wrapping_add(1); + if invalidate { + state.animation_timeline.remove_subtree(reset_nodes.iter()); + } } #[cfg(feature = "render")] let had_prepared_render = state.prepared_render.is_some(); #[cfg(feature = "render")] if invalidate { + let mutation_time_ms = (state.animation_timeline_origin.elapsed().as_secs_f64() + * 1_000.0) + .min(f64::from(f32::MAX)) as f32; + // Keep animation birth epochs local to the changed subtree. A + // single document-global timestamp made a later unrelated write + // restart every not-yet-sampled animation at the same instant. + let direct_root = match cmd.as_str() { + "append_child" => arg2.parse::().ok(), + "insert_before" + | "set_attribute" + | "remove_attribute" + | "set_attribute_ns" + | "remove_attribute_ns" => arg1.parse::().ok(), + _ => None, + } + .map(NodeId::new); + let direct_nodes = direct_root + .and_then(|root| { + state.dom.as_ref().map(|dom| { + std::iter::once(root) + .chain(dom.descendants(root)) + .collect::>() + }) + }) + .unwrap_or_default(); + for node in direct_nodes { + state + .animation_timeline + .note_start_candidate(node, mutation_time_ms); + } + let scope_root = match cmd.as_str() { + "append_child" => arg1.parse::().ok().map(NodeId::new), + "insert_before" => arg2 + .parse::() + .ok() + .map(NodeId::new) + .and_then(|reference| { + state.dom.as_ref()?.get_node(reference)?.parent + }), + "remove_child" => arg1 + .parse::() + .ok() + .map(NodeId::new) + .and_then(|child| state.dom.as_ref()?.get_node(child)?.parent), + "set_inner_html" | "set_inner_html_context" | "set_text_content" => { + arg1.parse::().ok().map(NodeId::new) + } + _ => None, + }; + if let Some(root) = scope_root { + state + .animation_timeline + .note_subtree_start_candidate(root, mutation_time_ms); + } if let Some(mutation) = retained_style_mutation { if state.prepared_render.is_some() && state.pending_style_mutations.len() < 256 @@ -1556,20 +1660,6 @@ async fn op_fetch_url( url ); - if let Ok(parsed_url) = url::Url::parse(&url) { - if let Err(e) = validate_fetch_url(&parsed_url) { - return Ok(serde_json::json!({ - "status": 0, - "body": "", - "url": url, - "headers": {}, - "blocked": true, - "error": e, - }) - .to_string()); - } - } - let (cookie_jar, in_flight, page_in_flight, intercept_tx, proxy_url, callbacks, http_client) = { let state_borrow = state.borrow(); let gs = state_borrow.borrow::().clone(); @@ -1622,6 +1712,26 @@ async fn op_fetch_url( gs.http_client.clone(), ) }; + // The private-network opt-in is a BrowserContext policy, not only a + // process-wide environment setting. Navigation already honours the + // context's configured HTTP client; scripted fetch/XHR must use the same + // policy for its initial URL and every URL it can reach below. + let allow_private_network = http_client + .as_ref() + .is_some_and(|client| client.allow_private_network); + if let Ok(parsed_url) = url::Url::parse(&url) { + if let Err(e) = validate_fetch_url(&parsed_url, allow_private_network) { + return Ok(serde_json::json!({ + "status": 0, + "body": "", + "url": url, + "headers": {}, + "blocked": true, + "error": e, + }) + .to_string()); + } + } struct PageInFlightGuard(Arc); impl Drop for PageInFlightGuard { fn drop(&mut self) { @@ -1707,7 +1817,7 @@ async fn op_fetch_url( // bypass validate_fetch_url entirely. let url = if let Some(new_url) = override_url { if let Ok(parsed) = url::Url::parse(&new_url) { - if let Err(reason) = validate_fetch_url(&parsed) { + if let Err(reason) = validate_fetch_url(&parsed, allow_private_network) { return Ok(serde_json::json!({ "status": 0, "body": "", @@ -1838,6 +1948,7 @@ async fn op_fetch_url( mode.clone(), credentials, callbacks.clone(), + allow_private_network, ) .await; } @@ -1947,7 +2058,7 @@ async fn op_fetch_url( }; // Re-validate every redirect target against the SSRF policy. - if let Err(reason) = validate_fetch_url(&next_url) { + if let Err(reason) = validate_fetch_url(&next_url, allow_private_network) { return Ok(serde_json::json!({ "status": 0, "body": "", @@ -2143,6 +2254,7 @@ async fn stealth_fetch_all( mode: String, credentials: FetchCredentials, callbacks: Option>, + allow_private_network: bool, ) -> Result { let mut current_url = url.clone(); let mut current_method = method; @@ -2194,7 +2306,7 @@ async fn stealth_fetch_all( }; // Re-validate every redirect target against the SSRF policy, matching // op_fetch_url (GHSA-8v6v-g4rh-jmcm). - if let Err(reason) = validate_fetch_url(&next_url) { + if let Err(reason) = validate_fetch_url(&next_url, allow_private_network) { return Ok(serde_json::json!({ "status": 0, "body": "", "url": next_url.to_string(), "headers": {}, "blocked": true, @@ -2305,7 +2417,7 @@ fn glob_match(pattern: &str, url: &str) -> bool { #[cfg(test)] mod tests { - use super::{cors_response_allows, glob_match, FetchCredentials}; + use super::{cors_response_allows, glob_match, validate_fetch_url, FetchCredentials}; #[test] fn glob_match_handles_cdp_blocked_url_patterns() { @@ -2378,9 +2490,15 @@ mod tests { "true", )); } + + #[test] + fn fetch_url_validation_honors_per_context_private_network_opt_in() { + let loopback = url::Url::parse("http://127.0.0.1:8080/resource").unwrap(); + assert!(validate_fetch_url(&loopback, true).is_ok()); + } } -fn validate_fetch_url(url: &url::Url) -> Result<(), String> { +fn validate_fetch_url(url: &url::Url, allow_private_network: bool) -> Result<(), String> { let scheme = url.scheme(); if scheme != "http" && scheme != "https" && scheme != "file" { return Err(format!( @@ -2389,7 +2507,10 @@ fn validate_fetch_url(url: &url::Url) -> Result<(), String> { )); } - if scheme == "file" || obscura_net::env_allows_private_network() { + if scheme == "file" + || allow_private_network + || obscura_net::env_allows_private_network() + { return Ok(()); } @@ -3119,8 +3240,10 @@ pub fn build_extension() -> Extension { // probes with typeof before calling, so the op's absence is a clean fallback. #[cfg(feature = "render")] { + ops.push(op_begin_render_task()); ops.push(op_set_dynamic_fonts()); ops.push(op_image_metadata()); + ops.push(op_load_image_metadata()); ops.push(op_layout_geometry()); ops.push(op_computed_style()); ops.push(op_css_supports()); @@ -3161,16 +3284,24 @@ pub(crate) fn ensure_prepared_render( ) -> Option<&obscura_render::PreparedRender> { let base_url = document_base_url(state); let viewport = state.viewport; + let animation_sample = state.animation_sample; let stale = state.prepared_render.as_ref().map_or(true, |prepared| { - prepared.viewport() != viewport || prepared.base_url() != base_url.as_deref() + prepared.viewport() != viewport + || prepared.base_url() != base_url.as_deref() + || prepared.animation_sample() != animation_sample }); if stale || !state.pending_style_mutations.is_empty() { + if let Some(dom) = state.dom.as_ref() { + state + .animation_timeline + .materialize_start_candidates(dom); + } let previous = (!stale).then(|| state.prepared_render.take()).flatten(); let mutations = std::mem::take(&mut state.pending_style_mutations); let prepared = { let dom = state.dom.as_ref()?; match previous { - Some(previous) => obscura_render::prepare_dom_with_retained_styles( + Some(previous) => obscura_render::prepare_dom_with_retained_styles_with_animation_state( dom, viewport, base_url.as_deref(), @@ -3179,33 +3310,92 @@ pub(crate) fn ensure_prepared_render( &mut state.stylesheet_cache, previous, &mutations, + animation_sample, + &mut state.animation_timeline, ) .or_else(|| { - obscura_render::prepare_dom_with_dynamic_fonts_and_stylesheet_cache( + obscura_render::prepare_dom_with_dynamic_fonts_and_stylesheet_cache_with_animation_state( dom, viewport, base_url.as_deref(), &mut state.render_resources, &state.dynamic_fonts, &mut state.stylesheet_cache, + animation_sample, + &mut state.animation_timeline, ) })?, - None => obscura_render::prepare_dom_with_dynamic_fonts_and_stylesheet_cache( + None => obscura_render::prepare_dom_with_dynamic_fonts_and_stylesheet_cache_with_animation_state( dom, viewport, base_url.as_deref(), &mut state.render_resources, &state.dynamic_fonts, &mut state.stylesheet_cache, + animation_sample, + &mut state.animation_timeline, )?, } }; + if animation_sample.mode == obscura_render::AnimationSampleMode::DocumentTime { + state.animation_timeline.clear_start_candidates(); + } + let connected = state.dom.as_ref().map(|dom| { + std::iter::once(dom.document()) + .chain(dom.descendants(dom.document())) + .collect::>() + }); + if let Some(connected) = connected { + state + .animation_timeline + .retain_nodes(|node| connected.contains(&node)); + } state.prepared_render = Some(prepared); state.resolved_scroll = None; } state.prepared_render.as_ref() } +#[cfg(feature = "render")] +pub(crate) fn sample_live_document_animations(state: &mut ObscuraState) { + if state.animation_sampled_task_generation == state.animation_task_generation { + return; + } + state.animation_sampled_task_generation = state.animation_task_generation; + let sample = obscura_render::AnimationSample::document( + (state.animation_timeline_origin.elapsed().as_secs_f64() * 1_000.0) + .min(f64::from(f32::MAX)) as f32, + ); + if state.animation_sample == sample { + return; + } + if sample.time.milliseconds > state.animation_sample.time.milliseconds + && state.animation_sample.mode == obscura_render::AnimationSampleMode::DocumentTime + && state.prepared_render.as_mut().is_some_and(|prepared| { + prepared.advance_inactive_animation_sample_time(sample.time) + }) + { + state.animation_sample = sample; + return; + } + state.animation_sample = sample; + state.prepared_render = None; + state.pending_style_mutations.clear(); + state.resolved_scroll = None; +} + +#[cfg(feature = "render")] +pub(crate) fn begin_animation_task(state: &mut ObscuraState) { + state.animation_task_generation = state.animation_task_generation.wrapping_add(1); +} + +#[cfg(feature = "render")] +#[op2(fast)] +fn op_begin_render_task(state: &OpState) { + let shared = state.borrow::().clone(); + begin_animation_task(&mut shared.borrow_mut()); +} + #[cfg(feature = "render")] pub(crate) fn ensure_resolved_scroll(state: &mut ObscuraState) -> Option<()> { ensure_prepared_render(state)?; @@ -3248,81 +3438,13 @@ pub(crate) fn ensure_resolved_scroll(state: &mut ObscuraState) -> Option<()> { Some(()) } -/// Probe one ordinary `` through the renderer's page-scoped resource -/// cache. This is deliberately a synchronous render-only op: the current -/// renderer loader is synchronous too, and sharing it is what guarantees that -/// HTMLImageElement lifecycle state and the subsequent paint see the same -/// success/failure and retained bytes. #[cfg(feature = "render")] -#[op2] -#[string] -fn op_image_metadata(state: &OpState, nid: u32, cached_only: bool) -> String { - let shared = state.borrow::().clone(); - let mut gs = shared.borrow_mut(); - let node_id = NodeId::new(nid); - let is_image = gs.dom.as_ref().is_some_and(|dom| { - dom.get_node(node_id).is_some_and(|node| { - node.as_element() - .is_some_and(|element| element.local.as_ref() == "img") - }) - }); - if !is_image { - return serde_json::json!({ "ok": false, "currentSrc": "" }).to_string(); - } - let base_url = document_base_url(&gs); - let viewport = gs.viewport; - let previous_dimensions = (!cached_only) - .then(|| { - gs.dom.as_ref().and_then(|dom| { - gs.render_resources - .cached_image_element_metadata( - dom, - node_id, - viewport, - base_url.as_deref(), - ) - .and_then(|(_, _, known, dimensions)| known.then_some(dimensions).flatten()) - }) - }) - .flatten(); - let ObscuraState { - dom, - render_resources, - .. - } = &mut *gs; - let Some(dom) = dom.as_ref() else { - return serde_json::json!({ "ok": false, "currentSrc": "" }).to_string(); - }; - let (current_src, density, known, dimensions) = if cached_only { - match render_resources.cached_image_element_metadata( - dom, - node_id, - viewport, - base_url.as_deref(), - ) { - Some(metadata) => metadata, - None => { - return serde_json::json!({ - "state": "error", - "ok": false, - "currentSrc": "", - }) - .to_string(); - } - } - } else { - match render_resources.image_element_metadata(dom, node_id, viewport, base_url.as_deref()) { - Some((current_src, density, dimensions)) => (current_src, density, true, dimensions), - None => { - return serde_json::json!({ - "state": "error", - "ok": false, - "currentSrc": "", - }) - .to_string(); - } - } - }; +fn image_metadata_json( + current_src: String, + density: f32, + known: bool, + dimensions: Option<(f32, f32)>, +) -> String { if !known { return serde_json::json!({ "state": "pending", @@ -3331,15 +3453,6 @@ fn op_image_metadata(state: &OpState, nid: u32, cached_only: bool) -> String { }) .to_string(); } - // The page transport invalidates prepared layout when it seeds successful - // bytes. Cover the synchronous compatibility-loader path too, but only - // when this call actually discovers a new decodable intrinsic size. A - // cache hit or retained decode failure cannot change used geometry. - if !cached_only && dimensions.is_some() && dimensions != previous_dimensions { - gs.prepared_render = None; - gs.pending_style_mutations.clear(); - gs.resolved_scroll = None; - } match dimensions { Some((width, height)) => serde_json::json!({ "state": "loaded", @@ -3360,6 +3473,348 @@ fn op_image_metadata(state: &OpState, nid: u32, cached_only: bool) -> String { } } +#[cfg(feature = "render")] +fn image_request_profile(dom: &DomTree, node_id: NodeId) -> ImageRequestProfile { + match dom + .get_node(node_id) + .and_then(|node| node.get_attribute("crossorigin").map(str::to_owned)) + .map(|value| value.trim().to_ascii_lowercase()) + .as_deref() + { + Some("use-credentials") => ImageRequestProfile::CorsInclude, + Some(_) => ImageRequestProfile::CorsSameOrigin, + None => ImageRequestProfile::NoCorsInclude, + } +} + +#[cfg(feature = "render")] +fn profiled_cached_image_metadata( + gs: &ObscuraState, + node_id: NodeId, +) -> Option<(String, f32, bool, Option<(f32, f32)>)> { + let dom = gs.dom.as_ref()?; + let base_url = document_base_url(gs); + gs.render_resources.cached_image_element_metadata( + dom, + node_id, + gs.viewport, + base_url.as_deref(), + ) +} + +#[cfg(feature = "render")] +fn cached_image_metadata_for_node(gs: &ObscuraState, node_id: NodeId) -> String { + match profiled_cached_image_metadata(gs, node_id) { + Some((current_src, density, known, dimensions)) => { + image_metadata_json(current_src, density, known, dimensions) + } + None => serde_json::json!({ "ok": false, "currentSrc": "" }).to_string(), + } +} + +/// Probe one ordinary `` through the renderer's page-scoped resource +/// cache. This op is intentionally cache-only. Lifecycle getters call it +/// synchronously and must never open a socket or wait on network I/O. +#[cfg(feature = "render")] +#[op2] +#[string] +fn op_image_metadata(state: &OpState, nid: u32, _cached_only: bool) -> String { + let shared = state.borrow::().clone(); + let gs = shared.borrow(); + let node_id = NodeId::new(nid); + let is_image = gs.dom.as_ref().is_some_and(|dom| { + dom.get_node(node_id).is_some_and(|node| { + node.as_element() + .is_some_and(|element| element.local.as_ref() == "img") + }) + }); + if !is_image { + return serde_json::json!({ "ok": false, "currentSrc": "" }).to_string(); + } + cached_image_metadata_for_node(&gs, node_id) +} + +/// Compatibility path for standalone render runtimes which deliberately +/// install an in-memory `RenderResourceLoader` but have no owning page +/// transport. Browser pages always install `ObscuraHttpClient` before page +/// script runs and never enter this synchronous loader. +#[cfg(feature = "render")] +fn load_image_metadata_without_page_transport(gs: &mut ObscuraState, node_id: NodeId) -> String { + let base_url = document_base_url(&gs); + let viewport = gs.viewport; + let previous_dimensions = gs.dom.as_ref().and_then(|dom| { + gs.render_resources + .cached_image_element_metadata(dom, node_id, viewport, base_url.as_deref()) + .and_then(|(_, _, known, dimensions)| known.then_some(dimensions).flatten()) + }); + let Some(dom) = gs.dom.as_ref() else { + return serde_json::json!({ "ok": false, "currentSrc": "" }).to_string(); + }; + let Some((current_src, density, dimensions)) = gs.render_resources.image_element_metadata( + dom, + node_id, + viewport, + base_url.as_deref(), + ) else { + return serde_json::json!({ + "state": "error", + "ok": false, + "currentSrc": "", + }) + .to_string(); + }; + if dimensions.is_some() && dimensions != previous_dimensions { + gs.prepared_render = None; + gs.pending_style_mutations.clear(); + gs.resolved_scroll = None; + } + image_metadata_json(current_src, density, true, dimensions) +} + +#[cfg(feature = "render")] +fn finish_async_image_metadata( + shared: &SharedState, + node_id: NodeId, + document_generation: u64, + expected_url: &str, + request_profile: ImageRequestProfile, +) -> String { + let mut gs = shared.borrow_mut(); + if gs.document_generation != document_generation { + return serde_json::json!({ "state": "stale", "currentSrc": expected_url }) + .to_string(); + } + let Some(dom) = gs.dom.as_ref() else { + return serde_json::json!({ "state": "stale", "currentSrc": expected_url }) + .to_string(); + }; + if image_request_profile(dom, node_id) != request_profile { + return serde_json::json!({ "state": "stale", "currentSrc": expected_url }) + .to_string(); + } + let Some((current_src, density, known, dimensions)) = + profiled_cached_image_metadata(&gs, node_id) + else { + return serde_json::json!({ "state": "stale", "currentSrc": expected_url }) + .to_string(); + }; + if current_src != expected_url { + return serde_json::json!({ "state": "stale", "currentSrc": current_src }).to_string(); + } + if known && dimensions.is_some() { + // This request began only because the selected resource was unknown. + // Newly available intrinsic dimensions can dirty ancestor geometry, + // exactly like Gecko's SIZE_AVAILABLE reflow notification. + gs.prepared_render = None; + gs.pending_style_mutations.clear(); + gs.resolved_scroll = None; + } + image_metadata_json(current_src, density, known, dimensions) +} + +/// Load HTMLImageElement bytes through the owning page's async transport. +/// Network runs after every RefCell borrow is released, requests for the same +/// navigation/URL/profile share one fetch, and completion revalidates both the +/// document identity and responsive candidate before exposing lifecycle state. +#[cfg(feature = "render")] +#[op2(async)] +#[string] +async fn op_load_image_metadata(state: Rc>, nid: u32) -> String { + let shared = { + let state = state.borrow(); + state.borrow::().clone() + }; + let node_id = NodeId::new(nid); + let ( + document_generation, + selected_url, + request_profile, + resource_request, + http_client, + callbacks, + page_in_flight, + blocked, + ) = { + let gs = shared.borrow(); + let Some(dom) = gs.dom.as_ref() else { + return serde_json::json!({ "state": "stale", "currentSrc": "" }).to_string(); + }; + let is_image = dom.get_node(node_id).is_some_and(|node| { + node.as_element() + .is_some_and(|element| element.local.as_ref() == "img") + }); + if !is_image { + return serde_json::json!({ "state": "stale", "currentSrc": "" }).to_string(); + } + let profile = image_request_profile(dom, node_id); + let Some((selected_url, _, known, _)) = + profiled_cached_image_metadata(&gs, node_id) + else { + return serde_json::json!({ "state": "error", "ok": false, "currentSrc": "" }) + .to_string(); + }; + if known { + return cached_image_metadata_for_node(&gs, node_id); + } + let initiator = url::Url::parse(&gs.url) + .or_else(|_| url::Url::parse(&selected_url)) + .unwrap_or_else(|_| url::Url::parse("about:blank").unwrap()); + let mut request = ResourceRequest::subresource(ResourceType::Image, &initiator); + match profile { + ImageRequestProfile::CorsInclude => { + request.mode = RequestMode::Cors; + request.credentials = RequestCredentials::Include; + } + ImageRequestProfile::CorsSameOrigin => { + request.mode = RequestMode::Cors; + request.credentials = RequestCredentials::SameOrigin; + } + ImageRequestProfile::NoCorsInclude => {} + } + let blocked = gs.blocked_urls.iter().any(|pattern| { + pattern == "*" || selected_url.contains(pattern) || glob_match(pattern, &selected_url) + }); + ( + gs.document_generation, + selected_url, + profile, + request, + gs.http_client.clone(), + gs.callbacks.clone(), + Arc::clone(&gs.page_in_flight), + blocked, + ) + }; + + #[cfg(feature = "stealth")] + let stealth_client = shared.borrow().stealth_client.clone(); + #[cfg(feature = "stealth")] + let has_page_transport = http_client.is_some() || stealth_client.is_some(); + #[cfg(not(feature = "stealth"))] + let has_page_transport = http_client.is_some(); + if !has_page_transport { + return load_image_metadata_without_page_transport(&mut shared.borrow_mut(), node_id); + } + + // Different CORS/credential profiles do not share an in-flight response. + let request_key = (document_generation, selected_url.clone(), request_profile); + let follower = { + let mut gs = shared.borrow_mut(); + if let Some(waiters) = gs.render_image_in_flight.get_mut(&request_key) { + let (sender, receiver) = tokio::sync::oneshot::channel(); + waiters.push(sender); + Some(receiver) + } else { + gs.render_image_in_flight.insert(request_key.clone(), Vec::new()); + None + } + }; + if let Some(receiver) = follower { + let _ = receiver.await; + return finish_async_image_metadata( + &shared, + node_id, + document_generation, + &selected_url, + request_profile, + ); + } + + struct PageImageInFlightGuard(Arc); + impl Drop for PageImageInFlightGuard { + fn drop(&mut self) { + self.0.fetch_sub(1, std::sync::atomic::Ordering::Relaxed); + } + } + page_in_flight.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let _page_in_flight = PageImageInFlightGuard(page_in_flight); + + let parsed_url = url::Url::parse(&selected_url).ok(); + let response = if blocked || parsed_url.is_none() { + None + } else { + let parsed_url = parsed_url.as_ref().unwrap(); + #[cfg(feature = "stealth")] + { + if let Some(client) = stealth_client { + client + .fetch_resource_with_callbacks( + parsed_url, + resource_request.clone(), + callbacks.as_deref(), + ) + .await + .ok() + } else { + http_client + .as_ref() + .unwrap() + .fetch_resource_with_callbacks( + parsed_url, + resource_request, + callbacks.as_deref(), + ) + .await + .ok() + } + } + #[cfg(not(feature = "stealth"))] + { + http_client + .as_ref() + .unwrap() + .fetch_resource_with_callbacks( + parsed_url, + resource_request, + callbacks.as_deref(), + ) + .await + .ok() + } + }; + let bytes = response.and_then(|response| { + (200..300) + .contains(&response.status) + .then_some(response.body) + }); + let waiters = { + let mut gs = shared.borrow_mut(); + if gs.document_generation == document_generation { + match bytes { + Some(bytes) => { + if obscura_render::image_intrinsic_dimensions(&bytes).is_some() { + gs.render_resources.seed_image( + selected_url.clone(), + request_profile, + bytes, + ); + } else { + gs.render_resources + .seed_image_missing(selected_url.clone(), request_profile); + } + } + None => { + gs.render_resources + .seed_image_missing(selected_url.clone(), request_profile); + } + } + } + gs.render_image_in_flight + .remove(&request_key) + .unwrap_or_default() + }; + for waiter in waiters { + let _ = waiter.send(()); + } + finish_async_image_metadata( + &shared, + node_id, + document_generation, + &selected_url, + request_profile, + ) +} + #[cfg(feature = "render")] pub(crate) fn clamp_scroll_offset(state: &mut ObscuraState, requested: (f32, f32)) -> (f32, f32) { let clamped = ensure_prepared_render(state) @@ -3391,6 +3846,7 @@ fn op_layout_geometry(state: &OpState, #[string] nid_str: String) -> String { let nid: u32 = nid_str.parse().unwrap_or(0); let nid = obscura_dom::tree::NodeId::new(nid); let mut gs = shared.borrow_mut(); + sample_live_document_animations(&mut gs); if ensure_resolved_scroll(&mut gs).is_some() { let Some((_, scroll)) = gs.resolved_scroll.as_ref() else { return String::new(); @@ -3445,6 +3901,7 @@ fn op_computed_style(state: &OpState, #[string] nid_str: String) -> String { let nid: u32 = nid_str.parse().unwrap_or(0); let nid = obscura_dom::tree::NodeId::new(nid); let mut gs = shared.borrow_mut(); + sample_live_document_animations(&mut gs); let Some(prepared) = ensure_prepared_render(&mut gs) else { return String::new(); }; @@ -3480,6 +3937,7 @@ fn op_css_supports(#[string] name: &str, #[string] value: &str) -> bool { fn op_layout_metrics(state: &OpState) -> String { let shared = state.borrow::().clone(); let mut gs = shared.borrow_mut(); + sample_live_document_animations(&mut gs); let viewport = gs.viewport; let content = ensure_prepared_render(&mut gs) .map(|prepared| prepared.content_size()) @@ -3497,6 +3955,7 @@ fn op_element_scroll_metrics(state: &OpState, #[string] nid_str: String) -> Stri let shared = state.borrow::().clone(); let nid = NodeId::new(nid_str.parse().unwrap_or(0)); let mut gs = shared.borrow_mut(); + sample_live_document_animations(&mut gs); if ensure_resolved_scroll(&mut gs).is_none() { return String::new(); } @@ -3533,6 +3992,7 @@ fn op_element_scroll_to(state: &OpState, #[string] nid_str: String, x: f64, y: f let shared = state.borrow::().clone(); let nid = NodeId::new(nid_str.parse().unwrap_or(0)); let mut gs = shared.borrow_mut(); + sample_live_document_animations(&mut gs); if ensure_resolved_scroll(&mut gs).is_none() { return String::new(); } @@ -3575,6 +4035,7 @@ fn op_element_scroll_to(state: &OpState, #[string] nid_str: String, x: f64, y: f fn op_scroll_offset(state: &OpState) -> String { let shared = state.borrow::().clone(); let mut gs = shared.borrow_mut(); + sample_live_document_animations(&mut gs); let requested = gs.scroll_offset; let (x, y) = clamp_scroll_offset(&mut gs, requested); format!("{{\"x\":{},\"y\":{}}}", x, y) @@ -3586,6 +4047,7 @@ fn op_scroll_offset(state: &OpState) -> String { fn op_scroll_to(state: &OpState, x: f64, y: f64) -> String { let shared = state.borrow::().clone(); let mut gs = shared.borrow_mut(); + sample_live_document_animations(&mut gs); let (x, y) = clamp_scroll_offset(&mut gs, (x as f32, y as f32)); format!("{{\"x\":{},\"y\":{}}}", x, y) } diff --git a/crates/obscura-js/src/runtime.rs b/crates/obscura-js/src/runtime.rs index 0a8ce2d..b15717e 100644 --- a/crates/obscura-js/src/runtime.rs +++ b/crates/obscura-js/src/runtime.rs @@ -10,12 +10,14 @@ use obscura_dom::{DomTree, NodeId}; pub use deno_core::v8::IsolateHandle; use crate::import_map::ImportMap; -use crate::module_loader::ObscuraModuleLoader; +use crate::module_loader::{ModuleLoadActivity, ObscuraModuleLoader}; #[cfg(all(test, feature = "render"))] use crate::ops::ensure_prepared_render; use crate::ops::{build_extension, node_is_script, ObscuraState, StoredNetworkResponseBody}; #[cfg(feature = "render")] -use crate::ops::{clamp_scroll_offset, document_base_url, ensure_resolved_scroll}; +use crate::ops::{ + begin_animation_task, clamp_scroll_offset, document_base_url, ensure_resolved_scroll, +}; static SNAPSHOT: &[u8] = include_bytes!(env!("OBSCURA_SNAPSHOT_PATH")); @@ -65,6 +67,10 @@ pub struct ObscuraJsRuntime { object_store: HashMap, object_counter: u64, import_map: Rc>, + /// Loader-owned signal for pending dynamic-import graph fetches. This is + /// intentionally separate from page fetch/XHR activity so analytics does + /// not hold screenshot readiness open. + module_load_activity: std::sync::Arc, /// Thread-safe handle to this runtime's V8 isolate, captured at /// construction. Lets a watchdog be armed from `&self` (the CDP dispatcher /// only holds `&Page` on the hot path) and is stable for the isolate's life. @@ -161,6 +167,14 @@ impl WatchdogToken { } impl ObscuraJsRuntime { + /// Freeze the document timeline for one JavaScript task. Browser timelines + /// update at task/rendering boundaries, not on each forced style or layout + /// read. Keeping one sample across the task also lets repeated CSSOM reads + /// share the retained layout on pages with running animations. + fn begin_javascript_task(&mut self) { + #[cfg(feature = "render")] + begin_animation_task(&mut self.state.borrow_mut()); + } pub fn new() -> Self { Self::with_base_url("about:blank") } @@ -177,12 +191,14 @@ impl ObscuraJsRuntime { let state_clone = state.clone(); let import_map = state.borrow().import_map.clone(); - let module_loader = Rc::new(ObscuraModuleLoader::with_page_state( + let module_loader = ObscuraModuleLoader::with_page_state( base_url, proxy_url, &state, import_map.clone(), - )); + ); + let module_load_activity = module_loader.activity(); + let module_loader = Rc::new(module_loader); // Build the isolate under the process-wide creation lock so two // connection threads never construct isolates concurrently (#430). @@ -217,6 +233,7 @@ impl ObscuraJsRuntime { object_store: HashMap::new(), object_counter: 0, import_map, + module_load_activity, isolate_handle, } } @@ -257,6 +274,7 @@ impl ObscuraJsRuntime { pub fn set_dom(&self, dom: DomTree) { let mut gs = self.state.borrow_mut(); gs.dom = Some(dom); + gs.document_generation = gs.document_generation.wrapping_add(1); gs.activity_generation = 0; gs.page_in_flight = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0)); gs.already_started_scripts.borrow_mut().clear(); @@ -264,8 +282,14 @@ impl ObscuraJsRuntime { #[cfg(feature = "render")] { gs.prepared_render = None; + gs.animation_sample = obscura_render::AnimationSample::default(); + gs.animation_timeline = obscura_render::AnimationTimelineState::default(); + gs.animation_timeline_origin = std::time::Instant::now(); + gs.animation_task_generation = 0; + gs.animation_sampled_task_generation = 0; gs.pending_style_mutations.clear(); gs.render_resources = obscura_render::RenderResourceCache::default(); + gs.render_image_in_flight.clear(); gs.stylesheet_cache = obscura_render::StylesheetCache::default(); gs.dynamic_fonts.clear(); gs.scroll_offset = (0.0, 0.0); @@ -428,6 +452,80 @@ impl ObscuraJsRuntime { clamp_scroll_offset(&mut state, requested) } + /// Select the document-timeline instant used by the next render flush. + /// Returns false for invalid times and preserves the current sample. + #[cfg(feature = "render")] + pub fn set_animation_sample_time( + &self, + sample: obscura_render::AnimationSampleTime, + ) -> bool { + self.set_animation_sample(obscura_render::AnimationSample::document( + sample.milliseconds, + )) + } + + #[cfg(feature = "render")] + pub fn set_animation_sample(&self, sample: obscura_render::AnimationSample) -> bool { + if !sample.time.milliseconds.is_finite() || sample.time.milliseconds < 0.0 { + return false; + } + let mut state = self.state.borrow_mut(); + if state.animation_sample != sample { + if sample.mode == obscura_render::AnimationSampleMode::DocumentTime + && state.animation_sample.mode == obscura_render::AnimationSampleMode::DocumentTime + && sample.time.milliseconds > state.animation_sample.time.milliseconds + && state.prepared_render.as_mut().is_some_and(|prepared| { + prepared.advance_inactive_animation_sample_time(sample.time) + }) + { + state.animation_sample = sample; + return true; + } + state.animation_sample = sample; + state.prepared_render = None; + state.pending_style_mutations.clear(); + state.resolved_scroll = None; + } + true + } + + #[cfg(feature = "render")] + pub fn animation_sample_time(&self) -> obscura_render::AnimationSampleTime { + self.state.borrow().animation_sample.time + } + + #[cfg(feature = "render")] + pub fn live_animation_sample(&self) -> obscura_render::AnimationSample { + let state = self.state.borrow(); + obscura_render::AnimationSample::document( + (state.animation_timeline_origin.elapsed().as_secs_f64() * 1_000.0) + .min(f64::from(f32::MAX)) as f32, + ) + } + + #[cfg(feature = "render")] + pub fn reset_animation_timeline(&self) { + let mut state = self.state.borrow_mut(); + state.animation_timeline_origin = std::time::Instant::now(); + state.animation_timeline = obscura_render::AnimationTimelineState::default(); + state.animation_sample = obscura_render::AnimationSample::default(); + state.prepared_render = None; + state.pending_style_mutations.clear(); + state.resolved_scroll = None; + } + + /// Read animation damage from the last prepared frame without causing a + /// style/layout flush. Screencast scheduling uses this to avoid rasterizing + /// static pages on every compositor tick. + #[cfg(feature = "render")] + pub fn prepared_has_active_css_animations(&self) -> bool { + self.state + .borrow() + .prepared_render + .as_ref() + .is_some_and(|prepared| prepared.has_active_css_animations()) + } + /// Capture the live render viewport from the same prepared layout used by /// CSSOM geometry. A mismatched ad-hoc viewport/base returns `None` so the /// browser layer can retain its compatibility one-shot path. @@ -527,7 +625,7 @@ impl ObscuraJsRuntime { /// concurrently through the page-owned transport before synchronous layout /// or paint observes the cache. #[cfg(feature = "render")] - pub fn pending_render_image_urls(&self) -> Vec { + pub fn pending_render_image_urls(&self) -> Vec<(String, crate::ops::ImageRequestProfile)> { let state = self.state.borrow(); let base_url = document_base_url(&state); let Some(dom) = state.dom.as_ref() else { @@ -553,7 +651,16 @@ impl ObscuraJsRuntime { continue; }; if !known && !url.starts_with("data:") { - urls.push(url); + let profile = match node + .get_attribute("crossorigin") + .map(|value| value.trim().to_ascii_lowercase()) + .as_deref() + { + Some("use-credentials") => crate::ops::ImageRequestProfile::CorsInclude, + Some(_) => crate::ops::ImageRequestProfile::CorsSameOrigin, + None => crate::ops::ImageRequestProfile::NoCorsInclude, + }; + urls.push((url, profile)); } } urls.sort(); @@ -579,11 +686,42 @@ impl ObscuraJsRuntime { } } + #[cfg(feature = "render")] + pub fn seed_render_image_resource( + &mut self, + url: String, + profile: crate::ops::ImageRequestProfile, + bytes: Option>, + ) { + let mut state = self.state.borrow_mut(); + match bytes { + Some(bytes) if obscura_render::image_intrinsic_dimensions(&bytes).is_some() => { + state.render_resources.seed_image(url, profile, bytes); + state.prepared_render = None; + state.pending_style_mutations.clear(); + state.resolved_scroll = None; + } + _ => state.render_resources.seed_image_missing(url, profile), + } + } + #[cfg(feature = "render")] pub fn render_resource_is_known(&self, url: &str) -> bool { self.state.borrow().render_resources.has_live_outcome(url) } + #[cfg(feature = "render")] + pub fn render_image_resource_is_known( + &self, + url: &str, + profile: crate::ops::ImageRequestProfile, + ) -> bool { + self.state + .borrow() + .render_resources + .has_live_image_outcome(url, profile) + } + /// Run __obscura_init() after all per-page properties (UA, platform, stealth, etc.) /// have been set. Must be called once per page setup, after all set_* methods. pub fn run_page_init(&mut self) { @@ -607,6 +745,7 @@ impl ObscuraJsRuntime { } pub fn evaluate(&mut self, expression: &str) -> Result { + self.begin_javascript_task(); let wrapped = Self::wrap_expression(expression); let result = self .runtime @@ -625,6 +764,7 @@ impl ObscuraJsRuntime { let val = self.evaluate(expression)?; return Ok(Self::info_from_json(&val)); } + self.begin_javascript_task(); self.object_counter += 1; let oid = self.make_oid(self.object_counter); @@ -765,6 +905,7 @@ impl ObscuraJsRuntime { return_by_value: bool, await_promise: bool, ) -> Result { + self.begin_javascript_task(); let this_expr = self.resolve_this(object_id); let (setup, args_list) = self.build_args(arguments); @@ -933,6 +1074,7 @@ impl ObscuraJsRuntime { .await } pub fn store_object(&mut self, js_expression: &str) -> Result { + self.begin_javascript_task(); self.object_counter += 1; let oid = self.make_oid(self.object_counter); let code = format!( @@ -953,6 +1095,7 @@ impl ObscuraJsRuntime { &mut self, js_expression: &str, ) -> Result { + self.begin_javascript_task(); self.object_counter += 1; let oid = self.make_oid(self.object_counter); let code = format!( @@ -1065,6 +1208,7 @@ impl ObscuraJsRuntime { budget_ms: u64, what: &str, ) -> Result<(), String> { + self.begin_javascript_task(); let budget = tokio::time::Duration::from_millis(budget_ms); let result = self.runtime.mod_evaluate(module_id); tokio::pin!(result); @@ -1185,6 +1329,7 @@ impl ObscuraJsRuntime { } fn execute_classic_script(&mut self, name: &str, source: &str) -> Result<(), String> { + self.begin_javascript_task(); // JsRuntime::execute_script in deno_core 0.350 restricts `name` to a // &'static str. Browser script URLs are runtime data, and V8 uses this // origin as import()'s referrer, so compile in the runtime's main @@ -1309,6 +1454,7 @@ impl ObscuraJsRuntime { } pub async fn run_event_loop(&mut self) -> Result<(), String> { + self.begin_javascript_task(); // A browser performs a microtask checkpoint at the end of each task. // deno_core's event loop may return immediately when no async op is // pending, leaving an already-resolved Promise continuation stranded @@ -1329,10 +1475,21 @@ impl ObscuraJsRuntime { /// Rust reads it through a hidden status function so page declarations /// cannot collide with or overwrite the queue itself. pub fn has_pending_dynamic_scripts(&mut self) -> bool { - self.evaluate("globalThis.__obscura_hasPendingDynamicScripts?.() === true") + let pending_dom_script = self + .evaluate("globalThis.__obscura_hasPendingDynamicScripts?.() === true") .ok() .and_then(|value| value.as_bool()) - .unwrap_or(false) + .unwrap_or(false); + // A short tail bridges the parser/evaluator hand-off between one + // fetched module and the next dependency. It is below the lifecycle's + // existing 500ms fast-settle floor, so static entry graphs pay no new + // latency while lazy import graphs remain observable. deno_core's + // dynamic-module evaluation/TLA counters are private; the event-loop + // pump itself remains responsible for that non-fetch portion. + pending_dom_script + || self + .module_load_activity + .is_pending_or_recent(std::time::Duration::from_millis(100)) } /// Generation of observable connected-document mutations. This excludes @@ -1518,6 +1675,7 @@ impl ObscuraJsRuntime { if timeout.is_zero() { return self.evaluate(expression); } + self.begin_javascript_task(); let wrapped = Self::wrap_expression(expression); let token = self.arm_watchdog(timeout); let result = self.runtime.execute_script("", wrapped); @@ -1537,6 +1695,7 @@ impl ObscuraJsRuntime { } pub async fn resolve_promises(&mut self) { + self.begin_javascript_task(); // Default settle: just pump until idle or 5s. let _ = tokio::time::timeout( tokio::time::Duration::from_secs(5), @@ -1565,6 +1724,7 @@ impl ObscuraJsRuntime { tokio::time::Instant::now() + tokio::time::Duration::from_millis(max_total_ms); let mut tick_ms: u64 = 1; loop { + self.begin_javascript_task(); if done_check(self) { return; } @@ -6092,6 +6252,30 @@ mod tests { assert_eq!(after[2].as_f64(), Some(80.0)); } + #[cfg(feature = "render")] + #[test] + fn element_text_content_replacement_recomputes_empty_selector() { + let mut rt = ObscuraJsRuntime::new(); + rt.set_dom(parse_html( + r#" +
text
"#, + )); + rt.run_page_init(); + + assert_eq!( + rt.evaluate( + r#"(() => { + const x = document.getElementById("x"); + const before = x.getBoundingClientRect().width; + x.textContent = ""; + return [before, x.matches(":empty"), x.getBoundingClientRect().width]; + })()"#, + ) + .unwrap(), + serde_json::json!([10, true, 30]) + ); + } + #[cfg(feature = "render")] #[test] fn prepared_render_survives_detached_no_op_and_same_viewport_updates() { @@ -6177,6 +6361,433 @@ mod tests { ); } + #[cfg(feature = "render")] + #[test] + fn forward_animation_samples_retain_static_prepared_render() { + let mut rt = ObscuraJsRuntime::new(); + rt.set_dom(parse_html( + r#" +
+ "#, + )); + rt.set_url("http://example.test/page"); + rt.set_viewport(80.0, 60.0); + rt.run_page_init(); + + assert!(rt.set_animation_sample_time(obscura_render::AnimationSampleTime { + milliseconds: 100.0, + })); + let first = rt + .screenshot_prepared((80.0, 60.0), Some("http://example.test/page")) + .expect("first static frame"); + let prepared_address = { + let state = rt.state.borrow(); + state.prepared_render.as_ref().unwrap() as *const _ as usize + }; + + assert!(rt.set_animation_sample_time(obscura_render::AnimationSampleTime { + milliseconds: 250.0, + })); + let second = rt + .screenshot_prepared((80.0, 60.0), Some("http://example.test/page")) + .expect("second static frame"); + let state = rt.state.borrow(); + assert_eq!( + state.prepared_render.as_ref().unwrap() as *const _ as usize, + prepared_address, + "a live timestamp alone must not relayout a static document" + ); + assert_eq!(first, second); + } + + #[cfg(feature = "render")] + #[test] + fn completed_animation_retains_forward_frame_but_backward_seek_rebuilds() { + let mut rt = ObscuraJsRuntime::new(); + rt.set_dom(parse_html( + r#"
"#, + )); + rt.set_url("http://example.test/page"); + rt.set_viewport(80.0, 60.0); + rt.run_page_init(); + + assert!(rt.set_animation_sample_time(obscura_render::AnimationSampleTime { + milliseconds: 150.0, + })); + let completed = rt + .screenshot_prepared((80.0, 60.0), Some("http://example.test/page")) + .expect("completed animation frame"); + assert!(!rt.prepared_has_active_css_animations()); + let prepared_address = { + let state = rt.state.borrow(); + state.prepared_render.as_ref().unwrap() as *const _ as usize + }; + + assert!(rt.set_animation_sample_time(obscura_render::AnimationSampleTime { + milliseconds: 300.0, + })); + let later = rt + .screenshot_prepared((80.0, 60.0), Some("http://example.test/page")) + .expect("later completed frame"); + assert_eq!(completed, later); + assert_eq!( + rt.state + .borrow() + .prepared_render + .as_ref() + .unwrap() as *const _ as usize, + prepared_address, + "a finite fill-forwards animation must not relayout after completion" + ); + + assert!(rt.set_animation_sample_time(obscura_render::AnimationSampleTime { + milliseconds: 0.0, + })); + assert!( + rt.state.borrow().prepared_render.is_none(), + "backward timeline seeks must invalidate the completed frame" + ); + let initial = rt + .screenshot_prepared((80.0, 60.0), Some("http://example.test/page")) + .expect("initial animation frame"); + assert_ne!(completed, initial); + assert!(rt.prepared_has_active_css_animations()); + } + + #[cfg(feature = "render")] + #[test] + fn unsupported_custom_property_animation_does_not_keep_render_damage_active() { + let mut rt = ObscuraJsRuntime::new(); + rt.set_dom(parse_html( + r#" +
+ "#, + )); + rt.set_url("http://example.test/page"); + rt.set_viewport(80.0, 60.0); + rt.run_page_init(); + + let first = rt + .screenshot_prepared((80.0, 60.0), Some("http://example.test/page")) + .expect("initial frame"); + assert!( + !rt.prepared_has_active_css_animations(), + "an unsupported custom-property-only animation has no render damage" + ); + let prepared_address = { + let state = rt.state.borrow(); + state.prepared_render.as_ref().unwrap() as *const _ as usize + }; + assert!(rt.set_animation_sample_time(obscura_render::AnimationSampleTime { + milliseconds: 5_000.0, + })); + let later = rt + .screenshot_prepared((80.0, 60.0), Some("http://example.test/page")) + .expect("later frame"); + assert_eq!(first, later); + assert_eq!( + rt.state.borrow().prepared_render.as_ref().unwrap() as *const _ as usize, + prepared_address + ); + } + + #[cfg(feature = "render")] + fn animation_test_width(rt: &ObscuraJsRuntime, id: &str) -> f32 { + let state = rt.state.borrow(); + let dom = state.dom.as_ref().unwrap(); + let node = dom.query_selector(&format!("#{id}")).unwrap().unwrap(); + match state.prepared_render.as_ref().unwrap().layout().styles[&node].width { + obscura_render::Dimension::Px(width) => width, + ref other => panic!("expected animated pixel width, got {other:?}"), + } + } + + #[cfg(feature = "render")] + fn animation_epoch_runtime() -> ObscuraJsRuntime { + let mut rt = ObscuraJsRuntime::new(); + rt.set_dom(parse_html( + r#""#, + )); + rt.set_url("http://example.test/page"); + rt.set_viewport(200.0, 80.0); + rt.run_page_init(); + rt + } + + #[cfg(feature = "render")] + #[test] + fn remove_and_reappend_restarts_animation_without_intermediate_flush() { + let mut rt = animation_epoch_runtime(); + rt.evaluate("var box=document.createElement('div');box.id='box';box.className='anim';document.body.appendChild(box)") + .unwrap(); + assert!(rt.set_animation_sample(obscura_render::AnimationSample::document(1_000.0))); + rt.screenshot_prepared((200.0, 80.0), Some("http://example.test/page")) + .unwrap(); + assert!(animation_test_width(&rt, "box") > 95.0); + + rt.state.borrow_mut().animation_timeline_origin = + std::time::Instant::now() - std::time::Duration::from_millis(1_000); + rt.evaluate("var box=document.getElementById('box');box.remove();document.body.appendChild(box)") + .unwrap(); + assert!(rt.set_animation_sample(obscura_render::AnimationSample::document(1_100.0))); + rt.screenshot_prepared((200.0, 80.0), Some("http://example.test/page")) + .unwrap(); + let restarted = animation_test_width(&rt, "box"); + assert!((5.0..20.0).contains(&restarted), "restarted width={restarted}"); + } + + #[cfg(feature = "render")] + #[test] + fn scoped_animation_epochs_survive_later_unrelated_mutations_and_t0_capture() { + let mut rt = animation_epoch_runtime(); + rt.state.borrow_mut().animation_timeline_origin = + std::time::Instant::now() - std::time::Duration::from_millis(100); + rt.evaluate("var a=document.createElement('div');a.id='first';a.className='anim';document.body.appendChild(a)") + .unwrap(); + rt.state.borrow_mut().animation_timeline_origin = + std::time::Instant::now() - std::time::Duration::from_millis(500); + rt.evaluate("var b=document.createElement('div');b.id='second';b.className='anim';document.body.appendChild(b)") + .unwrap(); + rt.state.borrow_mut().animation_timeline_origin = + std::time::Instant::now() - std::time::Duration::from_millis(600); + rt.evaluate("document.getElementById('anchor').setAttribute('data-unrelated','yes')") + .unwrap(); + + assert!(rt.set_animation_sample(obscura_render::AnimationSample::local_override(0.0))); + rt.screenshot_prepared((200.0, 80.0), Some("http://example.test/page")) + .unwrap(); + assert_eq!(animation_test_width(&rt, "first"), 0.0); + assert_eq!(animation_test_width(&rt, "second"), 0.0); + + assert!(rt.set_animation_sample(obscura_render::AnimationSample::document(700.0))); + rt.screenshot_prepared((200.0, 80.0), Some("http://example.test/page")) + .unwrap(); + let first = animation_test_width(&rt, "first"); + let second = animation_test_width(&rt, "second"); + assert!((55.0..65.0).contains(&first), "first width={first}"); + assert!((15.0..25.0).contains(&second), "second width={second}"); + } + + #[cfg(feature = "render")] + #[test] + fn timing_edits_preserve_identity_and_pause_holds_then_resumes() { + let mut rt = animation_epoch_runtime(); + rt.evaluate("var box=document.createElement('div');box.id='box';box.className='anim';document.body.appendChild(box)") + .unwrap(); + assert!(rt.set_animation_sample(obscura_render::AnimationSample::document(300.0))); + rt.screenshot_prepared((200.0, 80.0), Some("http://example.test/page")) + .unwrap(); + assert!((25.0..35.0).contains(&animation_test_width(&rt, "box"))); + + rt.state.borrow_mut().animation_timeline_origin = + std::time::Instant::now() - std::time::Duration::from_millis(300); + rt.evaluate("document.getElementById('box').setAttribute('style','animation-duration:2000ms;animation-play-state:paused')") + .unwrap(); + assert!(rt.set_animation_sample(obscura_render::AnimationSample::document(700.0))); + rt.screenshot_prepared((200.0, 80.0), Some("http://example.test/page")) + .unwrap(); + let held = animation_test_width(&rt, "box"); + assert!((12.0..18.0).contains(&held), "held width={held}"); + + rt.state.borrow_mut().animation_timeline_origin = + std::time::Instant::now() - std::time::Duration::from_millis(800); + rt.evaluate("document.getElementById('box').setAttribute('style','animation-duration:2000ms;animation-play-state:running')") + .unwrap(); + assert!(rt.set_animation_sample(obscura_render::AnimationSample::document(1_000.0))); + rt.screenshot_prepared((200.0, 80.0), Some("http://example.test/page")) + .unwrap(); + let resumed = animation_test_width(&rt, "box"); + assert!((22.0..28.0).contains(&resumed), "resumed width={resumed}"); + } + + #[cfg(feature = "render")] + #[test] + fn cssom_geometry_samples_live_document_time() { + let mut rt = animation_epoch_runtime(); + rt.evaluate("var box=document.createElement('div');box.id='box';box.className='anim';document.body.appendChild(box)") + .unwrap(); + let initial = rt + .evaluate("document.getElementById('box').getBoundingClientRect().width") + .unwrap() + .as_f64() + .unwrap(); + std::thread::sleep(std::time::Duration::from_millis(120)); + let later = rt + .evaluate("document.getElementById('box').getBoundingClientRect().width") + .unwrap() + .as_f64() + .unwrap(); + assert!(later >= initial + 8.0, "initial={initial}, later={later}"); + } + + #[cfg(feature = "render")] + #[test] + fn cssom_animation_sample_is_frozen_within_one_javascript_task() { + let mut rt = animation_epoch_runtime(); + rt.evaluate("var box=document.createElement('div');box.id='box';box.className='anim';document.body.appendChild(box)") + .unwrap(); + let values = rt + .evaluate( + r#"(function(){ + const box = document.getElementById('box'); + const first = box.getBoundingClientRect().width; + const deadline = Date.now() + 120; + while (Date.now() < deadline) {} + return [first, box.getBoundingClientRect().width]; + })()"#, + ) + .unwrap(); + let widths = values.as_array().unwrap(); + assert_eq!( + widths[0].as_f64(), + widths[1].as_f64(), + "forced layout reads in one long task must share one animation frame" + ); + } + + #[cfg(feature = "render")] + #[tokio::test(flavor = "current_thread")] + async fn timer_callback_starts_a_fresh_lazy_animation_sample() { + let mut rt = animation_epoch_runtime(); + rt.execute_script( + "timer-animation-sample", + r#" + var box=document.createElement('div'); + box.id='box';box.className='anim';document.body.appendChild(box); + globalThis.__beforeTimerWidth=box.getBoundingClientRect().width; + setTimeout(() => { + globalThis.__afterTimerWidth=box.getBoundingClientRect().width; + }, 100); + "#, + ) + .unwrap(); + rt.run_event_loop_bounded(250).await.unwrap(); + let values = rt + .evaluate("[globalThis.__beforeTimerWidth, globalThis.__afterTimerWidth]") + .unwrap(); + let widths = values.as_array().unwrap(); + let before = widths[0].as_f64().unwrap(); + let after = widths[1].as_f64().unwrap(); + assert!(after >= before + 7.0, "before={before}, after={after}"); + } + + #[cfg(feature = "render")] + #[test] + fn autocomplete_attribute_retains_prepared_render_until_geometry_flush() { + let dom = parse_html( + r#" + + "#, + ); + let mut rt = ObscuraJsRuntime::new(); + rt.set_dom(dom); + rt.set_viewport(200.0, 100.0); + rt.run_page_init(); + + assert_eq!( + rt.evaluate("document.getElementById('field').getBoundingClientRect().width") + .unwrap() + .as_f64(), + Some(40.0) + ); + assert!(rt.state.borrow().prepared_render.is_some()); + + rt.evaluate("document.getElementById('field').setAttribute('autocomplete', 'off')") + .unwrap(); + { + let state = rt.state.borrow(); + assert!( + state.prepared_render.is_some(), + "ordinary selector attributes must retain the prepared render until flush" + ); + assert!(matches!( + state.pending_style_mutations.as_slice(), + [obscura_render::RetainedStyleMutation::Attribute( + obscura_render::AttributeStyleMutation { name, old_value, new_value, .. } + )] if name == "autocomplete" + && old_value.as_deref() == Some("on") + && new_value.as_deref() == Some("off") + )); + } + + assert_eq!( + rt.evaluate("document.getElementById('field').getBoundingClientRect().width") + .unwrap() + .as_f64(), + Some(90.0), + "the retained selector invalidation must observe the new attribute value" + ); + assert!(rt.state.borrow().pending_style_mutations.is_empty()); + } + + #[cfg(feature = "render")] + #[test] + fn namespaced_attribute_mutations_participate_in_id_and_render_invalidation() { + let dom = parse_html( + r#"
"#, + ); + let mut rt = ObscuraJsRuntime::new(); + rt.set_dom(dom); + rt.set_viewport(200.0, 100.0); + rt.run_page_init(); + + assert_eq!( + rt.evaluate("document.getElementById('box').getBoundingClientRect().height") + .unwrap() + .as_f64(), + Some(30.0) + ); + assert!(rt.state.borrow().prepared_render.is_some()); + + rt.evaluate( + "document.getElementById('box').setAttributeNS(null, 'class', 'box')", + ) + .unwrap(); + assert!( + rt.state.borrow().prepared_render.is_some(), + "an identical null-namespace attribute must retain layout" + ); + + rt.evaluate( + "document.getElementById('box').setAttributeNS(null, 'style', 'height:70px;width:40px')", + ) + .unwrap(); + assert!( + rt.state.borrow().prepared_render.is_none(), + "a connected namespace-aware style mutation must invalidate layout" + ); + assert_eq!( + rt.evaluate("document.getElementById('box').getBoundingClientRect().height") + .unwrap() + .as_f64(), + Some(70.0) + ); + + let id_result = rt + .evaluate( + "(function(){const box=document.getElementById('box');box.setAttributeNS(null,'id','renamed');const found=document.getElementById('renamed')===box;box.removeAttributeNS(null,'id');return found && document.getElementById('renamed')===null;})()", + ) + .unwrap(); + assert_eq!(id_result, serde_json::json!(true)); + } + #[cfg(feature = "render")] #[test] fn stylesheet_index_cache_reuses_sources_but_not_live_cascade_or_viewport() { @@ -7943,6 +8554,270 @@ mod tests { .unwrap() } + #[cfg(feature = "render")] + #[tokio::test(flavor = "current_thread")] + async fn parser_images_load_concurrently_without_blocking_the_event_loop() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let accepted = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let active = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let max_active = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let server_accepted = accepted.clone(); + let server_active = active.clone(); + let server_max_active = max_active.clone(); + let png = two_by_three_png(); + std::thread::spawn(move || { + for _ in 0..4 { + let (mut stream, _) = listener.accept().unwrap(); + server_accepted.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let active = server_active.clone(); + let max_active = server_max_active.clone(); + let png = png.clone(); + std::thread::spawn(move || { + use std::io::{Read as _, Write as _}; + + let mut request = [0u8; 2048]; + let _ = stream.read(&mut request); + let concurrent = active.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1; + max_active.fetch_max(concurrent, std::sync::atomic::Ordering::SeqCst); + std::thread::sleep(std::time::Duration::from_millis(150)); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + png.len() + ); + stream.write_all(response.as_bytes()).unwrap(); + stream.write_all(&png).unwrap(); + active.fetch_sub(1, std::sync::atomic::Ordering::SeqCst); + }); + } + }); + + let base = format!("http://{address}"); + let html = format!( + r#" + + "# + ); + let mut rt = ObscuraJsRuntime::new(); + rt.set_dom(parse_html(&html)); + rt.set_url(&format!("{base}/page.html")); + rt.set_http_client(std::sync::Arc::new( + obscura_net::ObscuraHttpClient::with_full_options( + std::sync::Arc::new(obscura_net::CookieJar::new()), + None, + true, + ), + )); + rt.run_page_init(); + + let started = std::time::Instant::now(); + let result = rt + .evaluate_for_cdp( + r#" + new Promise(resolve => { + globalThis.__imageTimerRan = false; + setTimeout(() => { __imageTimerRan = true; }, 10); + const images = Array.from(document.images); + const events = []; + const finish = (type, image) => { + events.push([ + type, + __imageTimerRan, + image.naturalWidth, + image.naturalHeight, + ]); + if (events.length === images.length) resolve(events); + }; + for (const image of images) { + image.addEventListener("load", () => finish("load", image)); + image.addEventListener("error", () => finish("error", image)); + void image.complete; + } + setTimeout(() => resolve([["timed out"]]), 2000); + }) + "#, + true, + true, + ) + .await + .unwrap(); + let elapsed = started.elapsed(); + assert_eq!( + result.value.unwrap(), + serde_json::json!([ + ["load", true, 2, 3], + ["load", true, 2, 3], + ["load", true, 2, 3], + ["load", true, 2, 3], + ["load", true, 2, 3], + ]) + ); + assert_eq!( + accepted.load(std::sync::atomic::Ordering::SeqCst), + 4, + "two elements selecting one URL must share a single request" + ); + assert!( + max_active.load(std::sync::atomic::Ordering::SeqCst) >= 3, + "slow image requests did not overlap" + ); + assert!( + elapsed < std::time::Duration::from_millis(500), + "four 150ms image requests serialized: {elapsed:?}" + ); + assert_eq!( + rt.state + .borrow() + .page_in_flight + .load(std::sync::atomic::Ordering::SeqCst), + 0 + ); + } + + #[cfg(feature = "render")] + #[tokio::test(flavor = "current_thread")] + async fn image_lifecycle_cache_is_separated_by_cors_credentials_profile() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let requests = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let server_requests = requests.clone(); + let png = two_by_three_png(); + std::thread::spawn(move || { + use std::io::{Read as _, Write as _}; + for _ in 0..4 { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = [0u8; 4096]; + let read = stream.read(&mut request).unwrap(); + let request = String::from_utf8_lossy(&request[..read]).to_ascii_lowercase(); + let path = request + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .unwrap_or("") + .to_string(); + let mode = request + .lines() + .find_map(|line| line.strip_prefix("sec-fetch-mode: ")) + .unwrap_or("") + .trim() + .to_string(); + server_requests.lock().unwrap().push((path.clone(), mode)); + let cors_headers = if path == "/cors.png" { + // Anonymous accepts wildcard; use-credentials must reject + // it even when credentials permission is also present. + "Access-Control-Allow-Origin: *\r\nAccess-Control-Allow-Credentials: true\r\n" + } else { + "" + }; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: image/png\r\n{cors_headers}Content-Length: {}\r\nConnection: close\r\n\r\n", + png.len() + ); + stream.write_all(response.as_bytes()).unwrap(); + stream.write_all(&png).unwrap(); + } + }); + + let base = format!("http://{address}"); + let mut rt = ObscuraJsRuntime::new(); + rt.set_dom(parse_html(&format!(r#""#))); + // Deliberately make the image cross-origin from the document. + rt.set_url("http://127.0.0.1:1/page.html"); + rt.set_http_client(std::sync::Arc::new( + obscura_net::ObscuraHttpClient::with_full_options( + std::sync::Arc::new(obscura_net::CookieJar::new()), + None, + true, + ), + )); + rt.run_page_init(); + rt.execute_script( + "observe-profiled-image", + r#" + globalThis.image = document.getElementById("image"); + globalThis.__profileEvents = []; + image.addEventListener("load", () => __profileEvents.push("load")); + image.addEventListener("error", () => __profileEvents.push("error")); + void image.complete; + "#, + ) + .unwrap(); + rt.run_event_loop_bounded(100).await.unwrap(); + assert_eq!( + rt.evaluate("[image.complete, image.naturalWidth, __profileEvents]") + .unwrap(), + serde_json::json!([true, 2, ["load"]]) + ); + + rt.execute_script("require-anonymous-cors", r#"image.crossOrigin = "anonymous";"#) + .unwrap(); + rt.run_event_loop_bounded(100).await.unwrap(); + assert_eq!( + rt.evaluate("[image.complete, image.naturalWidth, __profileEvents]") + .unwrap(), + serde_json::json!([true, 0, ["load", "error"]]), + "URL-keyed no-CORS bytes must not satisfy an anonymous CORS request" + ); + + rt.execute_script("restore-no-cors", "image.removeAttribute('crossorigin');") + .unwrap(); + rt.run_event_loop_bounded(100).await.unwrap(); + assert_eq!( + rt.evaluate("[image.complete, image.naturalWidth, __profileEvents]") + .unwrap(), + serde_json::json!([true, 2, ["load", "error", "load"]]), + "a CORS failure must not poison the earlier no-CORS success" + ); + + rt.execute_script( + "load-anonymous-cors", + &format!(r#"image.crossOrigin = "anonymous"; image.src = "{base}/cors.png";"#), + ) + .unwrap(); + rt.run_event_loop_bounded(100).await.unwrap(); + assert_eq!( + rt.evaluate("[image.complete, image.naturalWidth, __profileEvents]") + .unwrap(), + serde_json::json!([true, 2, ["load", "error", "load", "load"]]) + ); + + rt.execute_script( + "require-credentialed-cors", + r#"image.crossOrigin = "use-credentials";"#, + ) + .unwrap(); + rt.run_event_loop_bounded(100).await.unwrap(); + assert_eq!( + rt.evaluate("[image.complete, image.naturalWidth, __profileEvents]") + .unwrap(), + serde_json::json!([true, 0, ["load", "error", "load", "load", "error"]]), + "anonymous CORS success must not satisfy use-credentials" + ); + + rt.execute_script( + "restore-anonymous-cors", + r#"image.crossOrigin = "anonymous";"#, + ) + .unwrap(); + rt.run_event_loop_bounded(100).await.unwrap(); + assert_eq!( + rt.evaluate("[image.complete, image.naturalWidth, __profileEvents]") + .unwrap(), + serde_json::json!([true, 2, ["load", "error", "load", "load", "error", "load"]]), + "credentialed CORS failure must not poison anonymous success" + ); + + assert_eq!( + *requests.lock().unwrap(), + vec![ + ("/plain.png".to_string(), "no-cors".to_string()), + ("/plain.png".to_string(), "cors".to_string()), + ("/cors.png".to_string(), "cors".to_string()), + ("/cors.png".to_string(), "cors".to_string()), + ] + ); + } + #[cfg(feature = "render")] #[tokio::test(flavor = "current_thread")] async fn parser_image_data_src_mutation_does_not_restart_lifecycle() { diff --git a/crates/obscura-render/src/css.rs b/crates/obscura-render/src/css.rs index ce176d9..1806ece 100644 --- a/crates/obscura-render/src/css.rs +++ b/crates/obscura-render/src/css.rs @@ -1290,6 +1290,19 @@ fn note_compound_dependencies( } } } + "dir" | "lang" => { + // The corresponding HTML attributes inherit through + // descendants, while the functional selector may + // observe language/direction resolved above the + // mutated node. Keep the dependency keyed, but require + // the retained planner's sound full fallback. + map.push_state( + name, + rule_order, + InvalidationReaches::CONSERVATIVE, + ); + map.mark_conservative(rule_order); + } _ => { // A compiled functional pseudo outside the explicitly // modeled set may hide selector or document state. @@ -1449,6 +1462,8 @@ fn note_declaration_attribute_dependencies( fn selector_requires_conservative_tracking(selector: &str) -> bool { let selector = selector.to_ascii_lowercase(); selector.contains(":has(") + || selector.contains(":dir(") + || selector.contains(":lang(") || selector.contains(":nth-child(") || selector.contains(":nth-last-child(") || selector.contains(":nth-of-type(") @@ -1638,6 +1653,86 @@ impl PseudoRuleMap { } self.rules.push(rule); } + + fn bucket_matches_container_query_rule( + &self, + bucket: Option<&Vec>, + tree: &DomTree, + matcher: &mut Matcher, + nid: NodeId, + ) -> bool { + bucket.is_some_and(|indices| { + indices.iter().copied().any(|index| { + let rule = &self.rules[index]; + rule.container_condition_id != ContainerConditionId::NONE + && (rule.candidate_slot == NO_CANDIDATE_SLOT + || matcher.mark_candidate(rule.candidate_slot as usize)) + && matcher.matches(tree, nid, &rule.sel) + }) + }) + } + + fn node_matches_container_query_rule( + &self, + tree: &DomTree, + matcher: &mut Matcher, + nid: NodeId, + ) -> bool { + if self.candidate_slot_count != 0 { + matcher.begin_candidate_collection(self.candidate_slot_count); + } + let Some(node) = tree.get_node(nid) else { + return false; + }; + node.as_element().is_some_and(|element| { + self.bucket_matches_container_query_rule( + self.by_local.get(element.local.as_ref()), + tree, + matcher, + nid, + ) || (is_root_element(tree, nid) + && self.bucket_matches_container_query_rule( + (!self.by_root.is_empty()).then_some(&self.by_root), + tree, + matcher, + nid, + )) + || node.get_attribute("id").is_some_and(|id| { + self.bucket_matches_container_query_rule( + self.by_id.get(id), + tree, + matcher, + nid, + ) + }) + || node.get_attribute("class").is_some_and(|classes| { + classes.split_whitespace().any(|class| { + self.bucket_matches_container_query_rule( + self.by_class.get(class), + tree, + matcher, + nid, + ) + }) + }) + || node.attrs().is_some_and(|attributes| { + attributes.iter().any(|attribute| { + self.bucket_matches_container_query_rule( + self.by_attribute.get(attribute.name.local.as_ref()), + tree, + matcher, + nid, + ) + }) + }) + || self.bucket_matches_container_query_rule( + (!self.universal.is_empty()).then_some(&self.universal), + tree, + matcher, + nid, + ) + }) + } } /// A cascade layer's first-declaration position at each nesting level. @@ -2330,6 +2425,98 @@ impl Stylesheet { .any(|rule| rule.container_condition_id != ContainerConditionId::NONE) } + /// Whether `nid` can receive a declaration from any container-conditional + /// rule in the current DOM state. Retained style uses this to reset only + /// conditional subjects (and their inherited subtrees) for its query-free + /// seed pass instead of discarding every clean computed style merely + /// because the document owns a query container. + fn bucket_matches_container_query_rule( + &self, + bucket: Option<&Vec>, + tree: &DomTree, + matcher: &mut Matcher, + nid: NodeId, + ) -> bool { + bucket.is_some_and(|indices| { + indices.iter().copied().any(|index| { + let rule = &self.rules[index]; + rule.container_condition_id != ContainerConditionId::NONE + && (rule.candidate_slot == NO_CANDIDATE_SLOT + || matcher.mark_candidate(rule.candidate_slot as usize)) + && matcher.matches(tree, nid, &rule.sel) + }) + }) + } + + pub(crate) fn node_matches_container_query_rule( + &self, + tree: &DomTree, + matcher: &mut Matcher, + nid: NodeId, + ) -> bool { + if self.candidate_slot_count != 0 { + matcher.begin_candidate_collection(self.candidate_slot_count); + } + let Some(node) = tree.get_node(nid) else { + return false; + }; + let normal_match = node.as_element().is_some_and(|element| { + self.bucket_matches_container_query_rule( + self.by_local.get(element.local.as_ref()), + tree, + matcher, + nid, + ) || (is_root_element(tree, nid) + && self.bucket_matches_container_query_rule( + (!self.by_root.is_empty()).then_some(&self.by_root), + tree, + matcher, + nid, + )) + || node.get_attribute("id").is_some_and(|id| { + self.bucket_matches_container_query_rule( + self.by_id.get(id), + tree, + matcher, + nid, + ) + }) + || node.get_attribute("class").is_some_and(|classes| { + classes.split_whitespace().any(|class| { + self.bucket_matches_container_query_rule( + self.by_class.get(class), + tree, + matcher, + nid, + ) + }) + }) + || node.attrs().is_some_and(|attributes| { + attributes.iter().any(|attribute| { + self.bucket_matches_container_query_rule( + self.by_attribute.get(attribute.name.local.as_ref()), + tree, + matcher, + nid, + ) + }) + }) + || self.bucket_matches_container_query_rule( + (!self.universal.is_empty()).then_some(&self.universal), + tree, + matcher, + nid, + ) + }); + normal_match + || self + .before_rules + .node_matches_container_query_rule(tree, matcher, nid) + || self + .after_rules + .node_matches_container_query_rule(tree, matcher, nid) + } + pub(crate) fn container_condition_depth(&self) -> usize { self.container_conditions .iter() @@ -2860,6 +3047,39 @@ impl Stylesheet { style: &mut LayoutStyle, parent_props: &HashMap, inline_css: Option<&str>, + ) -> Option> { + let mut animation_timeline = crate::AnimationTimelineState::default(); + self.apply_at_animation_time( + tree, + matcher, + nid, + id, + classes, + local, + style, + parent_props, + inline_css, + crate::AnimationSample { + time: self.animation_sample_time, + mode: crate::AnimationSampleMode::DocumentTime, + }, + &mut animation_timeline, + ) + } + + pub(crate) fn apply_at_animation_time( + &self, + tree: &DomTree, + matcher: &mut Matcher, + nid: NodeId, + id: Option<&str>, + classes: &[String], + local: &str, + style: &mut LayoutStyle, + parent_props: &HashMap, + inline_css: Option<&str>, + animation_sample: crate::AnimationSample, + animation_timeline: &mut crate::AnimationTimelineState, ) -> Option> { self.apply_internal( tree, @@ -2872,9 +3092,12 @@ impl Stylesheet { parent_props, inline_css, None, + animation_sample, + animation_timeline, ) } + #[cfg_attr(not(test), allow(dead_code))] pub(crate) fn apply_with_container_queries( &self, tree: &DomTree, @@ -2887,6 +3110,41 @@ impl Stylesheet { parent_props: &HashMap, inline_css: Option<&str>, evaluator: &mut ContainerQueryEvaluator<'_>, + ) -> Option> { + let mut animation_timeline = crate::AnimationTimelineState::default(); + self.apply_with_container_queries_at_animation_time( + tree, + matcher, + nid, + id, + classes, + local, + style, + parent_props, + inline_css, + evaluator, + crate::AnimationSample { + time: self.animation_sample_time, + mode: crate::AnimationSampleMode::DocumentTime, + }, + &mut animation_timeline, + ) + } + + pub(crate) fn apply_with_container_queries_at_animation_time( + &self, + tree: &DomTree, + matcher: &mut Matcher, + nid: NodeId, + id: Option<&str>, + classes: &[String], + local: &str, + style: &mut LayoutStyle, + parent_props: &HashMap, + inline_css: Option<&str>, + evaluator: &mut ContainerQueryEvaluator<'_>, + animation_sample: crate::AnimationSample, + animation_timeline: &mut crate::AnimationTimelineState, ) -> Option> { self.apply_internal( tree, @@ -2899,6 +3157,8 @@ impl Stylesheet { parent_props, inline_css, Some(evaluator), + animation_sample, + animation_timeline, ) } @@ -2914,6 +3174,8 @@ impl Stylesheet { parent_props: &HashMap, inline_css: Option<&str>, mut evaluator: Option<&mut ContainerQueryEvaluator<'_>>, + animation_sample: crate::AnimationSample, + animation_timeline: &mut crate::AnimationTimelineState, ) -> Option> { // Keep the two cascade priorities separate from the outset. A typical // stylesheet has very few important declarations, so cloning and @@ -3265,6 +3527,7 @@ impl Stylesheet { || important_matched .iter() .any(|&(_, _, i)| self.rules[i].important_flags.has_animation); + style.animation_has_render_effect = false; if !self.keyframes.is_empty() && (style.animation_name.is_some() || important_has_animation) { let mut animation_style = style.clone(); @@ -3290,15 +3553,31 @@ impl Stylesheet { } if let Some(name) = animation_style.animation_name.as_deref() { if let Some(keyframes) = self.keyframes.get(name) { + style.animation_has_render_effect = !keyframes.tracks.is_empty(); + let local_sample = animation_timeline.sample_for( + nid, + crate::AnimationInstanceKey { + name: name.to_string(), + }, + animation_style.animation_timing.play_state, + animation_sample, + ); + style.animation_local_time_ms = local_sample.milliseconds; sample_animation_properties( keyframes, style, &animation_style.animation_timing, - self.animation_sample_time, + local_sample, props, ); + } else { + animation_timeline.clear_animation(nid, animation_sample); } + } else { + animation_timeline.clear_animation(nid, animation_sample); } + } else { + animation_timeline.clear_animation(nid, animation_sample); } for &(_, _, i) in &important_matched { @@ -4251,11 +4530,10 @@ fn animation_directed_progress( } else { duration * iterations }; - let local_time = if timing.play_state == crate::AnimationPlayState::Paused { - 0.0 - } else { - sample_time.milliseconds - }; + // The timeline owns pause/resume hold time. Keeping that state outside the + // sampler lets a running animation freeze at its current progress and + // later resume without manufacturing a new instance. + let local_time = sample_time.milliseconds; if !local_time.is_finite() { return None; } diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs index fa0c638..6f84d69 100644 --- a/crates/obscura-render/src/dom.rs +++ b/crates/obscura-render/src/dom.rs @@ -355,9 +355,9 @@ pub struct DomLayout { } /// One connected element attribute mutation eligible for conservative -/// retained-style invalidation. Attributes with independent HTML rendering -/// semantics (image sources, form state, inline style, and similar) still use -/// the ordinary full-layout path. +/// retained-style invalidation. The planner distinguishes ordinary selector +/// attributes from mapped presentation hints and attributes which can change +/// global stylesheets or loaded resources. #[derive(Clone, Debug, PartialEq, Eq)] pub struct AttributeStyleMutation { pub node: NodeId, @@ -366,6 +366,153 @@ pub struct AttributeStyleMutation { pub new_value: Option, } +/// How an attribute mutation participates in retained style. +/// +/// This classification is public so DOM bindings can decide whether to keep a +/// prepared render without maintaining a second, inevitably divergent HTML +/// attribute allowlist. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RetainedAttributeMutationKind { + /// Only selector matching or declaration `attr()` can observe the value. + Selector, + /// The renderer maps the attribute into computed style or a native box. + Subtree, + /// The attribute can replace global CSS/resources or has document-wide + /// semantics which cannot be represented by a bounded style dirty set. + Full, +} + +/// Classify an element attribute for conservative retained-style planning. +/// +/// Unknown attributes are deliberately selector-only. Custom elements and +/// frameworks routinely toggle attributes such as `autocomplete`, `part`, +/// and `itemprop`; forcing a document cascade for an unreferenced attribute is +/// both unnecessary and particularly costly during hydration. Known HTML +/// resource/global attributes remain an explicit full fallback. +pub fn retained_attribute_mutation_kind( + tree: &DomTree, + node: NodeId, + name: &str, +) -> RetainedAttributeMutationKind { + use RetainedAttributeMutationKind::{Full, Selector, Subtree}; + + let name = name.to_ascii_lowercase(); + if name == "style" { + return Subtree; + } + let Some(local) = tree.get_node(node).and_then(|node| { + node.as_element() + .map(|element| element.local.as_ref().to_ascii_lowercase()) + }) else { + return Full; + }; + + // These nodes feed document-global stylesheet, URL, metadata, or script + // state. A selector dirty set cannot describe the external side effect. + if (local == "base" && matches!(name.as_str(), "href" | "target")) + || (local == "meta" + && matches!(name.as_str(), "charset" | "content" | "http-equiv" | "name")) + || (local == "style" && matches!(name.as_str(), "media" | "type" | "title")) + || (local == "link" + && matches!( + name.as_str(), + "as" | "crossorigin" + | "disabled" + | "fetchpriority" + | "href" + | "hreflang" + | "imagesizes" + | "imagesrcset" + | "integrity" + | "media" + | "referrerpolicy" + | "rel" + | "sizes" + | "type" + )) + || (local == "script" + && matches!( + name.as_str(), + "async" + | "crossorigin" + | "defer" + | "fetchpriority" + | "integrity" + | "nomodule" + | "referrerpolicy" + | "src" + | "type" + )) + { + return Full; + } + + // Resource selection changes require the embedding runtime to rebuild its + // decoded-resource/intrinsic-size inputs, not merely recompute CSS. + if (local == "img" + && matches!( + name.as_str(), + "crossorigin" + | "decoding" + | "fetchpriority" + | "referrerpolicy" + | "sizes" + | "src" + | "srcset" + )) + || (local == "source" + && matches!( + name.as_str(), + "height" | "media" | "sizes" | "src" | "srcset" | "type" | "width" + )) + || (matches!(local.as_str(), "audio" | "video" | "track") + && matches!( + name.as_str(), + "crossorigin" + | "kind" + | "label" + | "media" + | "poster" + | "preload" + | "src" + | "srclang" + | "type" + )) + || (matches!(local.as_str(), "embed" | "iframe") + && matches!(name.as_str(), "src" | "srcdoc" | "type")) + || (local == "object" && matches!(name.as_str(), "data" | "type")) + || (matches!(local.as_str(), "image" | "use") + && matches!(name.as_str(), "href" | "xlink:href")) + { + return Full; + } + + // These values enter the UA/presentational cascade or are folded into a + // native control's final used style. Refreshing the complete subtree also + // covers inheritance and generated content while retaining clean peers. + if matches!( + name.as_str(), + "align" + | "bgcolor" + | "cellpadding" + | "cellspacing" + | "color" + | "height" + | "hidden" + | "valign" + | "viewbox" + | "width" + ) || (local == "input" && matches!(name.as_str(), "size" | "type" | "value")) + || (local == "select" && name == "size") + || (local == "textarea" && matches!(name.as_str(), "cols" | "rows" | "wrap")) + || matches!(name.as_str(), "dir" | "lang" | "xml:lang") + { + return Subtree; + } + + Selector +} + /// A connected tree change which can reuse computed styles from the previous /// render. Node ids are stable across detach/reparent operations; recording /// both parents lets the post-mutation traversal invalidate the old and new @@ -1931,6 +2078,8 @@ fn cascade_walk( container_evaluator: &mut Option<&mut crate::css::ContainerQueryEvaluator<'_>>, quirks_mode: bool, viewport: (f32, f32), + animation_sample: crate::AnimationSample, + animation_timeline: &mut crate::AnimationTimelineState, inherited_cell_padding: Option, inherited_color_scheme_dark: bool, fresh_styles: Option<&HashSet>, @@ -2082,7 +2231,7 @@ fn cascade_walk( .map(|c| c.split_whitespace().map(|s| s.to_string()).collect()) .unwrap_or_default(); let effective_props = if let Some(evaluator) = container_evaluator.as_deref_mut() { - sheet.apply_with_container_queries( + sheet.apply_with_container_queries_at_animation_time( tree, matcher, id, @@ -2093,9 +2242,11 @@ fn cascade_walk( parent_props, node.get_attribute("style"), evaluator, + animation_sample, + animation_timeline, ) } else { - sheet.apply( + sheet.apply_at_animation_time( tree, matcher, id, @@ -2105,6 +2256,8 @@ fn cascade_walk( &mut style, parent_props, node.get_attribute("style"), + animation_sample, + animation_timeline, ) }; if let Some(m) = effective_props { @@ -2162,6 +2315,8 @@ fn cascade_walk( container_evaluator, quirks_mode, viewport, + animation_sample, + animation_timeline, descendant_cell_padding, descendant_color_scheme_dark, fresh_styles, @@ -2425,6 +2580,63 @@ fn add_style_subtree(tree: &DomTree, root: NodeId, dirty: &mut HashSet) dirty.extend(tree.ancestors(root)); } +fn add_container_query_reset_scopes( + tree: &DomTree, + id: NodeId, + sheet: &crate::css::Stylesheet, + matcher: &mut obscura_dom::selector::Matcher, + active_containers: &HashSet, + inside_active_container: bool, + selected_ancestor: bool, + dirty: &mut HashSet, +) { + let is_element = tree.get_node(id).is_some_and(|node| node.is_element()); + let can_query_here = inside_active_container || active_containers.contains(&id); + let selected_here = !selected_ancestor + && can_query_here + && is_element + && sheet.node_matches_container_query_rule(tree, matcher, id); + let selected = selected_ancestor || selected_here; + if selected { + // The retained style is the previous converged, query-enabled value. + // The convergence seed pass deliberately disables query rules, so + // reset every possible subject and inherited descendant to its base + // cascade before geometry is sampled again. + dirty.insert(id); + } + if selected_here { + // Stop as soon as an already-recorded context node is reached. An + // earlier selected sibling necessarily inserted the rest of this same + // ancestor chain, so each ancestor is visited at most once. + let mut ancestor = tree.get_node(id).and_then(|node| node.parent); + while let Some(parent) = ancestor { + if !dirty.insert(parent) { + break; + } + ancestor = tree.get_node(parent).and_then(|node| node.parent); + } + } + if is_element { + matcher.push_ancestor(tree, id); + } + let children_inside_container = inside_active_container || active_containers.contains(&id); + for child in tree.children(id) { + add_container_query_reset_scopes( + tree, + child, + sheet, + matcher, + active_containers, + children_inside_container, + selected, + dirty, + ); + } + if is_element { + matcher.pop_ancestor(); + } +} + fn add_following_sibling_subtrees( tree: &DomTree, node: NodeId, @@ -2583,6 +2795,13 @@ fn add_relational_tree_invalidation( continue; } if invalidation.unrepresentable_outer_path { + if std::env::var_os("OBSCURA_RENDER_TIMING").is_some() { + eprintln!( + "[timing] retained-style fallback reason=relational-unrepresentable rule_order={} anchor={} mutation={mutation:?}", + invalidation.rule_order, + anchor.index(), + ); + } return false; } add_relational_anchor_scope( @@ -2959,6 +3178,13 @@ fn retained_style_plan( || node_is_style_text(tree, node, old_parent) || node_is_style_text(tree, node, Some(new_parent)) { + if std::env::var_os("OBSCURA_RENDER_TIMING").is_some() { + eprintln!( + "[timing] retained-style fallback reason=style-subtree-insert node={} old_parent={old_parent:?} new_parent={}", + node.index(), + new_parent.index(), + ); + } return RetainedStylePlan::Full; } if !add_relational_tree_invalidation(tree, map, mutation, &mut dirty) { @@ -2989,6 +3215,13 @@ fn retained_style_plan( if subtree_contains_style_element(tree, node) || node_is_style_text(tree, node, Some(old_parent)) { + if std::env::var_os("OBSCURA_RENDER_TIMING").is_some() { + eprintln!( + "[timing] retained-style fallback reason=style-subtree-remove node={} old_parent={}", + node.index(), + old_parent.index(), + ); + } return RetainedStylePlan::Full; } if !add_relational_tree_invalidation(tree, map, mutation, &mut dirty) { @@ -3002,6 +3235,12 @@ fn retained_style_plan( } TreeStyleMutation::Text { node, parent } => { if node_is_style_text(tree, node, parent) { + if std::env::var_os("OBSCURA_RENDER_TIMING").is_some() { + eprintln!( + "[timing] retained-style fallback reason=style-text node={} parent={parent:?}", + node.index(), + ); + } return RetainedStylePlan::Full; } if !add_relational_tree_invalidation(tree, map, mutation, &mut dirty) { @@ -3018,24 +3257,15 @@ fn retained_style_plan( continue; }; let name = mutation.name.to_ascii_lowercase(); - // Phase one deliberately excludes attributes with independent HTML - // rendering behavior. class/id and data/ARIA state cover framework - // hydration churn while source selection, form state, mapped - // presentation hints, and inline declarations retain the full path. - if name != "class" - && name != "id" - && name != "style" - && !name.starts_with("data-") - && !name.starts_with("aria-") - { - return RetainedStylePlan::Full; - } - - // Inline declarations always change this element's cascade and may - // change inherited values/custom properties throughout its subtree, - // even when no author selector explicitly mentions `[style]`. - if name == "style" { - add_style_subtree(tree, mutation.node, &mut dirty); + match retained_attribute_mutation_kind(tree, mutation.node, &name) { + RetainedAttributeMutationKind::Full => return RetainedStylePlan::Full, + RetainedAttributeMutationKind::Subtree => { + // Mapped hints and native-control sizing enter the mutable + // LayoutStyle graph. Re-cascade from specified values before + // post-cascade used-value repair runs again. + add_style_subtree(tree, mutation.node, &mut dirty); + } + RetainedAttributeMutationKind::Selector => {} } let map = sheet.invalidation_map(); @@ -3059,6 +3289,26 @@ fn retained_style_plan( } } + // Several HTML boolean/value attributes also back selector pseudo + // states. Include both sides of paired states so removing an attribute + // invalidates rules such as `:enabled` and `:optional`, not only the + // positive state. + let states: &[&str] = match name.as_str() { + "checked" | "selected" => &["checked"], + "disabled" => &["disabled", "enabled"], + "dir" => &["dir"], + "href" => &["any-link", "link", "visited"], + "lang" | "xml:lang" => &["lang"], + "open" => &["open"], + "placeholder" | "value" => &["placeholder-shown"], + "readonly" => &["read-only", "read-write"], + "required" => &["required", "optional"], + _ => &[], + }; + for state in states { + dependencies.extend_from_slice(map.state_dependencies(state)); + } + for dependency in dependencies { let reaches = dependency.reaches; if reaches.contains(crate::css::InvalidationReaches::CONSERVATIVE) { @@ -3086,6 +3336,7 @@ pub(crate) fn layout_dom_with_web_fonts( layout_dom_with_web_fonts_measured(tree, viewport, intrinsic, fonts).0 } +#[cfg_attr(not(test), allow(dead_code))] pub(crate) fn layout_dom_with_web_fonts_and_stylesheet_cache( tree: &DomTree, viewport: (f32, f32), @@ -3093,7 +3344,49 @@ pub(crate) fn layout_dom_with_web_fonts_and_stylesheet_cache( fonts: &[crate::inline::WebFont], stylesheet_cache: &mut crate::css::StylesheetCache, ) -> DomLayout { - layout_dom_with_web_fonts_pass_limit( + layout_dom_with_web_fonts_and_stylesheet_cache_at_animation_time( + tree, + viewport, + intrinsic, + fonts, + stylesheet_cache, + crate::AnimationSampleTime::default(), + ) +} + +pub(crate) fn layout_dom_with_web_fonts_and_stylesheet_cache_at_animation_time( + tree: &DomTree, + viewport: (f32, f32), + intrinsic: &HashMap, + fonts: &[crate::inline::WebFont], + stylesheet_cache: &mut crate::css::StylesheetCache, + animation_sample_time: crate::AnimationSampleTime, +) -> DomLayout { + let mut animation_timeline = crate::AnimationTimelineState::default(); + layout_dom_with_web_fonts_and_stylesheet_cache_with_animation_state( + tree, + viewport, + intrinsic, + fonts, + stylesheet_cache, + crate::AnimationSample { + time: animation_sample_time, + mode: crate::AnimationSampleMode::DocumentTime, + }, + &mut animation_timeline, + ) +} + +pub(crate) fn layout_dom_with_web_fonts_and_stylesheet_cache_with_animation_state( + tree: &DomTree, + viewport: (f32, f32), + intrinsic: &HashMap, + fonts: &[crate::inline::WebFont], + stylesheet_cache: &mut crate::css::StylesheetCache, + animation_sample: crate::AnimationSample, + animation_timeline: &mut crate::AnimationTimelineState, +) -> DomLayout { + layout_dom_with_web_fonts_pass_limit_at_animation_time( tree, viewport, intrinsic, @@ -3102,10 +3395,13 @@ pub(crate) fn layout_dom_with_web_fonts_and_stylesheet_cache( Some(stylesheet_cache), None, &[], + animation_sample, + animation_timeline, ) .0 } +#[allow(dead_code)] pub(crate) fn layout_dom_with_web_fonts_and_retained_styles( tree: &DomTree, viewport: (f32, f32), @@ -3115,7 +3411,57 @@ pub(crate) fn layout_dom_with_web_fonts_and_retained_styles( retained: RetainedStyleMaps, mutations: &[RetainedStyleMutation], ) -> DomLayout { - layout_dom_with_web_fonts_pass_limit( + layout_dom_with_web_fonts_and_retained_styles_at_animation_time( + tree, + viewport, + intrinsic, + fonts, + stylesheet_cache, + retained, + mutations, + crate::AnimationSampleTime::default(), + ) +} + +pub(crate) fn layout_dom_with_web_fonts_and_retained_styles_at_animation_time( + tree: &DomTree, + viewport: (f32, f32), + intrinsic: &HashMap, + fonts: &[crate::inline::WebFont], + stylesheet_cache: &mut crate::css::StylesheetCache, + retained: RetainedStyleMaps, + mutations: &[RetainedStyleMutation], + animation_sample_time: crate::AnimationSampleTime, +) -> DomLayout { + let mut animation_timeline = crate::AnimationTimelineState::default(); + layout_dom_with_web_fonts_and_retained_styles_with_animation_state( + tree, + viewport, + intrinsic, + fonts, + stylesheet_cache, + retained, + mutations, + crate::AnimationSample { + time: animation_sample_time, + mode: crate::AnimationSampleMode::DocumentTime, + }, + &mut animation_timeline, + ) +} + +pub(crate) fn layout_dom_with_web_fonts_and_retained_styles_with_animation_state( + tree: &DomTree, + viewport: (f32, f32), + intrinsic: &HashMap, + fonts: &[crate::inline::WebFont], + stylesheet_cache: &mut crate::css::StylesheetCache, + retained: RetainedStyleMaps, + mutations: &[RetainedStyleMutation], + animation_sample: crate::AnimationSample, + animation_timeline: &mut crate::AnimationTimelineState, +) -> DomLayout { + layout_dom_with_web_fonts_pass_limit_at_animation_time( tree, viewport, intrinsic, @@ -3124,6 +3470,8 @@ pub(crate) fn layout_dom_with_web_fonts_and_retained_styles( Some(stylesheet_cache), Some(retained), mutations, + animation_sample, + animation_timeline, ) .0 } @@ -3146,6 +3494,33 @@ fn layout_dom_with_web_fonts_pass_limit( stylesheet_cache: Option<&mut crate::css::StylesheetCache>, retained: Option, mutations: &[RetainedStyleMutation], +) -> (DomLayout, ContainerLayoutTelemetry) { + let mut animation_timeline = crate::AnimationTimelineState::default(); + layout_dom_with_web_fonts_pass_limit_at_animation_time( + tree, + viewport, + intrinsic, + fonts, + pass_limit, + stylesheet_cache, + retained, + mutations, + crate::AnimationSample::default(), + &mut animation_timeline, + ) +} + +fn layout_dom_with_web_fonts_pass_limit_at_animation_time( + tree: &DomTree, + viewport: (f32, f32), + intrinsic: &HashMap, + fonts: &[crate::inline::WebFont], + pass_limit: Option, + stylesheet_cache: Option<&mut crate::css::StylesheetCache>, + retained: Option, + mutations: &[RetainedStyleMutation], + animation_sample: crate::AnimationSample, + animation_timeline: &mut crate::AnimationTimelineState, ) -> (DomLayout, ContainerLayoutTelemetry) { let timing = std::env::var("OBSCURA_RENDER_TIMING").is_ok(); @@ -3177,14 +3552,16 @@ fn layout_dom_with_web_fonts_pass_limit( let retained_requested = retained.as_ref().map_or(0, |retained| retained.styles.len()); let retained = retained.and_then(|mut retained| { - if !stylesheet_cache_hit - || retained - .styles - .values() - .any(|style| style.container_type != crate::ContainerType::Normal) - { + if !stylesheet_cache_hit { return None; } + let active_containers = retained + .styles + .iter() + .filter_map(|(node, style)| { + (style.container_type != crate::ContainerType::Normal).then_some(*node) + }) + .collect::>(); let connected = std::iter::once(tree.document()) .chain(tree.descendants(tree.document())) .collect::>(); @@ -3193,7 +3570,22 @@ fn layout_dom_with_web_fonts_pass_limit( .custom_properties .retain(|node, _| connected.contains(node)); match retained_style_plan(tree, &sheet, mutations) { - RetainedStylePlan::Reuse(dirty) => Some((retained, dirty)), + RetainedStylePlan::Reuse(mut dirty) => { + if !active_containers.is_empty() && sheet.has_container_queries() { + let mut matcher = tree.matcher(); + add_container_query_reset_scopes( + tree, + tree.document(), + &sheet, + &mut matcher, + &active_containers, + false, + false, + &mut dirty, + ); + } + Some((retained, dirty)) + } RetainedStylePlan::Full => None, } }); @@ -3209,7 +3601,17 @@ fn layout_dom_with_web_fonts_pass_limit( .map_or(0, |(retained, _)| retained.styles.len() - retained_fresh); let retained_fallback = usize::from(retained_requested != 0 && retained.is_none()); let (mut laid, _, mut query, mut cascade_time) = - layout_dom_once(tree, viewport, intrinsic, fonts, &sheet, None, retained); + layout_dom_once( + tree, + viewport, + intrinsic, + fonts, + &sheet, + None, + retained, + animation_sample, + animation_timeline, + ); if !sheet.has_container_queries() { if timing { let (r, i, c, a, l, u) = sheet.debug_stats(); @@ -3296,6 +3698,8 @@ fn layout_dom_with_web_fonts_pass_limit( &sheet, Some(&snapshot), None, + animation_sample, + animation_timeline, ); passes = pass; query.evaluations += pass_query.evaluations; @@ -3343,7 +3747,17 @@ fn layout_dom_with_web_fonts_pass_limit( // geometry used to choose them. Disable the unstable conditional // rules for this render and expose the downgrade in telemetry. let (fallback, _, fallback_query, fallback_cascade) = - layout_dom_once(tree, viewport, intrinsic, fonts, &sheet, None, None); + layout_dom_once( + tree, + viewport, + intrinsic, + fonts, + &sheet, + None, + None, + animation_sample, + animation_timeline, + ); laid = fallback; passes += 1; query.evaluations += fallback_query.evaluations; @@ -3377,6 +3791,8 @@ fn layout_dom_once( sheet: &crate::css::Stylesheet, snapshot: Option<&crate::css::ContainerSnapshot>, retained: Option<(RetainedStyleMaps, HashSet)>, + animation_sample: crate::AnimationSample, + animation_timeline: &mut crate::AnimationTimelineState, ) -> ( DomLayout, Option, @@ -3414,6 +3830,8 @@ fn layout_dom_once( &mut evaluator_ref, quirks_mode, viewport, + animation_sample, + animation_timeline, None, false, fresh_styles.as_ref(), @@ -13328,6 +13746,195 @@ mod tests { } } + #[test] + fn retained_insertions_with_active_container_queries_match_forced_full() { + let mut clean = String::new(); + for index in 0..200 { + clean.push_str(&format!("")); + } + let tree = parse_html(&format!( + r#"
+ +
"#, + )); + let container = tree.get_element_by_id("container").unwrap(); + let popup = tree.get_element_by_id("popup").unwrap(); + let first = tree.get_element_by_id("first").unwrap(); + let second = tree.get_element_by_id("second").unwrap(); + tree.remove_child(popup); + tree.remove_child(first); + tree.remove_child(second); + + let mut cache = crate::css::StylesheetCache::default(); + let mut initial = layout_dom_with_web_fonts_and_stylesheet_cache( + &tree, + (500.0, 300.0), + &HashMap::new(), + &[], + &mut cache, + ); + tree.append_child(container, popup); + tree.append_child(popup, first); + tree.append_child(popup, second); + let retained = RetainedStyleMaps { + styles: std::mem::take(&mut initial.styles), + custom_properties: std::mem::take(&mut initial.custom_properties), + }; + let mutations = [ + TreeStyleMutation::Insert { + node: popup, + old_parent: None, + new_parent: container, + }, + TreeStyleMutation::Insert { + node: first, + old_parent: None, + new_parent: popup, + }, + TreeStyleMutation::Insert { + node: second, + old_parent: None, + new_parent: popup, + }, + ] + .map(RetainedStyleMutation::from); + let (incremental, telemetry) = layout_dom_with_web_fonts_pass_limit( + &tree, + (500.0, 300.0), + &HashMap::new(), + &[], + None, + Some(&mut cache), + Some(retained), + &mutations, + ); + let full = layout_dom(&tree, (500.0, 300.0)); + + assert_computed_styles_match("active container insertion batch", &incremental, &full); + assert_eq!(incremental.rects, full.rects); + assert_eq!(telemetry.retained_fallback, 0, "{telemetry:?}"); + assert!(telemetry.retained_reused >= 200, "{telemetry:?}"); + assert!(telemetry.retained_fresh > 0, "{telemetry:?}"); + assert_eq!( + incremental.styles[&tree.get_element_by_id("stable").unwrap()].width, + crate::Dimension::Px(71.0) + ); + assert_eq!(incremental.styles[&popup].width, crate::Dimension::Px(71.0)); + } + + #[test] + fn retained_dormant_container_queries_do_not_dirty_large_tree() { + let mut peers = String::new(); + for index in 0..1_000 { + peers.push_str(&format!("")); + } + let tree = parse_html(&format!( + r#"
{peers}
"#, + )); + let subject = tree.get_element_by_id("subject").unwrap(); + let mut cache = crate::css::StylesheetCache::default(); + let mut initial = layout_dom_with_web_fonts_and_stylesheet_cache( + &tree, + (500.0, 300.0), + &HashMap::new(), + &[], + &mut cache, + ); + tree.with_node_mut(subject, |node| { + node.set_attribute("autocomplete", "off".into()) + }); + let retained = RetainedStyleMaps { + styles: std::mem::take(&mut initial.styles), + custom_properties: std::mem::take(&mut initial.custom_properties), + }; + let (_, telemetry) = layout_dom_with_web_fonts_pass_limit( + &tree, + (500.0, 300.0), + &HashMap::new(), + &[], + None, + Some(&mut cache), + Some(retained), + &[AttributeStyleMutation { + node: subject, + name: "autocomplete".into(), + old_value: None, + new_value: Some("off".into()), + } + .into()], + ); + assert_eq!(telemetry.retained_fallback, 0, "{telemetry:?}"); + assert_eq!(telemetry.retained_fresh, 0, "{telemetry:?}"); + assert!(telemetry.retained_reused >= 1_000, "{telemetry:?}"); + } + + #[test] + fn retained_nested_universal_container_reset_is_bounded_and_matches_full() { + let mut queried = String::new(); + let mut clean = String::new(); + for index in 0..500 { + queried.push_str(&format!("")); + clean.push_str(&format!("")); + } + let tree = parse_html(&format!( + r#"
{queried}
+
"#, + )); + let subject = tree.get_element_by_id("subject").unwrap(); + let mut cache = crate::css::StylesheetCache::default(); + let mut initial = layout_dom_with_web_fonts_and_stylesheet_cache( + &tree, + (800.0, 600.0), + &HashMap::new(), + &[], + &mut cache, + ); + tree.with_node_mut(subject, |node| { + node.set_attribute("autocomplete", "off".into()) + }); + let retained = RetainedStyleMaps { + styles: std::mem::take(&mut initial.styles), + custom_properties: std::mem::take(&mut initial.custom_properties), + }; + let (incremental, telemetry) = layout_dom_with_web_fonts_pass_limit( + &tree, + (800.0, 600.0), + &HashMap::new(), + &[], + None, + Some(&mut cache), + Some(retained), + &[AttributeStyleMutation { + node: subject, + name: "autocomplete".into(), + old_value: None, + new_value: Some("off".into()), + } + .into()], + ); + let full = layout_dom(&tree, (800.0, 600.0)); + assert_computed_styles_match("nested universal CQ reset", &incremental, &full); + assert_eq!(incremental.rects, full.rects); + assert_eq!(telemetry.retained_fallback, 0, "{telemetry:?}"); + assert!(telemetry.retained_fresh < 530, "{telemetry:?}"); + assert!(telemetry.retained_reused >= 500, "{telemetry:?}"); + } + #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum RetainedDifferentialExpectation { Incremental, @@ -13381,6 +13988,8 @@ mod tests {
+ + old "#, case.css @@ -13612,6 +14221,86 @@ mod tests { new_value: "true", expectation: ReuseAll, }, + RetainedDifferentialCase { + name: "unreferenced ordinary autocomplete mutation", + css: ".subject{width:103px;height:27px}", + target: "subject", + attribute: "autocomplete", + new_value: "off", + expectation: ReuseAll, + }, + RetainedDifferentialCase { + name: "ordinary autocomplete attribute selector", + css: ".subject[autocomplete=off]{width:107px;height:29px}", + target: "subject", + attribute: "autocomplete", + new_value: "off", + expectation: Incremental, + }, + RetainedDifferentialCase { + name: "ordinary autocomplete declaration attr dependency", + css: ".subject::before{content:attr(autocomplete);display:block;width:9px}", + target: "subject", + attribute: "autocomplete", + new_value: "off", + expectation: Incremental, + }, + RetainedDifferentialCase { + name: "mapped width presentation hint", + css: ".subject{height:31px}", + target: "subject", + attribute: "width", + new_value: "109", + expectation: Incremental, + }, + RetainedDifferentialCase { + name: "native input size mutation", + css: "#control{height:23px}", + target: "control", + attribute: "size", + new_value: "40", + expectation: Incremental, + }, + RetainedDifferentialCase { + name: "image resource mutation", + css: "#image{width:17px;height:19px}", + target: "image", + attribute: "src", + new_value: "replacement.png", + expectation: ConservativeFallback, + }, + RetainedDifferentialCase { + name: "inherited language semantic mutation", + css: ".scope{width:113px}", + target: "scope", + attribute: "lang", + new_value: "fr", + expectation: Incremental, + }, + RetainedDifferentialCase { + name: "inherited direction semantic mutation", + css: ".scope{width:127px}", + target: "scope", + attribute: "dir", + new_value: "rtl", + expectation: Incremental, + }, + RetainedDifferentialCase { + name: "functional language selector", + css: ".scope:lang(fr){width:131px}", + target: "scope", + attribute: "lang", + new_value: "fr", + expectation: ConservativeFallback, + }, + RetainedDifferentialCase { + name: "functional direction selector", + css: ".scope:dir(rtl){width:137px}", + target: "scope", + attribute: "dir", + new_value: "rtl", + expectation: ConservativeFallback, + }, ]; for case in &cases { @@ -13619,6 +14308,57 @@ mod tests { } } + #[test] + fn retained_unreferenced_ordinary_attribute_keeps_large_tree_clean() { + let mut peers = String::new(); + for index in 0..2_000 { + peers.push_str(&format!("")); + } + let tree = parse_html(&format!( + r#" +
{peers}
"#, + )); + let subject = tree.get_element_by_id("subject").unwrap(); + let mut cache = crate::css::StylesheetCache::default(); + let mut initial = layout_dom_with_web_fonts_and_stylesheet_cache( + &tree, + (800.0, 600.0), + &HashMap::new(), + &[], + &mut cache, + ); + tree.with_node_mut(subject, |node| { + node.set_attribute("autocomplete", "off".into()) + }); + let retained = RetainedStyleMaps { + styles: std::mem::take(&mut initial.styles), + custom_properties: std::mem::take(&mut initial.custom_properties), + }; + let (incremental, telemetry) = layout_dom_with_web_fonts_pass_limit( + &tree, + (800.0, 600.0), + &HashMap::new(), + &[], + None, + Some(&mut cache), + Some(retained), + &[AttributeStyleMutation { + node: subject, + name: "autocomplete".into(), + old_value: None, + new_value: Some("off".into()), + } + .into()], + ); + let full = layout_dom(&tree, (800.0, 600.0)); + + assert_computed_styles_match("ordinary attribute 2k tree", &incremental, &full); + assert_eq!(incremental.rects, full.rects); + assert_eq!(telemetry.retained_fallback, 0, "{telemetry:?}"); + assert_eq!(telemetry.retained_fresh, 0, "{telemetry:?}"); + assert!(telemetry.retained_reused >= 2_000, "{telemetry:?}"); + } + fn finish_retained_tree_case( name: &str, tree: &DomTree, diff --git a/crates/obscura-render/src/lib.rs b/crates/obscura-render/src/lib.rs index 07c31e8..fa0c39c 100644 --- a/crates/obscura-render/src/lib.rs +++ b/crates/obscura-render/src/lib.rs @@ -97,15 +97,24 @@ mod image_capability_tests { mod paint; #[cfg(feature = "paint")] pub use paint::{ - paint_dom, paint_dom_scrolled, paint_prepared, paint_prepared_region_with_scroll, - paint_prepared_with_scroll, prepare_dom, prepare_dom_with_dynamic_fonts, - prepare_dom_with_dynamic_fonts_and_stylesheet_cache, screenshot_png, screenshot_png_scrolled, + image_intrinsic_dimensions, paint_dom, paint_dom_scrolled, + paint_dom_scrolled_at_animation_time, paint_prepared, + paint_prepared_region_with_scroll, + paint_prepared_with_scroll, prepare_dom, prepare_dom_at_animation_time, + prepare_dom_with_dynamic_fonts, prepare_dom_with_dynamic_fonts_at_animation_time, + prepare_dom_with_dynamic_fonts_and_stylesheet_cache, + prepare_dom_with_dynamic_fonts_and_stylesheet_cache_at_animation_time, + prepare_dom_with_dynamic_fonts_and_stylesheet_cache_with_animation_state, + screenshot_png, screenshot_png_scrolled, screenshot_png_scrolled_at_animation_time, prepare_dom_with_retained_attribute_styles, prepare_dom_with_retained_styles, + prepare_dom_with_retained_styles_at_animation_time, + prepare_dom_with_retained_styles_with_animation_state, screenshot_prepared, screenshot_prepared_region_with_scroll, screenshot_prepared_region_with_scroll_and_backgrounds, screenshot_prepared_with_scroll, validate_capture_region, CaptureError, CaptureRegion, DynamicFontFace, ElementScrollMetrics, - PreparedRender, RenderResourceCache, RenderResourceLoader, ResolvedScrollState, SelectedImage, + ImageRequestProfile, PreparedRender, RenderResourceCache, RenderResourceLoader, + ResolvedScrollState, SelectedImage, MAX_CAPTURE_DIMENSION, MAX_CAPTURE_PIXELS, }; @@ -1140,6 +1149,12 @@ pub struct LayoutStyle { /// before author `!important`, matching the animation cascade origin. pub animation_name: Option, pub animation_timing: AnimationTiming, + /// True when the selected keyframes contain at least one property this + /// renderer can sample. Unsupported custom-property-only animations must + /// not keep layout or screencast damage active forever. + pub animation_has_render_effect: bool, + /// Local time used for this element's sampled animation instance. + pub animation_local_time_ms: f32, /// `vertical-align` for a table cell's content. Cells effectively default /// to `middle` in browsers (the HTML UA sheet sets it on row groups and /// cells inherit it); obscura applies it as main-axis alignment of the @@ -1488,15 +1503,49 @@ pub enum WordBreak { BreakWord, } -/// Deterministic CSS animation sample time relative to the animation's -/// timeline origin. Screenshot rendering currently uses the initial value -/// (0ms); keeping it explicit lets capture plumbing freeze other engines at -/// the same instant instead of depending on wall-clock settling. +/// CSS animation sample time relative to the document timeline origin. +/// Live capture uses elapsed document time, while deterministic comparison +/// harnesses can request an exact instant such as T=0. #[derive(Debug, Clone, Copy, PartialEq, Default)] pub struct AnimationSampleTime { pub milliseconds: f32, } +/// Which clock an animation sample represents. +/// +/// Live rendering subtracts each element's animation-instance start epoch +/// from document time. Deterministic comparison instead assigns one local +/// time to every animation, matching Web Animations `currentTime` rather than +/// pretending all dynamically-created animations began at navigation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum AnimationSampleMode { + #[default] + DocumentTime, + LocalOverride, +} + +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct AnimationSample { + pub time: AnimationSampleTime, + pub mode: AnimationSampleMode, +} + +impl AnimationSample { + pub fn document(milliseconds: f32) -> Self { + Self { + time: AnimationSampleTime { milliseconds }, + mode: AnimationSampleMode::DocumentTime, + } + } + + pub fn local_override(milliseconds: f32) -> Self { + Self { + time: AnimationSampleTime { milliseconds }, + mode: AnimationSampleMode::LocalOverride, + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum AnimationDirection { #[default] @@ -1549,6 +1598,148 @@ impl Default for AnimationTiming { } } +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct AnimationInstanceKey { + pub name: String, +} + +#[derive(Debug, Clone)] +struct AnimationInstance { + key: AnimationInstanceKey, + start_ms: f32, + hold_time_ms: Option, + was_paused: bool, +} + +/// Page-owned CSS animation instance history retained across layout rebuilds. +/// Node ids are document-scoped, so navigation must replace this value. +#[derive(Debug, Default)] +pub struct AnimationTimelineState { + instances: std::collections::HashMap, + start_candidates: std::collections::HashMap, + subtree_start_candidates: std::collections::HashMap, +} + +impl AnimationTimelineState { + pub fn note_start_candidate( + &mut self, + node: obscura_dom::tree::NodeId, + document_time_ms: f32, + ) { + if document_time_ms.is_finite() && document_time_ms >= 0.0 { + self.start_candidates.insert(node, document_time_ms); + } + } + + /// Defer expansion until the next style flush. This is needed for DOM + /// string APIs whose imported descendants do not exist until after the + /// mutation op has completed. + pub fn note_subtree_start_candidate( + &mut self, + root: obscura_dom::tree::NodeId, + document_time_ms: f32, + ) { + if document_time_ms.is_finite() && document_time_ms >= 0.0 { + self.subtree_start_candidates + .insert(root, document_time_ms); + } + } + + pub fn materialize_start_candidates(&mut self, tree: &obscura_dom::DomTree) { + for (root, start_ms) in std::mem::take(&mut self.subtree_start_candidates) { + if tree.get_node(root).is_none() { + continue; + } + self.start_candidates.insert(root, start_ms); + for node in tree.descendants(root) { + self.start_candidates.entry(node).or_insert(start_ms); + } + } + } + + pub fn remove_subtree<'a>( + &mut self, + nodes: impl IntoIterator, + ) { + for node in nodes { + self.instances.remove(node); + self.start_candidates.remove(node); + self.subtree_start_candidates.remove(node); + } + } + + pub(crate) fn sample_for( + &mut self, + node: obscura_dom::tree::NodeId, + key: AnimationInstanceKey, + play_state: AnimationPlayState, + sample: AnimationSample, + ) -> AnimationSampleTime { + if sample.mode == AnimationSampleMode::LocalOverride { + return sample.time; + } + let document_time_ms = sample.time.milliseconds; + let transition_time_ms = self.start_candidates.remove(&node); + let retained = self + .instances + .get_mut(&node) + .filter(|instance| instance.key == key); + let instance = match retained { + Some(instance) => instance, + None => { + let candidate = transition_time_ms.unwrap_or(0.0); + let paused = play_state == AnimationPlayState::Paused; + self.instances.insert( + node, + AnimationInstance { + key, + start_ms: if paused { document_time_ms } else { candidate }, + hold_time_ms: paused.then_some(0.0), + was_paused: paused, + }, + ); + self.instances.get_mut(&node).expect("inserted animation instance") + } + }; + + let paused = play_state == AnimationPlayState::Paused; + if paused && !instance.was_paused { + let paused_at = transition_time_ms.unwrap_or(document_time_ms); + instance.hold_time_ms = Some((paused_at - instance.start_ms).max(0.0)); + } else if !paused && instance.was_paused { + let held = instance.hold_time_ms.take().unwrap_or(0.0); + let resumed_at = transition_time_ms.unwrap_or(document_time_ms); + instance.start_ms = resumed_at - held; + } + instance.was_paused = paused; + AnimationSampleTime { + milliseconds: instance + .hold_time_ms + .unwrap_or_else(|| (document_time_ms - instance.start_ms).max(0.0)), + } + } + + pub(crate) fn clear_animation(&mut self, node: obscura_dom::tree::NodeId, sample: AnimationSample) { + if sample.mode == AnimationSampleMode::DocumentTime { + self.instances.remove(&node); + } + } + + pub fn retain_nodes( + &mut self, + mut keep: impl FnMut(obscura_dom::tree::NodeId) -> bool, + ) { + self.instances.retain(|node, _| keep(*node)); + self.start_candidates.retain(|node, _| keep(*node)); + self.subtree_start_candidates.retain(|node, _| keep(*node)); + } + + pub fn clear_start_candidates(&mut self) { + self.start_candidates.clear(); + self.subtree_start_candidates.clear(); + } +} + /// The implemented `text-wrap-style` values. Other line-breaking strategies /// such as `pretty` remain unsupported until their distinct scoring model is /// available; treating them as `auto` would make `@supports` lie. diff --git a/crates/obscura-render/src/paint.rs b/crates/obscura-render/src/paint.rs index ea62028..7bed4a2 100644 --- a/crates/obscura-render/src/paint.rs +++ b/crates/obscura-render/src/paint.rs @@ -23,8 +23,8 @@ static FONT_OBLIQUE_BYTES: &[u8] = include_bytes!("../assets/liberation-sans-obl static FONT_BOLD_OBLIQUE_BYTES: &[u8] = include_bytes!("../assets/liberation-sans-boldoblique.ttf"); use crate::dom::{ - layout_dom_with_web_fonts_and_retained_styles, - layout_dom_with_web_fonts_and_stylesheet_cache, RetainedStyleMaps, + layout_dom_with_web_fonts_and_retained_styles_with_animation_state, + layout_dom_with_web_fonts_and_stylesheet_cache_with_animation_state, RetainedStyleMaps, }; const DEFAULT_RESOURCE_CACHE_ENTRIES: usize = 512; @@ -81,6 +81,49 @@ enum CachedResource { Missing(std::time::Instant), } +/// Fetch credentials/CORS identity for an HTML image request. No-CORS uses +/// the ordinary URL cache key so CSS images can share the same response; +/// CORS variants use private keys and can never contaminate one another. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub enum ImageRequestProfile { + NoCorsInclude, + CorsSameOrigin, + CorsInclude, +} + +fn image_request_profile(tree: &DomTree, id: obscura_dom::tree::NodeId) -> ImageRequestProfile { + match tree + .get_node(id) + .and_then(|node| node.get_attribute("crossorigin").map(str::to_owned)) + .map(|value| value.trim().to_ascii_lowercase()) + .as_deref() + { + Some("use-credentials") => ImageRequestProfile::CorsInclude, + Some(_) => ImageRequestProfile::CorsSameOrigin, + None => ImageRequestProfile::NoCorsInclude, + } +} + +fn image_resource_key(url: &str, profile: ImageRequestProfile) -> String { + let url = network_resource_url(url); + match profile { + ImageRequestProfile::NoCorsInclude => url, + ImageRequestProfile::CorsSameOrigin => format!("\0obscura-img-cors\0{url}"), + ImageRequestProfile::CorsInclude => format!("\0obscura-img-credentials\0{url}"), + } +} + +fn network_resource_url(url: &str) -> String { + if url.starts_with('\0') || url.starts_with("data:") { + return url.to_string(); + } + let Ok(mut parsed) = url::Url::parse(url) else { + return url.to_string(); + }; + parsed.set_fragment(None); + parsed.to_string() +} + #[derive(Clone, Debug, PartialEq)] struct RememberedContentImageIntrinsic { resolved_url: String, @@ -162,13 +205,35 @@ impl RenderResourceCache { } pub fn has_live_outcome(&self, url: &str) -> bool { - match self.entries.get(url) { + match self.entries.get(&network_resource_url(url)) { Some(CachedResource::Bytes(_)) => true, Some(CachedResource::Missing(at)) => at.elapsed() < MISSING_RESOURCE_RETRY_AFTER, None => false, } } + /// Whether this URL currently retains usable bytes (as opposed to a + /// short-lived negative-cache entry). Profile-specific HTML image loads + /// use this to avoid replacing paint bytes from another credential mode. + pub fn has_cached_bytes(&self, url: &str) -> bool { + matches!( + self.entries.get(&network_resource_url(url)), + Some(CachedResource::Bytes(_)) + ) + } + + pub fn has_live_image_outcome(&self, url: &str, profile: ImageRequestProfile) -> bool { + self.has_live_outcome(&image_resource_key(url, profile)) + } + + pub fn seed_image(&mut self, url: String, profile: ImageRequestProfile, bytes: Vec) { + self.seed(image_resource_key(&url, profile), bytes); + } + + pub fn seed_image_missing(&mut self, url: String, profile: ImageRequestProfile) { + self.seed_missing(image_resource_key(&url, profile)); + } + /// Seed bytes fetched by the owning page's asynchronous browser transport. /// /// Layout and paint are synchronous, so letting them open their own HTTP @@ -176,6 +241,7 @@ impl RenderResourceCache { /// layer uses this entry point to fetch a bounded resource batch through /// its cookie/proxy/CORS-aware connection pool before entering layout. pub fn seed(&mut self, url: String, bytes: Vec) { + let url = network_resource_url(&url); self.remove(&url); self.insert_bytes(url, Arc::from(bytes)); } @@ -183,6 +249,7 @@ impl RenderResourceCache { /// Retain a page-transport failure so capture does not immediately repeat /// the same slow request through the renderer's compatibility loader. pub fn seed_missing(&mut self, url: String) { + let url = network_resource_url(&url); self.remove(&url); self.insert_missing(url); } @@ -224,7 +291,30 @@ impl RenderResourceCache { .map(|(width, height)| (resolved_url, width, height)), ); } - match self.entries.get(&resolved_url) { + match self.entries.get(&network_resource_url(&resolved_url)) { + Some(CachedResource::Bytes(bytes)) => Some( + image_metadata_from_bytes(bytes) + .map(|(width, height)| (resolved_url, width, height)), + ), + Some(CachedResource::Missing(at)) if at.elapsed() < MISSING_RESOURCE_RETRY_AFTER => { + Some(None) + } + _ => None, + } + } + + fn cached_profiled_image_metadata( + &self, + src: &str, + base_url: Option<&str>, + profile: ImageRequestProfile, + ) -> Option> { + let resolved_url = resolve_resource_url(src, base_url)?; + if resolved_url.starts_with("data:") { + return self.cached_image_metadata(&resolved_url, None); + } + let key = image_resource_key(&resolved_url, profile); + match self.entries.get(&key) { Some(CachedResource::Bytes(bytes)) => Some( image_metadata_from_bytes(bytes) .map(|(width, height)| (resolved_url, width, height)), @@ -250,7 +340,8 @@ impl RenderResourceCache { ) -> Option<(String, f32, Option<(f32, f32)>)> { let (src, density) = resolve_img_url(tree, id, viewport)?; let resolved_url = resolve_resource_url(&src, base_url).unwrap_or(src); - let dimensions = fetch_bytes(&resolved_url, None, self) + let profile = image_request_profile(tree, id); + let dimensions = fetch_profiled_image_bytes(&resolved_url, None, self, profile) .and_then(|bytes| image_metadata_from_bytes(&bytes)) .map(|(width, height)| (width / density, height / density)); Some((resolved_url, density, dimensions)) @@ -268,7 +359,8 @@ impl RenderResourceCache { ) -> Option<(String, f32, bool, Option<(f32, f32)>)> { let (src, density) = resolve_img_url(tree, id, viewport)?; let resolved_url = resolve_resource_url(&src, base_url).unwrap_or(src); - match self.cached_image_metadata(&resolved_url, None) { + let profile = image_request_profile(tree, id); + match self.cached_profiled_image_metadata(&resolved_url, None, profile) { None => Some((resolved_url, density, false, None)), Some(dimensions) => Some(( resolved_url, @@ -280,7 +372,8 @@ impl RenderResourceCache { } fn get_or_load(&mut self, url: &str) -> Option> { - if let Some(entry) = self.entries.get(url) { + let url = network_resource_url(url); + if let Some(entry) = self.entries.get(&url) { match entry { CachedResource::Bytes(bytes) => return Some(Arc::clone(bytes)), CachedResource::Missing(at) if at.elapsed() < MISSING_RESOURCE_RETRY_AFTER => { @@ -292,16 +385,53 @@ impl RenderResourceCache { if !self.sync_loading_enabled { return None; } - self.remove(url); + self.remove(&url); - let loaded = self.loader.load(url).map(Arc::<[u8]>::from); + let loaded = self.loader.load(&url).map(Arc::<[u8]>::from); match loaded { Some(bytes) => { - self.insert_bytes(url.to_string(), Arc::clone(&bytes)); + self.insert_bytes(url, Arc::clone(&bytes)); Some(bytes) } None => { - self.insert_missing(url.to_string()); + self.insert_missing(url); + None + } + } + } + + fn get_or_load_image( + &mut self, + url: &str, + profile: ImageRequestProfile, + ) -> Option> { + let key = image_resource_key(url, profile); + if let Some(entry) = self.entries.get(&key) { + match entry { + CachedResource::Bytes(bytes) => return Some(Arc::clone(bytes)), + CachedResource::Missing(at) if at.elapsed() < MISSING_RESOURCE_RETRY_AFTER => { + return None; + } + CachedResource::Missing(_) => {} + } + } + if !self.sync_loading_enabled { + return None; + } + self.remove(&key); + // The private profile key is cache identity only and must never escape + // into a network loader. + let loaded = self + .loader + .load(&network_resource_url(url)) + .map(Arc::<[u8]>::from); + match loaded { + Some(bytes) => { + self.insert_bytes(key, Arc::clone(&bytes)); + Some(bytes) + } + None => { + self.insert_missing(key); None } } @@ -382,6 +512,7 @@ impl RenderResourceCache { SelectedImage { resolved_url: value.resolved_url, density: 1.0, + profile: ImageRequestProfile::NoCorsInclude, }, ); seeded.insert(nid); @@ -426,11 +557,19 @@ impl RenderResourceCache { } } +/// Inspect already-fetched image bytes without inserting them into a resource +/// cache. HTMLImageElement keeps request-profile-specific lifecycle outcomes +/// even though the renderer's ordinary paint cache is URL-keyed. +pub fn image_intrinsic_dimensions(bytes: &[u8]) -> Option<(f32, f32)> { + image_metadata_from_bytes(bytes) +} + /// The exact responsive image candidate chosen during preparation. #[derive(Clone, Debug, PartialEq)] pub struct SelectedImage { pub resolved_url: String, pub density: f32, + pub profile: ImageRequestProfile, } /// CSSOM scrolling metrics for one element box. Non-scroll containers expose @@ -616,6 +755,7 @@ impl ResolvedScrollState { /// The DOM must not be mutated while this value is reused. pub struct PreparedRender { viewport: (f32, f32), + animation_sample: crate::AnimationSample, root_font_size: f32, base_url: Option, content_size: (f32, f32), @@ -632,6 +772,51 @@ impl PreparedRender { self.viewport } + pub fn animation_sample_time(&self) -> crate::AnimationSampleTime { + self.animation_sample.time + } + + pub fn animation_sample(&self) -> crate::AnimationSample { + self.animation_sample + } + + /// Whether advancing the document timeline can still change a sampled CSS + /// animation. Finite animations stop producing compositor damage after + /// their active interval; paused and zero-duration animations never do. + pub fn has_active_css_animations(&self) -> bool { + self.layout.styles.values().any(|style| { + if style.animation_name.is_none() + || !style.animation_has_render_effect + || style.animation_timing.play_state == crate::AnimationPlayState::Paused + || style.animation_timing.duration_ms <= 0.0 + || style.animation_timing.iteration_count <= 0.0 + { + return false; + } + let end = style.animation_timing.delay_ms + + style.animation_timing.duration_ms * style.animation_timing.iteration_count; + end.is_infinite() || style.animation_local_time_ms < end.max(0.0) + }) + } + + /// Advance a frame whose animation cascade is already time-invariant. + /// This preserves retained layout for static pages and completed finite + /// animations. Backward seeks are never eligible because they can re-enter + /// an earlier active interval or undo forwards fill. + pub fn advance_inactive_animation_sample_time( + &mut self, + sample: crate::AnimationSampleTime, + ) -> bool { + if self.animation_sample.mode != crate::AnimationSampleMode::DocumentTime + || sample.milliseconds < self.animation_sample.time.milliseconds + || self.has_active_css_animations() + { + return false; + } + self.animation_sample.time = sample; + true + } + pub fn base_url(&self) -> Option<&str> { self.base_url.as_deref() } @@ -1562,9 +1747,31 @@ pub fn paint_dom_scrolled( viewport: (f32, f32), base_url: Option<&str>, scroll: (f32, f32), +) -> Option { + paint_dom_scrolled_at_animation_time( + tree, + viewport, + base_url, + scroll, + crate::AnimationSampleTime::default(), + ) +} + +pub fn paint_dom_scrolled_at_animation_time( + tree: &DomTree, + viewport: (f32, f32), + base_url: Option<&str>, + scroll: (f32, f32), + animation_sample_time: crate::AnimationSampleTime, ) -> Option { let mut resources = RenderResourceCache::default(); - let mut prepared = prepare_dom(tree, viewport, base_url, &mut resources)?; + let mut prepared = prepare_dom_at_animation_time( + tree, + viewport, + base_url, + &mut resources, + animation_sample_time, + )?; paint_prepared(tree, &mut prepared, &mut resources, scroll) } @@ -1576,7 +1783,30 @@ pub fn prepare_dom( base_url: Option<&str>, resources: &mut RenderResourceCache, ) -> Option { - prepare_dom_with_dynamic_fonts(tree, viewport, base_url, resources, &[]) + prepare_dom_at_animation_time( + tree, + viewport, + base_url, + resources, + crate::AnimationSampleTime::default(), + ) +} + +pub fn prepare_dom_at_animation_time( + tree: &DomTree, + viewport: (f32, f32), + base_url: Option<&str>, + resources: &mut RenderResourceCache, + animation_sample_time: crate::AnimationSampleTime, +) -> Option { + prepare_dom_with_dynamic_fonts_at_animation_time( + tree, + viewport, + base_url, + resources, + &[], + animation_sample_time, + ) } /// Prepare a DOM with the document's script-created `FontFace` registrations. @@ -1589,15 +1819,34 @@ pub fn prepare_dom_with_dynamic_fonts( base_url: Option<&str>, resources: &mut RenderResourceCache, dynamic_fonts: &[DynamicFontFace], +) -> Option { + prepare_dom_with_dynamic_fonts_at_animation_time( + tree, + viewport, + base_url, + resources, + dynamic_fonts, + crate::AnimationSampleTime::default(), + ) +} + +pub fn prepare_dom_with_dynamic_fonts_at_animation_time( + tree: &DomTree, + viewport: (f32, f32), + base_url: Option<&str>, + resources: &mut RenderResourceCache, + dynamic_fonts: &[DynamicFontFace], + animation_sample_time: crate::AnimationSampleTime, ) -> Option { let mut stylesheet_cache = crate::css::StylesheetCache::default(); - prepare_dom_with_dynamic_fonts_and_stylesheet_cache( + prepare_dom_with_dynamic_fonts_and_stylesheet_cache_at_animation_time( tree, viewport, base_url, resources, dynamic_fonts, &mut stylesheet_cache, + animation_sample_time, ) } @@ -1611,6 +1860,52 @@ pub fn prepare_dom_with_dynamic_fonts_and_stylesheet_cache( resources: &mut RenderResourceCache, dynamic_fonts: &[DynamicFontFace], stylesheet_cache: &mut crate::css::StylesheetCache, +) -> Option { + prepare_dom_with_dynamic_fonts_and_stylesheet_cache_at_animation_time( + tree, + viewport, + base_url, + resources, + dynamic_fonts, + stylesheet_cache, + crate::AnimationSampleTime::default(), + ) +} + +pub fn prepare_dom_with_dynamic_fonts_and_stylesheet_cache_at_animation_time( + tree: &DomTree, + viewport: (f32, f32), + base_url: Option<&str>, + resources: &mut RenderResourceCache, + dynamic_fonts: &[DynamicFontFace], + stylesheet_cache: &mut crate::css::StylesheetCache, + animation_sample_time: crate::AnimationSampleTime, +) -> Option { + let mut animation_timeline = crate::AnimationTimelineState::default(); + prepare_dom_with_dynamic_fonts_and_stylesheet_cache_with_animation_state( + tree, + viewport, + base_url, + resources, + dynamic_fonts, + stylesheet_cache, + crate::AnimationSample { + time: animation_sample_time, + mode: crate::AnimationSampleMode::DocumentTime, + }, + &mut animation_timeline, + ) +} + +pub fn prepare_dom_with_dynamic_fonts_and_stylesheet_cache_with_animation_state( + tree: &DomTree, + viewport: (f32, f32), + base_url: Option<&str>, + resources: &mut RenderResourceCache, + dynamic_fonts: &[DynamicFontFace], + stylesheet_cache: &mut crate::css::StylesheetCache, + animation_sample: crate::AnimationSample, + animation_timeline: &mut crate::AnimationTimelineState, ) -> Option { prepare_dom_with_dynamic_fonts_and_stylesheet_cache_internal( tree, @@ -1620,6 +1915,8 @@ pub fn prepare_dom_with_dynamic_fonts_and_stylesheet_cache( dynamic_fonts, stylesheet_cache, None, + animation_sample, + animation_timeline, ) } @@ -1663,9 +1960,76 @@ pub fn prepare_dom_with_retained_styles( resources: &mut RenderResourceCache, dynamic_fonts: &[DynamicFontFace], stylesheet_cache: &mut crate::css::StylesheetCache, - mut previous: PreparedRender, + previous: PreparedRender, mutations: &[crate::dom::RetainedStyleMutation], ) -> Option { + prepare_dom_with_retained_styles_at_animation_time( + tree, + viewport, + base_url, + resources, + dynamic_fonts, + stylesheet_cache, + previous, + mutations, + crate::AnimationSampleTime::default(), + ) +} + +pub fn prepare_dom_with_retained_styles_at_animation_time( + tree: &DomTree, + viewport: (f32, f32), + base_url: Option<&str>, + resources: &mut RenderResourceCache, + dynamic_fonts: &[DynamicFontFace], + stylesheet_cache: &mut crate::css::StylesheetCache, + previous: PreparedRender, + mutations: &[crate::dom::RetainedStyleMutation], + animation_sample_time: crate::AnimationSampleTime, +) -> Option { + let mut animation_timeline = crate::AnimationTimelineState::default(); + prepare_dom_with_retained_styles_with_animation_state( + tree, + viewport, + base_url, + resources, + dynamic_fonts, + stylesheet_cache, + previous, + mutations, + crate::AnimationSample { + time: animation_sample_time, + mode: crate::AnimationSampleMode::DocumentTime, + }, + &mut animation_timeline, + ) +} + +pub fn prepare_dom_with_retained_styles_with_animation_state( + tree: &DomTree, + viewport: (f32, f32), + base_url: Option<&str>, + resources: &mut RenderResourceCache, + dynamic_fonts: &[DynamicFontFace], + stylesheet_cache: &mut crate::css::StylesheetCache, + mut previous: PreparedRender, + mutations: &[crate::dom::RetainedStyleMutation], + animation_sample: crate::AnimationSample, + animation_timeline: &mut crate::AnimationTimelineState, +) -> Option { + if previous.animation_sample != animation_sample { + drop(previous); + return prepare_dom_with_dynamic_fonts_and_stylesheet_cache_with_animation_state( + tree, + viewport, + base_url, + resources, + dynamic_fonts, + stylesheet_cache, + animation_sample, + animation_timeline, + ); + } let retained = RetainedStyleMaps { styles: std::mem::take(&mut previous.layout.styles), custom_properties: std::mem::take(&mut previous.layout.custom_properties), @@ -1679,6 +2043,8 @@ pub fn prepare_dom_with_retained_styles( dynamic_fonts, stylesheet_cache, Some((retained, mutations)), + animation_sample, + animation_timeline, ) } @@ -1690,6 +2056,8 @@ fn prepare_dom_with_dynamic_fonts_and_stylesheet_cache_internal( dynamic_fonts: &[DynamicFontFace], stylesheet_cache: &mut crate::css::StylesheetCache, retained: Option<(RetainedStyleMaps, &[crate::dom::RetainedStyleMutation])>, + animation_sample: crate::AnimationSample, + animation_timeline: &mut crate::AnimationTimelineState, ) -> Option { if !viewport.0.is_finite() || !viewport.1.is_finite() || viewport.0 <= 0.0 || viewport.1 <= 0.0 { @@ -1731,7 +2099,7 @@ fn prepare_dom_with_dynamic_fonts_and_stylesheet_cache_internal( svg_font_database() }; let mut laid = match retained { - Some((retained, mutations)) => layout_dom_with_web_fonts_and_retained_styles( + Some((retained, mutations)) => layout_dom_with_web_fonts_and_retained_styles_with_animation_state( tree, viewport, &intrinsic, @@ -1739,13 +2107,17 @@ fn prepare_dom_with_dynamic_fonts_and_stylesheet_cache_internal( stylesheet_cache, retained, mutations, + animation_sample, + animation_timeline, ), - None => layout_dom_with_web_fonts_and_stylesheet_cache( + None => layout_dom_with_web_fonts_and_stylesheet_cache_with_animation_state( tree, viewport, &intrinsic, &fonts, stylesheet_cache, + animation_sample, + animation_timeline, ), }; // `content:url(...)` is computed by the author cascade, whereas ordinary @@ -1767,12 +2139,14 @@ fn prepare_dom_with_dynamic_fonts_and_stylesheet_cache_internal( { resources.content_image_layout_retries += 1; } - laid = layout_dom_with_web_fonts_and_stylesheet_cache( + laid = layout_dom_with_web_fonts_and_stylesheet_cache_with_animation_state( tree, viewport, &intrinsic, &fonts, stylesheet_cache, + animation_sample, + animation_timeline, ); } let content_size = laid.scrolling_content_size(tree, viewport); @@ -1788,6 +2162,7 @@ fn prepare_dom_with_dynamic_fonts_and_stylesheet_cache_internal( .unwrap_or(16.0); Some(PreparedRender { viewport, + animation_sample, root_font_size, base_url: base_url.map(str::to_string), content_size, @@ -2945,6 +3320,7 @@ fn paint_laid_dom_scrolled( &mut pixmap, image_cache, None, + None, background.clip_radii, background_mask, ); @@ -3048,6 +3424,7 @@ fn paint_laid_dom_scrolled( style.object_fit, &mut pixmap, image_cache, + Some(source.profile), None, radius, element_clip_mask, @@ -3676,6 +4053,7 @@ fn paint_inline_fragment_decorations( pixmap, image_cache, None, + None, background.clip_radii, background_mask.as_ref(), ); @@ -4704,6 +5082,23 @@ pub fn screenshot_png_scrolled( paint_dom_scrolled(tree, viewport, base_url, scroll).and_then(|pixmap| pixmap.encode_png().ok()) } +pub fn screenshot_png_scrolled_at_animation_time( + tree: &DomTree, + viewport: (f32, f32), + base_url: Option<&str>, + scroll: (f32, f32), + animation_sample_time: crate::AnimationSampleTime, +) -> Option> { + paint_dom_scrolled_at_animation_time( + tree, + viewport, + base_url, + scroll, + animation_sample_time, + ) + .and_then(|pixmap| pixmap.encode_png().ok()) +} + /// PNG convenience wrapper for a retained resource-aware layout. pub fn screenshot_prepared( tree: &DomTree, @@ -5181,6 +5576,19 @@ fn fetch_bytes( cache.get_or_load(&resolved) } +fn fetch_profiled_image_bytes( + src: &str, + base_url: Option<&str>, + cache: &mut RenderResourceCache, + profile: ImageRequestProfile, +) -> Option> { + if src.starts_with("data:") { + return fetch_bytes(src, base_url, cache); + } + let resolved = resolve_resource_url(src, base_url)?; + cache.get_or_load_image(&resolved, profile) +} + fn resolve_resource_url(src: &str, base_url: Option<&str>) -> Option { if src.starts_with("data:") { return Some(src.to_string()); @@ -6818,6 +7226,7 @@ fn paint_in_flow_generated_box( pixmap, image_cache, None, + None, background.clip_radii, background_mask.as_ref(), ); @@ -7051,6 +7460,7 @@ fn paint_positioned_pseudo( pixmap, image_cache, None, + None, background.clip_radii, background_mask.as_ref(), ); @@ -7143,14 +7553,16 @@ fn collect_image_intrinsics( continue; }; let resolved_url = resolve_resource_url(&url, base_url).unwrap_or(url); + let profile = image_request_profile(tree, nid); selected.insert( nid, SelectedImage { resolved_url: resolved_url.clone(), density, + profile, }, ); - let Some(bytes) = fetch_bytes(&resolved_url, None, cache) else { + let Some(bytes) = fetch_profiled_image_bytes(&resolved_url, None, cache, profile) else { continue; }; let dimensions = image_dimensions(&bytes) @@ -7208,6 +7620,7 @@ fn collect_content_image_intrinsics( SelectedImage { resolved_url: resolved_url.clone(), density: 1.0, + profile: ImageRequestProfile::NoCorsInclude, }, ); // CSS content replaces the element's ordinary source. Remove the src @@ -7528,6 +7941,7 @@ fn paint_image( object_fit: crate::ObjectFit, pixmap: &mut Pixmap, cache: &mut RenderResourceCache, + profile: Option, transform: Option, clip_radius: crate::ResolvedBorderRadii, extra_clip: Option<&tiny_skia::Mask>, @@ -7541,7 +7955,11 @@ fn paint_image( if !rect_intersects_paint_surface(visible_rect, pixmap, 1.0) { return false; } - let Some(bytes) = fetch_bytes(src, base_url, cache) else { + let bytes = match profile { + Some(profile) => fetch_profiled_image_bytes(src, base_url, cache, profile), + None => fetch_bytes(src, base_url, cache), + }; + let Some(bytes) = bytes else { return false; }; let svg = is_svg(&bytes); @@ -8746,6 +9164,56 @@ mod tests { assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0); } + #[test] + fn html_image_profiles_keep_intrinsic_geometry_and_paint_separate() { + let network_url = "https://assets.test/shared.svg"; + let url = "https://assets.test/shared.svg#icon"; + let tree = parse_html(&format!( + r#" + + + + "# + )); + let mut resources = RenderResourceCache::with_loader(|_url: &str| { + panic!("seeded profile resources must not reach the loader") + }); + resources.seed_image( + network_url.to_string(), + ImageRequestProfile::NoCorsInclude, + br##""##.to_vec(), + ); + resources.seed_image( + network_url.to_string(), + ImageRequestProfile::CorsSameOrigin, + br##""##.to_vec(), + ); + resources.seed_image_missing(network_url.to_string(), ImageRequestProfile::CorsInclude); + + let mut prepared = + prepare_dom(&tree, (80.0, 60.0), None, &mut resources).expect("profiled layout"); + let node = |selector| tree.query_selector(selector).unwrap().unwrap(); + let plain = prepared.layout().rects[&node("#plain")]; + let anonymous = prepared.layout().rects[&node("#anonymous")]; + assert_eq!(prepared.selected_image(node("#plain")).unwrap().resolved_url, url); + assert_eq!((plain.width, plain.height), (20.0, 10.0)); + assert_eq!((anonymous.width, anonymous.height), (40.0, 30.0)); + + let pixmap = paint_prepared(&tree, &mut prepared, &mut resources, (0.0, 0.0)) + .expect("profiled paint"); + let red = pixmap.pixel(5, 5).unwrap(); + assert!(red.red() > 240 && red.green() < 20, "{red:?}"); + let green = pixmap.pixel(5, 20).unwrap(); + assert!(green.green() > 240 && green.red() < 20, "{green:?}"); + let failed = pixmap.pixel(5, 45).unwrap(); + assert_eq!( + (failed.red(), failed.green(), failed.blue(), failed.alpha()), + (255, 255, 255, 255), + "failed credentialed image must not paint bytes from another profile" + ); + } + #[test] fn image_accept_advertises_exactly_decodable_mime_types() { assert!(!IMAGE_ACCEPT.contains('*')); @@ -9075,6 +9543,7 @@ mod tests { Some(&SelectedImage { resolved_url: "https://assets.test/hero.svg".to_string(), density: 2.0, + profile: ImageRequestProfile::NoCorsInclude, }) ); let hero_rect = prepared.layout().rects.get(&hero).expect("hero rect"); @@ -12572,6 +13041,7 @@ mod tests { &mut pixmap, &mut resources, None, + None, crate::ResolvedBorderRadii::default(), None, )); @@ -12612,4 +13082,49 @@ mod tests { let sticky = pixmap.pixel(75, 15).expect("sticky pixel"); assert!(sticky.blue() > 240 && sticky.red() < 20); } + + #[test] + fn prepared_render_samples_explicit_animation_time_and_reports_live_damage() { + let tree = parse_html( + r#"
"#, + ); + let overlay = tree.query_selector("#overlay").unwrap().unwrap(); + let mut resources = RenderResourceCache::with_loader(|_url: &str| None); + let mut stylesheets = crate::css::StylesheetCache::default(); + let at_zero = prepare_dom_with_dynamic_fonts_and_stylesheet_cache_at_animation_time( + &tree, + (40.0, 40.0), + None, + &mut resources, + &[], + &mut stylesheets, + crate::AnimationSampleTime { milliseconds: 0.0 }, + ) + .expect("T=0 render"); + assert_eq!(at_zero.animation_sample_time().milliseconds, 0.0); + assert_eq!(at_zero.layout.styles[&overlay].opacity, Some(1.0)); + assert_ne!(at_zero.layout.styles[&overlay].visibility_hidden, Some(true)); + assert!(at_zero.has_active_css_animations()); + + let at_end = prepare_dom_with_dynamic_fonts_and_stylesheet_cache_at_animation_time( + &tree, + (40.0, 40.0), + None, + &mut resources, + &[], + &mut stylesheets, + crate::AnimationSampleTime { milliseconds: 600.0 }, + ) + .expect("animation-end render"); + assert_eq!(at_end.layout.styles[&overlay].opacity, Some(0.0)); + assert_eq!(at_end.layout.styles[&overlay].visibility_hidden, Some(true)); + assert!(at_end.layout.styles[&overlay].effectively_invisible); + assert!(!at_end.has_active_css_animations()); + } } diff --git a/render-repros/paired-corpus.py b/render-repros/paired-corpus.py index 258178c..3ab8442 100755 --- a/render-repros/paired-corpus.py +++ b/render-repros/paired-corpus.py @@ -561,7 +561,7 @@ def parse_obscura_scroll_report(stdout): } -def obscura_environment(width, height): +def obscura_environment(width, height, animation_time_ms=None): env = dict( os.environ, OBSCURA_SHOT_W=str(width), @@ -587,6 +587,8 @@ def obscura_environment(width, height): # same warm-up shot in the paired process. OBSCURA_SHOT_RESOURCE_WARMUP="1", ) + if animation_time_ms is not None: + env["OBSCURA_SHOT_ANIMATION_TIME_MS"] = str(animation_time_ms) return env @@ -719,9 +721,10 @@ def capture_obscura( settle_ms, scroll=None, geometry_selectors=None, + animation_time_ms=None, ): env = with_controlled_scroll_environment( - obscura_environment(width, height), scroll + obscura_environment(width, height, animation_time_ms), scroll ) command = [ binary, @@ -1987,6 +1990,7 @@ def main(): args.settle_ms, controlled_scroll, args.geometry_selector, + args.animation_time_ms, ) baseline_future = None if args.baseline_bin: @@ -2001,6 +2005,7 @@ def main(): args.settle_ms, controlled_scroll, args.geometry_selector, + args.animation_time_ms, ) chrome_started = time.time() diff --git a/render-repros/test_paired_corpus.py b/render-repros/test_paired_corpus.py index 4cbabd4..20f3c54 100644 --- a/render-repros/test_paired_corpus.py +++ b/render-repros/test_paired_corpus.py @@ -119,6 +119,11 @@ class ControlledScrollTests(unittest.TestCase): self.assertEqual(environment["OBSCURA_SHOT_SCROLL_Y"], "bottom") self.assertEqual(environment["OBSCURA_SHOT_EVAL_AT_CAPTURE"], "1") self.assertEqual(environment["OBSCURA_SHOT_RESOURCE_WARMUP"], "1") + self.assertNotIn("OBSCURA_SHOT_ANIMATION_TIME_MS", environment) + + def test_obscura_capture_exports_explicit_animation_sample(self): + environment = paired_corpus.obscura_environment(1280, 720, 0) + self.assertEqual(environment["OBSCURA_SHOT_ANIMATION_TIME_MS"], "0") def test_paired_state_expression_is_read_only_at_capture_boundary(self): expression = paired_corpus.obscura_state_eval_expression(