Retain clean styles across animation frames

This commit is contained in:
SGavrl committed 2026-08-04 11:14:01 +00:00
1 parent fd87ddd0b9
commit 47e9aa096b
5 files changed
+345 -14

No files matched your search

+124 -3
View File
@@ -540,6 +540,10 @@ pub enum TreeStyleMutation {
pub enum RetainedStyleMutation {
Attribute(AttributeStyleMutation),
Tree(TreeStyleMutation),
/// A document-timeline sample changed while the DOM and stylesheet stayed
/// stable. Re-cascade this animation owner and its inheritance-dependent
/// subtree while retaining unrelated branches.
Animation { node: NodeId },
}
impl From<AttributeStyleMutation> for RetainedStyleMutation {
@@ -2594,7 +2598,10 @@ fn container_iteration_termination<T: PartialEq>(
}
enum RetainedStylePlan {
Reuse(HashSet<NodeId>),
Reuse {
dirty: HashSet<NodeId>,
has_animation_damage: bool,
},
Full,
}
@@ -3126,6 +3133,7 @@ fn empty_state_may_have_changed(
.iter()
.filter(|mutation| match mutation {
RetainedStyleMutation::Attribute(_) => false,
RetainedStyleMutation::Animation { .. } => false,
RetainedStyleMutation::Tree(TreeStyleMutation::Insert {
old_parent,
new_parent,
@@ -3189,7 +3197,17 @@ fn retained_style_plan(
mutations: &[RetainedStyleMutation],
) -> RetainedStylePlan {
let mut dirty = HashSet::new();
let mut has_animation_damage = false;
for mutation in mutations {
if let RetainedStyleMutation::Animation { node } = mutation {
// Animated color and visibility inherit, keyframe endpoints can
// consume inherited custom properties, and generated pseudos are
// rebuilt with their originating element. The existing subtree
// expansion covers all three while retaining sibling branches.
add_style_subtree(tree, *node, &mut dirty);
has_animation_damage = true;
continue;
}
let RetainedStyleMutation::Attribute(mutation) = mutation else {
let RetainedStyleMutation::Tree(mutation) = mutation else {
unreachable!()
@@ -3353,7 +3371,10 @@ fn retained_style_plan(
}
}
}
RetainedStylePlan::Reuse(dirty)
RetainedStylePlan::Reuse {
dirty,
has_animation_damage,
}
}
pub(crate) fn layout_dom_with_web_fonts(
@@ -3599,7 +3620,10 @@ fn layout_dom_with_web_fonts_pass_limit_at_animation_time(
.custom_properties
.retain(|node, _| connected.contains(node));
match retained_style_plan(tree, &sheet, mutations) {
RetainedStylePlan::Reuse(mut dirty) => {
RetainedStylePlan::Reuse {
mut dirty,
has_animation_damage,
} => {
if !active_containers.is_empty() && sheet.has_container_queries() {
let mut matcher = tree.matcher();
add_container_query_reset_scopes(
@@ -3613,6 +3637,22 @@ fn layout_dom_with_web_fonts_pass_limit_at_animation_time(
&mut dirty,
);
}
// Once animation damage reaches at least half of the retained
// style graph, sparse HashMap reuse no longer offsets dirty-set
// bookkeeping and branch checks. This is a document-relative
// coverage threshold, not a site- or node-count heuristic.
if has_animation_damage
&& dirty.len().saturating_mul(2) >= retained.styles.len()
{
if std::env::var_os("OBSCURA_RENDER_TIMING").is_some() {
eprintln!(
"[timing] retained-style fallback reason=animation-dirty-coverage dirty={} retained={}",
dirty.len(),
retained.styles.len(),
);
}
return None;
}
Some((retained, dirty))
}
RetainedStylePlan::Full => None,
@@ -14581,6 +14621,87 @@ mod tests {
}
}
#[test]
fn retained_animation_restyle_reuses_clean_branches_and_matches_full() {
let mut clean = String::new();
for index in 0..2_000 {
clean.push_str(&format!(
"<span class=clean data-index={index}><i></i></span>"
));
}
let tree = parse_html(&format!(
r#"<style>
html,body{{margin:0}}
@keyframes grow {{
from {{ width:20px; color:#ff0000 }}
to {{ width:120px; color:#0000ff }}
}}
#animated{{height:20px;animation:grow 1000ms linear both}}
#animated > b{{color:inherit}}
.clean{{display:block;width:3px;height:1px}}
</style><main><section id=animated><b id=child>x</b></section>
<aside>{clean}</aside></main>"#,
));
let animated = tree.get_element_by_id("animated").unwrap();
let mut cache = crate::css::StylesheetCache::default();
let mut timeline = crate::AnimationTimelineState::default();
let (mut initial, _) = layout_dom_with_web_fonts_pass_limit_at_animation_time(
&tree,
(800.0, 600.0),
&HashMap::new(),
&[],
None,
Some(&mut cache),
None,
&[],
crate::AnimationSample::document(0.0),
&mut timeline,
);
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_at_animation_time(
&tree,
(800.0, 600.0),
&HashMap::new(),
&[],
None,
Some(&mut cache),
Some(retained),
&[RetainedStyleMutation::Animation { node: animated }],
crate::AnimationSample::document(500.0),
&mut timeline,
);
let mut full_timeline = crate::AnimationTimelineState::default();
let (full, _) = layout_dom_with_web_fonts_pass_limit_at_animation_time(
&tree,
(800.0, 600.0),
&HashMap::new(),
&[],
None,
Some(&mut cache),
None,
&[],
crate::AnimationSample::document(500.0),
&mut full_timeline,
);
assert_computed_styles_match("animation retained-vs-full", &incremental, &full);
assert_eq!(incremental.rects, full.rects);
assert_eq!(telemetry.retained_fallback, 0, "{telemetry:?}");
assert!(telemetry.retained_reused >= 4_000, "{telemetry:?}");
assert!(telemetry.retained_fresh < 16, "{telemetry:?}");
assert!(
(incremental.rects[&animated].width - 70.0).abs() < 0.1,
"animated geometry did not advance: {:?}",
incremental.rects[&animated]
);
let child = tree.get_element_by_id("child").unwrap();
assert_eq!(incremental.styles[&child].color, Some([128, 0, 128, 255]));
}
#[test]
fn retained_insertions_with_active_container_queries_match_forced_full() {
let mut clean = String::new();
+15
View File
@@ -1886,6 +1886,21 @@ impl AnimationTimelineState {
self.waapi.insert(animation.id, animation);
}
/// Exact set of nodes currently targeted by registered Web Animations.
/// The render preparation boundary additionally filters disconnected
/// targets against the current DOM before producing restyle damage.
pub fn waapi_nodes(
&self,
) -> std::collections::HashSet<obscura_dom::tree::NodeId> {
self.waapi.values().map(|animation| animation.node).collect()
}
/// New animation instance epochs must observe a complete cascade. They
/// can be created by DOM mutations which have not yet reached style flush.
pub fn has_pending_start_candidates(&self) -> bool {
!self.start_candidates.is_empty() || !self.subtree_start_candidates.is_empty()
}
pub fn cancel_waapi(&mut self, id: u64) -> bool {
self.waapi.remove(&id).is_some()
}
+88 -1
View File
@@ -2098,7 +2098,14 @@ pub fn prepare_dom_with_retained_styles_with_animation_state(
animation_sample: crate::AnimationSample,
animation_timeline: &mut crate::AnimationTimelineState,
) -> Option<PreparedRender> {
if previous.animation_sample != animation_sample {
let sample_changed = previous.animation_sample != animation_sample;
let forward_document_sample = sample_changed
&& previous.animation_sample.mode == crate::AnimationSampleMode::DocumentTime
&& animation_sample.mode == crate::AnimationSampleMode::DocumentTime
&& animation_sample.time.milliseconds >= previous.animation_sample.time.milliseconds;
if sample_changed
&& (!forward_document_sample || animation_timeline.has_pending_start_candidates())
{
drop(previous);
return prepare_dom_with_dynamic_fonts_and_stylesheet_cache_with_animation_state(
tree,
@@ -2111,6 +2118,22 @@ pub fn prepare_dom_with_retained_styles_with_animation_state(
animation_timeline,
);
}
let animation_nodes = sample_changed
.then(|| retained_animation_restyle_nodes(tree, &previous.layout.styles, animation_timeline))
.unwrap_or_default();
let animation_mutations;
let mutations = if animation_nodes.is_empty() {
mutations
} else {
animation_mutations = mutations
.iter()
.cloned()
.chain(animation_nodes.into_iter().map(|node| {
crate::dom::RetainedStyleMutation::Animation { node }
}))
.collect::<Vec<_>>();
animation_mutations.as_slice()
};
let retained = RetainedStyleMaps {
styles: std::mem::take(&mut previous.layout.styles),
custom_properties: std::mem::take(&mut previous.layout.custom_properties),
@@ -2129,6 +2152,26 @@ pub fn prepare_dom_with_retained_styles_with_animation_state(
)
}
fn retained_animation_restyle_nodes(
tree: &DomTree,
styles: &HashMap<obscura_dom::tree::NodeId, crate::LayoutStyle>,
animation_timeline: &crate::AnimationTimelineState,
) -> std::collections::HashSet<obscura_dom::tree::NodeId> {
let mut nodes = styles
.iter()
.filter_map(|(node, style)| {
(style.animation_name.is_some() && style.animation_has_render_effect)
.then_some(*node)
})
.collect::<std::collections::HashSet<_>>();
nodes.extend(animation_timeline.waapi_nodes());
nodes.retain(|node| {
tree.get_node(*node).is_some()
&& (*node == tree.document() || tree.ancestors(*node).contains(&tree.document()))
});
nodes
}
fn prepare_dom_with_dynamic_fonts_and_stylesheet_cache_internal(
tree: &DomTree,
viewport: (f32, f32),
@@ -14407,6 +14450,50 @@ mod tests {
assert!(sticky.blue() > 240 && sticky.red() < 20);
}
#[test]
fn animation_restyle_nodes_deduplicate_waapi_and_filter_disconnected_targets() {
let tree = parse_html(
"<main><div id=connected></div><div id=detached></div></main>",
);
let connected = tree.get_element_by_id("connected").unwrap();
let detached = tree.get_element_by_id("detached").unwrap();
tree.remove_child(detached);
let animation = |id, node| crate::WaapiAnimation {
id,
node,
keyframes: vec![crate::WaapiKeyframe {
offset: 0.0,
opacity: Some(0.0),
transform: None,
}],
timing: crate::AnimationTiming {
duration_ms: 1_000.0,
..crate::AnimationTiming::default()
},
easing: None,
linear_easing: None,
start_time_ms: 0.0,
hold_time_ms: None,
play_state: crate::WaapiPlayState::Running,
};
let mut timeline = crate::AnimationTimelineState::default();
timeline.register_waapi(animation(1, connected));
timeline.register_waapi(animation(2, connected));
timeline.register_waapi(animation(3, detached));
assert_eq!(
timeline.waapi_nodes(),
std::collections::HashSet::from([connected, detached]),
"the timeline accessor must return the exact deduplicated target set"
);
let nodes = retained_animation_restyle_nodes(&tree, &HashMap::new(), &timeline);
assert_eq!(
nodes,
std::collections::HashSet::from([connected]),
"only connected WAAPI targets may enter retained style damage"
);
}
#[test]
fn prepared_render_samples_explicit_animation_time_and_reports_live_damage() {
let tree = parse_html(