From 8a0c9ca8bee3c3c5ff4a79980a501312b3f94488 Mon Sep 17 00:00:00 2001 From: Jessica Janiuk Date: Thu, 25 Sep 2025 09:11:25 -0700 Subject: [PATCH] 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 --- .../src/render3/instructions/animation.ts | 11 ++- .../core/test/acceptance/animation_spec.ts | 74 +++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/packages/core/src/render3/instructions/animation.ts b/packages/core/src/render3/instructions/animation.ts index d94f8ceea03..a409ad08e43 100644 --- a/packages/core/src/render3/instructions/animation.ts +++ b/packages/core/src/render3/instructions/animation.ts @@ -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 diff --git a/packages/core/test/acceptance/animation_spec.ts b/packages/core/test/acceptance/animation_spec.ts index f87329d0f04..333fe6ee200 100644 --- a/packages/core/test/acceptance/animation_spec.ts +++ b/packages/core/test/acceptance/animation_spec.ts @@ -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: ` +
+ @if (show()) { +

+ I should slide in. + +

+ } +
+ `, + 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); + })); }); });