Reduce render latency and improve headless input

This commit is contained in:
SGavrl committed 2026-08-03 21:17:17 +00:00
1 parent b8bdac2163
commit 222b50e480
15 files changed
+2268 -147

No files matched your search

+101 -10
View File
@@ -129,6 +129,11 @@ pub struct ObscuraState {
/// DOM/style/viewport changes clear this value but retain resource bytes.
#[cfg(feature = "render")]
pub prepared_render: Option<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;
/// relayout of the same document reuses it without refetching.
#[cfg(feature = "render")]
@@ -197,6 +202,8 @@ impl ObscuraState {
#[cfg(feature = "render")]
prepared_render: None,
#[cfg(feature = "render")]
pending_attribute_style_mutations: Vec::new(),
#[cfg(feature = "render")]
render_resources: obscura_render::RenderResourceCache::default(),
#[cfg(feature = "render")]
stylesheet_cache: obscura_render::StylesheetCache::default(),
@@ -465,6 +472,48 @@ fn render_mutation_impact(
}
}
#[cfg(feature = "render")]
fn retained_attribute_style_mutation(
dom: &DomTree,
cmd: &str,
arg1: &str,
arg2: &str,
) -> Option<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")]
fn render_timing_enabled() -> bool {
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()
.map(|dom| render_mutation_impact(dom, &cmd, &arg1, &arg2))
.unwrap_or_default();
#[cfg(feature = "render")]
let retained_attribute_mutation = state
.dom
.as_ref()
.and_then(|dom| retained_attribute_style_mutation(dom, &cmd, &arg1, &arg2));
let invalidate = impact.connected && impact.actual_change;
if invalidate {
state.activity_generation = state.activity_generation.wrapping_add(1);
@@ -620,7 +674,19 @@ fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> Str
let had_prepared_render = state.prepared_render.is_some();
#[cfg(feature = "render")]
if invalidate {
state.prepared_render = None;
if let Some(mutation) = retained_attribute_mutation {
if state.prepared_render.is_some()
&& state.pending_attribute_style_mutations.len() < 256
{
state.pending_attribute_style_mutations.push(mutation);
} else {
state.prepared_render = None;
state.pending_attribute_style_mutations.clear();
}
} else {
state.prepared_render = None;
state.pending_attribute_style_mutations.clear();
}
state.resolved_scroll = None;
}
#[cfg(feature = "render")]
@@ -2958,6 +3024,7 @@ fn op_set_dynamic_fonts(state: &OpState, #[string] registrations: &str) -> bool
if state.dynamic_fonts != fonts {
state.dynamic_fonts = fonts;
state.prepared_render = None;
state.pending_attribute_style_mutations.clear();
state.resolved_scroll = None;
}
true
@@ -3041,17 +3108,41 @@ pub(crate) fn ensure_prepared_render(
let stale = state.prepared_render.as_ref().map_or(true, |prepared| {
prepared.viewport() != viewport || prepared.base_url() != base_url.as_deref()
});
if stale {
if stale || !state.pending_attribute_style_mutations.is_empty() {
let previous = (!stale).then(|| state.prepared_render.take()).flatten();
let mutations = std::mem::take(&mut state.pending_attribute_style_mutations);
let prepared = {
let dom = state.dom.as_ref()?;
obscura_render::prepare_dom_with_dynamic_fonts_and_stylesheet_cache(
dom,
viewport,
base_url.as_deref(),
&mut state.render_resources,
&state.dynamic_fonts,
&mut state.stylesheet_cache,
)?
match previous {
Some(previous) => obscura_render::prepare_dom_with_retained_attribute_styles(
dom,
viewport,
base_url.as_deref(),
&mut state.render_resources,
&state.dynamic_fonts,
&mut state.stylesheet_cache,
previous,
&mutations,
)
.or_else(|| {
obscura_render::prepare_dom_with_dynamic_fonts_and_stylesheet_cache(
dom,
viewport,
base_url.as_deref(),
&mut state.render_resources,
&state.dynamic_fonts,
&mut state.stylesheet_cache,
)
})?,
None => obscura_render::prepare_dom_with_dynamic_fonts_and_stylesheet_cache(
dom,
viewport,
base_url.as_deref(),
&mut state.render_resources,
&state.dynamic_fonts,
&mut state.stylesheet_cache,
)?,
}
};
state.prepared_render = Some(prepared);
state.resolved_scroll = None;
+75 -42
View File
@@ -31,6 +31,24 @@ static SNAPSHOT: &[u8] = include_bytes!(env!("OBSCURA_SNAPSHOT_PATH"));
/// parallel, each isolate on its own thread with no shared lock.
static ISOLATE_CREATE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[cfg(feature = "render")]
fn with_sync_render_loading_disabled<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)]
pub struct RemoteObjectInfo {
pub js_type: String,
@@ -246,6 +264,7 @@ impl ObscuraJsRuntime {
#[cfg(feature = "render")]
{
gs.prepared_render = None;
gs.pending_attribute_style_mutations.clear();
gs.render_resources = obscura_render::RenderResourceCache::default();
gs.stylesheet_cache = obscura_render::StylesheetCache::default();
gs.dynamic_fonts.clear();
@@ -266,6 +285,7 @@ impl ObscuraJsRuntime {
// present. Keep already-fetched absolute bytes, but rebuild
// candidate selection/layout against the new base.
state.prepared_render = None;
state.pending_attribute_style_mutations.clear();
state.resolved_scroll = None;
}
}
@@ -376,6 +396,7 @@ impl ObscuraJsRuntime {
if state.viewport != viewport {
state.viewport = viewport;
state.prepared_render = None;
state.pending_attribute_style_mutations.clear();
state.resolved_scroll = None;
}
}
@@ -418,21 +439,23 @@ impl ObscuraJsRuntime {
if viewport != state.viewport || base_url != effective_base.as_deref() {
return None;
}
ensure_resolved_scroll(&mut state)?;
let ObscuraState {
dom,
prepared_render,
render_resources,
resolved_scroll,
..
} = &mut *state;
let (_, scroll) = resolved_scroll.as_ref()?;
obscura_render::screenshot_prepared_with_scroll(
dom.as_ref()?,
prepared_render.as_mut()?,
render_resources,
scroll,
)
with_sync_render_loading_disabled(&mut state, |state| {
ensure_resolved_scroll(state)?;
let ObscuraState {
dom,
prepared_render,
render_resources,
resolved_scroll,
..
} = state;
let (_, scroll) = resolved_scroll.as_ref()?;
obscura_render::screenshot_prepared_with_scroll(
dom.as_ref()?,
prepared_render.as_mut()?,
render_resources,
scroll,
)
})
}
/// Capture a document-space rectangle without changing the live viewport,
@@ -444,27 +467,29 @@ impl ObscuraJsRuntime {
region: obscura_render::CaptureRegion,
) -> Result<Vec<u8>, obscura_render::CaptureError> {
let mut state = self.state.borrow_mut();
ensure_resolved_scroll(&mut state).ok_or(obscura_render::CaptureError::PaintFailed)?;
let ObscuraState {
dom,
prepared_render,
render_resources,
resolved_scroll,
..
} = &mut *state;
let (_, scroll) = resolved_scroll
.as_ref()
.ok_or(obscura_render::CaptureError::PaintFailed)?;
obscura_render::screenshot_prepared_region_with_scroll(
dom.as_ref()
.ok_or(obscura_render::CaptureError::PaintFailed)?,
prepared_render
.as_mut()
.ok_or(obscura_render::CaptureError::PaintFailed)?,
render_resources,
scroll,
region,
)
with_sync_render_loading_disabled(&mut state, |state| {
ensure_resolved_scroll(state).ok_or(obscura_render::CaptureError::PaintFailed)?;
let ObscuraState {
dom,
prepared_render,
render_resources,
resolved_scroll,
..
} = state;
let (_, scroll) = resolved_scroll
.as_ref()
.ok_or(obscura_render::CaptureError::PaintFailed)?;
obscura_render::screenshot_prepared_region_with_scroll(
dom.as_ref()
.ok_or(obscura_render::CaptureError::PaintFailed)?,
prepared_render
.as_mut()
.ok_or(obscura_render::CaptureError::PaintFailed)?,
render_resources,
scroll,
region,
)
})
}
/// Return the retained layout's scrollable document size without changing
@@ -473,11 +498,13 @@ impl ObscuraJsRuntime {
#[cfg(feature = "render")]
pub fn prepared_content_size(&self) -> Option<(f32, f32)> {
let mut state = self.state.borrow_mut();
ensure_resolved_scroll(&mut state)?;
state
.prepared_render
.as_ref()
.map(|render| render.content_size())
with_sync_render_loading_disabled(&mut state, |state| {
ensure_resolved_scroll(state)?;
state
.prepared_render
.as_ref()
.map(|render| render.content_size())
})
}
/// Return the exact responsive candidates selected for live `<img>`
@@ -530,6 +557,7 @@ impl ObscuraJsRuntime {
Some(bytes) => {
state.render_resources.seed(url, bytes);
state.prepared_render = None;
state.pending_attribute_style_mutations.clear();
state.resolved_scroll = None;
}
None => state.render_resources.seed_missing(url),
@@ -1548,6 +1576,7 @@ impl ObscuraJsRuntime {
#[cfg(feature = "render")]
{
state.prepared_render = None;
state.pending_attribute_style_mutations.clear();
state.render_resources = obscura_render::RenderResourceCache::default();
state.stylesheet_cache = obscura_render::StylesheetCache::default();
state.dynamic_fonts.clear();
@@ -6157,7 +6186,11 @@ mod tests {
// must observe the new class on the live connected element.
rt.evaluate("document.getElementById('box').className = 'b'")
.unwrap();
assert!(rt.state.borrow().prepared_render.is_none());
{
let state = rt.state.borrow();
assert!(state.prepared_render.is_some());
assert_eq!(state.pending_attribute_style_mutations.len(), 1);
}
assert_eq!(
rt.evaluate("document.getElementById('box').getBoundingClientRect().width")
.unwrap()