Skip to content

Canceling a @keyframes animation freezes the computed style for a later transition on the same element #48225

Description

@neeopy

Describe the bug:
When canceling a @keyframes animation and subsequently initiating a CSS transition on the same element, the computed transform may remain static and fail to update. This occurs despite the transition being correctly declared and the animation-name set to none. However, applying the same transition to a new element functions as expected.


To Reproduce:

<!doctype html>
<meta charset="utf-8" />
<style>
#box {
	position: absolute;
	top: 100px;
	left: 24px;
	width: 64px;
	height: 64px;
	background: rgb(30, 90, 210);
}
</style>
<div id="box"></div> <pre id="log"></pre>
<script>
const box = document.getElementById('box');
const log = document.getElementById('log');
const sleep = ms => new Promise(r => setTimeout(r, ms));
const kf = document.createElement('style');
kf.textContent = '@keyframes moving {from {transform:translateX(0px);opacity:.2;width:64px;background-color:rgb(30,90,210)} to {transform:translateX(400px);opacity:1;width:140px;background-color:rgb(230,50,70)}}';
document.head.append(kf);
let running = true;
(function tick() {
	if(!running) return;
	log.textContent = 'transform: ' + getComputedStyle(box).transform + '\nanimationName: ' + getComputedStyle(box).animationName;
	requestAnimationFrame(tick);
})();
(async() => {
	box.style.animation = 'moving 1000ms linear infinite alternate';
	await sleep(2300);
	kf.remove();
	await sleep(50);
	box.style.cssText = '';
	await sleep(200);
	box.style.transform = 'translateX(0px)';
	void box.offsetWidth;
	box.style.transition = 'transform 1000ms linear';
	for(let n = 0; n < 4; n++) {
		box.style.transform = n % 2 ? 'translateX(0px)' : 'translateX(400px)';
		await sleep(1100);
	}
})();
</script>

After cancellation, #box remains frozen at its last animated transform value instead of transitioning between 0 and 400 px, even though animation-name is none.

The cause is a missing dirty flag update in Stylo’s ElementAnimationSet::update_animations_for_new_style (style/servo/animation.rs:1288-1304).. This path sets the animation state to Canceled without marking the set dirty, unlike the other cancellation paths:

     for animation in self.animations.iter_mut() {
         if animation.is_cancelled_in_new_style(new_style) {
             animation.state = AnimationState::Canceled;
+            self.dirty = true;
         }
     }

I confirmed this with manual tests: the original build freezes, adding this line restores all four transitions with correct intermediate and final values, and reverting it brings the freeze back.


Platform:
Arch-based Linux (CachyOS), KDE/Wayland, NVIDIA RTX 2060 SUPER.

Activity

  1. webbedspace commented on Sep 20, 2026

    @webbedspace

    This is the same bug as #48210, right?

  2. neeopy commented on Sep 20, 2026

    @neeopy
    Author

    This is the same bug as #48210, right?

    I can't confirm this without testing, but they look like different bugs. #48210 is about new keyframes not replacing old ones. This issue is about the style freezing after an animation is canceled, because the set isn't marked dirty. So, different repro, different cause.

  3. webbedspace commented on Sep 20, 2026

    @webbedspace

    OK. I was thinking, like, maybe the replacement keyframes cancel the old animation and start a new one (which is what looks like happens in Firefox).

  4. TimvdLippe commented on Sep 21, 2026

    @TimvdLippe
    Contributor

    @neeopy thanks for the investigation and potential fix. Can you open a PR for Stylo to fix the bug and a corresponding PR for Servo which updates Stylo and adds the reproduction case as WPT test?

  5. neeopy commented on Sep 21, 2026

    @neeopy
    Author

    @neeopy thanks for the investigation and potential fix. Can you open a PR for Stylo to fix the bug and a corresponding PR for Servo which updates Stylo and adds the reproduction case as WPT test?

    Sure

  6. neeopy commented on Sep 21, 2026

    @neeopy
    Author

    @TimvdLippe prior to submitting the PR, I reviewed this section again and identified two additional bugs within the same function (ElementAnimationSet::update_animations_for_new_style, style/servo/animation.rs:1288-1304) that result in the same symptom (frozen computed style) triggered by different factors. I'm documenting them here instead of creating new issues, as they belong to the same root cause category and have the identical fix location as the previous one.

    If the stylesheet removes the only @keyframes rule that corresponds to an element's animation-name, and that animation-name is still declared, the animated value will freeze at the last computed state at the moment the rule was removed, rather than reverting to the element's default non-animated style. Reproduces on: repro-keyframes-removed.html

    The second case follows the same pattern: a missing cascade invalidation, just triggered differently. When the new style applies display: none, cancellation happens after the rule substitution and recascade instead of before it, so the old animation rule gets evaluated one more time before being removed. This lets a stale animated value reappear once the element becomes visible again. Reproduces on: repro-display-none-ordering.html

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions