fix(core): ensure animate events do not have duplicate elements (#63216)

This applies the same fix that fixed the class version to the event binding version. It prevents duplicate elements from being on screen when animations have been toggled too fast.

fixes: #63127

PR Close #63216
This commit is contained in:
Jessica Janiuk
2025-08-18 13:28:12 +02:00
parent 6c8d000016
commit a7cfb42c03
2 changed files with 102 additions and 11 deletions
@@ -88,6 +88,23 @@ const longestAnimations = new WeakMap<HTMLElement, LongestAnimation>();
// from an `@if` or `@for`.
const leavingNodes = new WeakMap<TNode, HTMLElement[]>();
function clearLeavingNodes(tNode: TNode): void {
if (leavingNodes.get(tNode)?.length === 0) {
leavingNodes.delete(tNode);
}
}
function trackLeavingNodes(tNode: TNode, el: HTMLElement): void {
// We need to track this tNode's element just to be sure we don't add
// a new RNode for this TNode while this one is still animating away.
// once the animation is complete, we remove this reference.
if (leavingNodes.has(tNode)) {
leavingNodes.get(tNode)?.push(el);
} else {
leavingNodes.set(tNode, [el]);
}
}
/**
* Instruction to handle the `animate.enter` behavior for class bindings.
*
@@ -208,6 +225,15 @@ export function ɵɵanimateEnterListener(value: AnimationFunction): typeof ɵɵa
const tNode = getCurrentTNode()!;
const nativeElement = getNativeByTNode(tNode, lView) as HTMLElement;
// In the case that we have an existing node that's animating away, like when
// an `@if` toggles quickly or `@for` adds and removes elements quickly, we
// need to end the animation for the former node and remove it right away to
// prevent duplicate nodes showing up.
leavingNodes
.get(tNode)
?.pop()
?.dispatchEvent(new CustomEvent('animationend', {detail: {cancel: true}}));
value.call(lView[CONTEXT], {target: nativeElement, animationComplete: noOpAnimationComplete});
return ɵɵanimateEnterListener;
@@ -320,7 +346,10 @@ export function ɵɵanimateLeaveListener(value: AnimationFunction): typeof ɵɵa
elementRegistry.elements,
'Expected `ElementRegistry` to be present in animations subsystem',
);
const renderer = lView[RENDERER];
const animationsDisabled = areAnimationsDisabled(lView);
const ngZone = lView[INJECTOR]!.get(NgZone);
const animate: AnimationEventFunction = (
_el: Element,
@@ -333,9 +362,15 @@ export function ɵɵanimateLeaveListener(value: AnimationFunction): typeof ɵɵa
const event: AnimationCallbackEvent = {
target: nativeElement,
animationComplete: () => {
clearLeavingNodes(tNode);
removeFn();
},
};
trackLeavingNodes(tNode, _el as HTMLElement);
ngZone.runOutsideAngular(() => {
renderer.listen(_el, 'animationend', () => removeFn(), {once: true});
});
value.call(lView[CONTEXT], event);
}
};
@@ -485,9 +520,7 @@ function animateLeaveClassRunner(
// affect any other animations on the page.
event.stopImmediatePropagation();
longestAnimations.delete(el);
if (leavingNodes.get(tNode)?.length === 0) {
leavingNodes.delete(tNode);
}
clearLeavingNodes(tNode);
finalRemoveFn();
}
};
@@ -499,14 +532,7 @@ function animateLeaveClassRunner(
renderer.listen(el, 'animationend', handleOutAnimationEnd);
renderer.listen(el, 'transitionend', handleOutAnimationEnd);
});
// We need to track this tNode's element just to be sure we don't add
// a new RNode for this TNode while this one is still animating away.
// once the animation is complete, we remove this reference.
if (leavingNodes.has(tNode)) {
leavingNodes.get(tNode)?.push(el);
} else {
leavingNodes.set(tNode, [el]);
}
trackLeavingNodes(tNode, el);
for (const item of classList) {
renderer.addClass(el, item);
}
@@ -978,5 +978,70 @@ describe('Animation', () => {
const paragraphs = fixture.debugElement.queryAll(By.css('p'));
expect(paragraphs.length).toBe(1);
});
it('should reset leave animation and not duplicate node when toggled quickly using event bindings', () => {
const animateStyles = `
.slide-in {
animation: slide-in 500ms;
}
.fade {
animation: fade-out 500ms;
}
@keyframes slide-in {
from {
transform: translateX(-10px);
}
to {
transform: translateX(0);
}
}
@keyframes fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
`;
@Component({
selector: 'test-cmp',
styles: animateStyles,
template:
'<div>@if (show()) {<p (animate.enter)="slideIn($event)" (animate.leave)="fade($event)" #el>I should slide in</p>}</div>',
encapsulation: ViewEncapsulation.None,
})
class TestComponent {
show = signal(false);
@ViewChild('el', {read: ElementRef}) el!: ElementRef<HTMLParagraphElement>;
slideIn(event: AnimationCallbackEvent) {
event.target.classList.add('slide-in');
setTimeout(() => event.animationComplete(), 500);
}
fade(event: AnimationCallbackEvent) {
event.target.classList.add('fade');
setTimeout(() => event.animationComplete(), 500);
}
}
TestBed.configureTestingModule({animationsEnabled: true});
const fixture = TestBed.createComponent(TestComponent);
const cmp = fixture.componentInstance;
fixture.detectChanges();
cmp.show.set(true);
fixture.detectChanges();
expect(cmp.show()).toBeTruthy();
cmp.show.set(false);
fixture.detectChanges();
expect(cmp.show()).toBeFalsy();
cmp.show.set(true);
fixture.detectChanges();
expect(cmp.show()).toBeTruthy();
const paragraphs = fixture.debugElement.queryAll(By.css('p'));
expect(paragraphs.length).toBe(1);
});
});
});