From 222b50e480090a859aa6fa50a3569c521c4cb1f9 Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:17:17 +0000 Subject: [PATCH] Reduce render latency and improve headless input --- crates/obscura-browser/src/page.rs | 172 +++- crates/obscura-cdp/src/dispatch.rs | 8 + crates/obscura-cdp/src/domains/input.rs | 208 ++++- crates/obscura-cdp/src/domains/page.rs | 207 ++++- crates/obscura-cdp/src/domains/pdf.rs | 6 +- crates/obscura-cdp/src/server.rs | 134 +++ .../form_submit_method_bypasses_listener.rs | 9 + .../tests/input_mouse_event_parity.rs | 377 +++++++++ crates/obscura-js/js/bootstrap.js | 40 +- crates/obscura-js/src/ops.rs | 111 ++- crates/obscura-js/src/runtime.rs | 117 ++- crates/obscura-render/src/css.rs | 132 +++ crates/obscura-render/src/dom.rs | 770 +++++++++++++++++- crates/obscura-render/src/lib.rs | 6 +- crates/obscura-render/src/paint.rs | 118 ++- 15 files changed, 2268 insertions(+), 147 deletions(-) create mode 100644 crates/obscura-cdp/tests/input_mouse_event_parity.rs diff --git a/crates/obscura-browser/src/page.rs b/crates/obscura-browser/src/page.rs index f9437d2..65fb9ed 100644 --- a/crates/obscura-browser/src/page.rs +++ b/crates/obscura-browser/src/page.rs @@ -85,6 +85,20 @@ fn truncate_on_char_boundary(s: &str, max: usize) -> &str { &s[..end] } +#[cfg(feature = "render")] +fn remaining_settle_resource_warmup_ms( + max_ms: u64, + elapsed: std::time::Duration, + configured_ms: u64, +) -> u64 { + std::time::Duration::from_millis(max_ms) + .checked_sub(elapsed) + .map(|remaining| { + (remaining.as_millis().min(u128::from(u64::MAX)) as u64).min(configured_ms) + }) + .unwrap_or(0) +} + #[cfg(feature = "stealth")] use obscura_net::StealthHttpClient; @@ -1948,6 +1962,7 @@ impl Page { if max_ms == 0 { return; } + let settle_started = std::time::Instant::now(); if let Some(js) = &mut self.js { if std::env::var_os("OBSCURA_STRICT_SETTLE").is_some() { Self::settle_runtime_for_duration(js, max_ms).await; @@ -1961,6 +1976,25 @@ impl Page { let _ = js.run_event_loop_until_quiescent(max_ms, 150).await; } } + #[cfg(feature = "render")] + { + // Timers, fetch completions, and framework commits commonly append + // images or @font-face rules during settling. Seed those resources + // here so a following capture remains a fast observation of the + // retained page rather than initiating its own network phase. + let warmup_ms = std::env::var("OBSCURA_RENDER_RESOURCE_SETTLE_WARMUP_MS") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(1_000); + let remaining_ms = remaining_settle_resource_warmup_ms( + max_ms, + settle_started.elapsed(), + warmup_ms, + ); + if remaining_ms != 0 { + let _ = self.prepare_screenshot_resources(remaining_ms).await; + } + } } /// Pump the event loop and retain the full requested wall-clock delay. @@ -2252,7 +2286,7 @@ impl Page { // renderer's synchronous resource loader and serial network latency pins // V8, making framework startup take many seconds. This is deliberately // bounded: navigation should not wait indefinitely for decorative - // resources, and screenshot/PDF capture can use its longer final wait. + // resources. #[cfg(feature = "render")] { let warmup_ms = std::env::var("OBSCURA_RENDER_RESOURCE_WARMUP_MS") @@ -2270,6 +2304,21 @@ impl Page { // of waitUntil and DCL means "DOM parsed AND scripts executed". self.execute_scripts().await; + #[cfg(feature = "render")] + { + // Page scripts and their bounded post-script event-loop pass can + // create responsive images, inline styles, and @font-face rules + // that did not exist during the parser warmup above. Discover them + // before navigation becomes capture-ready. Known parser resources + // are filtered by the render cache, so ordinary pages pay only the + // inexpensive scan on this second pass. + let warmup_ms = std::env::var("OBSCURA_RENDER_RESOURCE_POST_SCRIPT_WARMUP_MS") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(1_000); + let _ = self.prepare_screenshot_resources(warmup_ms).await; + } + self.lifecycle = LifecycleState::DomContentLoaded; if wait_until == crate::lifecycle::WaitUntil::DomContentLoaded { @@ -3172,6 +3221,8 @@ mod tests { navigation_referrer, parse_import_url, rebase_css_urls, script_response_is_executable, split_css_imports, truncate_on_char_boundary, url_matches_cdp_pattern, }; + #[cfg(feature = "render")] + use super::remaining_settle_resource_warmup_ms; use base64::Engine as _; use obscura_dom::parse_html; @@ -4173,6 +4224,87 @@ mod tests { ); } + #[cfg(feature = "render")] + #[tokio::test(flavor = "current_thread")] + async fn navigation_post_script_warmup_seeds_dynamic_images_and_fonts() { + use std::io::{Read, Write}; + + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let (seen_tx, seen_rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + for _ in 0..3 { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = [0u8; 4096]; + let read = stream.read(&mut request).unwrap_or(0); + let path = String::from_utf8_lossy(&request[..read]) + .lines() + .next() + .and_then(|line| line.split_ascii_whitespace().nth(1)) + .unwrap_or("/") + .to_string(); + seen_tx.send(path.clone()).unwrap(); + let (content_type, body): (&str, &[u8]) = match path.as_str() { + "/page" => ( + "text/html", + br#""#, + ), + "/dynamic.svg" => ( + "image/svg+xml", + br#""#, + ), + "/dynamic.woff2" => ("font/woff2", b"not-a-real-font"), + _ => ("text/plain", b"not found"), + }; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + stream.write_all(response.as_bytes()).unwrap(); + stream.write_all(body).unwrap(); + } + }); + + let context = std::sync::Arc::new(crate::BrowserContext::with_storage_and_network( + "dynamic-render-warmup".to_string(), + None, + false, + None, + None, + true, + )); + let mut page = super::Page::new("dynamic-render-warmup".to_string(), context); + let page_url = format!("http://{address}/page"); + page.navigate(&page_url).await.unwrap(); + + let mut paths = (0..3) + .map(|_| seen_rx.recv_timeout(std::time::Duration::from_secs(2)).unwrap()) + .collect::>(); + paths.sort(); + assert_eq!( + paths, + vec![ + "/dynamic.svg".to_string(), + "/dynamic.woff2".to_string(), + "/page".to_string(), + ] + ); + let js = page.js.as_ref().expect("navigation runtime"); + assert!(js.render_resource_is_known(&format!( + "http://{address}/dynamic.svg" + ))); + assert!(js.render_resource_is_known(&format!( + "http://{address}/dynamic.woff2" + ))); + } + #[cfg(feature = "render")] #[test] fn page_screenshot_uses_the_live_window_scroll_offset() { @@ -4510,6 +4642,44 @@ mod tests { assert_client_replacement_survived(&mut page); } + #[cfg(feature = "render")] + #[test] + fn settle_resource_warmup_uses_only_remaining_absolute_budget() { + assert_eq!( + remaining_settle_resource_warmup_ms( + 1_000, + std::time::Duration::from_millis(250), + 1_000, + ), + 750 + ); + assert_eq!( + remaining_settle_resource_warmup_ms( + 1_000, + std::time::Duration::from_millis(250), + 100, + ), + 100 + ); + assert_eq!( + remaining_settle_resource_warmup_ms( + 1_000, + std::time::Duration::from_micros(999_500), + 1_000, + ), + 0, + "a sub-millisecond remainder cannot safely fund a millisecond timeout" + ); + assert_eq!( + remaining_settle_resource_warmup_ms( + 1_000, + std::time::Duration::from_millis(1_001), + 1_000, + ), + 0 + ); + } + #[test] fn url_matches_cdp_pattern_handles_wildcards_across_url_parts() { assert!(url_matches_cdp_pattern( diff --git a/crates/obscura-cdp/src/dispatch.rs b/crates/obscura-cdp/src/dispatch.rs index 3358ee1..6dcb713 100644 --- a/crates/obscura-cdp/src/dispatch.rs +++ b/crates/obscura-cdp/src/dispatch.rs @@ -27,6 +27,14 @@ pub(crate) struct ScreencastState { pub command_frame_counter: u64, pub session_id: i64, pub frames_in_flight: u8, + /// Last connected-document generation observed by the frame producer. + /// The autonomous pump uses this as a cheap compositor-damage signal, so + /// an idle screencast does not continuously rasterize identical frames. + pub observed_activity_generation: u64, + /// A changed generation remains pending across sampling skips and + /// acknowledgement backpressure. Once capacity returns, the newest page + /// state is captured instead of losing the change. + pub autonomous_frame_pending: bool, } pub struct CdpContext { diff --git a/crates/obscura-cdp/src/domains/input.rs b/crates/obscura-cdp/src/domains/input.rs index e394f49..dfcc0c4 100644 --- a/crates/obscura-cdp/src/domains/input.rs +++ b/crates/obscura-cdp/src/domains/input.rs @@ -55,6 +55,37 @@ const BACKSPACE_JS: &str = "(function() {\ t.dispatchEvent(globalThis.__obscura_markTrusted(new Event('input', {bubbles:true})));\ })()"; +fn mouse_button_code(button: &str) -> u8 { + match button { + "middle" => 1, + "right" => 2, + "back" => 3, + "forward" => 4, + _ => 0, + } +} + +fn mouse_button_mask(button: &str) -> u64 { + match button { + "right" => 2, + "middle" => 4, + "back" => 8, + "forward" => 16, + "none" => 0, + _ => 1, + } +} + +fn modifier_flags(modifiers: u64) -> (bool, bool, bool, bool) { + // CDP Input.Modifier: Alt=1, Ctrl=2, Meta=4, Shift=8. + ( + modifiers & 1 != 0, + modifiers & 2 != 0, + modifiers & 4 != 0, + modifiers & 8 != 0, + ) +} + pub async fn handle( method: &str, params: &Value, @@ -66,8 +97,15 @@ pub async fn handle( let event_type = params.get("type").and_then(|v| v.as_str()).unwrap_or(""); let x = params.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0); let y = params.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0); - let _button = params.get("button").and_then(|v| v.as_str()).unwrap_or("left"); + let button = params.get("button").and_then(|v| v.as_str()).unwrap_or("left"); + let button_code = mouse_button_code(button); + let buttons = params + .get("buttons") + .and_then(|v| v.as_u64()) + .unwrap_or_else(|| mouse_button_mask(button)); let click_count = params.get("clickCount").and_then(|v| v.as_u64()).unwrap_or(1); + let modifiers = params.get("modifiers").and_then(|v| v.as_u64()).unwrap_or(0); + let (alt_key, ctrl_key, meta_key, shift_key) = modifier_flags(modifiers); if event_type == "mousePressed" { if let Some(page) = ctx.get_session_page_mut(session_id) { @@ -76,48 +114,21 @@ pub async fn handle( var target = (document.elementFromPoint && document.elementFromPoint({x},{y})) || globalThis.__obscura_click_target || document.activeElement || document.body;\ if (!target) return;\ globalThis.__obscura_click_target = target;\ - var evt = globalThis.__obscura_markTrusted(new MouseEvent('mousedown', {{bubbles:true,cancelable:true,clientX:{x},clientY:{y},button:0,detail:{click_count}}}));\ + globalThis.__obscura_mouse_down = {{target:target,button:{button_code},clickCount:{click_count}}};\ + var evt = globalThis.__obscura_markTrusted(new MouseEvent('mousedown', {{bubbles:true,cancelable:true,view:globalThis,clientX:{x},clientY:{y},button:{button_code},buttons:{buttons},detail:{click_count},altKey:{alt_key},ctrlKey:{ctrl_key},metaKey:{meta_key},shiftKey:{shift_key}}}));\ target.dispatchEvent(evt);\ - var click = globalThis.__obscura_markTrusted(new MouseEvent('click', {{bubbles:true,cancelable:true,clientX:{x},clientY:{y},button:0,detail:{click_count}}}));\ - var cancelled = !target.dispatchEvent(click);\ - if (!cancelled) {{\ - var link = target.closest ? target.closest('a[href]') : null;\ - if (!link && target.tagName === 'A' && target.getAttribute('href')) link = target;\ - if (link) {{\ - var href = link.getAttribute('href');\ - if (href && !href.startsWith('#') && !href.startsWith('javascript:')) {{\ - location.assign(href);\ - }}\ - }} else {{\ - var tag = target.tagName;\ - var type = (target.getAttribute && target.getAttribute('type') || '').toLowerCase();\ - if (tag === 'BUTTON' && type !== 'button' && type !== 'reset') {{\ - var form = target.closest ? target.closest('form') : null;\ - if (form) {{ try {{ if (typeof form.requestSubmit === 'function') {{ form.requestSubmit(target); }} else {{ form.submit(target); }} }} catch(e) {{}} }}\ - }} else if (tag === 'INPUT' && (type === 'submit' || type === 'image')) {{\ - var form2 = target.closest ? target.closest('form') : null;\ - if (form2) {{ try {{ if (typeof form2.requestSubmit === 'function') {{ form2.requestSubmit(target); }} else {{ form2.submit(target); }} }} catch(e) {{}} }}\ - }} else if (tag === 'INPUT' && (type === 'checkbox' || type === 'radio')) {{\ - target.checked = !target.checked;\ - try {{ target.dispatchEvent(globalThis.__obscura_markTrusted(new Event('change', {{bubbles:true}}))); }} catch(e) {{}}\ - }} else if ({click_count} >= 3 && (tag === 'INPUT' || tag === 'TEXTAREA')) {{\ - // Triple-click selects all text (browser native behavior not replicated - // by synthetic MouseEvent, so we do it manually). - var len = target.value ? target.value.length : 0;\ - if (target.setSelectionRange) {{\ - target.setSelectionRange(0, len);\ - }} else {{\ - target.selectionStart = 0;\ - target.selectionEnd = len;\ - }}\ - }}\ - }}\ - }}\ }})()", - x = x, y = y, click_count = click_count, + x = x, + y = y, + button_code = button_code, + buttons = buttons, + click_count = click_count, + alt_key = alt_key, + ctrl_key = ctrl_key, + meta_key = meta_key, + shift_key = shift_key, ); page.evaluate(&code); - page.process_pending_navigation().await.map_err(|e| e.to_string())?; } } else if event_type == "mouseReleased" { if let Some(page) = ctx.get_session_page_mut(session_id) { @@ -125,10 +136,125 @@ pub async fn handle( "(function() {{\ var target = (document.elementFromPoint && document.elementFromPoint({x},{y})) || globalThis.__obscura_click_target || document.activeElement || document.body;\ if (!target) return;\ - var evt = globalThis.__obscura_markTrusted(new MouseEvent('mouseup', {{bubbles:true,cancelable:true,clientX:{x},clientY:{y},button:0}}));\ + var down = globalThis.__obscura_mouse_down;\ + globalThis.__obscura_mouse_down = null;\ + var evt = globalThis.__obscura_markTrusted(new MouseEvent('mouseup', {{bubbles:true,cancelable:true,view:globalThis,clientX:{x},clientY:{y},button:{button_code},buttons:0,detail:{click_count},altKey:{alt_key},ctrlKey:{ctrl_key},metaKey:{meta_key},shiftKey:{shift_key}}}));\ target.dispatchEvent(evt);\ + if (!down || down.button !== {button_code} || {button_code} !== 0) return;\ + var clickTarget = down.target;\ + while (clickTarget && clickTarget !== target && !(clickTarget.contains && clickTarget.contains(target))) {{\ + clickTarget = clickTarget.parentElement;\ + }}\ + if (!clickTarget) return;\ + var tag = clickTarget.tagName;\ + var type = (clickTarget.getAttribute && clickTarget.getAttribute('type') || '').toLowerCase();\ + var checkable = tag === 'INPUT' && (type === 'checkbox' || type === 'radio');\ + var oldChecked = checkable ? !!clickTarget.checked : false;\ + var radioStates = null;\ + if (checkable && type === 'radio') {{\ + var radioName = clickTarget.getAttribute('name') || '';\ + if (radioName) {{\ + var candidates = document.querySelectorAll('input');\ + radioStates = [];\ + for (var ri = 0; ri < candidates.length; ri++) {{\ + var radio = candidates[ri];\ + if ((radio.getAttribute('type') || '').toLowerCase() !== 'radio' || (radio.getAttribute('name') || '') !== radioName || radio.form !== clickTarget.form) continue;\ + radioStates.push([radio, !!radio.checked]);\ + if (radio !== clickTarget) radio.checked = false;\ + }}\ + }}\ + clickTarget.checked = true;\ + }} else if (checkable) {{\ + clickTarget.checked = !oldChecked;\ + }}\ + var click = globalThis.__obscura_markTrusted(new MouseEvent('click', {{bubbles:true,cancelable:true,view:globalThis,clientX:{x},clientY:{y},button:0,buttons:0,detail:{click_count},altKey:{alt_key},ctrlKey:{ctrl_key},metaKey:{meta_key},shiftKey:{shift_key}}}));\ + var cancelled = !clickTarget.dispatchEvent(click);\ + if (cancelled) {{\ + if (radioStates) {{\ + for (var rr = 0; rr < radioStates.length; rr++) radioStates[rr][0].checked = radioStates[rr][1];\ + }} else if (checkable) clickTarget.checked = oldChecked;\ + return;\ + }}\ + if (checkable && clickTarget.checked !== oldChecked) {{\ + try {{ clickTarget.dispatchEvent(globalThis.__obscura_markTrusted(new Event('input', {{bubbles:true}}))); }} catch(e) {{}}\ + try {{ clickTarget.dispatchEvent(globalThis.__obscura_markTrusted(new Event('change', {{bubbles:true}}))); }} catch(e) {{}}\ + return;\ + }}\ + var link = clickTarget.closest ? clickTarget.closest('a[href]') : null;\ + if (!link && tag === 'A' && clickTarget.getAttribute('href')) link = clickTarget;\ + if (link) {{\ + var href = link.getAttribute('href');\ + if (href && !href.startsWith('#') && !href.startsWith('javascript:')) location.assign(href);\ + }} else if (tag === 'BUTTON' && type !== 'button' && type !== 'reset') {{\ + var form = clickTarget.closest ? clickTarget.closest('form') : null;\ + if (form) {{ try {{ if (typeof form.requestSubmit === 'function') {{ form.requestSubmit(clickTarget); }} else {{ form.submit(clickTarget); }} }} catch(e) {{}} }}\ + }} else if (tag === 'INPUT' && (type === 'submit' || type === 'image')) {{\ + var form2 = clickTarget.closest ? clickTarget.closest('form') : null;\ + if (form2) {{ try {{ if (typeof form2.requestSubmit === 'function') {{ form2.requestSubmit(clickTarget); }} else {{ form2.submit(clickTarget); }} }} catch(e) {{}} }}\ + }} else if ({click_count} >= 3 && (tag === 'INPUT' || tag === 'TEXTAREA')) {{\ + var len = clickTarget.value ? clickTarget.value.length : 0;\ + if (clickTarget.setSelectionRange) clickTarget.setSelectionRange(0, len);\ + else {{ clickTarget.selectionStart = 0; clickTarget.selectionEnd = len; }}\ + }}\ }})()", - x = x, y = y, + x = x, + y = y, + button_code = button_code, + click_count = click_count, + alt_key = alt_key, + ctrl_key = ctrl_key, + meta_key = meta_key, + shift_key = shift_key, + ); + page.evaluate(&code); + page.process_pending_navigation().await.map_err(|e| e.to_string())?; + } + } else if event_type == "mouseWheel" { + let delta_x = params.get("deltaX").and_then(|v| v.as_f64()).unwrap_or(0.0); + let delta_y = params.get("deltaY").and_then(|v| v.as_f64()).unwrap_or(0.0); + if let Some(page) = ctx.get_session_page_mut(session_id) { + let code = format!( + "(function() {{\ + var target = (document.elementFromPoint && document.elementFromPoint({x},{y})) || document.body || document.documentElement;\ + if (!target) return;\ + var wheel = globalThis.__obscura_markTrusted(new WheelEvent('wheel', {{bubbles:true,cancelable:true,view:globalThis,clientX:{x},clientY:{y},deltaX:{delta_x},deltaY:{delta_y},deltaMode:0,altKey:{alt_key},ctrlKey:{ctrl_key},metaKey:{meta_key},shiftKey:{shift_key}}}));\ + if (!target.dispatchEvent(wheel)) return;\ + var dx = {delta_x}, dy = {delta_y};\ + var root = document.scrollingElement || document.documentElement || document.body;\ + var scrollTarget = null;\ + var el = target;\ + while (el && el.nodeType === 1 && el !== root && el !== document.body && el !== document.documentElement) {{\ + var maxX = Math.max(0, (el.scrollWidth || 0) - (el.clientWidth || 0));\ + var maxY = Math.max(0, (el.scrollHeight || 0) - (el.clientHeight || 0));\ + var style = null;\ + try {{ style = getComputedStyle(el); }} catch (_e) {{}}\ + var ox = style ? (style.overflowX || style.overflow || '') : '';\ + var oy = style ? (style.overflowY || style.overflow || '') : '';\ + var allowX = ox === 'auto' || ox === 'scroll' || ox === 'overlay';\ + var allowY = oy === 'auto' || oy === 'scroll' || oy === 'overlay';\ + var consumesX = allowX && ((dx > 0 && el.scrollLeft < maxX) || (dx < 0 && el.scrollLeft > 0));\ + var consumesY = allowY && ((dy > 0 && el.scrollTop < maxY) || (dy < 0 && el.scrollTop > 0));\ + if (consumesX || consumesY) {{ scrollTarget = el; break; }}\ + el = el.parentElement;\ + }}\ + if (!scrollTarget) scrollTarget = root;\ + if (scrollTarget === root && root && typeof root.scrollBy === 'function') {{\ + var beforeX = root.scrollLeft, beforeY = root.scrollTop;\ + root.scrollBy(dx, dy);\ + if (root.scrollLeft !== beforeX || root.scrollTop !== beforeY) setTimeout(function() {{\ + try {{ document.dispatchEvent(new Event('scroll', {{bubbles:false}})); }} catch (_e) {{}}\ + try {{ globalThis.dispatchEvent(new Event('scroll', {{bubbles:false}})); }} catch (_e) {{}}\ + }}, 0);\ + }} else if (scrollTarget && typeof scrollTarget.scrollBy === 'function') scrollTarget.scrollBy(dx, dy);\ + }})()", + x = x, + y = y, + delta_x = delta_x, + delta_y = delta_y, + alt_key = alt_key, + ctrl_key = ctrl_key, + meta_key = meta_key, + shift_key = shift_key, ); page.evaluate(&code); } diff --git a/crates/obscura-cdp/src/domains/page.rs b/crates/obscura-cdp/src/domains/page.rs index 70fe096..72d45f7 100644 --- a/crates/obscura-cdp/src/domains/page.rs +++ b/crates/obscura-cdp/src/domains/page.rs @@ -266,6 +266,26 @@ fn encode_screenshot( Ok(output) } +/// Keep capture itself an observation of the retained page state. Chromium's +/// capture methods do not start a hidden resource-loading phase, and doing so +/// here added up to three seconds to every screenshot/PDF/screencast start. +/// The browser navigation and settle paths seed render resources proactively. +/// Retain the old environment variable only as an explicit diagnostic escape +/// hatch for callers investigating a slow or missing asset. +#[cfg(feature = "render")] +pub(crate) async fn prepare_capture_resources_if_requested( + page: &mut obscura_browser::Page, +) { + let Some(deadline_ms) = std::env::var("OBSCURA_RENDER_RESOURCE_DEADLINE_MS") + .ok() + .and_then(|value| value.parse::().ok()) + .filter(|value| *value != 0) + else { + return; + }; + let _ = page.prepare_screenshot_resources(deadline_ms).await; +} + #[cfg(feature = "render")] fn screencast_int32(params: &Value, name: &str) -> Result, String> { match params.get(name) { @@ -316,6 +336,8 @@ fn parse_screencast_state(params: &Value, session_id: i64) -> Result = ctx.screencasts.keys().cloned().collect(); + for cdp_session_id in sessions { + if !ctx.screencasts.contains_key(&cdp_session_id) { + continue; + } + let attached_session = Some(cdp_session_id.clone()); + let activity_generation = { + let Some(page) = ctx.get_session_page_mut(&attached_session) else { + continue; + }; + let Some(js) = page.js.as_mut() else { + continue; + }; + // 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 should_attempt = { + let Some(state) = ctx.screencasts.get_mut(&cdp_session_id) else { + continue; + }; + if state.observed_activity_generation != activity_generation { + state.observed_activity_generation = activity_generation; + state.autonomous_frame_pending = true; + } + state.autonomous_frame_pending + }; + if !should_attempt { + continue; + } + if let Err(error) = queue_screencast_frame(ctx, &attached_session, false) { + tracing::warn!(cdp_session_id, "could not produce autonomous screencast frame: {error}"); + } + } +} + #[cfg(feature = "render")] pub(crate) fn command_can_change_screencast_frame(method: &str) -> bool { matches!( @@ -1097,16 +1176,10 @@ pub async fn handle( .as_ref() .ok_or("Page.startScreencast requires an attached target session")? .clone(); - let resource_deadline_ms = std::env::var("OBSCURA_RENDER_RESOURCE_DEADLINE_MS") - .ok() - .and_then(|value| value.parse::().ok()) - .unwrap_or(3_000); let page = ctx .get_session_page_mut(session_id) .ok_or("No page for session")?; - let _ = page - .prepare_screenshot_resources(resource_deadline_ms) - .await; + prepare_capture_resources_if_requested(page).await; let stream_id = ctx.next_screencast_session(); let state = parse_screencast_state(params, stream_id)?; ctx.screencasts.insert(cdp_session.clone(), state); @@ -1121,15 +1194,11 @@ pub async fn handle( ctx.screencasts.remove(&cdp_session); return Err(error); } - tracing::debug!( - cdp_session, - stream_id, - "started command-driven screencast; autonomous compositor frames unavailable" - ); - // Extension fields expose the MVP boundary to direct callers. - Ok( - json!({"obscuraFrameSource": "command-driven", "obscuraAutonomousFrames": false}), - ) + tracing::debug!(cdp_session, stream_id, "started activity-driven screencast"); + Ok(json!({ + "obscuraFrameSource": "activity-driven", + "obscuraAutonomousFrames": true, + })) } #[cfg(not(feature = "render"))] Err("Page.startScreencast requires a build with the render feature".into()) @@ -1183,13 +1252,7 @@ pub async fn handle( let page = ctx .get_session_page_mut(session_id) .ok_or("No page for session")?; - let resource_deadline_ms = std::env::var("OBSCURA_RENDER_RESOURCE_DEADLINE_MS") - .ok() - .and_then(|value| value.parse::().ok()) - .unwrap_or(3_000); - let _ = page - .prepare_screenshot_resources(resource_deadline_ms) - .await; + prepare_capture_resources_if_requested(page).await; let viewport = page.viewport; let device_scale_factor = f64::from(page.device_scale_factor); let trusted_scroll = page.screenshot_scroll_offset(); @@ -1447,8 +1510,8 @@ mod tests { ) .await .expect("start screencast"); - assert_eq!(result["obscuraFrameSource"], "command-driven"); - assert_eq!(result["obscuraAutonomousFrames"], false); + assert_eq!(result["obscuraFrameSource"], "activity-driven"); + assert_eq!(result["obscuraAutonomousFrames"], true); assert_eq!(ctx.pending_events.len(), 2); assert_eq!( ctx.pending_events[0].method, @@ -1535,6 +1598,96 @@ mod tests { assert!(!queue_screencast_frame(&mut ctx, &session, false).unwrap()); } + #[cfg(feature = "render")] + #[tokio::test(flavor = "current_thread")] + async fn capture_methods_do_not_start_default_resource_warmups() { + use std::io::{Read as _, Write as _}; + use std::sync::atomic::{AtomicUsize, Ordering}; + + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let address = listener.local_addr().unwrap(); + let requests = std::sync::Arc::new(AtomicUsize::new(0)); + let observed = requests.clone(); + std::thread::spawn(move || { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while std::time::Instant::now() < deadline { + match listener.accept() { + Ok((mut stream, _)) => { + let mut request = [0u8; 2048]; + let _ = stream.read(&mut request); + observed.fetch_add(1, Ordering::SeqCst); + let body = br#""#; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: image/svg+xml\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + let _ = stream.write_all(response.as_bytes()); + let _ = stream.write_all(body); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(std::time::Duration::from_millis(2)); + } + Err(_) => break, + } + } + }); + + let context = std::sync::Arc::new( + obscura_browser::BrowserContext::with_storage_and_network( + "capture-without-warmup".to_string(), + None, + false, + None, + None, + true, + ), + ); + let mut ctx = CdpContext::new_with_shared_context(context); + 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((100.0, 80.0)); + handle( + "navigate", + &json!({ + "url": "data:text/html,", + "waitUntil": "load", + }), + &mut ctx, + &session, + ) + .await + .expect("navigate capture fixture"); + + for (index, method) in ["captureScreenshot", "startScreencast", "printToPDF"] + .into_iter() + .enumerate() + { + let asset = format!("http://{address}/capture-{index}.svg"); + let script = format!( + "(function(){{const box=document.createElement('div');box.setAttribute('style','width:10px;height:10px;background-image:url('+{}+')');document.body.appendChild(box);}})()", + serde_json::to_string(&asset).unwrap() + ); + ctx.get_session_page_mut(&session) + .expect("page") + .evaluate(&script); + handle(method, &json!({}), &mut ctx, &session) + .await + .unwrap_or_else(|error| panic!("{method} failed: {error}")); + } + + std::thread::sleep(std::time::Duration::from_millis(75)); + assert_eq!( + requests.load(Ordering::SeqCst), + 0, + "capture APIs must observe retained state without initiating network warmup" + ); + } + #[cfg(feature = "render")] #[test] fn screencast_options_validate_protocol_shapes() { diff --git a/crates/obscura-cdp/src/domains/pdf.rs b/crates/obscura-cdp/src/domains/pdf.rs index b1ef5f8..5a55f73 100644 --- a/crates/obscura-cdp/src/domains/pdf.rs +++ b/crates/obscura-cdp/src/domains/pdf.rs @@ -135,14 +135,10 @@ pub async fn print_to_pdf( #[cfg(feature = "render")] { let options = parse_options(params)?; - let deadline_ms = std::env::var("OBSCURA_RENDER_RESOURCE_DEADLINE_MS") - .ok() - .and_then(|value| value.parse::().ok()) - .unwrap_or(3_000); let page = ctx .get_session_page_mut(session_id) .ok_or("No page for session")?; - let _ = page.prepare_screenshot_resources(deadline_ms).await; + crate::domains::page::prepare_capture_resources_if_requested(page).await; let pdf = page .raster_pdf(options.raster) .map_err(|error| error.to_string())?; diff --git a/crates/obscura-cdp/src/server.rs b/crates/obscura-cdp/src/server.rs index acf9d47..9edf1e0 100644 --- a/crates/obscura-cdp/src/server.rs +++ b/crates/obscura-cdp/src/server.rs @@ -711,6 +711,12 @@ async fn cdp_processor( // registers and stays registered across iterations, so a later // `notify_waiters()` wakes this processor even while it is mid-dispatch. let mut shutdown = Box::pin(shutdown_notify.notified()); + // Chromium's PageHandler receives compositor video frames continuously. + // Obscura has no separate compositor thread yet, so active screencasts get + // a bounded 30 Hz opportunity on this connection's owning LocalSet. + let mut screencast_tick = tokio::time::interval(tokio::time::Duration::from_millis(33)); + screencast_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut connection_reply_tx: Option> = None; loop { // Drain any deferred messages from the previous interception window @@ -725,6 +731,13 @@ async fn cdp_processor( Some(m) => m, None => break, }, + _ = screencast_tick.tick(), if has_active_screencast(&ctx) => { + pump_and_forward_screencast_frames( + &mut ctx, + connection_reply_tx.as_ref(), + ).await; + continue; + }, _ = &mut shutdown => { tracing::info!("Shutdown signal received (connection processor)"); break; @@ -734,6 +747,7 @@ async fn cdp_processor( match msg { ServerMessage::NewConnection { reply_tx } => { + connection_reply_tx = Some(reply_tx.clone()); let _ = reply_tx.send( json!({"__init": true}) .to_string(), @@ -772,6 +786,37 @@ async fn cdp_processor( let _ = &ctx; } +fn has_active_screencast(ctx: &CdpContext) -> bool { + #[cfg(feature = "render")] + { + !ctx.screencasts.is_empty() + } + #[cfg(not(feature = "render"))] + { + let _ = ctx; + false + } +} + +async fn pump_and_forward_screencast_frames( + ctx: &mut CdpContext, + reply_tx: Option<&mpsc::UnboundedSender>, +) { + #[cfg(feature = "render")] + crate::domains::page::pump_screencast_frames(ctx).await; + #[cfg(not(feature = "render"))] + let _ = ctx; + + let Some(reply_tx) = reply_tx else { + return; + }; + for event in ctx.pending_events.drain(..) { + if let Ok(json) = serde_json::to_string(&event) { + let _ = reply_tx.send(json); + } + } +} + // Whether a raw CDP frame is exactly a `Page.navigate` call, and so should take // the spawn-and-defer navigation path. Matching on the parsed method rather than // a `contains("Page.navigate")` substring avoids catching @@ -1357,6 +1402,8 @@ async fn handle_connection_ws( #[cfg(test)] mod tests { use super::{is_navigate_method, merge_cookie_delta, parse_cdp_headers}; + #[cfg(feature = "render")] + use super::pump_and_forward_screencast_frames; use obscura_net::{CookieInfo, CookieJar}; use serde_json::json; @@ -1436,4 +1483,91 @@ mod tests { fn parse_cdp_headers_absent_is_none() { assert!(parse_cdp_headers(&json!({"url": "https://example.com"})).is_none()); } + + #[cfg(feature = "render")] + #[tokio::test(flavor = "current_thread")] + async fn autonomous_screencast_pumps_timers_and_retains_backpressured_damage() { + let mut ctx = crate::dispatch::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.clone()); + ctx.get_session_page_mut(&session) + .expect("page") + .set_viewport((96.0, 64.0)); + crate::domains::page::handle( + "navigate", + &json!({ + "url": "data:text/html,", + "waitUntil": "load", + }), + &mut ctx, + &session, + ) + .await + .expect("navigate screencast fixture"); + ctx.pending_events.clear(); + crate::domains::page::handle( + "startScreencast", + &json!({}), + &mut ctx, + &session, + ) + .await + .expect("start screencast"); + let stream_id = ctx + .pending_events + .iter() + .find(|event| event.method == "Page.screencastFrame") + .and_then(|event| event.params["sessionId"].as_i64()) + .expect("initial stream id"); + ctx.pending_events.clear(); + + let (reply_tx, mut reply_rx) = tokio::sync::mpsc::unbounded_channel(); + ctx.get_session_page_mut(&session) + .expect("page") + .evaluate( + "setTimeout(() => document.body.setAttribute('style', 'margin:0;width:96px;height:64px;background:green'), 0)", + ); + pump_and_forward_screencast_frames(&mut ctx, Some(&reply_tx)).await; + let first_update: serde_json::Value = serde_json::from_str( + &reply_rx.try_recv().expect("timer mutation should emit a frame"), + ) + .unwrap(); + assert_eq!(first_update["method"], "Page.screencastFrame"); + assert_eq!(first_update["params"]["sessionId"], stream_id); + assert!(reply_rx.try_recv().is_err()); + assert_eq!(ctx.screencasts[&session_id].frames_in_flight, 2); + + // The second mutation is pumped while the two-frame acknowledgement + // window is full. It must not emit yet, but its damage must remain + // pending and appear immediately after capacity is returned. + ctx.get_session_page_mut(&session) + .expect("page") + .evaluate( + "setTimeout(() => document.body.setAttribute('style', 'margin:0;width:96px;height:64px;background:blue'), 0)", + ); + pump_and_forward_screencast_frames(&mut ctx, Some(&reply_tx)).await; + assert!(reply_rx.try_recv().is_err()); + assert!(ctx.screencasts[&session_id].autonomous_frame_pending); + + crate::domains::page::handle( + "screencastFrameAck", + &json!({"sessionId": stream_id}), + &mut ctx, + &session, + ) + .await + .expect("ack current frame"); + pump_and_forward_screencast_frames(&mut ctx, Some(&reply_tx)).await; + let after_ack: serde_json::Value = serde_json::from_str( + &reply_rx + .try_recv() + .expect("backpressured damage should emit after ack"), + ) + .unwrap(); + assert_eq!(after_ack["method"], "Page.screencastFrame"); + assert_eq!(after_ack["params"]["sessionId"], stream_id); + assert!(!ctx.screencasts[&session_id].autonomous_frame_pending); + } } diff --git a/crates/obscura-cdp/tests/form_submit_method_bypasses_listener.rs b/crates/obscura-cdp/tests/form_submit_method_bypasses_listener.rs index 5f068b6..7e7506f 100644 --- a/crates/obscura-cdp/tests/form_submit_method_bypasses_listener.rs +++ b/crates/obscura-cdp/tests/form_submit_method_bypasses_listener.rs @@ -131,6 +131,15 @@ async fn request_submit_is_vetoed_by_prevent_default_listener() { ) .await; + cdp( + &mut ctx, + 4, + "Input.dispatchMouseEvent", + json!({"type": "mouseReleased", "x": 0.0, "y": 0.0, "button": "left", "clickCount": 1}), + session_id, + ) + .await; + let page = ctx.get_page_mut(&page_id).unwrap(); assert_ne!( page.url.as_ref().unwrap().path(), diff --git a/crates/obscura-cdp/tests/input_mouse_event_parity.rs b/crates/obscura-cdp/tests/input_mouse_event_parity.rs new file mode 100644 index 0000000..08ce1b2 --- /dev/null +++ b/crates/obscura-cdp/tests/input_mouse_event_parity.rs @@ -0,0 +1,377 @@ +#![cfg(feature = "render")] + +use obscura_cdp::dispatch::{dispatch, CdpContext}; +use obscura_cdp::types::CdpRequest; +use serde_json::{json, Value}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; + +async fn serve_fixture() -> String { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut buf = [0u8; 2048]; + let _ = socket.read(&mut buf).await.unwrap(); + let body = r#" +
+
+ +
+ + +
+ "#; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + let _ = socket.write_all(response.as_bytes()).await; + }); + format!("http://{addr}/") +} + +async fn cdp( + ctx: &mut CdpContext, + id: u64, + method: &str, + params: Value, + session_id: &str, +) -> Value { + let response = dispatch( + &CdpRequest { + id, + method: method.to_string(), + params, + session_id: Some(session_id.to_string()), + }, + ctx, + ) + .await; + assert!(response.error.is_none(), "CDP {method} failed: {:?}", response.error); + response.result.unwrap_or_else(|| json!({})) +} + +async fn evaluate(ctx: &mut CdpContext, id: u64, expression: &str, session_id: &str) -> Value { + cdp( + ctx, + id, + "Runtime.evaluate", + json!({"expression": expression, "returnByValue": true, "awaitPromise": true}), + session_id, + ) + .await +} + +async fn setup() -> (CdpContext, String) { + std::env::set_var("OBSCURA_ALLOW_PRIVATE_NETWORK", "1"); + let url = serve_fixture().await; + let mut ctx = CdpContext::new(); + let page_id = ctx.create_page(); + let session_id = "input-mouse-session"; + ctx.sessions.insert(session_id.to_string(), page_id); + cdp( + &mut ctx, + 1, + "Page.navigate", + json!({"url": url, "waitUntil": "load"}), + session_id, + ) + .await; + (ctx, session_id.to_string()) +} + +async fn wheel(ctx: &mut CdpContext, id: u64, sid: &str, x: f64, y: f64, dx: f64, dy: f64) { + cdp( + ctx, + id, + "Input.dispatchMouseEvent", + json!({"type": "mouseWheel", "x": x, "y": y, "deltaX": dx, "deltaY": dy}), + sid, + ) + .await; +} + +async fn scroll_state(ctx: &mut CdpContext, id: u64, sid: &str) -> Value { + let result = evaluate( + ctx, + id, + r#"JSON.stringify({ + rootX: scrollX, rootY: scrollY, + boxX: document.getElementById('box').scrollLeft, + boxY: document.getElementById('box').scrollTop, + rootScrollWidth: document.scrollingElement.scrollWidth, + rootClientWidth: document.scrollingElement.clientWidth, + pageRect: document.getElementById('page').getBoundingClientRect().toJSON(), + maxBoxX: document.getElementById('box').scrollWidth - document.getElementById('box').clientWidth, + maxBoxY: document.getElementById('box').scrollHeight - document.getElementById('box').clientHeight + })"#, + sid, + ) + .await; + serde_json::from_str(result["result"]["value"].as_str().unwrap()).unwrap() +} + +#[tokio::test(flavor = "current_thread")] +async fn wheel_over_page_scrolls_the_root_on_both_axes() { + let (mut ctx, sid) = setup().await; + wheel(&mut ctx, 2, &sid, 600.0, 300.0, 45.0, 160.0).await; + let state = scroll_state(&mut ctx, 3, &sid).await; + assert_eq!(state["rootX"], 45.0, "unexpected root geometry: {state}"); + assert_eq!(state["rootY"], 160.0); + assert_eq!(state["boxX"], 0.0); + assert_eq!(state["boxY"], 0.0); +} + +#[tokio::test(flavor = "current_thread")] +async fn wheel_over_nested_overflow_scrolls_the_nested_container() { + let (mut ctx, sid) = setup().await; + wheel(&mut ctx, 2, &sid, 50.0, 50.0, 70.0, 110.0).await; + let state = scroll_state(&mut ctx, 3, &sid).await; + assert_eq!(state["boxX"], 70.0); + assert_eq!(state["boxY"], 110.0); + assert_eq!(state["rootX"], 0.0, "nested wheel must not leak to the viewport"); + assert_eq!(state["rootY"], 0.0, "nested wheel must not leak to the viewport"); +} + +#[tokio::test(flavor = "current_thread")] +async fn wheel_offsets_clamp_to_nested_scroll_extents() { + let (mut ctx, sid) = setup().await; + wheel(&mut ctx, 2, &sid, 50.0, 50.0, 100_000.0, 100_000.0).await; + let state = scroll_state(&mut ctx, 3, &sid).await; + assert_eq!(state["boxX"], state["maxBoxX"]); + assert_eq!(state["boxY"], state["maxBoxY"]); + + wheel(&mut ctx, 4, &sid, 50.0, 50.0, -100_000.0, -100_000.0).await; + let state = scroll_state(&mut ctx, 5, &sid).await; + assert_eq!(state["boxX"], 0.0); + assert_eq!(state["boxY"], 0.0); +} + +#[tokio::test(flavor = "current_thread")] +async fn wheel_chains_to_root_when_nested_scroller_is_saturated() { + let (mut ctx, sid) = setup().await; + evaluate( + &mut ctx, + 2, + "(() => { const box = document.getElementById('box'); box.scrollTop = box.scrollHeight; })()", + &sid, + ) + .await; + let saturated = scroll_state(&mut ctx, 3, &sid).await; + assert_eq!(saturated["boxY"], saturated["maxBoxY"]); + + wheel(&mut ctx, 4, &sid, 50.0, 50.0, 0.0, 90.0).await; + let state = scroll_state(&mut ctx, 5, &sid).await; + assert_eq!(state["boxY"], state["maxBoxY"], "inner remains clamped"); + assert_eq!(state["rootY"], 90.0, "remaining wheel gesture chains to the viewport"); +} + +#[tokio::test(flavor = "current_thread")] +async fn canceling_wheel_prevents_its_scroll_default() { + let (mut ctx, sid) = setup().await; + evaluate( + &mut ctx, + 2, + r#"(() => { + globalThis.wheelProbe = null; + const page = document.getElementById('page'); + document.elementFromPoint = () => page; + page.addEventListener('wheel', event => { + wheelProbe = { + x: event.clientX, y: event.clientY, + dx: event.deltaX, dy: event.deltaY, + ctrl: event.ctrlKey, trusted: event.isTrusted + }; + event.preventDefault(); + }); + })()"#, + &sid, + ) + .await; + cdp( + &mut ctx, + 3, + "Input.dispatchMouseEvent", + json!({ + "type": "mouseWheel", "x": 600.0, "y": 300.0, + "deltaX": 25.0, "deltaY": 75.0, "modifiers": 2 + }), + &sid, + ) + .await; + let state = scroll_state(&mut ctx, 4, &sid).await; + assert_eq!(state["rootX"], 0.0); + assert_eq!(state["rootY"], 0.0); + let probe = evaluate(&mut ctx, 5, "JSON.stringify(wheelProbe)", &sid).await; + let probe: Value = serde_json::from_str(probe["result"]["value"].as_str().unwrap()).unwrap(); + assert_eq!(probe["x"], 600.0); + assert_eq!(probe["y"], 300.0); + assert_eq!(probe["dx"], 25.0); + assert_eq!(probe["dy"], 75.0); + assert_eq!(probe["ctrl"], true); + assert_eq!(probe["trusted"], true); +} + +#[tokio::test(flavor = "current_thread")] +async fn hit_testing_clips_scrolled_children_at_overflow_padding_edge() { + let (mut ctx, sid) = setup().await; + let result = evaluate( + &mut ctx, + 2, + r#"(() => { + const box = document.getElementById('box'); + box.scrollLeft = 50; + const inner = document.getElementById('inner').getBoundingClientRect(); + return JSON.stringify({ + hit: document.elementFromPoint(25, 50).id, + innerLeft: inner.left, innerRight: inner.right, + boxLeft: box.getBoundingClientRect().left + }); + })()"#, + &sid, + ) + .await; + let result: Value = serde_json::from_str(result["result"]["value"].as_str().unwrap()).unwrap(); + assert!(result["innerLeft"].as_f64().unwrap() <= 25.0); + assert!(result["innerRight"].as_f64().unwrap() >= 25.0); + assert_eq!(result["boxLeft"], 20.0); + assert_eq!(result["hit"], "box", "content hidden behind the border cannot win hit testing"); +} + +#[tokio::test(flavor = "current_thread")] +async fn press_release_orders_events_and_defers_click_activation() { + let (mut ctx, sid) = setup().await; + evaluate( + &mut ctx, + 2, + r#"(() => { + const target = document.getElementById('check'); + document.elementFromPoint = () => target; + globalThis.mouseLog = []; + for (const type of ['mousedown', 'mouseup', 'click', 'input', 'change']) { + target.addEventListener(type, event => mouseLog.push({ + type, checked: target.checked, x: event.clientX, + ctrl: event.ctrlKey, shift: event.shiftKey, trusted: event.isTrusted + })); + } + })()"#, + &sid, + ) + .await; + + cdp( + &mut ctx, + 3, + "Input.dispatchMouseEvent", + json!({ + "type": "mousePressed", "x": 31.0, "y": 42.0, + "button": "left", "clickCount": 1, "modifiers": 10 + }), + &sid, + ) + .await; + let pressed = evaluate( + &mut ctx, + 4, + "JSON.stringify({log: mouseLog, checked: document.getElementById('check').checked})", + &sid, + ) + .await; + let pressed: Value = serde_json::from_str(pressed["result"]["value"].as_str().unwrap()).unwrap(); + assert_eq!(pressed["checked"], false, "checkbox activation must wait for release"); + assert_eq!(pressed["log"][0]["type"], "mousedown"); + assert_eq!(pressed["log"].as_array().unwrap().len(), 1, "press must not synthesize click"); + + cdp( + &mut ctx, + 5, + "Input.dispatchMouseEvent", + json!({ + "type": "mouseReleased", "x": 31.0, "y": 42.0, + "button": "left", "clickCount": 1, "modifiers": 10 + }), + &sid, + ) + .await; + let released = evaluate( + &mut ctx, + 6, + "JSON.stringify({log: mouseLog, checked: document.getElementById('check').checked})", + &sid, + ) + .await; + let released: Value = serde_json::from_str(released["result"]["value"].as_str().unwrap()).unwrap(); + let types: Vec<&str> = released["log"] + .as_array() + .unwrap() + .iter() + .map(|entry| entry["type"].as_str().unwrap()) + .collect(); + assert_eq!(types, ["mousedown", "mouseup", "click", "input", "change"]); + assert_eq!(released["checked"], true); + assert_eq!(released["log"][2]["checked"], true, "click sees checkbox pre-activation"); + assert_eq!(released["log"][2]["x"], 31.0); + assert_eq!(released["log"][2]["ctrl"], true); + assert_eq!(released["log"][2]["shift"], true); + assert_eq!(released["log"][2]["trusted"], true); +} + +#[tokio::test(flavor = "current_thread")] +async fn radio_release_selects_only_the_target_in_its_group() { + let (mut ctx, sid) = setup().await; + evaluate( + &mut ctx, + 2, + r#"(() => { + const a = document.getElementById('radio-a'); + const b = document.getElementById('radio-b'); + document.elementFromPoint = () => b; + globalThis.radioEvents = []; + for (const radio of [a, b]) { + for (const type of ['mousedown', 'mouseup', 'click', 'input', 'change']) { + radio.addEventListener(type, () => radioEvents.push(radio.id + ':' + type)); + } + } + })()"#, + &sid, + ) + .await; + cdp( + &mut ctx, + 3, + "Input.dispatchMouseEvent", + json!({"type": "mousePressed", "x": 10.0, "y": 10.0, "button": "left"}), + &sid, + ) + .await; + cdp( + &mut ctx, + 4, + "Input.dispatchMouseEvent", + json!({"type": "mouseReleased", "x": 10.0, "y": 10.0, "button": "left"}), + &sid, + ) + .await; + let result = evaluate( + &mut ctx, + 5, + "JSON.stringify({a: document.getElementById('radio-a').checked, b: document.getElementById('radio-b').checked, events: radioEvents})", + &sid, + ) + .await; + let result: Value = serde_json::from_str(result["result"]["value"].as_str().unwrap()).unwrap(); + assert_eq!(result["a"], false); + assert_eq!(result["b"], true); + assert_eq!( + result["events"], + json!(["radio-b:mousedown", "radio-b:mouseup", "radio-b:click", "radio-b:input", "radio-b:change"]), + "the newly selected radio alone receives activation events" + ); +} diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index 763b2d1..4de6ced 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -7123,7 +7123,12 @@ globalThis.UIEvent = class extends Event { this.detail=detail||0; } }; -globalThis.WheelEvent = class extends Event { constructor(t,o={}) { super(t,o);this.deltaX=o.deltaX||0;this.deltaY=o.deltaY||0;this.deltaZ=o.deltaZ||0;this.deltaMode=o.deltaMode||0; } }; +// WheelEvent inherits all MouseEvent coordinates and modifier state. CDP +// Input.dispatchMouseEvent supplies those fields and automation libraries use +// them to distinguish wheel gestures over nested panes. +globalThis.WheelEvent = class extends MouseEvent { + constructor(t,o={}) { super(t,o);this.deltaX=o.deltaX||0;this.deltaY=o.deltaY||0;this.deltaZ=o.deltaZ||0;this.deltaMode=o.deltaMode||0; } +}; globalThis.CompositionEvent = class extends Event { constructor(t,o={}) { super(t,o);this.view=o.view||null;this.detail=o.detail||0;this.data=o.data||""; } @@ -11632,6 +11637,39 @@ if (typeof Document !== 'undefined' && !Document.prototype.elementFromPoint) { var r = el.getBoundingClientRect(); if (r.width === 0 || r.height === 0) continue; if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) { + // A descendant's layout rect can extend beyond an overflow clip. It + // must not win hit testing where its scrolling ancestor hides it — + // otherwise a wheel well outside a small pane scrolls that pane + // instead of the page behind it. + var visible = true; + var ancestor = el.parentElement; + while (ancestor && ancestor !== this.documentElement && ancestor !== this.body) { + var style = null; + try { style = getComputedStyle(ancestor); } catch (_e) {} + var ox = style ? (style.overflowX || style.overflow || '') : ''; + var oy = style ? (style.overflowY || style.overflow || '') : ''; + var clipsX = ox === 'auto' || ox === 'scroll' || ox === 'hidden' || ox === 'clip'; + var clipsY = oy === 'auto' || oy === 'scroll' || oy === 'hidden' || oy === 'clip'; + if (clipsX || clipsY) { + var ar = ancestor.getBoundingClientRect(); + // Overflow clips at the padding box, inside the border. Renderer + // client metrics expose that box's size; computed border widths + // locate it within the border-box rect. + var borderLeft = parseFloat(style && style.borderLeftWidth) || 0; + var borderTop = parseFloat(style && style.borderTopWidth) || 0; + var clipLeft = ar.left + borderLeft; + var clipTop = ar.top + borderTop; + var clipRight = clipLeft + ancestor.clientWidth; + var clipBottom = clipTop + ancestor.clientHeight; + if ((clipsX && (x < clipLeft || x > clipRight)) || + (clipsY && (y < clipTop || y > clipBottom))) { + visible = false; + break; + } + } + ancestor = ancestor.parentElement; + } + if (!visible) continue; var nid = el._nid | 0; if (nid > bestNid) { best = el; bestNid = nid; } } diff --git a/crates/obscura-js/src/ops.rs b/crates/obscura-js/src/ops.rs index 7bdb11f..a6acc39 100644 --- a/crates/obscura-js/src/ops.rs +++ b/crates/obscura-js/src/ops.rs @@ -129,6 +129,11 @@ pub struct ObscuraState { /// DOM/style/viewport changes clear this value but retain resource bytes. #[cfg(feature = "render")] pub prepared_render: Option, + /// Connected attribute mutations awaiting dependency-indexed retained + /// style refresh. Tree/text and non-attribute invalidations still discard + /// the prepared render immediately. + #[cfg(feature = "render")] + pub pending_attribute_style_mutations: Vec, /// Page-lifetime raw image/font bytes. A new document resets this cache; /// relayout of the same document reuses it without refetching. #[cfg(feature = "render")] @@ -197,6 +202,8 @@ impl ObscuraState { #[cfg(feature = "render")] prepared_render: None, #[cfg(feature = "render")] + pending_attribute_style_mutations: Vec::new(), + #[cfg(feature = "render")] render_resources: obscura_render::RenderResourceCache::default(), #[cfg(feature = "render")] stylesheet_cache: obscura_render::StylesheetCache::default(), @@ -465,6 +472,48 @@ fn render_mutation_impact( } } +#[cfg(feature = "render")] +fn retained_attribute_style_mutation( + dom: &DomTree, + cmd: &str, + arg1: &str, + 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.starts_with("data-") + || name.starts_with("aria-") + }; + match cmd { + "set_attribute" => { + let (name, value) = arg2.split_once('\0')?; + if !can_retain(name) { + return None; + } + Some(obscura_render::AttributeStyleMutation { + node, + name: name.to_string(), + old_value: dom + .with_node(node, |node| node.get_attribute(name).map(str::to_owned)) + .flatten(), + new_value: Some(value.to_string()), + }) + } + "remove_attribute" => can_retain(arg2).then(|| obscura_render::AttributeStyleMutation { + node, + name: arg2.to_string(), + old_value: dom + .with_node(node, |node| node.get_attribute(arg2).map(str::to_owned)) + .flatten(), + new_value: None, + }), + _ => None, + } +} + #[cfg(feature = "render")] fn render_timing_enabled() -> bool { static ENABLED: std::sync::OnceLock = std::sync::OnceLock::new(); @@ -605,6 +654,11 @@ fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> Str .as_ref() .map(|dom| render_mutation_impact(dom, &cmd, &arg1, &arg2)) .unwrap_or_default(); + #[cfg(feature = "render")] + let retained_attribute_mutation = state + .dom + .as_ref() + .and_then(|dom| retained_attribute_style_mutation(dom, &cmd, &arg1, &arg2)); let invalidate = impact.connected && impact.actual_change; if invalidate { state.activity_generation = state.activity_generation.wrapping_add(1); @@ -620,7 +674,19 @@ fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> Str let had_prepared_render = state.prepared_render.is_some(); #[cfg(feature = "render")] if invalidate { - state.prepared_render = None; + if let Some(mutation) = retained_attribute_mutation { + if state.prepared_render.is_some() + && state.pending_attribute_style_mutations.len() < 256 + { + state.pending_attribute_style_mutations.push(mutation); + } else { + state.prepared_render = None; + state.pending_attribute_style_mutations.clear(); + } + } else { + state.prepared_render = None; + state.pending_attribute_style_mutations.clear(); + } state.resolved_scroll = None; } #[cfg(feature = "render")] @@ -2958,6 +3024,7 @@ fn op_set_dynamic_fonts(state: &OpState, #[string] registrations: &str) -> bool if state.dynamic_fonts != fonts { state.dynamic_fonts = fonts; state.prepared_render = None; + state.pending_attribute_style_mutations.clear(); state.resolved_scroll = None; } true @@ -3041,17 +3108,41 @@ pub(crate) fn ensure_prepared_render( let stale = state.prepared_render.as_ref().map_or(true, |prepared| { prepared.viewport() != viewport || prepared.base_url() != base_url.as_deref() }); - if stale { + if stale || !state.pending_attribute_style_mutations.is_empty() { + let previous = (!stale).then(|| state.prepared_render.take()).flatten(); + let mutations = std::mem::take(&mut state.pending_attribute_style_mutations); let prepared = { let dom = state.dom.as_ref()?; - obscura_render::prepare_dom_with_dynamic_fonts_and_stylesheet_cache( - dom, - viewport, - base_url.as_deref(), - &mut state.render_resources, - &state.dynamic_fonts, - &mut state.stylesheet_cache, - )? + match previous { + Some(previous) => obscura_render::prepare_dom_with_retained_attribute_styles( + dom, + viewport, + base_url.as_deref(), + &mut state.render_resources, + &state.dynamic_fonts, + &mut state.stylesheet_cache, + previous, + &mutations, + ) + .or_else(|| { + obscura_render::prepare_dom_with_dynamic_fonts_and_stylesheet_cache( + dom, + viewport, + base_url.as_deref(), + &mut state.render_resources, + &state.dynamic_fonts, + &mut state.stylesheet_cache, + ) + })?, + None => obscura_render::prepare_dom_with_dynamic_fonts_and_stylesheet_cache( + dom, + viewport, + base_url.as_deref(), + &mut state.render_resources, + &state.dynamic_fonts, + &mut state.stylesheet_cache, + )?, + } }; state.prepared_render = Some(prepared); state.resolved_scroll = None; diff --git a/crates/obscura-js/src/runtime.rs b/crates/obscura-js/src/runtime.rs index 8ccd312..6431fd3 100644 --- a/crates/obscura-js/src/runtime.rs +++ b/crates/obscura-js/src/runtime.rs @@ -31,6 +31,24 @@ static SNAPSHOT: &[u8] = include_bytes!(env!("OBSCURA_SNAPSHOT_PATH")); /// parallel, each isolate on its own thread with no shared lock. static ISOLATE_CREATE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); +#[cfg(feature = "render")] +fn with_sync_render_loading_disabled( + state: &mut ObscuraState, + capture: impl FnOnce(&mut ObscuraState) -> R, +) -> R { + let previous = state + .render_resources + .set_sync_loading_enabled(false); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| capture(state))); + state + .render_resources + .set_sync_loading_enabled(previous); + match result { + Ok(value) => value, + Err(payload) => std::panic::resume_unwind(payload), + } +} + #[derive(Debug, Clone)] pub struct RemoteObjectInfo { pub js_type: String, @@ -246,6 +264,7 @@ impl ObscuraJsRuntime { #[cfg(feature = "render")] { gs.prepared_render = None; + gs.pending_attribute_style_mutations.clear(); gs.render_resources = obscura_render::RenderResourceCache::default(); gs.stylesheet_cache = obscura_render::StylesheetCache::default(); gs.dynamic_fonts.clear(); @@ -266,6 +285,7 @@ impl ObscuraJsRuntime { // present. Keep already-fetched absolute bytes, but rebuild // candidate selection/layout against the new base. state.prepared_render = None; + state.pending_attribute_style_mutations.clear(); state.resolved_scroll = None; } } @@ -376,6 +396,7 @@ impl ObscuraJsRuntime { if state.viewport != viewport { state.viewport = viewport; state.prepared_render = None; + state.pending_attribute_style_mutations.clear(); state.resolved_scroll = None; } } @@ -418,21 +439,23 @@ impl ObscuraJsRuntime { if viewport != state.viewport || base_url != effective_base.as_deref() { return None; } - ensure_resolved_scroll(&mut state)?; - let ObscuraState { - dom, - prepared_render, - render_resources, - resolved_scroll, - .. - } = &mut *state; - let (_, scroll) = resolved_scroll.as_ref()?; - obscura_render::screenshot_prepared_with_scroll( - dom.as_ref()?, - prepared_render.as_mut()?, - render_resources, - scroll, - ) + with_sync_render_loading_disabled(&mut state, |state| { + ensure_resolved_scroll(state)?; + let ObscuraState { + dom, + prepared_render, + render_resources, + resolved_scroll, + .. + } = state; + let (_, scroll) = resolved_scroll.as_ref()?; + obscura_render::screenshot_prepared_with_scroll( + dom.as_ref()?, + prepared_render.as_mut()?, + render_resources, + scroll, + ) + }) } /// Capture a document-space rectangle without changing the live viewport, @@ -444,27 +467,29 @@ impl ObscuraJsRuntime { region: obscura_render::CaptureRegion, ) -> Result, obscura_render::CaptureError> { let mut state = self.state.borrow_mut(); - ensure_resolved_scroll(&mut state).ok_or(obscura_render::CaptureError::PaintFailed)?; - let ObscuraState { - dom, - prepared_render, - render_resources, - resolved_scroll, - .. - } = &mut *state; - let (_, scroll) = resolved_scroll - .as_ref() - .ok_or(obscura_render::CaptureError::PaintFailed)?; - obscura_render::screenshot_prepared_region_with_scroll( - dom.as_ref() - .ok_or(obscura_render::CaptureError::PaintFailed)?, - prepared_render - .as_mut() - .ok_or(obscura_render::CaptureError::PaintFailed)?, - render_resources, - scroll, - region, - ) + with_sync_render_loading_disabled(&mut state, |state| { + ensure_resolved_scroll(state).ok_or(obscura_render::CaptureError::PaintFailed)?; + let ObscuraState { + dom, + prepared_render, + render_resources, + resolved_scroll, + .. + } = state; + let (_, scroll) = resolved_scroll + .as_ref() + .ok_or(obscura_render::CaptureError::PaintFailed)?; + obscura_render::screenshot_prepared_region_with_scroll( + dom.as_ref() + .ok_or(obscura_render::CaptureError::PaintFailed)?, + prepared_render + .as_mut() + .ok_or(obscura_render::CaptureError::PaintFailed)?, + render_resources, + scroll, + region, + ) + }) } /// Return the retained layout's scrollable document size without changing @@ -473,11 +498,13 @@ impl ObscuraJsRuntime { #[cfg(feature = "render")] pub fn prepared_content_size(&self) -> Option<(f32, f32)> { let mut state = self.state.borrow_mut(); - ensure_resolved_scroll(&mut state)?; - state - .prepared_render - .as_ref() - .map(|render| render.content_size()) + with_sync_render_loading_disabled(&mut state, |state| { + ensure_resolved_scroll(state)?; + state + .prepared_render + .as_ref() + .map(|render| render.content_size()) + }) } /// Return the exact responsive candidates selected for live `` @@ -530,6 +557,7 @@ impl ObscuraJsRuntime { Some(bytes) => { state.render_resources.seed(url, bytes); state.prepared_render = None; + state.pending_attribute_style_mutations.clear(); state.resolved_scroll = None; } None => state.render_resources.seed_missing(url), @@ -1548,6 +1576,7 @@ impl ObscuraJsRuntime { #[cfg(feature = "render")] { state.prepared_render = None; + state.pending_attribute_style_mutations.clear(); state.render_resources = obscura_render::RenderResourceCache::default(); state.stylesheet_cache = obscura_render::StylesheetCache::default(); state.dynamic_fonts.clear(); @@ -6157,7 +6186,11 @@ mod tests { // must observe the new class on the live connected element. rt.evaluate("document.getElementById('box').className = 'b'") .unwrap(); - assert!(rt.state.borrow().prepared_render.is_none()); + { + let state = rt.state.borrow(); + assert!(state.prepared_render.is_some()); + assert_eq!(state.pending_attribute_style_mutations.len(), 1); + } assert_eq!( rt.evaluate("document.getElementById('box').getBoundingClientRect().width") .unwrap() diff --git a/crates/obscura-render/src/css.rs b/crates/obscura-render/src/css.rs index d62d2df..0e59aaf 100644 --- a/crates/obscura-render/src/css.rs +++ b/crates/obscura-render/src/css.rs @@ -601,6 +601,96 @@ fn note_selector_for_invalidation( note_selector_dependencies(map, selector, InvalidationReaches::SELF, rule_order); } +/// Record element attributes read from declaration values. +/// +/// Selector invalidation alone is insufficient for generated content such as +/// `.label::before { content: attr(data-label) }`: changing `data-label` +/// changes the computed pseudo style even though the attribute does not occur +/// in the selector. Keep this deliberately broader than the property parser; +/// an extra self invalidation is cheap, while missing a supported `attr()` +/// spelling would retain stale computed values. +fn note_declaration_attribute_dependencies( + map: &mut InvalidationMap, + declarations: &str, + rule_order: usize, +) { + // Avoid another declaration-vector allocation on the stylesheet hot path: + // almost every rule exits through this byte scan, and the uncommon rule + // containing `attr` pays for the balanced character walk below. + if !declarations + .as_bytes() + .windows(4) + .any(|window| window.eq_ignore_ascii_case(b"attr")) + { + return; + } + let chars: Vec = declarations.chars().collect(); + let mut index = 0usize; + let mut quote = None; + while index < chars.len() { + let ch = chars[index]; + if ch == '\\' { + index = (index + 2).min(chars.len()); + continue; + } + if let Some(active_quote) = quote { + if ch == active_quote { + quote = None; + } + index += 1; + continue; + } + if ch == '\'' || ch == '"' { + quote = Some(ch); + index += 1; + continue; + } + if ch == '/' && chars.get(index + 1) == Some(&'*') { + index += 2; + while index + 1 < chars.len() + && !(chars[index] == '*' && chars[index + 1] == '/') + { + index += 1; + } + index = (index + 2).min(chars.len()); + continue; + } + if !(ch.is_alphabetic() || ch == '_' || ch == '-' || !ch.is_ascii()) { + index += 1; + continue; + } + let (function, next) = consume_css_identifier(&chars, index); + index = next.max(index + 1); + if !function.eq_ignore_ascii_case("attr") { + continue; + } + let mut open = index; + while chars.get(open).is_some_and(|ch| ch.is_whitespace()) { + open += 1; + } + if chars.get(open) != Some(&'(') { + continue; + } + let Some(close) = matching_delimiter(&chars, open, '(', ')') else { + // An unterminated function cannot be consumed by the current + // declaration parser, so it has no live attribute dependency. + break; + }; + let mut name_start = open + 1; + while chars + .get(name_start) + .is_some_and(|ch| ch.is_whitespace()) + { + name_start += 1; + } + let (attribute, name_end) = consume_css_identifier(&chars, name_start); + if !attribute.is_empty() && name_end <= close { + map.push_attribute(attribute, rule_order, InvalidationReaches::SELF); + } + index = close + 1; + } +} + fn selector_requires_conservative_tracking(selector: &str) -> bool { let selector = selector.to_ascii_lowercase(); selector.contains(":has(") @@ -1610,6 +1700,11 @@ impl Stylesheet { base, order, ); + note_declaration_attribute_dependencies( + &mut sheet.invalidation_map, + &decls, + order, + ); let (normal_decls, important_decls) = crate::style::partition_declarations(&decls); let normal_flags = declaration_stream_flags(&normal_decls); @@ -1648,6 +1743,11 @@ impl Stylesheet { base, order, ); + note_declaration_attribute_dependencies( + &mut sheet.invalidation_map, + &decls, + order, + ); let (normal_decls, important_decls) = crate::style::partition_declarations(&decls); let normal_flags = declaration_stream_flags(&normal_decls); @@ -1691,6 +1791,11 @@ impl Stylesheet { &selector, order, ); + note_declaration_attribute_dependencies( + &mut sheet.invalidation_map, + &decls, + order, + ); let (normal_decls, important_decls) = crate::style::partition_declarations(&decls); let specificity = sel.specificity(); let normal_flags = declaration_stream_flags(&normal_decls); @@ -5587,6 +5692,33 @@ mod tests { assert!(!map.requires_conservative_invalidation()); } + #[test] + fn invalidation_map_indexes_declaration_attr_dependencies() { + let map = test_invalidation_map( + r#" + .label::before { content: ATTR( DATA-LABEL ) } + .typed { --label: attr(Aria-Label string, "fallback") } + .noise::after { + content: "attr(data-in-string)"; + background: url("attr(data-in-url)"); + color: red /* attr(data-in-comment) */; + } + "#, + ); + + assert!(dependencies_reach( + map.attribute_dependencies("data-label"), + InvalidationReaches::SELF, + )); + assert!(dependencies_reach( + map.attribute_dependencies("ARIA-LABEL"), + InvalidationReaches::SELF, + )); + assert!(map.attribute_dependencies("data-in-string").is_empty()); + assert!(map.attribute_dependencies("data-in-url").is_empty()); + assert!(map.attribute_dependencies("data-in-comment").is_empty()); + } + #[test] fn invalidation_map_marks_relative_and_nth_dependencies_conservative() { let map = test_invalidation_map( diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs index c533234..6176a77 100644 --- a/crates/obscura-render/src/dom.rs +++ b/crates/obscura-render/src/dom.rs @@ -354,6 +354,26 @@ pub struct DomLayout { pub(crate) generated_boxes: Vec, } +/// One connected element attribute mutation eligible for conservative +/// retained-style invalidation. Callers must use the ordinary full-layout +/// path for tree/text mutations and for attributes with non-CSS rendering +/// semantics (image sources, form state, inline style, and similar). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AttributeStyleMutation { + pub node: NodeId, + pub name: String, + pub old_value: Option, + pub new_value: Option, +} + +/// Style maps moved out of a previous layout. Moving, rather than cloning, +/// keeps incremental restyle from retaining a second heap-heavy LayoutStyle +/// graph beside the final used styles. +pub(crate) struct RetainedStyleMaps { + pub styles: HashMap, + pub custom_properties: HashMap>>, +} + /// Dense identifier for one scrolling area in a prepared render. Index zero /// is always the root viewport; element scroll containers follow in DOM order. /// The ids are rebuild-local and must never be persisted by an embedding @@ -1872,6 +1892,7 @@ fn cascade_walk( viewport: (f32, f32), inherited_cell_padding: Option, inherited_color_scheme_dark: bool, + fresh_styles: Option<&HashSet>, ) { let Some(node) = tree.get_node(id) else { return; @@ -1882,7 +1903,25 @@ fn cascade_walk( let mut this_props = parent_props.clone(); let mut descendant_cell_padding = inherited_cell_padding; let mut descendant_color_scheme_dark = inherited_color_scheme_dark; - if let Some(elem) = node.as_element() { + let reuse_style = node.is_element() + && fresh_styles.is_some_and(|fresh| !fresh.contains(&id)) + && styles.contains_key(&id) + && custom_properties.contains_key(&id); + if reuse_style { + this_props = custom_properties + .get(&id) + .cloned() + .unwrap_or_else(|| parent_props.clone()); + descendant_color_scheme_dark = styles + .get(&id) + .is_some_and(|style| style.color_scheme_dark); + if node.as_element().is_some_and(|elem| elem.local.as_ref() == "table") { + descendant_cell_padding = node + .get_attribute("cellpadding") + .and_then(|value| value.trim().parse::().ok()) + .filter(|value| value.is_finite() && *value >= 0.0); + } + } else if let Some(elem) = node.as_element() { if elem.local.as_ref() == "table" { // `cellpadding` is a table-scoped presentational hint applied to // its cells, below author CSS. Entering any nested table resets @@ -2084,6 +2123,7 @@ fn cascade_walk( viewport, descendant_cell_padding, descendant_color_scheme_dark, + fresh_styles, ); } if is_element { @@ -2309,6 +2349,9 @@ struct ContainerLayoutTelemetry { passes: usize, termination: ContainerLayoutTermination, query: crate::css::ContainerQueryStats, + retained_reused: usize, + retained_fresh: usize, + retained_fallback: usize, } fn container_iteration_termination( @@ -2325,6 +2368,97 @@ fn container_iteration_termination( } } +enum RetainedStylePlan { + Reuse(HashSet), + Full, +} + +fn add_style_subtree(tree: &DomTree, root: NodeId, dirty: &mut HashSet) { + dirty.insert(root); + dirty.extend(tree.descendants(root)); + // Rebuild the context chain too. A retained ancestor may carry a final + // post-layout Px repair (native controls, blockification, table/grid + // fixups) where a full pass would feed its specified Auto/inherit form to + // the fresh descendant's normalization. Ancestors are fresh individually; + // their unrelated sibling subtrees remain reusable. + dirty.extend(tree.ancestors(root)); +} + +fn add_following_sibling_subtrees( + tree: &DomTree, + node: NodeId, + dirty: &mut HashSet, +) { + let mut sibling = tree.get_node(node).and_then(|node| node.next_sibling); + while let Some(id) = sibling { + add_style_subtree(tree, id, dirty); + sibling = tree.get_node(id).and_then(|node| node.next_sibling); + } +} + +/// Convert old/new selector keys into a conservative set of fresh cascade +/// roots. Every selected root is expanded to its complete subtree so ordinary +/// CSS inheritance, custom properties, generated content, and later selector +/// compounds remain sound without retaining Gecko's full dependency chains. +fn retained_style_plan( + tree: &DomTree, + sheet: &crate::css::Stylesheet, + mutations: &[AttributeStyleMutation], +) -> RetainedStylePlan { + let mut dirty = HashSet::new(); + for mutation in mutations { + 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.starts_with("data-") + && !name.starts_with("aria-") + { + return RetainedStylePlan::Full; + } + + let map = sheet.invalidation_map(); + let mut dependencies = Vec::new(); + dependencies.extend_from_slice(map.attribute_dependencies(&name)); + if name == "id" { + if let Some(old) = mutation.old_value.as_deref() { + dependencies.extend_from_slice(map.id_dependencies(old)); + } + if let Some(new) = mutation.new_value.as_deref() { + dependencies.extend_from_slice(map.id_dependencies(new)); + } + } else if name == "class" { + for class in mutation + .old_value + .iter() + .chain(&mutation.new_value) + .flat_map(|value| value.split_whitespace()) + { + dependencies.extend_from_slice(map.class_dependencies(class)); + } + } + + for dependency in dependencies { + let reaches = dependency.reaches; + if reaches.contains(crate::css::InvalidationReaches::CONSERVATIVE) { + return RetainedStylePlan::Full; + } + if reaches.contains(crate::css::InvalidationReaches::SELF) + || reaches.contains(crate::css::InvalidationReaches::DESCENDANTS) + { + add_style_subtree(tree, mutation.node, &mut dirty); + } + if reaches.contains(crate::css::InvalidationReaches::SIBLINGS) { + add_following_sibling_subtrees(tree, mutation.node, &mut dirty); + } + } + } + RetainedStylePlan::Reuse(dirty) +} + pub(crate) fn layout_dom_with_web_fonts( tree: &DomTree, viewport: (f32, f32), @@ -2348,6 +2482,30 @@ pub(crate) fn layout_dom_with_web_fonts_and_stylesheet_cache( fonts, None, Some(stylesheet_cache), + None, + &[], + ) + .0 +} + +pub(crate) fn layout_dom_with_web_fonts_and_retained_styles( + tree: &DomTree, + viewport: (f32, f32), + intrinsic: &HashMap, + fonts: &[crate::inline::WebFont], + stylesheet_cache: &mut crate::css::StylesheetCache, + retained: RetainedStyleMaps, + mutations: &[AttributeStyleMutation], +) -> DomLayout { + layout_dom_with_web_fonts_pass_limit( + tree, + viewport, + intrinsic, + fonts, + None, + Some(stylesheet_cache), + Some(retained), + mutations, ) .0 } @@ -2358,7 +2516,7 @@ fn layout_dom_with_web_fonts_measured( intrinsic: &HashMap, fonts: &[crate::inline::WebFont], ) -> (DomLayout, ContainerLayoutTelemetry) { - layout_dom_with_web_fonts_pass_limit(tree, viewport, intrinsic, fonts, None, None) + layout_dom_with_web_fonts_pass_limit(tree, viewport, intrinsic, fonts, None, None, None, &[]) } fn layout_dom_with_web_fonts_pass_limit( @@ -2368,6 +2526,8 @@ fn layout_dom_with_web_fonts_pass_limit( fonts: &[crate::inline::WebFont], pass_limit: Option, stylesheet_cache: Option<&mut crate::css::StylesheetCache>, + retained: Option, + mutations: &[AttributeStyleMutation], ) -> (DomLayout, ContainerLayoutTelemetry) { let timing = std::env::var("OBSCURA_RENDER_TIMING").is_ok(); @@ -2397,12 +2557,38 @@ fn layout_dom_with_web_fonts_pass_limit( }; let t_parse = t0.elapsed(); + let retained_requested = retained.as_ref().map_or(0, |retained| retained.styles.len()); + let retained = retained.and_then(|retained| { + if !stylesheet_cache_hit + || retained + .styles + .values() + .any(|style| style.container_type != crate::ContainerType::Normal) + { + return None; + } + match retained_style_plan(tree, &sheet, mutations) { + RetainedStylePlan::Reuse(dirty) => Some((retained, dirty)), + RetainedStylePlan::Full => None, + } + }); + let retained_fresh = retained.as_ref().map_or(0, |(retained, fresh)| { + retained + .styles + .keys() + .filter(|node| fresh.contains(node)) + .count() + }); + let retained_reused = retained + .as_ref() + .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); + layout_dom_once(tree, viewport, intrinsic, fonts, &sheet, None, retained); if !sheet.has_container_queries() { if timing { let (r, i, c, a, l, u) = sheet.debug_stats(); - eprintln!("[timing] parse+index={:?} stylesheet_cache_hit={} cascade={:?} rules={} id_keys={} class_keys={} attr_keys={} local_keys={} universal={} cq_passes=1 cq_termination=no-queries", t_parse, stylesheet_cache_hit, cascade_time, r, i, c, a, l, u); + eprintln!("[timing] parse+index={:?} stylesheet_cache_hit={} cascade={:?} rules={} id_keys={} class_keys={} attr_keys={} local_keys={} universal={} cq_passes=1 cq_termination=no-queries retained_reused={} retained_fresh={} retained_fallback={}", t_parse, stylesheet_cache_hit, cascade_time, r, i, c, a, l, u, retained_reused, retained_fresh, retained_fallback); } return ( laid, @@ -2410,6 +2596,9 @@ fn layout_dom_with_web_fonts_pass_limit( passes: 1, termination: ContainerLayoutTermination::NoQueries, query, + retained_reused, + retained_fresh, + retained_fallback, }, ); } @@ -2424,7 +2613,7 @@ fn layout_dom_with_web_fonts_pass_limit( if snapshot.boxes.is_empty() { if timing { let (r, i, c, a, l, u) = sheet.debug_stats(); - eprintln!("[timing] parse+index={:?} stylesheet_cache_hit={} cascade={:?} rules={} id_keys={} class_keys={} attr_keys={} local_keys={} universal={} cq_passes=1 cq_termination=no-containers", t_parse, stylesheet_cache_hit, cascade_time, r, i, c, a, l, u); + eprintln!("[timing] parse+index={:?} stylesheet_cache_hit={} cascade={:?} rules={} id_keys={} class_keys={} attr_keys={} local_keys={} universal={} cq_passes=1 cq_termination=no-containers retained_reused={} retained_fresh={} retained_fallback={}", t_parse, stylesheet_cache_hit, cascade_time, r, i, c, a, l, u, retained_reused, retained_fresh, retained_fallback); } return ( laid, @@ -2432,6 +2621,9 @@ fn layout_dom_with_web_fonts_pass_limit( passes: 1, termination: ContainerLayoutTermination::NoContainers, query, + retained_reused, + retained_fresh, + retained_fallback, }, ); } @@ -2471,7 +2663,15 @@ fn layout_dom_with_web_fonts_pass_limit( let mut needs_fallback = false; for pass in 2..=max_passes { let (next, signature, pass_query, pass_cascade) = - layout_dom_once(tree, viewport, intrinsic, fonts, &sheet, Some(&snapshot)); + layout_dom_once( + tree, + viewport, + intrinsic, + fonts, + &sheet, + Some(&snapshot), + None, + ); passes = pass; query.evaluations += pass_query.evaluations; query.cache_hits += pass_query.cache_hits; @@ -2518,7 +2718,7 @@ 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); + layout_dom_once(tree, viewport, intrinsic, fonts, &sheet, None, None); laid = fallback; passes += 1; query.evaluations += fallback_query.evaluations; @@ -2529,7 +2729,7 @@ fn layout_dom_with_web_fonts_pass_limit( if timing { let (r, i, c, a, l, u) = sheet.debug_stats(); - eprintln!("[timing] parse+index={:?} stylesheet_cache_hit={} cascade_total={:?} rules={} id_keys={} class_keys={} attr_keys={} local_keys={} universal={} cq_passes={} cq_termination={:?} cq_evaluations={} cq_cache_hits={} cq_ancestor_steps={}", t_parse, stylesheet_cache_hit, cascade_time, r, i, c, a, l, u, passes, termination, query.evaluations, query.cache_hits, query.ancestor_steps); + eprintln!("[timing] parse+index={:?} stylesheet_cache_hit={} cascade_total={:?} rules={} id_keys={} class_keys={} attr_keys={} local_keys={} universal={} cq_passes={} cq_termination={:?} cq_evaluations={} cq_cache_hits={} cq_ancestor_steps={} retained_reused={} retained_fresh={} retained_fallback={}", t_parse, stylesheet_cache_hit, cascade_time, r, i, c, a, l, u, passes, termination, query.evaluations, query.cache_hits, query.ancestor_steps, retained_reused, retained_fresh, retained_fallback); } ( laid, @@ -2537,6 +2737,9 @@ fn layout_dom_with_web_fonts_pass_limit( passes, termination, query, + retained_reused, + retained_fresh, + retained_fallback, }, ) } @@ -2548,6 +2751,7 @@ fn layout_dom_once( fonts: &[crate::inline::WebFont], sheet: &crate::css::Stylesheet, snapshot: Option<&crate::css::ContainerSnapshot>, + retained: Option<(RetainedStyleMaps, HashSet)>, ) -> ( DomLayout, Option, @@ -2556,8 +2760,10 @@ fn layout_dom_once( ) { let t1 = std::time::Instant::now(); let mut matcher = tree.matcher(); - let mut styles: HashMap = HashMap::new(); - let mut custom_properties = HashMap::new(); + let (mut styles, mut custom_properties, fresh_styles) = match retained { + Some((retained, fresh)) => (retained.styles, retained.custom_properties, Some(fresh)), + None => (HashMap::new(), HashMap::new(), None), + }; let root_props = std::rc::Rc::new(HashMap::new()); let mut evaluator = snapshot.map(|snapshot| crate::css::ContainerQueryEvaluator::new(tree, snapshot)); @@ -2585,6 +2791,7 @@ fn layout_dom_once( viewport, None, false, + fresh_styles.as_ref(), ); resolve_css_counters(tree, &mut styles); let cascade_time = t1.elapsed(); @@ -2771,6 +2978,142 @@ fn layout_dom_once( // (updated to its content width inside the block below). let mut child_cb_width = inh.cb_width; let mut child_cb_height_definite = false; + let reused_computed_style = fresh_styles + .as_ref() + .is_some_and(|fresh| !fresh.contains(&id)); + if reused_computed_style { + // Retained styles have already passed through this destructive + // normalization once. Do not resolve their em/rem/percent or + // CSS-wide inheritance markers a second time. Seed the + // inherited context for a fresh descendant directly from the + // prior computed values, then retain the style byte-for-byte. + if let Some(style) = styles.get(&id) { + inh.display = style.display; + inh.display_contents = style.display_contents; + inh.is_inline_block = style.is_inline_block; + inh.flow_root = style.flow_root; + inh.is_table_box = style.is_table_box; + inh.color = style.color.or(inh.color); + inh.font_size = style.font_size.or(inh.font_size); + inh.font_weight = crate::style::used_font_weight(style); + if let Some(family) = style.font_family.as_ref() { + inh.font_family = Some(family.clone()); + } + if let Some(value) = style.font_optical_sizing { + inh.font_optical_sizing = value; + } + if let Some(settings) = style.font_variation_settings.as_ref() { + inh.font_variation_settings.clone_from(settings); + } + if let Some(spacing) = style.letter_spacing { + inh.letter_spacing = spacing; + } + if let Some(non_normal) = style.letter_spacing_non_normal { + inh.letter_spacing_non_normal = non_normal; + } + inh.container_type = style.container_type; + inh.container_names.clone_from(&style.container_names); + if let Some(align) = style.text_align { + inh.text_align = Some(align); + inh.legacy_center = style.legacy_center; + } + if let Some(indent) = style.text_indent { + inh.text_indent = indent; + } + inh.visibility_hidden = style + .visibility_hidden + .unwrap_or(inh.visibility_hidden); + inh.opacity_product *= style.opacity.unwrap_or(1.0); + if let Some(value) = style.list_style { + inh.list_style = value; + } + if let Some(value) = style.line_height { + inh.line_height = value; + } + if let Some(value) = style.white_space { + inh.white_space = value; + } + if let Some(value) = style.overflow_wrap { + inh.overflow_wrap = value; + } + if let Some(value) = style.word_break { + inh.word_break = value; + } + if let Some(value) = style.text_wrap_style { + inh.text_wrap_style = value; + } + if let Some(value) = style.text_transform { + inh.text_transform = value; + } + if let Some(value) = style.font_style_italic { + inh.italic = value; + } + inh.box_sizing = style.box_sizing; + if let Some(value) = style.border_collapse { + inh.border_collapse = value; + } + let is_table_part = tree.get_node(id).is_some_and(|node| { + node.as_element().is_some_and(|element| { + matches!( + element.local.as_ref(), + "tbody" | "thead" | "tfoot" | "tr" | "td" | "th" + ) + }) + }); + if is_table_part { + if let Some(value) = style.vertical_align { + inh.table_vertical_align = Some(value); + } + } + inh.overflow_x = if style.overflow_scroll_x { + 2 + } else if style.overflow_clip_x { + 1 + } else { + 0 + }; + inh.overflow_y = if style.overflow_scroll_y { + 2 + } else if style.overflow_clip_y { + 1 + } else { + 0 + }; + child_cb_height_definite = matches!( + style.height, + crate::Dimension::Px(_) | crate::Dimension::Percent(_) + ); + if child_cb_height_definite { + definite_height_nodes.insert(id); + } + let cb_w = inh.cb_width; + let used_w = match style.width { + crate::Dimension::Px(width) => width, + crate::Dimension::Percent(percent) => percent * cb_w, + _ => (cb_w - style.margin.left - style.margin.right).max(0.0), + }; + let definite_content_box = matches!( + style.width, + crate::Dimension::Px(_) | crate::Dimension::Percent(_) + ) && style.box_sizing == crate::BoxSizing::ContentBox; + child_cb_width = if definite_content_box { + used_w.max(0.0) + } else { + (used_w + - style.padding.left + - style.padding.right + - style.border.left + - style.border.right) + .max(0.0) + }; + } + inh.cb_width = child_cb_width; + inh.cb_height_definite = child_cb_height_definite; + for child in tree.children(id).into_iter().rev() { + queue.push((child, inh.clone())); + } + continue; + } let inherited_grid_auto_tracks = styles.get(&id).and_then(|style| { (style.grid_auto_columns_inherit || style.grid_auto_rows_inherit).then(|| { tree.get_node(id) @@ -11997,6 +12340,8 @@ mod tests { &[], Some(2), None, + None, + &[], ); let target = tree.get_element_by_id("target").unwrap(); assert_eq!(laid.styles[&target].width, crate::Dimension::Px(1.0)); @@ -12056,6 +12401,9 @@ mod tests { passes: 1, termination: ContainerLayoutTermination::NoQueries, query: crate::css::ContainerQueryStats::default(), + retained_reused: 0, + retained_fresh: 0, + retained_fallback: 0, } ); } @@ -12078,10 +12426,412 @@ mod tests { passes: 1, termination: ContainerLayoutTermination::NoContainers, query: crate::css::ContainerQueryStats::default(), + retained_reused: 0, + retained_fresh: 0, + retained_fallback: 0, } ); } + #[test] + fn retained_attribute_styles_match_forced_full_with_dormant_container_rules() { + let initial_html = r#" +
+
+
+ +
x
+ "#; + let final_html = initial_html + .replace("data-theme=light", "data-theme=dark") + .replace("data-open=false", "data-open=true") + .replace("data-double=false", "data-double=true"); + let tree = parse_html(initial_html); + let mut cache = crate::css::StylesheetCache::default(); + let mut initial = layout_dom_with_web_fonts_and_stylesheet_cache( + &tree, + (300.0, 200.0), + &HashMap::new(), + &[], + &mut cache, + ); + let theme = tree.get_element_by_id("theme").unwrap(); + let toggle = tree.get_element_by_id("toggle").unwrap(); + let counter_change = tree.get_element_by_id("counter-change").unwrap(); + tree.with_node_mut(theme, |node| node.set_attribute("data-theme", "dark".into())); + tree.with_node_mut(toggle, |node| node.set_attribute("data-open", "true".into())); + tree.with_node_mut(counter_change, |node| { + node.set_attribute("data-double", "true".into()) + }); + let mutations = vec![ + AttributeStyleMutation { + node: theme, + name: "data-theme".into(), + old_value: Some("light".into()), + new_value: Some("dark".into()), + }, + AttributeStyleMutation { + node: toggle, + name: "data-open".into(), + old_value: Some("false".into()), + new_value: Some("true".into()), + }, + AttributeStyleMutation { + node: counter_change, + name: "data-double".into(), + old_value: Some("false".into()), + new_value: Some("true".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, + (300.0, 200.0), + &HashMap::new(), + &[], + None, + Some(&mut cache), + Some(retained), + &mutations, + ); + let final_tree = parse_html(&final_html); + let full = layout_dom(&final_tree, (300.0, 200.0)); + + assert_eq!(telemetry.termination, ContainerLayoutTermination::NoContainers); + assert!(telemetry.retained_reused > 0, "clean branch must be reused"); + assert!(telemetry.retained_fresh > 0, "dirty subtrees must be fresh"); + assert_eq!(telemetry.retained_fallback, 0); + for id in [ + "card", + "child", + "peer", + "counter-one", + "counter-change", + "counter-after", + "clean", + "control", + "inherit", + ] { + let incremental_id = tree.get_element_by_id(id).unwrap(); + let full_id = final_tree.get_element_by_id(id).unwrap(); + assert_eq!( + incremental.rects.get(&incremental_id), + full.rects.get(&full_id), + "{id} rect" + ); + assert_eq!( + incremental.styles[&incremental_id].width, + full.styles[&full_id].width, + "{id} width" + ); + assert_eq!( + incremental.styles[&incremental_id].color, + full.styles[&full_id].color, + "{id} color" + ); + assert_eq!( + incremental.styles[&incremental_id].before_content, + full.styles[&full_id].before_content, + "{id} generated content" + ); + assert_eq!( + incremental.custom_properties[&incremental_id], + full.custom_properties[&full_id], + "{id} custom properties" + ); + } + } + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + enum RetainedDifferentialExpectation { + Incremental, + ReuseAll, + ConservativeFallback, + } + + struct RetainedDifferentialCase { + name: &'static str, + css: &'static str, + target: &'static str, + attribute: &'static str, + new_value: &'static str, + expectation: RetainedDifferentialExpectation, + } + + /// Compare every computed-style field through its Debug representation. + /// `LayoutStyle` intentionally does not implement PartialEq because it is + /// a large, evolving renderer-internal type; keeping this check centralized + /// means new fields automatically enter the retained-vs-full oracle. + fn assert_computed_styles_match( + case: &str, + incremental: &DomLayout, + full: &DomLayout, + ) { + assert_eq!( + incremental.styles.keys().collect::>(), + full.styles.keys().collect::>(), + "{case}: computed-style node set" + ); + for (node, incremental_style) in &incremental.styles { + assert_eq!( + format!("{incremental_style:#?}"), + format!("{:#?}", full.styles[node]), + "{case}: computed style for {node:?}" + ); + } + } + + fn run_retained_differential_case(case: &RetainedDifferentialCase) { + let html = format!( + r#" +
+
+ +
+
+
+
+ + "#, + case.css + ); + let tree = parse_html(&html); + let mut cache = crate::css::StylesheetCache::default(); + let mut initial = layout_dom_with_web_fonts_and_stylesheet_cache( + &tree, + (320.0, 240.0), + &HashMap::new(), + &[], + &mut cache, + ); + let target = tree.get_element_by_id(case.target).unwrap(); + let old_value = tree + .get_node(target) + .and_then(|node| node.get_attribute(case.attribute).map(str::to_owned)); + tree.with_node_mut(target, |node| { + node.set_attribute(case.attribute, case.new_value.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, + (320.0, 240.0), + &HashMap::new(), + &[], + None, + Some(&mut cache), + Some(retained), + &[AttributeStyleMutation { + node: target, + name: case.attribute.into(), + old_value, + new_value: Some(case.new_value.into()), + }], + ); + let full = layout_dom(&tree, (320.0, 240.0)); + + match case.expectation { + RetainedDifferentialExpectation::Incremental => { + assert_eq!(telemetry.retained_fallback, 0, "{}", case.name); + assert!(telemetry.retained_fresh > 0, "{}", case.name); + assert!(telemetry.retained_reused > 0, "{}", case.name); + } + RetainedDifferentialExpectation::ReuseAll => { + assert_eq!(telemetry.retained_fallback, 0, "{}", case.name); + assert_eq!(telemetry.retained_fresh, 0, "{}", case.name); + assert!(telemetry.retained_reused > 0, "{}", case.name); + } + RetainedDifferentialExpectation::ConservativeFallback => { + assert_eq!(telemetry.retained_fallback, 1, "{}", case.name); + assert_eq!(telemetry.retained_reused, 0, "{}", case.name); + } + } + assert_computed_styles_match(case.name, &incremental, &full); + assert_eq!(incremental.rects, full.rects, "{}: border boxes", case.name); + assert_eq!( + incremental.inline_fragments, full.inline_fragments, + "{}: inline fragments", + case.name + ); + assert_eq!(incremental.text_runs, full.text_runs, "{}: text runs", case.name); + assert_eq!( + incremental.custom_properties, full.custom_properties, + "{}: custom properties", + case.name + ); + } + + #[test] + fn retained_attribute_styles_match_forced_full_selector_matrix() { + use RetainedDifferentialExpectation::{ + ConservativeFallback, Incremental, ReuseAll, + }; + let cases = [ + RetainedDifferentialCase { + name: "self attribute selector", + css: ".subject[data-state=on]{width:41px;height:7px;color:#123456}", + target: "subject", + attribute: "data-state", + new_value: "on", + expectation: Incremental, + }, + RetainedDifferentialCase { + name: "child and descendant selectors", + css: ".subject[data-state=on]>.child .grand{width:43px;height:9px}", + target: "subject", + attribute: "data-state", + new_value: "on", + expectation: Incremental, + }, + RetainedDifferentialCase { + name: "ancestor selector", + css: ".scope[data-mode=on] .grand{width:47px;height:11px}", + target: "scope", + attribute: "data-mode", + new_value: "on", + expectation: Incremental, + }, + RetainedDifferentialCase { + name: "adjacent sibling selector", + css: ".subject[data-state=on]+.panel{width:53px;height:13px}", + target: "subject", + attribute: "data-state", + new_value: "on", + expectation: Incremental, + }, + RetainedDifferentialCase { + name: "general sibling selector", + css: ".subject[data-state=on]~.panel{width:59px;height:15px}", + target: "subject", + attribute: "data-state", + new_value: "on", + expectation: Incremental, + }, + RetainedDifferentialCase { + name: "inheritance and custom property", + css: ".scope[data-mode=on]{--leaf-width:61px;color:#234567;font-size:20px}.leaf{width:var(--leaf-width,17px);height:1em;color:inherit}", + target: "scope", + attribute: "data-mode", + new_value: "on", + expectation: Incremental, + }, + RetainedDifferentialCase { + name: "generated before and after content", + css: ".subject[data-state=on]::before{content:'before';display:block;width:5px}.subject[data-state=on]::after{content:'after';display:block;width:7px}", + target: "subject", + attribute: "data-state", + new_value: "on", + expectation: Incremental, + }, + RetainedDifferentialCase { + name: "declaration attr dependency", + css: ".subject::before{content:attr(data-state);display:block;width:11px}", + target: "subject", + attribute: "data-state", + new_value: "on", + expectation: Incremental, + }, + RetainedDifferentialCase { + name: "counter propagation through retained siblings", + css: ".scope{counter-reset:item}.subject,.panel{counter-increment:item}.subject[data-state=on]{counter-increment:item 3}.subject::before,.panel::before{content:counter(item)}", + target: "subject", + attribute: "data-state", + new_value: "on", + expectation: Incremental, + }, + RetainedDifferentialCase { + name: "class replacement", + css: ".subject.state-on .grand{width:67px;height:17px}", + target: "subject", + attribute: "class", + new_value: "subject state-on", + expectation: Incremental, + }, + RetainedDifferentialCase { + name: "id replacement", + css: "#subject-on .grand{width:71px;height:19px}", + target: "subject", + attribute: "id", + new_value: "subject-on", + expectation: Incremental, + }, + RetainedDifferentialCase { + name: "selector-list pseudo", + css: ".scope:is([data-mode=on],.alternate) .grand{width:73px;height:21px}", + target: "scope", + attribute: "data-mode", + new_value: "on", + expectation: Incremental, + }, + RetainedDifferentialCase { + name: "negation pseudo", + css: ".subject:not([data-state=off]){width:79px;height:23px}", + target: "subject", + attribute: "data-state", + new_value: "on", + expectation: Incremental, + }, + RetainedDifferentialCase { + name: "mixed sibling descendant traversal", + css: ".subject[data-state=on]~.panel .leaf{width:83px;height:25px}", + target: "subject", + attribute: "data-state", + new_value: "on", + expectation: ConservativeFallback, + }, + RetainedDifferentialCase { + name: "relative selector ancestor traversal", + css: ".scope:has(.subject[data-state=on]){width:89px}", + target: "subject", + attribute: "data-state", + new_value: "on", + expectation: ConservativeFallback, + }, + RetainedDifferentialCase { + name: "nth of selector structural traversal", + css: ".subject:nth-child(1 of [data-state=on]){width:97px}", + target: "subject", + attribute: "data-state", + new_value: "on", + expectation: ConservativeFallback, + }, + RetainedDifferentialCase { + name: "unreferenced aria mutation", + css: ".subject{width:101px;height:27px}", + target: "subject", + attribute: "aria-expanded", + new_value: "true", + expectation: ReuseAll, + }, + ]; + + for case in &cases { + run_retained_differential_case(case); + } + } + #[test] fn container_convergence_classifies_self_consistent_stability() { assert_eq!( diff --git a/crates/obscura-render/src/lib.rs b/crates/obscura-render/src/lib.rs index 5a569a6..26815ca 100644 --- a/crates/obscura-render/src/lib.rs +++ b/crates/obscura-render/src/lib.rs @@ -27,7 +27,8 @@ pub use border::{ pub mod dom; pub use dom::{ - layout_dom, layout_dom_with_images, layout_dom_with_resources, DomLayout, StickyLayout, + layout_dom, layout_dom_with_images, layout_dom_with_resources, AttributeStyleMutation, + DomLayout, StickyLayout, }; /// Whether an image MIME type names a format supported by the renderer build. @@ -99,7 +100,8 @@ 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, - screenshot_prepared, screenshot_prepared_region_with_scroll, screenshot_prepared_with_scroll, + prepare_dom_with_retained_attribute_styles, screenshot_prepared, + screenshot_prepared_region_with_scroll, screenshot_prepared_with_scroll, validate_capture_region, CaptureError, CaptureRegion, DynamicFontFace, ElementScrollMetrics, PreparedRender, RenderResourceCache, RenderResourceLoader, ResolvedScrollState, SelectedImage, MAX_CAPTURE_DIMENSION, MAX_CAPTURE_PIXELS, diff --git a/crates/obscura-render/src/paint.rs b/crates/obscura-render/src/paint.rs index 285a57e..7176974 100644 --- a/crates/obscura-render/src/paint.rs +++ b/crates/obscura-render/src/paint.rs @@ -22,7 +22,10 @@ static FONT_BOLD_BYTES: &[u8] = include_bytes!("../assets/liberation-sans-bold.t static FONT_OBLIQUE_BYTES: &[u8] = include_bytes!("../assets/liberation-sans-oblique.ttf"); static FONT_BOLD_OBLIQUE_BYTES: &[u8] = include_bytes!("../assets/liberation-sans-boldoblique.ttf"); -use crate::dom::layout_dom_with_web_fonts_and_stylesheet_cache; +use crate::dom::{ + layout_dom_with_web_fonts_and_retained_styles, + layout_dom_with_web_fonts_and_stylesheet_cache, RetainedStyleMaps, +}; const DEFAULT_RESOURCE_CACHE_ENTRIES: usize = 512; const DEFAULT_RESOURCE_CACHE_BYTES: usize = 64 * 1024 * 1024; @@ -98,6 +101,7 @@ pub struct RenderResourceCache { max_content_image_intrinsics: usize, #[cfg(test)] content_image_layout_retries: usize, + sync_loading_enabled: bool, loader: Box, } @@ -136,10 +140,19 @@ impl RenderResourceCache { max_content_image_intrinsics: max_entries.min(DEFAULT_CONTENT_IMAGE_INTRINSIC_ENTRIES), #[cfg(test)] content_image_layout_retries: 0, + sync_loading_enabled: true, loader: Box::new(loader), } } + /// Temporarily control the compatibility loader used by synchronous + /// layout and paint. Capture callers disable it so a screenshot observes + /// only bytes already prepared by the page transport. Unknown URLs remain + /// unknown and can still be fetched by a later navigation/settle warmup. + pub fn set_sync_loading_enabled(&mut self, enabled: bool) -> bool { + std::mem::replace(&mut self.sync_loading_enabled, enabled) + } + pub fn retained_entry_count(&self) -> usize { self.entries.len() } @@ -276,6 +289,9 @@ impl RenderResourceCache { CachedResource::Missing(_) => {} } } + if !self.sync_loading_enabled { + return None; + } self.remove(url); let loaded = self.loader.load(url).map(Arc::<[u8]>::from); @@ -1595,6 +1611,56 @@ 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_internal( + tree, + viewport, + base_url, + resources, + dynamic_fonts, + stylesheet_cache, + None, + ) +} + +/// Rebuild geometry while moving clean computed styles out of the previous +/// prepared render. The renderer validates every mutation against the cached +/// stylesheet's dependency map and automatically takes the full cascade path +/// whenever the retained subset is not provably sound. +pub fn prepare_dom_with_retained_attribute_styles( + 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::AttributeStyleMutation], +) -> Option { + let retained = RetainedStyleMaps { + styles: std::mem::take(&mut previous.layout.styles), + custom_properties: std::mem::take(&mut previous.layout.custom_properties), + }; + drop(previous); + prepare_dom_with_dynamic_fonts_and_stylesheet_cache_internal( + tree, + viewport, + base_url, + resources, + dynamic_fonts, + stylesheet_cache, + Some((retained, mutations)), + ) +} + +fn prepare_dom_with_dynamic_fonts_and_stylesheet_cache_internal( + tree: &DomTree, + viewport: (f32, f32), + base_url: Option<&str>, + resources: &mut RenderResourceCache, + dynamic_fonts: &[DynamicFontFace], + stylesheet_cache: &mut crate::css::StylesheetCache, + retained: Option<(RetainedStyleMaps, &[crate::dom::AttributeStyleMutation])>, ) -> Option { if !viewport.0.is_finite() || !viewport.1.is_finite() || viewport.0 <= 0.0 || viewport.1 <= 0.0 { @@ -1635,13 +1701,24 @@ pub fn prepare_dom_with_dynamic_fonts_and_stylesheet_cache( } else { svg_font_database() }; - let mut laid = layout_dom_with_web_fonts_and_stylesheet_cache( - tree, - viewport, - &intrinsic, - &fonts, - stylesheet_cache, - ); + let mut laid = match retained { + Some((retained, mutations)) => layout_dom_with_web_fonts_and_retained_styles( + tree, + viewport, + &intrinsic, + &fonts, + stylesheet_cache, + retained, + mutations, + ), + None => layout_dom_with_web_fonts_and_stylesheet_cache( + tree, + viewport, + &intrinsic, + &fonts, + stylesheet_cache, + ), + }; // `content:url(...)` is computed by the author cascade, whereas ordinary // HTML image sources are available before layout. Pay for a second layout // only on the uncommon pages that actually use a CSS image as replaced @@ -8219,6 +8296,31 @@ mod tests { use crate::dom::layout_dom_with_web_fonts; use obscura_dom::tree_sink::parse_html; + #[test] + fn cache_only_mode_does_not_load_or_negative_cache_unknown_urls() { + let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let observed = calls.clone(); + let mut cache = RenderResourceCache::with_loader(move |_url: &str| { + observed.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Some(vec![1, 2, 3]) + }); + let url = "https://example.test/dynamic.png"; + + let previous = cache.set_sync_loading_enabled(false); + assert!(previous); + assert!(cache.get_or_load(url).is_none()); + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0); + assert!( + !cache.has_live_outcome(url), + "a cache-only miss must remain eligible for later preparation" + ); + + cache.set_sync_loading_enabled(previous); + cache.seed(url.to_string(), vec![9, 8, 7]); + assert_eq!(cache.get_or_load(url).as_deref(), Some([9, 8, 7].as_slice())); + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0); + } + #[test] fn image_accept_advertises_exactly_decodable_mime_types() { assert!(!IMAGE_ACCEPT.contains('*'));