diff --git a/crates/obscura-js/src/ops.rs b/crates/obscura-js/src/ops.rs
index e739ebd..c941108 100644
--- a/crates/obscura-js/src/ops.rs
+++ b/crates/obscura-js/src/ops.rs
@@ -3427,18 +3427,22 @@ pub(crate) fn ensure_prepared_render(
let base_url = document_base_url(state);
let viewport = state.viewport;
let animation_sample = state.animation_sample;
- let stale = state.prepared_render.as_ref().map_or(true, |prepared| {
+ let incompatible = state.prepared_render.as_ref().is_some_and(|prepared| {
prepared.viewport() != viewport
|| prepared.base_url() != base_url.as_deref()
- || prepared.animation_sample() != animation_sample
});
- if stale || !state.pending_style_mutations.is_empty() {
+ let needs_rebuild = state.prepared_render.as_ref().map_or(true, |prepared| {
+ incompatible || prepared.animation_sample() != animation_sample
+ }) || !state.pending_style_mutations.is_empty();
+ if needs_rebuild {
if let Some(dom) = state.dom.as_ref() {
state
.animation_timeline
.materialize_start_candidates(dom);
}
- let previous = (!stale).then(|| state.prepared_render.take()).flatten();
+ let previous = (!incompatible)
+ .then(|| state.prepared_render.take())
+ .flatten();
let mutations = std::mem::take(&mut state.pending_style_mutations);
let prepared = {
let dom = state.dom.as_ref()?;
@@ -3520,9 +3524,15 @@ pub(crate) fn sample_live_document_animations(state: &mut ObscuraState) {
state.animation_sample = sample;
return;
}
+ let forward_document_sample =
+ sample.mode == obscura_render::AnimationSampleMode::DocumentTime
+ && state.animation_sample.mode == obscura_render::AnimationSampleMode::DocumentTime
+ && sample.time.milliseconds > state.animation_sample.time.milliseconds;
state.animation_sample = sample;
- state.prepared_render = None;
- state.pending_style_mutations.clear();
+ if !forward_document_sample {
+ state.prepared_render = None;
+ state.pending_style_mutations.clear();
+ }
state.resolved_scroll = None;
}
diff --git a/crates/obscura-js/src/runtime.rs b/crates/obscura-js/src/runtime.rs
index 9998b47..81fcdf0 100644
--- a/crates/obscura-js/src/runtime.rs
+++ b/crates/obscura-js/src/runtime.rs
@@ -471,9 +471,11 @@ impl ObscuraJsRuntime {
}
let mut state = self.state.borrow_mut();
if state.animation_sample != sample {
- if sample.mode == obscura_render::AnimationSampleMode::DocumentTime
+ let forward_document_sample =
+ sample.mode == obscura_render::AnimationSampleMode::DocumentTime
&& state.animation_sample.mode == obscura_render::AnimationSampleMode::DocumentTime
- && sample.time.milliseconds > state.animation_sample.time.milliseconds
+ && sample.time.milliseconds > state.animation_sample.time.milliseconds;
+ if forward_document_sample
&& state.prepared_render.as_mut().is_some_and(|prepared| {
prepared.advance_inactive_animation_sample_time(sample.time)
})
@@ -482,8 +484,10 @@ impl ObscuraJsRuntime {
return true;
}
state.animation_sample = sample;
- state.prepared_render = None;
- state.pending_style_mutations.clear();
+ if !forward_document_sample {
+ state.prepared_render = None;
+ state.pending_style_mutations.clear();
+ }
state.resolved_scroll = None;
}
true
@@ -6879,6 +6883,100 @@ mod tests {
assert_eq!(first, second);
}
+ #[cfg(feature = "render")]
+ #[test]
+ fn forward_active_animation_sample_updates_geometry_and_paint_from_retained_frame() {
+ let mut rt = ObscuraJsRuntime::new();
+ rt.set_dom(parse_html(
+ r#"
"#,
+ ));
+ rt.set_url("http://example.test/page");
+ rt.set_viewport(120.0, 40.0);
+ rt.run_page_init();
+
+ assert!(rt.set_animation_sample(obscura_render::AnimationSample::document(0.0)));
+ let initial = rt
+ .screenshot_prepared((120.0, 40.0), Some("http://example.test/page"))
+ .expect("initial animation frame");
+ assert!((animation_test_width(&rt, "box") - 20.0).abs() < 0.1);
+
+ assert!(rt.set_animation_sample(obscura_render::AnimationSample::document(500.0)));
+ assert!(
+ rt.state.borrow().prepared_render.is_some(),
+ "a forward active sample should retain the previous style graph until flush"
+ );
+ let midpoint = rt
+ .screenshot_prepared((120.0, 40.0), Some("http://example.test/page"))
+ .expect("retained midpoint animation frame");
+
+ assert!((animation_test_width(&rt, "box") - 60.0).abs() < 0.1);
+ assert_ne!(initial, midpoint, "animated paint output must advance");
+ assert_eq!(
+ rt.state
+ .borrow()
+ .prepared_render
+ .as_ref()
+ .unwrap()
+ .animation_sample_time()
+ .milliseconds,
+ 500.0
+ );
+ }
+
+ #[cfg(feature = "render")]
+ #[test]
+ fn forward_waapi_sample_updates_retained_style_and_paint() {
+ let mut rt = setup_runtime(
+ r#"
+
+ "#,
+ );
+ rt.set_viewport(120.0, 40.0);
+ rt.state.borrow_mut().animation_timeline_origin = std::time::Instant::now();
+ assert!(rt.set_animation_sample(obscura_render::AnimationSample::document(0.0)));
+ rt.execute_script(
+ "waapi-retained-frame",
+ r#"document.getElementById('box').animate(
+ [{opacity:0,transform:'translateX(0px)'},
+ {opacity:1,transform:'translateX(80px)'}],
+ {duration:1000,fill:'both',easing:'linear'}
+ )"#,
+ )
+ .unwrap();
+ let initial = rt
+ .screenshot_prepared((120.0, 40.0), Some("http://example.com/test"))
+ .expect("initial WAAPI frame");
+
+ assert!(rt.set_animation_sample(obscura_render::AnimationSample::document(500.0)));
+ assert!(
+ rt.state.borrow().prepared_render.is_some(),
+ "a forward WAAPI sample should preserve the prepared style graph until flush"
+ );
+ let midpoint = rt
+ .screenshot_prepared((120.0, 40.0), Some("http://example.com/test"))
+ .expect("retained WAAPI midpoint");
+ let midpoint_opacity = {
+ let state = rt.state.borrow();
+ let dom = state.dom.as_ref().unwrap();
+ let box_node = dom.get_element_by_id("box").unwrap();
+ state.prepared_render.as_ref().unwrap().layout().styles[&box_node]
+ .opacity
+ .unwrap()
+ };
+
+ assert!(
+ (0.45..0.55).contains(&midpoint_opacity),
+ "WAAPI midpoint opacity={midpoint_opacity}"
+ );
+ assert_ne!(initial, midpoint, "WAAPI paint output must advance");
+ }
+
#[cfg(feature = "render")]
#[test]
fn completed_animation_retains_forward_frame_but_backward_seek_rebuilds() {
diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs
index e686c5d..54d12a5 100644
--- a/crates/obscura-render/src/dom.rs
+++ b/crates/obscura-render/src/dom.rs
@@ -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 for RetainedStyleMutation {
@@ -2594,7 +2598,10 @@ fn container_iteration_termination(
}
enum RetainedStylePlan {
- Reuse(HashSet),
+ Reuse {
+ dirty: HashSet,
+ 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!(
+ ""
+ ));
+ }
+ let tree = parse_html(&format!(
+ r#"
+ "#,
+ ));
+ 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();
diff --git a/crates/obscura-render/src/lib.rs b/crates/obscura-render/src/lib.rs
index dd554c4..1134d85 100644
--- a/crates/obscura-render/src/lib.rs
+++ b/crates/obscura-render/src/lib.rs
@@ -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 {
+ 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()
}
diff --git a/crates/obscura-render/src/paint.rs b/crates/obscura-render/src/paint.rs
index 85f1b6a..788875b 100644
--- a/crates/obscura-render/src/paint.rs
+++ b/crates/obscura-render/src/paint.rs
@@ -2098,7 +2098,14 @@ pub fn prepare_dom_with_retained_styles_with_animation_state(
animation_sample: crate::AnimationSample,
animation_timeline: &mut crate::AnimationTimelineState,
) -> Option {
- 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::>();
+ 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,
+ animation_timeline: &crate::AnimationTimelineState,
+) -> std::collections::HashSet {
+ let mut nodes = styles
+ .iter()
+ .filter_map(|(node, style)| {
+ (style.animation_name.is_some() && style.animation_has_render_effect)
+ .then_some(*node)
+ })
+ .collect::>();
+ 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(
+ "",
+ );
+ 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(