Reduce render latency and improve headless input
This commit is contained in:
1 parent
b8bdac2163
commit
222b50e480
15 files changed
+2219
-98
No files matched your search
@@ -85,6 +85,20 @@ fn truncate_on_char_boundary(s: &str, max: usize) -> &str {
|
|||||||
&s[..end]
|
&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")]
|
#[cfg(feature = "stealth")]
|
||||||
use obscura_net::StealthHttpClient;
|
use obscura_net::StealthHttpClient;
|
||||||
|
|
||||||
@@ -1948,6 +1962,7 @@ impl Page {
|
|||||||
if max_ms == 0 {
|
if max_ms == 0 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
let settle_started = std::time::Instant::now();
|
||||||
if let Some(js) = &mut self.js {
|
if let Some(js) = &mut self.js {
|
||||||
if std::env::var_os("OBSCURA_STRICT_SETTLE").is_some() {
|
if std::env::var_os("OBSCURA_STRICT_SETTLE").is_some() {
|
||||||
Self::settle_runtime_for_duration(js, max_ms).await;
|
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;
|
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::<u64>().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.
|
/// 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
|
// renderer's synchronous resource loader and serial network latency pins
|
||||||
// V8, making framework startup take many seconds. This is deliberately
|
// V8, making framework startup take many seconds. This is deliberately
|
||||||
// bounded: navigation should not wait indefinitely for decorative
|
// bounded: navigation should not wait indefinitely for decorative
|
||||||
// resources, and screenshot/PDF capture can use its longer final wait.
|
// resources.
|
||||||
#[cfg(feature = "render")]
|
#[cfg(feature = "render")]
|
||||||
{
|
{
|
||||||
let warmup_ms = std::env::var("OBSCURA_RENDER_RESOURCE_WARMUP_MS")
|
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".
|
// of waitUntil and DCL means "DOM parsed AND scripts executed".
|
||||||
self.execute_scripts().await;
|
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::<u64>().ok())
|
||||||
|
.unwrap_or(1_000);
|
||||||
|
let _ = self.prepare_screenshot_resources(warmup_ms).await;
|
||||||
|
}
|
||||||
|
|
||||||
self.lifecycle = LifecycleState::DomContentLoaded;
|
self.lifecycle = LifecycleState::DomContentLoaded;
|
||||||
|
|
||||||
if wait_until == crate::lifecycle::WaitUntil::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,
|
navigation_referrer, parse_import_url, rebase_css_urls, script_response_is_executable,
|
||||||
split_css_imports, truncate_on_char_boundary, url_matches_cdp_pattern,
|
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 base64::Engine as _;
|
||||||
use obscura_dom::parse_html;
|
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#"<!doctype html><html><head></head><body><script>
|
||||||
|
const image = document.createElement('img');
|
||||||
|
image.src = '/dynamic.svg';
|
||||||
|
document.body.appendChild(image);
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = "@font-face{font-family:Dynamic;src:url('/dynamic.woff2')}body{font-family:Dynamic}";
|
||||||
|
document.head.appendChild(style);
|
||||||
|
</script></body></html>"#,
|
||||||
|
),
|
||||||
|
"/dynamic.svg" => (
|
||||||
|
"image/svg+xml",
|
||||||
|
br#"<svg xmlns="http://www.w3.org/2000/svg" width="20" height="10"><rect width="20" height="10" fill="red"/></svg>"#,
|
||||||
|
),
|
||||||
|
"/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::<Vec<_>>();
|
||||||
|
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")]
|
#[cfg(feature = "render")]
|
||||||
#[test]
|
#[test]
|
||||||
fn page_screenshot_uses_the_live_window_scroll_offset() {
|
fn page_screenshot_uses_the_live_window_scroll_offset() {
|
||||||
@@ -4510,6 +4642,44 @@ mod tests {
|
|||||||
assert_client_replacement_survived(&mut page);
|
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]
|
#[test]
|
||||||
fn url_matches_cdp_pattern_handles_wildcards_across_url_parts() {
|
fn url_matches_cdp_pattern_handles_wildcards_across_url_parts() {
|
||||||
assert!(url_matches_cdp_pattern(
|
assert!(url_matches_cdp_pattern(
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ pub(crate) struct ScreencastState {
|
|||||||
pub command_frame_counter: u64,
|
pub command_frame_counter: u64,
|
||||||
pub session_id: i64,
|
pub session_id: i64,
|
||||||
pub frames_in_flight: u8,
|
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 {
|
pub struct CdpContext {
|
||||||
|
|||||||
@@ -55,6 +55,37 @@ const BACKSPACE_JS: &str = "(function() {\
|
|||||||
t.dispatchEvent(globalThis.__obscura_markTrusted(new Event('input', {bubbles:true})));\
|
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(
|
pub async fn handle(
|
||||||
method: &str,
|
method: &str,
|
||||||
params: &Value,
|
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 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 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 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 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 event_type == "mousePressed" {
|
||||||
if let Some(page) = ctx.get_session_page_mut(session_id) {
|
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;\
|
var target = (document.elementFromPoint && document.elementFromPoint({x},{y})) || globalThis.__obscura_click_target || document.activeElement || document.body;\
|
||||||
if (!target) return;\
|
if (!target) return;\
|
||||||
globalThis.__obscura_click_target = target;\
|
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);\
|
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.evaluate(&code);
|
||||||
page.process_pending_navigation().await.map_err(|e| e.to_string())?;
|
|
||||||
}
|
}
|
||||||
} else if event_type == "mouseReleased" {
|
} else if event_type == "mouseReleased" {
|
||||||
if let Some(page) = ctx.get_session_page_mut(session_id) {
|
if let Some(page) = ctx.get_session_page_mut(session_id) {
|
||||||
@@ -125,10 +136,125 @@ pub async fn handle(
|
|||||||
"(function() {{\
|
"(function() {{\
|
||||||
var target = (document.elementFromPoint && document.elementFromPoint({x},{y})) || globalThis.__obscura_click_target || document.activeElement || document.body;\
|
var target = (document.elementFromPoint && document.elementFromPoint({x},{y})) || globalThis.__obscura_click_target || document.activeElement || document.body;\
|
||||||
if (!target) return;\
|
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);\
|
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);
|
page.evaluate(&code);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -266,6 +266,26 @@ fn encode_screenshot(
|
|||||||
Ok(output)
|
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::<u64>().ok())
|
||||||
|
.filter(|value| *value != 0)
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let _ = page.prepare_screenshot_resources(deadline_ms).await;
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(feature = "render")]
|
#[cfg(feature = "render")]
|
||||||
fn screencast_int32(params: &Value, name: &str) -> Result<Option<i64>, String> {
|
fn screencast_int32(params: &Value, name: &str) -> Result<Option<i64>, String> {
|
||||||
match params.get(name) {
|
match params.get(name) {
|
||||||
@@ -316,6 +336,8 @@ fn parse_screencast_state(params: &Value, session_id: i64) -> Result<ScreencastS
|
|||||||
command_frame_counter: 0,
|
command_frame_counter: 0,
|
||||||
session_id,
|
session_id,
|
||||||
frames_in_flight: 0,
|
frames_in_flight: 0,
|
||||||
|
observed_activity_generation: 0,
|
||||||
|
autonomous_frame_pending: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,7 +421,7 @@ pub(crate) fn queue_screencast_frame(
|
|||||||
state.clone()
|
state.clone()
|
||||||
};
|
};
|
||||||
let attached_session = Some(cdp_session_id.to_string());
|
let attached_session = Some(cdp_session_id.to_string());
|
||||||
let (viewport, scroll, png) = {
|
let (viewport, scroll, png, activity_generation) = {
|
||||||
let page = ctx
|
let page = ctx
|
||||||
.get_session_page_mut(&attached_session)
|
.get_session_page_mut(&attached_session)
|
||||||
.ok_or("No page for session")?;
|
.ok_or("No page for session")?;
|
||||||
@@ -425,7 +447,12 @@ pub(crate) fn queue_screencast_frame(
|
|||||||
let png = page.screenshot(viewport).ok_or_else(|| {
|
let png = page.screenshot(viewport).ok_or_else(|| {
|
||||||
"Page.startScreencast failed: the page has no visible DOM surface to render".to_string()
|
"Page.startScreencast failed: the page has no visible DOM surface to render".to_string()
|
||||||
})?;
|
})?;
|
||||||
(viewport, scroll, png)
|
let activity_generation = page
|
||||||
|
.js
|
||||||
|
.as_ref()
|
||||||
|
.map(|js| js.activity_generation())
|
||||||
|
.unwrap_or(0);
|
||||||
|
(viewport, scroll, png, activity_generation)
|
||||||
};
|
};
|
||||||
let encoded = encode_screencast_frame(png, &state)?;
|
let encoded = encode_screencast_frame(png, &state)?;
|
||||||
use base64::Engine as _;
|
use base64::Engine as _;
|
||||||
@@ -437,6 +464,8 @@ pub(crate) fn queue_screencast_frame(
|
|||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
live.frames_in_flight = live.frames_in_flight.saturating_add(1);
|
live.frames_in_flight = live.frames_in_flight.saturating_add(1);
|
||||||
|
live.observed_activity_generation = activity_generation;
|
||||||
|
live.autonomous_frame_pending = false;
|
||||||
ctx.pending_events.push(CdpEvent {
|
ctx.pending_events.push(CdpEvent {
|
||||||
method: "Page.screencastFrame".into(),
|
method: "Page.screencastFrame".into(),
|
||||||
params: json!({
|
params: json!({
|
||||||
@@ -454,6 +483,56 @@ pub(crate) fn queue_screencast_frame(
|
|||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Advance active page task queues for one bounded compositor slice and emit
|
||||||
|
/// a frame when connected-document activity has changed. Chromium feeds
|
||||||
|
/// `Page.screencastFrame` from its video consumer on compositor frames; this
|
||||||
|
/// is Obscura's single-threaded equivalent until it owns a real compositor.
|
||||||
|
///
|
||||||
|
/// Generation tracking avoids full-page raster work while the page is idle.
|
||||||
|
/// A dirty generation is retained across `everyNthFrame` sampling and the
|
||||||
|
/// acknowledgement window, ensuring neither mechanism loses the latest frame.
|
||||||
|
#[cfg(feature = "render")]
|
||||||
|
pub(crate) async fn pump_screencast_frames(ctx: &mut CdpContext) {
|
||||||
|
const EVENT_LOOP_SLICE_MS: u64 = 4;
|
||||||
|
|
||||||
|
let sessions: Vec<String> = 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")]
|
#[cfg(feature = "render")]
|
||||||
pub(crate) fn command_can_change_screencast_frame(method: &str) -> bool {
|
pub(crate) fn command_can_change_screencast_frame(method: &str) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
@@ -1097,16 +1176,10 @@ pub async fn handle(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or("Page.startScreencast requires an attached target session")?
|
.ok_or("Page.startScreencast requires an attached target session")?
|
||||||
.clone();
|
.clone();
|
||||||
let resource_deadline_ms = std::env::var("OBSCURA_RENDER_RESOURCE_DEADLINE_MS")
|
|
||||||
.ok()
|
|
||||||
.and_then(|value| value.parse::<u64>().ok())
|
|
||||||
.unwrap_or(3_000);
|
|
||||||
let page = ctx
|
let page = ctx
|
||||||
.get_session_page_mut(session_id)
|
.get_session_page_mut(session_id)
|
||||||
.ok_or("No page for session")?;
|
.ok_or("No page for session")?;
|
||||||
let _ = page
|
prepare_capture_resources_if_requested(page).await;
|
||||||
.prepare_screenshot_resources(resource_deadline_ms)
|
|
||||||
.await;
|
|
||||||
let stream_id = ctx.next_screencast_session();
|
let stream_id = ctx.next_screencast_session();
|
||||||
let state = parse_screencast_state(params, stream_id)?;
|
let state = parse_screencast_state(params, stream_id)?;
|
||||||
ctx.screencasts.insert(cdp_session.clone(), state);
|
ctx.screencasts.insert(cdp_session.clone(), state);
|
||||||
@@ -1121,15 +1194,11 @@ pub async fn handle(
|
|||||||
ctx.screencasts.remove(&cdp_session);
|
ctx.screencasts.remove(&cdp_session);
|
||||||
return Err(error);
|
return Err(error);
|
||||||
}
|
}
|
||||||
tracing::debug!(
|
tracing::debug!(cdp_session, stream_id, "started activity-driven screencast");
|
||||||
cdp_session,
|
Ok(json!({
|
||||||
stream_id,
|
"obscuraFrameSource": "activity-driven",
|
||||||
"started command-driven screencast; autonomous compositor frames unavailable"
|
"obscuraAutonomousFrames": true,
|
||||||
);
|
}))
|
||||||
// Extension fields expose the MVP boundary to direct callers.
|
|
||||||
Ok(
|
|
||||||
json!({"obscuraFrameSource": "command-driven", "obscuraAutonomousFrames": false}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
#[cfg(not(feature = "render"))]
|
#[cfg(not(feature = "render"))]
|
||||||
Err("Page.startScreencast requires a build with the render feature".into())
|
Err("Page.startScreencast requires a build with the render feature".into())
|
||||||
@@ -1183,13 +1252,7 @@ pub async fn handle(
|
|||||||
let page = ctx
|
let page = ctx
|
||||||
.get_session_page_mut(session_id)
|
.get_session_page_mut(session_id)
|
||||||
.ok_or("No page for session")?;
|
.ok_or("No page for session")?;
|
||||||
let resource_deadline_ms = std::env::var("OBSCURA_RENDER_RESOURCE_DEADLINE_MS")
|
prepare_capture_resources_if_requested(page).await;
|
||||||
.ok()
|
|
||||||
.and_then(|value| value.parse::<u64>().ok())
|
|
||||||
.unwrap_or(3_000);
|
|
||||||
let _ = page
|
|
||||||
.prepare_screenshot_resources(resource_deadline_ms)
|
|
||||||
.await;
|
|
||||||
let viewport = page.viewport;
|
let viewport = page.viewport;
|
||||||
let device_scale_factor = f64::from(page.device_scale_factor);
|
let device_scale_factor = f64::from(page.device_scale_factor);
|
||||||
let trusted_scroll = page.screenshot_scroll_offset();
|
let trusted_scroll = page.screenshot_scroll_offset();
|
||||||
@@ -1447,8 +1510,8 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("start screencast");
|
.expect("start screencast");
|
||||||
assert_eq!(result["obscuraFrameSource"], "command-driven");
|
assert_eq!(result["obscuraFrameSource"], "activity-driven");
|
||||||
assert_eq!(result["obscuraAutonomousFrames"], false);
|
assert_eq!(result["obscuraAutonomousFrames"], true);
|
||||||
assert_eq!(ctx.pending_events.len(), 2);
|
assert_eq!(ctx.pending_events.len(), 2);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
ctx.pending_events[0].method,
|
ctx.pending_events[0].method,
|
||||||
@@ -1535,6 +1598,96 @@ mod tests {
|
|||||||
assert!(!queue_screencast_frame(&mut ctx, &session, false).unwrap());
|
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#"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"/>"#;
|
||||||
|
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,<html style='margin:0'><body style='margin:0;height:160px'></body></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")]
|
#[cfg(feature = "render")]
|
||||||
#[test]
|
#[test]
|
||||||
fn screencast_options_validate_protocol_shapes() {
|
fn screencast_options_validate_protocol_shapes() {
|
||||||
|
|||||||
@@ -135,14 +135,10 @@ pub async fn print_to_pdf(
|
|||||||
#[cfg(feature = "render")]
|
#[cfg(feature = "render")]
|
||||||
{
|
{
|
||||||
let options = parse_options(params)?;
|
let options = parse_options(params)?;
|
||||||
let deadline_ms = std::env::var("OBSCURA_RENDER_RESOURCE_DEADLINE_MS")
|
|
||||||
.ok()
|
|
||||||
.and_then(|value| value.parse::<u64>().ok())
|
|
||||||
.unwrap_or(3_000);
|
|
||||||
let page = ctx
|
let page = ctx
|
||||||
.get_session_page_mut(session_id)
|
.get_session_page_mut(session_id)
|
||||||
.ok_or("No page for session")?;
|
.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
|
let pdf = page
|
||||||
.raster_pdf(options.raster)
|
.raster_pdf(options.raster)
|
||||||
.map_err(|error| error.to_string())?;
|
.map_err(|error| error.to_string())?;
|
||||||
|
|||||||
@@ -711,6 +711,12 @@ async fn cdp_processor(
|
|||||||
// registers and stays registered across iterations, so a later
|
// registers and stays registered across iterations, so a later
|
||||||
// `notify_waiters()` wakes this processor even while it is mid-dispatch.
|
// `notify_waiters()` wakes this processor even while it is mid-dispatch.
|
||||||
let mut shutdown = Box::pin(shutdown_notify.notified());
|
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<mpsc::UnboundedSender<String>> = None;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
// Drain any deferred messages from the previous interception window
|
// Drain any deferred messages from the previous interception window
|
||||||
@@ -725,6 +731,13 @@ async fn cdp_processor(
|
|||||||
Some(m) => m,
|
Some(m) => m,
|
||||||
None => break,
|
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 => {
|
_ = &mut shutdown => {
|
||||||
tracing::info!("Shutdown signal received (connection processor)");
|
tracing::info!("Shutdown signal received (connection processor)");
|
||||||
break;
|
break;
|
||||||
@@ -734,6 +747,7 @@ async fn cdp_processor(
|
|||||||
|
|
||||||
match msg {
|
match msg {
|
||||||
ServerMessage::NewConnection { reply_tx } => {
|
ServerMessage::NewConnection { reply_tx } => {
|
||||||
|
connection_reply_tx = Some(reply_tx.clone());
|
||||||
let _ = reply_tx.send(
|
let _ = reply_tx.send(
|
||||||
json!({"__init": true})
|
json!({"__init": true})
|
||||||
.to_string(),
|
.to_string(),
|
||||||
@@ -772,6 +786,37 @@ async fn cdp_processor(
|
|||||||
let _ = &ctx;
|
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<String>>,
|
||||||
|
) {
|
||||||
|
#[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
|
// 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
|
// the spawn-and-defer navigation path. Matching on the parsed method rather than
|
||||||
// a `contains("Page.navigate")` substring avoids catching
|
// a `contains("Page.navigate")` substring avoids catching
|
||||||
@@ -1357,6 +1402,8 @@ async fn handle_connection_ws(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{is_navigate_method, merge_cookie_delta, parse_cdp_headers};
|
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 obscura_net::{CookieInfo, CookieJar};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
@@ -1436,4 +1483,91 @@ mod tests {
|
|||||||
fn parse_cdp_headers_absent_is_none() {
|
fn parse_cdp_headers_absent_is_none() {
|
||||||
assert!(parse_cdp_headers(&json!({"url": "https://example.com"})).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,<html style='margin:0'><body style='margin:0;width:96px;height:64px;background:red'></body></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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -131,6 +131,15 @@ async fn request_submit_is_vetoed_by_prevent_default_listener() {
|
|||||||
)
|
)
|
||||||
.await;
|
.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();
|
let page = ctx.get_page_mut(&page_id).unwrap();
|
||||||
assert_ne!(
|
assert_ne!(
|
||||||
page.url.as_ref().unwrap().path(),
|
page.url.as_ref().unwrap().path(),
|
||||||
|
|||||||
@@ -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#"<!doctype html><html><head><style>
|
||||||
|
html, body { margin: 0; }
|
||||||
|
#page { width: 1800px; height: 2400px; }
|
||||||
|
#box { position: absolute; left: 20px; top: 20px; width: 180px;
|
||||||
|
height: 120px; overflow: auto; border: 10px solid black; }
|
||||||
|
#inner { width: 700px; height: 800px; }
|
||||||
|
</style></head><body>
|
||||||
|
<div id="page"></div>
|
||||||
|
<div id="box"><div id="inner"></div></div>
|
||||||
|
<input id="check" type="checkbox">
|
||||||
|
<form id="radio-form">
|
||||||
|
<input id="radio-a" type="radio" name="choice" checked>
|
||||||
|
<input id="radio-b" type="radio" name="choice">
|
||||||
|
</form>
|
||||||
|
</body></html>"#;
|
||||||
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
Vendored
+39
-1
@@ -7123,7 +7123,12 @@ globalThis.UIEvent = class extends Event {
|
|||||||
this.detail=detail||0;
|
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 {
|
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||""; }
|
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();
|
var r = el.getBoundingClientRect();
|
||||||
if (r.width === 0 || r.height === 0) continue;
|
if (r.width === 0 || r.height === 0) continue;
|
||||||
if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) {
|
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;
|
var nid = el._nid | 0;
|
||||||
if (nid > bestNid) { best = el; bestNid = nid; }
|
if (nid > bestNid) { best = el; bestNid = nid; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,6 +129,11 @@ pub struct ObscuraState {
|
|||||||
/// DOM/style/viewport changes clear this value but retain resource bytes.
|
/// DOM/style/viewport changes clear this value but retain resource bytes.
|
||||||
#[cfg(feature = "render")]
|
#[cfg(feature = "render")]
|
||||||
pub prepared_render: Option<obscura_render::PreparedRender>,
|
pub prepared_render: Option<obscura_render::PreparedRender>,
|
||||||
|
/// 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<obscura_render::AttributeStyleMutation>,
|
||||||
/// Page-lifetime raw image/font bytes. A new document resets this cache;
|
/// Page-lifetime raw image/font bytes. A new document resets this cache;
|
||||||
/// relayout of the same document reuses it without refetching.
|
/// relayout of the same document reuses it without refetching.
|
||||||
#[cfg(feature = "render")]
|
#[cfg(feature = "render")]
|
||||||
@@ -197,6 +202,8 @@ impl ObscuraState {
|
|||||||
#[cfg(feature = "render")]
|
#[cfg(feature = "render")]
|
||||||
prepared_render: None,
|
prepared_render: None,
|
||||||
#[cfg(feature = "render")]
|
#[cfg(feature = "render")]
|
||||||
|
pending_attribute_style_mutations: Vec::new(),
|
||||||
|
#[cfg(feature = "render")]
|
||||||
render_resources: obscura_render::RenderResourceCache::default(),
|
render_resources: obscura_render::RenderResourceCache::default(),
|
||||||
#[cfg(feature = "render")]
|
#[cfg(feature = "render")]
|
||||||
stylesheet_cache: obscura_render::StylesheetCache::default(),
|
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<obscura_render::AttributeStyleMutation> {
|
||||||
|
let node = NodeId::new(arg1.parse::<u32>().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")]
|
#[cfg(feature = "render")]
|
||||||
fn render_timing_enabled() -> bool {
|
fn render_timing_enabled() -> bool {
|
||||||
static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||||
@@ -605,6 +654,11 @@ fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> Str
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|dom| render_mutation_impact(dom, &cmd, &arg1, &arg2))
|
.map(|dom| render_mutation_impact(dom, &cmd, &arg1, &arg2))
|
||||||
.unwrap_or_default();
|
.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;
|
let invalidate = impact.connected && impact.actual_change;
|
||||||
if invalidate {
|
if invalidate {
|
||||||
state.activity_generation = state.activity_generation.wrapping_add(1);
|
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();
|
let had_prepared_render = state.prepared_render.is_some();
|
||||||
#[cfg(feature = "render")]
|
#[cfg(feature = "render")]
|
||||||
if invalidate {
|
if invalidate {
|
||||||
|
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.prepared_render = None;
|
||||||
|
state.pending_attribute_style_mutations.clear();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
state.prepared_render = None;
|
||||||
|
state.pending_attribute_style_mutations.clear();
|
||||||
|
}
|
||||||
state.resolved_scroll = None;
|
state.resolved_scroll = None;
|
||||||
}
|
}
|
||||||
#[cfg(feature = "render")]
|
#[cfg(feature = "render")]
|
||||||
@@ -2958,6 +3024,7 @@ fn op_set_dynamic_fonts(state: &OpState, #[string] registrations: &str) -> bool
|
|||||||
if state.dynamic_fonts != fonts {
|
if state.dynamic_fonts != fonts {
|
||||||
state.dynamic_fonts = fonts;
|
state.dynamic_fonts = fonts;
|
||||||
state.prepared_render = None;
|
state.prepared_render = None;
|
||||||
|
state.pending_attribute_style_mutations.clear();
|
||||||
state.resolved_scroll = None;
|
state.resolved_scroll = None;
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
@@ -3041,9 +3108,23 @@ pub(crate) fn ensure_prepared_render(
|
|||||||
let stale = state.prepared_render.as_ref().map_or(true, |prepared| {
|
let stale = state.prepared_render.as_ref().map_or(true, |prepared| {
|
||||||
prepared.viewport() != viewport || prepared.base_url() != base_url.as_deref()
|
prepared.viewport() != viewport || prepared.base_url() != base_url.as_deref()
|
||||||
});
|
});
|
||||||
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 prepared = {
|
||||||
let dom = state.dom.as_ref()?;
|
let dom = state.dom.as_ref()?;
|
||||||
|
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(
|
obscura_render::prepare_dom_with_dynamic_fonts_and_stylesheet_cache(
|
||||||
dom,
|
dom,
|
||||||
viewport,
|
viewport,
|
||||||
@@ -3051,7 +3132,17 @@ pub(crate) fn ensure_prepared_render(
|
|||||||
&mut state.render_resources,
|
&mut state.render_resources,
|
||||||
&state.dynamic_fonts,
|
&state.dynamic_fonts,
|
||||||
&mut state.stylesheet_cache,
|
&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.prepared_render = Some(prepared);
|
||||||
state.resolved_scroll = None;
|
state.resolved_scroll = None;
|
||||||
|
|||||||
@@ -31,6 +31,24 @@ static SNAPSHOT: &[u8] = include_bytes!(env!("OBSCURA_SNAPSHOT_PATH"));
|
|||||||
/// parallel, each isolate on its own thread with no shared lock.
|
/// parallel, each isolate on its own thread with no shared lock.
|
||||||
static ISOLATE_CREATE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
static ISOLATE_CREATE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||||
|
|
||||||
|
#[cfg(feature = "render")]
|
||||||
|
fn with_sync_render_loading_disabled<R>(
|
||||||
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct RemoteObjectInfo {
|
pub struct RemoteObjectInfo {
|
||||||
pub js_type: String,
|
pub js_type: String,
|
||||||
@@ -246,6 +264,7 @@ impl ObscuraJsRuntime {
|
|||||||
#[cfg(feature = "render")]
|
#[cfg(feature = "render")]
|
||||||
{
|
{
|
||||||
gs.prepared_render = None;
|
gs.prepared_render = None;
|
||||||
|
gs.pending_attribute_style_mutations.clear();
|
||||||
gs.render_resources = obscura_render::RenderResourceCache::default();
|
gs.render_resources = obscura_render::RenderResourceCache::default();
|
||||||
gs.stylesheet_cache = obscura_render::StylesheetCache::default();
|
gs.stylesheet_cache = obscura_render::StylesheetCache::default();
|
||||||
gs.dynamic_fonts.clear();
|
gs.dynamic_fonts.clear();
|
||||||
@@ -266,6 +285,7 @@ impl ObscuraJsRuntime {
|
|||||||
// present. Keep already-fetched absolute bytes, but rebuild
|
// present. Keep already-fetched absolute bytes, but rebuild
|
||||||
// candidate selection/layout against the new base.
|
// candidate selection/layout against the new base.
|
||||||
state.prepared_render = None;
|
state.prepared_render = None;
|
||||||
|
state.pending_attribute_style_mutations.clear();
|
||||||
state.resolved_scroll = None;
|
state.resolved_scroll = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -376,6 +396,7 @@ impl ObscuraJsRuntime {
|
|||||||
if state.viewport != viewport {
|
if state.viewport != viewport {
|
||||||
state.viewport = viewport;
|
state.viewport = viewport;
|
||||||
state.prepared_render = None;
|
state.prepared_render = None;
|
||||||
|
state.pending_attribute_style_mutations.clear();
|
||||||
state.resolved_scroll = None;
|
state.resolved_scroll = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -418,14 +439,15 @@ impl ObscuraJsRuntime {
|
|||||||
if viewport != state.viewport || base_url != effective_base.as_deref() {
|
if viewport != state.viewport || base_url != effective_base.as_deref() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
ensure_resolved_scroll(&mut state)?;
|
with_sync_render_loading_disabled(&mut state, |state| {
|
||||||
|
ensure_resolved_scroll(state)?;
|
||||||
let ObscuraState {
|
let ObscuraState {
|
||||||
dom,
|
dom,
|
||||||
prepared_render,
|
prepared_render,
|
||||||
render_resources,
|
render_resources,
|
||||||
resolved_scroll,
|
resolved_scroll,
|
||||||
..
|
..
|
||||||
} = &mut *state;
|
} = state;
|
||||||
let (_, scroll) = resolved_scroll.as_ref()?;
|
let (_, scroll) = resolved_scroll.as_ref()?;
|
||||||
obscura_render::screenshot_prepared_with_scroll(
|
obscura_render::screenshot_prepared_with_scroll(
|
||||||
dom.as_ref()?,
|
dom.as_ref()?,
|
||||||
@@ -433,6 +455,7 @@ impl ObscuraJsRuntime {
|
|||||||
render_resources,
|
render_resources,
|
||||||
scroll,
|
scroll,
|
||||||
)
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Capture a document-space rectangle without changing the live viewport,
|
/// Capture a document-space rectangle without changing the live viewport,
|
||||||
@@ -444,14 +467,15 @@ impl ObscuraJsRuntime {
|
|||||||
region: obscura_render::CaptureRegion,
|
region: obscura_render::CaptureRegion,
|
||||||
) -> Result<Vec<u8>, obscura_render::CaptureError> {
|
) -> Result<Vec<u8>, obscura_render::CaptureError> {
|
||||||
let mut state = self.state.borrow_mut();
|
let mut state = self.state.borrow_mut();
|
||||||
ensure_resolved_scroll(&mut state).ok_or(obscura_render::CaptureError::PaintFailed)?;
|
with_sync_render_loading_disabled(&mut state, |state| {
|
||||||
|
ensure_resolved_scroll(state).ok_or(obscura_render::CaptureError::PaintFailed)?;
|
||||||
let ObscuraState {
|
let ObscuraState {
|
||||||
dom,
|
dom,
|
||||||
prepared_render,
|
prepared_render,
|
||||||
render_resources,
|
render_resources,
|
||||||
resolved_scroll,
|
resolved_scroll,
|
||||||
..
|
..
|
||||||
} = &mut *state;
|
} = state;
|
||||||
let (_, scroll) = resolved_scroll
|
let (_, scroll) = resolved_scroll
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or(obscura_render::CaptureError::PaintFailed)?;
|
.ok_or(obscura_render::CaptureError::PaintFailed)?;
|
||||||
@@ -465,6 +489,7 @@ impl ObscuraJsRuntime {
|
|||||||
scroll,
|
scroll,
|
||||||
region,
|
region,
|
||||||
)
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the retained layout's scrollable document size without changing
|
/// Return the retained layout's scrollable document size without changing
|
||||||
@@ -473,11 +498,13 @@ impl ObscuraJsRuntime {
|
|||||||
#[cfg(feature = "render")]
|
#[cfg(feature = "render")]
|
||||||
pub fn prepared_content_size(&self) -> Option<(f32, f32)> {
|
pub fn prepared_content_size(&self) -> Option<(f32, f32)> {
|
||||||
let mut state = self.state.borrow_mut();
|
let mut state = self.state.borrow_mut();
|
||||||
ensure_resolved_scroll(&mut state)?;
|
with_sync_render_loading_disabled(&mut state, |state| {
|
||||||
|
ensure_resolved_scroll(state)?;
|
||||||
state
|
state
|
||||||
.prepared_render
|
.prepared_render
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|render| render.content_size())
|
.map(|render| render.content_size())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the exact responsive candidates selected for live `<img>`
|
/// Return the exact responsive candidates selected for live `<img>`
|
||||||
@@ -530,6 +557,7 @@ impl ObscuraJsRuntime {
|
|||||||
Some(bytes) => {
|
Some(bytes) => {
|
||||||
state.render_resources.seed(url, bytes);
|
state.render_resources.seed(url, bytes);
|
||||||
state.prepared_render = None;
|
state.prepared_render = None;
|
||||||
|
state.pending_attribute_style_mutations.clear();
|
||||||
state.resolved_scroll = None;
|
state.resolved_scroll = None;
|
||||||
}
|
}
|
||||||
None => state.render_resources.seed_missing(url),
|
None => state.render_resources.seed_missing(url),
|
||||||
@@ -1548,6 +1576,7 @@ impl ObscuraJsRuntime {
|
|||||||
#[cfg(feature = "render")]
|
#[cfg(feature = "render")]
|
||||||
{
|
{
|
||||||
state.prepared_render = None;
|
state.prepared_render = None;
|
||||||
|
state.pending_attribute_style_mutations.clear();
|
||||||
state.render_resources = obscura_render::RenderResourceCache::default();
|
state.render_resources = obscura_render::RenderResourceCache::default();
|
||||||
state.stylesheet_cache = obscura_render::StylesheetCache::default();
|
state.stylesheet_cache = obscura_render::StylesheetCache::default();
|
||||||
state.dynamic_fonts.clear();
|
state.dynamic_fonts.clear();
|
||||||
@@ -6157,7 +6186,11 @@ mod tests {
|
|||||||
// must observe the new class on the live connected element.
|
// must observe the new class on the live connected element.
|
||||||
rt.evaluate("document.getElementById('box').className = 'b'")
|
rt.evaluate("document.getElementById('box').className = 'b'")
|
||||||
.unwrap();
|
.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!(
|
assert_eq!(
|
||||||
rt.evaluate("document.getElementById('box').getBoundingClientRect().width")
|
rt.evaluate("document.getElementById('box').getBoundingClientRect().width")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
|||||||
@@ -601,6 +601,96 @@ fn note_selector_for_invalidation(
|
|||||||
note_selector_dependencies(map, selector, InvalidationReaches::SELF, rule_order);
|
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<char> = 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 {
|
fn selector_requires_conservative_tracking(selector: &str) -> bool {
|
||||||
let selector = selector.to_ascii_lowercase();
|
let selector = selector.to_ascii_lowercase();
|
||||||
selector.contains(":has(")
|
selector.contains(":has(")
|
||||||
@@ -1610,6 +1700,11 @@ impl Stylesheet {
|
|||||||
base,
|
base,
|
||||||
order,
|
order,
|
||||||
);
|
);
|
||||||
|
note_declaration_attribute_dependencies(
|
||||||
|
&mut sheet.invalidation_map,
|
||||||
|
&decls,
|
||||||
|
order,
|
||||||
|
);
|
||||||
let (normal_decls, important_decls) =
|
let (normal_decls, important_decls) =
|
||||||
crate::style::partition_declarations(&decls);
|
crate::style::partition_declarations(&decls);
|
||||||
let normal_flags = declaration_stream_flags(&normal_decls);
|
let normal_flags = declaration_stream_flags(&normal_decls);
|
||||||
@@ -1648,6 +1743,11 @@ impl Stylesheet {
|
|||||||
base,
|
base,
|
||||||
order,
|
order,
|
||||||
);
|
);
|
||||||
|
note_declaration_attribute_dependencies(
|
||||||
|
&mut sheet.invalidation_map,
|
||||||
|
&decls,
|
||||||
|
order,
|
||||||
|
);
|
||||||
let (normal_decls, important_decls) =
|
let (normal_decls, important_decls) =
|
||||||
crate::style::partition_declarations(&decls);
|
crate::style::partition_declarations(&decls);
|
||||||
let normal_flags = declaration_stream_flags(&normal_decls);
|
let normal_flags = declaration_stream_flags(&normal_decls);
|
||||||
@@ -1691,6 +1791,11 @@ impl Stylesheet {
|
|||||||
&selector,
|
&selector,
|
||||||
order,
|
order,
|
||||||
);
|
);
|
||||||
|
note_declaration_attribute_dependencies(
|
||||||
|
&mut sheet.invalidation_map,
|
||||||
|
&decls,
|
||||||
|
order,
|
||||||
|
);
|
||||||
let (normal_decls, important_decls) = crate::style::partition_declarations(&decls);
|
let (normal_decls, important_decls) = crate::style::partition_declarations(&decls);
|
||||||
let specificity = sel.specificity();
|
let specificity = sel.specificity();
|
||||||
let normal_flags = declaration_stream_flags(&normal_decls);
|
let normal_flags = declaration_stream_flags(&normal_decls);
|
||||||
@@ -5587,6 +5692,33 @@ mod tests {
|
|||||||
assert!(!map.requires_conservative_invalidation());
|
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]
|
#[test]
|
||||||
fn invalidation_map_marks_relative_and_nth_dependencies_conservative() {
|
fn invalidation_map_marks_relative_and_nth_dependencies_conservative() {
|
||||||
let map = test_invalidation_map(
|
let map = test_invalidation_map(
|
||||||
|
|||||||
@@ -354,6 +354,26 @@ pub struct DomLayout {
|
|||||||
pub(crate) generated_boxes: Vec<GeneratedBox>,
|
pub(crate) generated_boxes: Vec<GeneratedBox>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
pub new_value: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<NodeId, crate::LayoutStyle>,
|
||||||
|
pub custom_properties: HashMap<NodeId, std::rc::Rc<HashMap<String, String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Dense identifier for one scrolling area in a prepared render. Index zero
|
/// Dense identifier for one scrolling area in a prepared render. Index zero
|
||||||
/// is always the root viewport; element scroll containers follow in DOM order.
|
/// 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
|
/// The ids are rebuild-local and must never be persisted by an embedding
|
||||||
@@ -1872,6 +1892,7 @@ fn cascade_walk(
|
|||||||
viewport: (f32, f32),
|
viewport: (f32, f32),
|
||||||
inherited_cell_padding: Option<f32>,
|
inherited_cell_padding: Option<f32>,
|
||||||
inherited_color_scheme_dark: bool,
|
inherited_color_scheme_dark: bool,
|
||||||
|
fresh_styles: Option<&HashSet<NodeId>>,
|
||||||
) {
|
) {
|
||||||
let Some(node) = tree.get_node(id) else {
|
let Some(node) = tree.get_node(id) else {
|
||||||
return;
|
return;
|
||||||
@@ -1882,7 +1903,25 @@ fn cascade_walk(
|
|||||||
let mut this_props = parent_props.clone();
|
let mut this_props = parent_props.clone();
|
||||||
let mut descendant_cell_padding = inherited_cell_padding;
|
let mut descendant_cell_padding = inherited_cell_padding;
|
||||||
let mut descendant_color_scheme_dark = inherited_color_scheme_dark;
|
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::<f32>().ok())
|
||||||
|
.filter(|value| value.is_finite() && *value >= 0.0);
|
||||||
|
}
|
||||||
|
} else if let Some(elem) = node.as_element() {
|
||||||
if elem.local.as_ref() == "table" {
|
if elem.local.as_ref() == "table" {
|
||||||
// `cellpadding` is a table-scoped presentational hint applied to
|
// `cellpadding` is a table-scoped presentational hint applied to
|
||||||
// its cells, below author CSS. Entering any nested table resets
|
// its cells, below author CSS. Entering any nested table resets
|
||||||
@@ -2084,6 +2123,7 @@ fn cascade_walk(
|
|||||||
viewport,
|
viewport,
|
||||||
descendant_cell_padding,
|
descendant_cell_padding,
|
||||||
descendant_color_scheme_dark,
|
descendant_color_scheme_dark,
|
||||||
|
fresh_styles,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if is_element {
|
if is_element {
|
||||||
@@ -2309,6 +2349,9 @@ struct ContainerLayoutTelemetry {
|
|||||||
passes: usize,
|
passes: usize,
|
||||||
termination: ContainerLayoutTermination,
|
termination: ContainerLayoutTermination,
|
||||||
query: crate::css::ContainerQueryStats,
|
query: crate::css::ContainerQueryStats,
|
||||||
|
retained_reused: usize,
|
||||||
|
retained_fresh: usize,
|
||||||
|
retained_fallback: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn container_iteration_termination<T: PartialEq>(
|
fn container_iteration_termination<T: PartialEq>(
|
||||||
@@ -2325,6 +2368,97 @@ fn container_iteration_termination<T: PartialEq>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum RetainedStylePlan {
|
||||||
|
Reuse(HashSet<NodeId>),
|
||||||
|
Full,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_style_subtree(tree: &DomTree, root: NodeId, dirty: &mut HashSet<NodeId>) {
|
||||||
|
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<NodeId>,
|
||||||
|
) {
|
||||||
|
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(
|
pub(crate) fn layout_dom_with_web_fonts(
|
||||||
tree: &DomTree,
|
tree: &DomTree,
|
||||||
viewport: (f32, f32),
|
viewport: (f32, f32),
|
||||||
@@ -2348,6 +2482,30 @@ pub(crate) fn layout_dom_with_web_fonts_and_stylesheet_cache(
|
|||||||
fonts,
|
fonts,
|
||||||
None,
|
None,
|
||||||
Some(stylesheet_cache),
|
Some(stylesheet_cache),
|
||||||
|
None,
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn layout_dom_with_web_fonts_and_retained_styles(
|
||||||
|
tree: &DomTree,
|
||||||
|
viewport: (f32, f32),
|
||||||
|
intrinsic: &HashMap<NodeId, (f32, f32)>,
|
||||||
|
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
|
.0
|
||||||
}
|
}
|
||||||
@@ -2358,7 +2516,7 @@ fn layout_dom_with_web_fonts_measured(
|
|||||||
intrinsic: &HashMap<NodeId, (f32, f32)>,
|
intrinsic: &HashMap<NodeId, (f32, f32)>,
|
||||||
fonts: &[crate::inline::WebFont],
|
fonts: &[crate::inline::WebFont],
|
||||||
) -> (DomLayout, ContainerLayoutTelemetry) {
|
) -> (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(
|
fn layout_dom_with_web_fonts_pass_limit(
|
||||||
@@ -2368,6 +2526,8 @@ fn layout_dom_with_web_fonts_pass_limit(
|
|||||||
fonts: &[crate::inline::WebFont],
|
fonts: &[crate::inline::WebFont],
|
||||||
pass_limit: Option<usize>,
|
pass_limit: Option<usize>,
|
||||||
stylesheet_cache: Option<&mut crate::css::StylesheetCache>,
|
stylesheet_cache: Option<&mut crate::css::StylesheetCache>,
|
||||||
|
retained: Option<RetainedStyleMaps>,
|
||||||
|
mutations: &[AttributeStyleMutation],
|
||||||
) -> (DomLayout, ContainerLayoutTelemetry) {
|
) -> (DomLayout, ContainerLayoutTelemetry) {
|
||||||
let timing = std::env::var("OBSCURA_RENDER_TIMING").is_ok();
|
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 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) =
|
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 !sheet.has_container_queries() {
|
||||||
if timing {
|
if timing {
|
||||||
let (r, i, c, a, l, u) = sheet.debug_stats();
|
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 (
|
return (
|
||||||
laid,
|
laid,
|
||||||
@@ -2410,6 +2596,9 @@ fn layout_dom_with_web_fonts_pass_limit(
|
|||||||
passes: 1,
|
passes: 1,
|
||||||
termination: ContainerLayoutTermination::NoQueries,
|
termination: ContainerLayoutTermination::NoQueries,
|
||||||
query,
|
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 snapshot.boxes.is_empty() {
|
||||||
if timing {
|
if timing {
|
||||||
let (r, i, c, a, l, u) = sheet.debug_stats();
|
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 (
|
return (
|
||||||
laid,
|
laid,
|
||||||
@@ -2432,6 +2621,9 @@ fn layout_dom_with_web_fonts_pass_limit(
|
|||||||
passes: 1,
|
passes: 1,
|
||||||
termination: ContainerLayoutTermination::NoContainers,
|
termination: ContainerLayoutTermination::NoContainers,
|
||||||
query,
|
query,
|
||||||
|
retained_reused,
|
||||||
|
retained_fresh,
|
||||||
|
retained_fallback,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2471,7 +2663,15 @@ fn layout_dom_with_web_fonts_pass_limit(
|
|||||||
let mut needs_fallback = false;
|
let mut needs_fallback = false;
|
||||||
for pass in 2..=max_passes {
|
for pass in 2..=max_passes {
|
||||||
let (next, signature, pass_query, pass_cascade) =
|
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;
|
passes = pass;
|
||||||
query.evaluations += pass_query.evaluations;
|
query.evaluations += pass_query.evaluations;
|
||||||
query.cache_hits += pass_query.cache_hits;
|
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
|
// geometry used to choose them. Disable the unstable conditional
|
||||||
// rules for this render and expose the downgrade in telemetry.
|
// rules for this render and expose the downgrade in telemetry.
|
||||||
let (fallback, _, fallback_query, fallback_cascade) =
|
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;
|
laid = fallback;
|
||||||
passes += 1;
|
passes += 1;
|
||||||
query.evaluations += fallback_query.evaluations;
|
query.evaluations += fallback_query.evaluations;
|
||||||
@@ -2529,7 +2729,7 @@ fn layout_dom_with_web_fonts_pass_limit(
|
|||||||
|
|
||||||
if timing {
|
if timing {
|
||||||
let (r, i, c, a, l, u) = sheet.debug_stats();
|
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,
|
laid,
|
||||||
@@ -2537,6 +2737,9 @@ fn layout_dom_with_web_fonts_pass_limit(
|
|||||||
passes,
|
passes,
|
||||||
termination,
|
termination,
|
||||||
query,
|
query,
|
||||||
|
retained_reused,
|
||||||
|
retained_fresh,
|
||||||
|
retained_fallback,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -2548,6 +2751,7 @@ fn layout_dom_once(
|
|||||||
fonts: &[crate::inline::WebFont],
|
fonts: &[crate::inline::WebFont],
|
||||||
sheet: &crate::css::Stylesheet,
|
sheet: &crate::css::Stylesheet,
|
||||||
snapshot: Option<&crate::css::ContainerSnapshot>,
|
snapshot: Option<&crate::css::ContainerSnapshot>,
|
||||||
|
retained: Option<(RetainedStyleMaps, HashSet<NodeId>)>,
|
||||||
) -> (
|
) -> (
|
||||||
DomLayout,
|
DomLayout,
|
||||||
Option<crate::css::ContainerDecisionSignature>,
|
Option<crate::css::ContainerDecisionSignature>,
|
||||||
@@ -2556,8 +2760,10 @@ fn layout_dom_once(
|
|||||||
) {
|
) {
|
||||||
let t1 = std::time::Instant::now();
|
let t1 = std::time::Instant::now();
|
||||||
let mut matcher = tree.matcher();
|
let mut matcher = tree.matcher();
|
||||||
let mut styles: HashMap<NodeId, crate::LayoutStyle> = HashMap::new();
|
let (mut styles, mut custom_properties, fresh_styles) = match retained {
|
||||||
let mut custom_properties = HashMap::new();
|
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 root_props = std::rc::Rc::new(HashMap::new());
|
||||||
let mut evaluator =
|
let mut evaluator =
|
||||||
snapshot.map(|snapshot| crate::css::ContainerQueryEvaluator::new(tree, snapshot));
|
snapshot.map(|snapshot| crate::css::ContainerQueryEvaluator::new(tree, snapshot));
|
||||||
@@ -2585,6 +2791,7 @@ fn layout_dom_once(
|
|||||||
viewport,
|
viewport,
|
||||||
None,
|
None,
|
||||||
false,
|
false,
|
||||||
|
fresh_styles.as_ref(),
|
||||||
);
|
);
|
||||||
resolve_css_counters(tree, &mut styles);
|
resolve_css_counters(tree, &mut styles);
|
||||||
let cascade_time = t1.elapsed();
|
let cascade_time = t1.elapsed();
|
||||||
@@ -2771,6 +2978,142 @@ fn layout_dom_once(
|
|||||||
// (updated to its content width inside the block below).
|
// (updated to its content width inside the block below).
|
||||||
let mut child_cb_width = inh.cb_width;
|
let mut child_cb_width = inh.cb_width;
|
||||||
let mut child_cb_height_definite = false;
|
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| {
|
let inherited_grid_auto_tracks = styles.get(&id).and_then(|style| {
|
||||||
(style.grid_auto_columns_inherit || style.grid_auto_rows_inherit).then(|| {
|
(style.grid_auto_columns_inherit || style.grid_auto_rows_inherit).then(|| {
|
||||||
tree.get_node(id)
|
tree.get_node(id)
|
||||||
@@ -11997,6 +12340,8 @@ mod tests {
|
|||||||
&[],
|
&[],
|
||||||
Some(2),
|
Some(2),
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
|
&[],
|
||||||
);
|
);
|
||||||
let target = tree.get_element_by_id("target").unwrap();
|
let target = tree.get_element_by_id("target").unwrap();
|
||||||
assert_eq!(laid.styles[&target].width, crate::Dimension::Px(1.0));
|
assert_eq!(laid.styles[&target].width, crate::Dimension::Px(1.0));
|
||||||
@@ -12056,6 +12401,9 @@ mod tests {
|
|||||||
passes: 1,
|
passes: 1,
|
||||||
termination: ContainerLayoutTermination::NoQueries,
|
termination: ContainerLayoutTermination::NoQueries,
|
||||||
query: crate::css::ContainerQueryStats::default(),
|
query: crate::css::ContainerQueryStats::default(),
|
||||||
|
retained_reused: 0,
|
||||||
|
retained_fresh: 0,
|
||||||
|
retained_fallback: 0,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -12078,10 +12426,412 @@ mod tests {
|
|||||||
passes: 1,
|
passes: 1,
|
||||||
termination: ContainerLayoutTermination::NoContainers,
|
termination: ContainerLayoutTermination::NoContainers,
|
||||||
query: crate::css::ContainerQueryStats::default(),
|
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#"<html><head><style>
|
||||||
|
html,body{margin:0}
|
||||||
|
.theme[data-theme=dark] .card{--card-width:42px;color:#123456}
|
||||||
|
.card .child{width:var(--card-width,18px);height:2em;font-size:10px}
|
||||||
|
.theme[data-theme=dark] .card::before{content:'dark';display:block;width:7px}
|
||||||
|
.toggle[data-open=true] + .panel{--peer-width:31px}
|
||||||
|
.panel .peer{width:var(--peer-width,13px);height:9px}
|
||||||
|
.counter-scope{counter-reset:step}
|
||||||
|
.counter-item{counter-increment:step;display:block;height:9px}
|
||||||
|
.counter-item[data-double=true]{counter-increment:step 2}
|
||||||
|
.counter-item::before{content:counter(step)}
|
||||||
|
.clean{width:77px;height:11px}
|
||||||
|
@container dormant (min-width:10px){.child{width:99px}}
|
||||||
|
</style></head><body>
|
||||||
|
<section id=theme class=theme data-theme=light><div id=card class=card><span id=child class=child></span></div></section>
|
||||||
|
<div id=toggle class=toggle data-open=false></div><div class=panel><span id=peer class=peer></span></div>
|
||||||
|
<section class=counter-scope><span id=counter-one class=counter-item></span><span id=counter-change class=counter-item data-double=false></span><span id=counter-after class=counter-item></span></section>
|
||||||
|
<aside id=clean class=clean></aside>
|
||||||
|
<div style="display:flex"><button id=control><span>button</span></button><div id=inherit style="display:inherit">x</div></div>
|
||||||
|
</body></html>"#;
|
||||||
|
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::<HashSet<_>>(),
|
||||||
|
full.styles.keys().collect::<HashSet<_>>(),
|
||||||
|
"{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#"<!doctype html><html><head><style>
|
||||||
|
html,body{{margin:0}}
|
||||||
|
.clean{{width:71px;height:13px}}
|
||||||
|
{}
|
||||||
|
</style></head><body>
|
||||||
|
<section id="scope" class="scope theme-off" data-mode="off">
|
||||||
|
<div id="subject" class="subject state-off" data-state="off">
|
||||||
|
<span id="child" class="child"><i id="grand" class="grand leaf"></i></span>
|
||||||
|
</div>
|
||||||
|
<div id="adjacent" class="panel"><span id="adjacent-child" class="leaf"></span></div>
|
||||||
|
<div id="following" class="panel"><span id="following-child" class="leaf"></span></div>
|
||||||
|
</section>
|
||||||
|
<aside id="clean" class="clean"><span id="clean-child"></span></aside>
|
||||||
|
</body></html>"#,
|
||||||
|
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]
|
#[test]
|
||||||
fn container_convergence_classifies_self_consistent_stability() {
|
fn container_convergence_classifies_self_consistent_stability() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ pub use border::{
|
|||||||
|
|
||||||
pub mod dom;
|
pub mod dom;
|
||||||
pub use 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.
|
/// 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_dom, paint_dom_scrolled, paint_prepared, paint_prepared_region_with_scroll,
|
||||||
paint_prepared_with_scroll, prepare_dom, prepare_dom_with_dynamic_fonts,
|
paint_prepared_with_scroll, prepare_dom, prepare_dom_with_dynamic_fonts,
|
||||||
prepare_dom_with_dynamic_fonts_and_stylesheet_cache, screenshot_png, screenshot_png_scrolled,
|
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,
|
validate_capture_region, CaptureError, CaptureRegion, DynamicFontFace, ElementScrollMetrics,
|
||||||
PreparedRender, RenderResourceCache, RenderResourceLoader, ResolvedScrollState, SelectedImage,
|
PreparedRender, RenderResourceCache, RenderResourceLoader, ResolvedScrollState, SelectedImage,
|
||||||
MAX_CAPTURE_DIMENSION, MAX_CAPTURE_PIXELS,
|
MAX_CAPTURE_DIMENSION, MAX_CAPTURE_PIXELS,
|
||||||
|
|||||||
@@ -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_OBLIQUE_BYTES: &[u8] = include_bytes!("../assets/liberation-sans-oblique.ttf");
|
||||||
static FONT_BOLD_OBLIQUE_BYTES: &[u8] = include_bytes!("../assets/liberation-sans-boldoblique.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_ENTRIES: usize = 512;
|
||||||
const DEFAULT_RESOURCE_CACHE_BYTES: usize = 64 * 1024 * 1024;
|
const DEFAULT_RESOURCE_CACHE_BYTES: usize = 64 * 1024 * 1024;
|
||||||
@@ -98,6 +101,7 @@ pub struct RenderResourceCache {
|
|||||||
max_content_image_intrinsics: usize,
|
max_content_image_intrinsics: usize,
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
content_image_layout_retries: usize,
|
content_image_layout_retries: usize,
|
||||||
|
sync_loading_enabled: bool,
|
||||||
loader: Box<dyn RenderResourceLoader>,
|
loader: Box<dyn RenderResourceLoader>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,10 +140,19 @@ impl RenderResourceCache {
|
|||||||
max_content_image_intrinsics: max_entries.min(DEFAULT_CONTENT_IMAGE_INTRINSIC_ENTRIES),
|
max_content_image_intrinsics: max_entries.min(DEFAULT_CONTENT_IMAGE_INTRINSIC_ENTRIES),
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
content_image_layout_retries: 0,
|
content_image_layout_retries: 0,
|
||||||
|
sync_loading_enabled: true,
|
||||||
loader: Box::new(loader),
|
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 {
|
pub fn retained_entry_count(&self) -> usize {
|
||||||
self.entries.len()
|
self.entries.len()
|
||||||
}
|
}
|
||||||
@@ -276,6 +289,9 @@ impl RenderResourceCache {
|
|||||||
CachedResource::Missing(_) => {}
|
CachedResource::Missing(_) => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !self.sync_loading_enabled {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
self.remove(url);
|
self.remove(url);
|
||||||
|
|
||||||
let loaded = self.loader.load(url).map(Arc::<[u8]>::from);
|
let loaded = self.loader.load(url).map(Arc::<[u8]>::from);
|
||||||
@@ -1595,6 +1611,56 @@ pub fn prepare_dom_with_dynamic_fonts_and_stylesheet_cache(
|
|||||||
resources: &mut RenderResourceCache,
|
resources: &mut RenderResourceCache,
|
||||||
dynamic_fonts: &[DynamicFontFace],
|
dynamic_fonts: &[DynamicFontFace],
|
||||||
stylesheet_cache: &mut crate::css::StylesheetCache,
|
stylesheet_cache: &mut crate::css::StylesheetCache,
|
||||||
|
) -> Option<PreparedRender> {
|
||||||
|
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<PreparedRender> {
|
||||||
|
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<PreparedRender> {
|
) -> Option<PreparedRender> {
|
||||||
if !viewport.0.is_finite() || !viewport.1.is_finite() || viewport.0 <= 0.0 || viewport.1 <= 0.0
|
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 {
|
} else {
|
||||||
svg_font_database()
|
svg_font_database()
|
||||||
};
|
};
|
||||||
let mut laid = layout_dom_with_web_fonts_and_stylesheet_cache(
|
let mut laid = match retained {
|
||||||
|
Some((retained, mutations)) => layout_dom_with_web_fonts_and_retained_styles(
|
||||||
tree,
|
tree,
|
||||||
viewport,
|
viewport,
|
||||||
&intrinsic,
|
&intrinsic,
|
||||||
&fonts,
|
&fonts,
|
||||||
stylesheet_cache,
|
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
|
// `content:url(...)` is computed by the author cascade, whereas ordinary
|
||||||
// HTML image sources are available before layout. Pay for a second layout
|
// 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
|
// 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 crate::dom::layout_dom_with_web_fonts;
|
||||||
use obscura_dom::tree_sink::parse_html;
|
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]
|
#[test]
|
||||||
fn image_accept_advertises_exactly_decodable_mime_types() {
|
fn image_accept_advertises_exactly_decodable_mime_types() {
|
||||||
assert!(!IMAGE_ACCEPT.contains('*'));
|
assert!(!IMAGE_ACCEPT.contains('*'));
|
||||||
|
|||||||
Reference in new issue
Block a user