From 81bd671906321a77a0b75e7d846f96847a1f5d4e Mon Sep 17 00:00:00 2001 From: Jessica Janiuk Date: Wed, 22 Oct 2025 11:30:28 -0700 Subject: [PATCH] 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 --- .../src/render3/instructions/animation.ts | 5 +- .../core/test/acceptance/animation_spec.ts | 51 +++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/packages/core/src/render3/instructions/animation.ts b/packages/core/src/render3/instructions/animation.ts index 14e3ed7656e..5b34015fa84 100644 --- a/packages/core/src/render3/instructions/animation.ts +++ b/packages/core/src/render3/instructions/animation.ts @@ -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, () => diff --git a/packages/core/test/acceptance/animation_spec.ts b/packages/core/test/acceptance/animation_spec.ts index 9faa29cc46b..d6592d8c221 100644 --- a/packages/core/test/acceptance/animation_spec.ts +++ b/packages/core/test/acceptance/animation_spec.ts @@ -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: '
@if (show()) {

I should fade

}
', + 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 {