fix(core): prevent duplicate nodes from being retained with fast animate.leave` calls (#64592)

We were clearing duplicate nodes when `animate.enter` fired fast, but not when solely `animate.leave` is fired and rapid toggles occur. This ensures that the `cancelLeavingNodes` function is called in all cases instead of just enter animations.

fixes: #64581

PR Close #64592
This commit is contained in:
Jessica Janiuk
2025-10-22 11:30:28 -07:00
parent a3e91b2e96
commit 81bd671906
2 changed files with 54 additions and 2 deletions
@@ -68,7 +68,6 @@ export function ɵɵanimateEnter(value: string | Function): typeof ɵɵanimateEn
}
const tNode = getCurrentTNode()!;
cancelLeavingNodes(tNode, lView);
addAnimationToLView(getLViewEnterAnimations(lView), tNode, () =>
@@ -196,7 +195,6 @@ export function ɵɵanimateEnterListener(value: AnimationFunction): typeof ɵɵa
return ɵɵanimateEnterListener;
}
const tNode = getCurrentTNode()!;
cancelLeavingNodes(tNode, lView);
addAnimationToLView(getLViewEnterAnimations(lView), tNode, () =>
@@ -251,6 +249,7 @@ export function ɵɵanimateLeave(value: string | Function): typeof ɵɵanimateLe
}
const tNode = getCurrentTNode()!;
cancelLeavingNodes(tNode, lView);
addAnimationToLView(getLViewLeaveAnimations(lView), tNode, () =>
runLeaveAnimations(lView, tNode, value),
@@ -383,6 +382,8 @@ export function ɵɵanimateLeaveListener(value: AnimationFunction): typeof ɵɵa
const lView = getLView();
const tNode = getCurrentTNode()!;
cancelLeavingNodes(tNode, lView);
allLeavingAnimations.add(lView);
addAnimationToLView(getLViewLeaveAnimations(lView), tNode, () =>
@@ -12,10 +12,12 @@ import {
AfterViewInit,
AnimationCallbackEvent,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
computed,
Directive,
ElementRef,
inject,
NgModule,
OnDestroy,
provideZonelessChangeDetection,
@@ -1627,6 +1629,55 @@ describe('Animation', () => {
expect(paragraphs.length).toBe(1);
}));
it('should reset leave animation and not duplicate node when toggled programmatically very quickly', fakeAsync(() => {
const animateStyles = `
.fade {
animation: fade-out 500ms;
}
@keyframes fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
`;
@Component({
selector: 'test-cmp',
styles: animateStyles,
template: '<div>@if (show()) {<p animate.leave="fade">I should fade</p>}</div>',
encapsulation: ViewEncapsulation.None,
})
class TestComponent {
show = signal(false);
cdr = inject(ChangeDetectorRef);
toggle() {
this.show.update((s) => !s);
setTimeout(() => {
this.show.update((s) => !s);
this.cdr.detectChanges();
setTimeout(() => {
this.show.update((s) => !s);
this.cdr.detectChanges();
});
});
}
}
TestBed.configureTestingModule({animationsEnabled: true});
const fixture = TestBed.createComponent(TestComponent);
const cmp = fixture.componentInstance;
cmp.toggle();
fixture.detectChanges();
tickAnimationFrames(1);
const paragraphs = fixture.debugElement.queryAll(By.css('p'));
expect(paragraphs.length).toBe(1);
}));
it('should always run animations for `@for` loops when adding and removing quickly', fakeAsync(() => {
const animateStyles = `
.slide-in {