refactor(core): distinguish animations that share a name

animate.leave waits for the longest-running animation before removing an element. When multiple animations use the same keyframe name, their animationend events have the same animationName, so a shorter animation can be mistaken for the longest one and remove the element too early.

Track the longest animation duration returned by getAnimations() and compare it with the duration from event.animation when available. Keep the existing name/property checks as a fallback for older browsers and computed-style detection.

Allow a 1ms tolerance for rounding differences between CSSOM and Web Animations and add a regression test using values equivalent to fractional calc() durations.

(cherry picked from commit a26fbfa641)
This commit is contained in:
SkyZeroZx
2026-08-11 11:54:09 -05:00
committed by Alon Mishne
parent dc8c0717a0
commit d22eb386a5
4 changed files with 148 additions and 19 deletions
@@ -69,6 +69,24 @@ function longestExists(longest: LongestAnimation): boolean {
);
}
/** Gets an animation's total duration, including its delay and playback rate. */
export function getAnimationDuration(animation: Animation): number | undefined {
const timing = animation.effect?.getTiming();
if (timing === undefined) return undefined;
// duration can be a string 'auto' or a number.
const animationDuration = typeof timing.duration === 'number' ? timing.duration : 0;
let duration = (timing.delay ?? 0) + animationDuration;
// Account for playback rate if it is set.
const playbackRate = animation.playbackRate;
if (playbackRate !== undefined && playbackRate !== 0 && playbackRate !== 1) {
duration /= Math.abs(playbackRate);
}
return duration;
}
/**
* Determines the longest animation, but with `getComputedStyles` instead of `getAnimations`. This
* is ultimately safer than getAnimations because it can be used when recalculations are in
@@ -127,15 +145,7 @@ function determineLongestAnimationFromElementAnimations(
if (timing?.iterations === Infinity) {
continue;
}
// duration can be a string 'auto' or a number.
const animDuration = typeof timing?.duration === 'number' ? timing.duration : 0;
let duration = (timing?.delay ?? 0) + animDuration;
// Account for playback rate if it is set
const playbackRate = animation.playbackRate;
if (playbackRate !== undefined && playbackRate !== 0 && playbackRate !== 1) {
duration /= Math.abs(playbackRate);
}
const duration = getAnimationDuration(animation) ?? 0;
let propertyName: string | undefined;
let animationName: string | undefined;
+38 -8
View File
@@ -21,8 +21,10 @@ import {RuntimeError, RuntimeErrorCode} from '../errors';
import {Renderer} from '../render3/interfaces/renderer';
import {RElement} from '../render3/interfaces/renderer_dom';
import {TNode} from '../render3/interfaces/node';
import {getAnimationDuration} from './longest_animation';
const DEFAULT_ANIMATIONS_DISABLED = false;
const ANIMATION_DURATION_TOLERANCE_MS = 1;
export const areAnimationSupported =
(typeof ngServerMode === 'undefined' || !ngServerMode) &&
@@ -332,14 +334,42 @@ export function isLongestAnimation(
// If we don't have any record of a longest animation, then we shouldn't
// block the animationend/transitionend event from doing its work.
if (longestAnimation === undefined) return true;
return (
nativeElement === getEventTarget(event) &&
((longestAnimation.animationName !== undefined &&
(event as AnimationEvent).animationName === longestAnimation.animationName) ||
(longestAnimation.propertyName !== undefined &&
(longestAnimation.propertyName === 'all' ||
(event as TransitionEvent).propertyName === longestAnimation.propertyName)))
);
if (nativeElement !== getEventTarget(event)) return false;
// Distinct CSS animations can share a name. Chrome 151 stable exposes their instance:
// https://developer.chrome.com/release-notes/151#animation_accessor_on_animation_and_transition_events
const eventAnimation = (
event as (AnimationEvent | TransitionEvent) & {readonly animation?: Animation | null}
).animation;
// Compare the event animation's duration instead of retaining the Animation object. This also
// disambiguates records obtained from computed styles when getAnimations() was empty.
if (eventAnimation) {
const eventAnimationDuration = getAnimationDuration(eventAnimation);
// CSSOM can round serialized times while Web Animations retains more precision. Only reject an
// event when it is shorter by more than the tolerance so the longest event is not ignored.
if (
eventAnimationDuration !== undefined &&
eventAnimationDuration + ANIMATION_DURATION_TOLERANCE_MS < longestAnimation.duration
) {
return false;
}
}
// Fall back to strings for older browsers.
if (longestAnimation.animationName !== undefined) {
return (event as AnimationEvent).animationName === longestAnimation.animationName;
}
if (longestAnimation.propertyName !== undefined) {
return (
longestAnimation.propertyName === 'all' ||
(event as TransitionEvent).propertyName === longestAnimation.propertyName
);
}
return false;
}
/**
@@ -864,6 +864,68 @@ describe('Animation', () => {
expect(fixture.debugElement.query(By.css('div'))).toBeNull();
}));
it('should wait for the longest duplicate-named animation when getAnimations is empty', fakeAsync(() => {
const multiple = `
.duplicate-animation-name {
animation:
duplicate-name 10s linear,
duplicate-name 20s linear;
}
@keyframes duplicate-name {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
`;
@Component({
changeDetection: ChangeDetectionStrategy.Eager,
selector: 'test-cmp',
styles: multiple,
template:
'@if (show()) { <p animate.leave="duplicate-animation-name">Element with text</p> }',
encapsulation: ViewEncapsulation.None,
})
class TestComponent {
show = signal(true);
}
TestBed.configureTestingModule({animationsEnabled: true});
const fixture = TestBed.createComponent(TestComponent);
const cmp = fixture.componentInstance;
fixture.detectChanges();
const paragraph = fixture.debugElement.query(By.css('p'));
const getAnimations = paragraph.nativeElement.getAnimations.bind(paragraph.nativeElement);
spyOn(paragraph.nativeElement, 'getAnimations').and.returnValue([]);
expect(paragraph.nativeElement.className).not.toContain('duplicate-animation-name');
cmp.show.set(false);
fixture.detectChanges();
tickAnimationFrames(1);
expect(cmp.show()).toBeFalse();
fixture.detectChanges();
expect(paragraph.nativeElement.className).toContain('duplicate-animation-name');
const [shortAnimation, longAnimation] = getAnimations();
const dispatchAnimationEnd = (animation: Animation) => {
const event = new AnimationEvent('animationend', {animationName: 'duplicate-name'});
Object.defineProperty(event, 'animation', {value: animation});
paragraph.nativeElement.dispatchEvent(event);
};
dispatchAnimationEnd(shortAnimation);
tick();
expect(fixture.nativeElement.outerHTML).toContain('duplicate-animation-name');
dispatchAnimationEnd(longAnimation);
tick();
expect(fixture.nativeElement.outerHTML).not.toContain('duplicate-animation-name');
expect(fixture.debugElement.query(By.css('p'))).toBeNull();
}));
describe('legacy animations compatibility', () => {
beforeAll(() => {
TestBed.resetTestEnvironment();
@@ -2678,8 +2740,7 @@ describe('Animation', () => {
const panels = () => Array.from(fixture.nativeElement.querySelectorAll('.panel'));
const panelByText = (text: string) =>
panels().find((el) => (el as HTMLElement).textContent?.includes(text)) as
| HTMLElement
| undefined;
HTMLElement | undefined;
expect(panels().length).toBe(1);
expect(panelByText('Panel A')).toBeTruthy();
@@ -8,6 +8,7 @@
import {determineLongestAnimation} from '../../src/animation/longest_animation';
import {LongestAnimation} from '../../src/animation/interfaces';
import {isLongestAnimation, longestAnimations} from '../../src/animation/utils';
import {isNode} from '@angular/private/testing';
describe('determineLongestAnimation', () => {
@@ -27,6 +28,33 @@ describe('determineLongestAnimation', () => {
expect(el.getAnimations).not.toHaveBeenCalled();
});
it('should tolerate duration rounding when checking the longest animation event', () => {
const el = document.createElement('div');
longestAnimations.set(el, {
animationName: 'duplicate-name',
propertyName: undefined,
// Equivalent to calc(20s / 3) + calc(1s / 7) after CSSOM serialization.
duration: 6809.527,
});
const createAnimationEndEvent = (duration: number) => {
const event = new AnimationEvent('animationend', {animationName: 'duplicate-name'});
const animation = {
playbackRate: 1,
effect: {
getTiming: () => ({duration, delay: 1000 / 7}),
},
} as unknown as Animation;
Object.defineProperty(event, 'animation', {value: animation});
spyOn(event, 'composedPath').and.returnValue([el]);
return event;
};
expect(isLongestAnimation(createAnimationEndEvent(20_000 / 3), el)).toBeTrue();
expect(isLongestAnimation(createAnimationEndEvent(10_000 / 3), el)).toBeFalse();
longestAnimations.delete(el);
});
describe('with getAnimations() support', () => {
it('should find the longest animation among multiple animations', () => {
const el = document.createElement('div');