fix(core): prevents unintended early termination of leave animations and hoisting (#64088)

The event listeners for animationstart and animationend weren't properly checking whether the animation event fired matched the node we're bound to, since animation events bubble. This resulted in child node animation events bubbling up and causing elements to get prematurely removed.

fixes: #64084

PR Close #64088
This commit is contained in:
Jessica Janiuk
2025-09-25 09:11:25 -07:00
committed by kirjs
parent 566077ff05
commit 8a0c9ca8be
2 changed files with 84 additions and 1 deletions
@@ -89,6 +89,9 @@ export function runEnterAnimation(lView: LView, tNode: TNode, value: string | Fu
// This also allows us to setup cancellation of animations in progress if the
// gets removed early.
const handleEnterAnimationStart = (event: AnimationEvent | TransitionEvent) => {
// this early exit case is to prevent issues with bubbling events that are from child element animations
if (event.target !== nativeElement) return;
const eventName = event instanceof AnimationEvent ? 'animationend' : 'transitionend';
ngZone.runOutsideAngular(() => {
cleanupFns.push(renderer.listen(nativeElement, eventName, handleEnterAnimationEnd));
@@ -97,6 +100,9 @@ export function runEnterAnimation(lView: LView, tNode: TNode, value: string | Fu
// When the longest animation ends, we can remove all the classes
const handleEnterAnimationEnd = (event: AnimationEvent | TransitionEvent) => {
// this early exit case is to prevent issues with bubbling events that are from child element animations
if (event.target !== nativeElement) return;
enterAnimationEnd(event, nativeElement, renderer);
};
@@ -135,7 +141,8 @@ function enterAnimationEnd(
renderer: Renderer,
) {
const elementData = enterClassMap.get(nativeElement);
if (!elementData) return;
// this event.target check is to prevent issues with bubbling events that are from child element animations
if (event.target !== nativeElement || !elementData) return;
if (isLongestAnimation(event, nativeElement)) {
// Now that we've found the longest animation, there's no need
// to keep bubbling up this event as it's not going to apply to
@@ -279,6 +286,8 @@ function animateLeaveClassRunner(
cancelAnimationsIfRunning(el, renderer);
const handleOutAnimationEnd = (event: AnimationEvent | TransitionEvent | CustomEvent) => {
// this early exit case is to prevent issues with bubbling events that are from child element animations
if (event.target !== el) return;
if (event instanceof CustomEvent || isLongestAnimation(event, el)) {
// Now that we've found the longest animation, there's no need
// to keep bubbling up this event as it's not going to apply to
@@ -1673,5 +1673,79 @@ describe('Animation', () => {
tick();
expect(fixture.debugElement.queryAll(By.css('p')).length).toBe(3);
}));
it('should not remove elements when child element animations finish', fakeAsync(() => {
const animateStyles = `
.fade {
animation: fade-out 500ms;
}
.flash {
animation: flash 200ms;
}
@keyframes flash {
from {
background-color: rgba(255, 255, 255, 1);
}
to {
background-color: rgba(255, 255, 255, 0);;
}
}
@keyframes fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
`;
@Component({
selector: 'test-cmp',
styles: animateStyles,
template: `
<div>
@if (show()) {
<p animate.leave="fade" #el>
I should slide in.
<button [class]="buttonClass()" (click)="flash()">click me</button>
</p>
}
</div>
`,
encapsulation: ViewEncapsulation.None,
})
class TestComponent {
show = signal(true);
buttonClass = signal('');
flash() {
this.show.update((val) => !val);
this.buttonClass.set('flash');
}
}
TestBed.configureTestingModule({animationsEnabled: true});
const fixture = TestBed.createComponent(TestComponent);
const cmp = fixture.componentInstance;
fixture.detectChanges();
cmp.flash();
fixture.detectChanges();
tickAnimationFrames(1);
fixture.debugElement
.query(By.css('button'))
.nativeElement.dispatchEvent(
new AnimationEvent('animationend', {animationName: 'flash', bubbles: true}),
);
tick();
expect(fixture.debugElement.queryAll(By.css('p')).length).toBe(1);
fixture.debugElement
.query(By.css('p'))
.nativeElement.dispatchEvent(
new AnimationEvent('animationend', {animationName: 'fade-out', bubbles: true}),
);
tick();
expect(fixture.debugElement.queryAll(By.css('p')).length).toBe(0);
}));
});
});