From 2a325f25db48944059dcb200d3581b95ab9526b0 Mon Sep 17 00:00:00 2001 From: Jessica Janiuk Date: Mon, 4 Aug 2025 12:36:56 +0200 Subject: [PATCH] fix(core): Fix `animate.enter` class removal when composing classes In the case when composing animation classes with `animate.enter` on the element itself and also with host bindings, the removal would only have context for one of the classes added: the last one added. This allows for tracking of the classes added by `animate.enter` via a WeakMap so we know the exact classes added and which to remove. Also shores up the tests to make sure we are fully testing animate.enter. --- .../src/render3/instructions/animation.ts | 37 ++++++-- .../core/test/acceptance/animation_spec.ts | 93 +++++++++++++++++++ 2 files changed, 123 insertions(+), 7 deletions(-) diff --git a/packages/core/src/render3/instructions/animation.ts b/packages/core/src/render3/instructions/animation.ts index d1a45a56b8b5..9801b3aba069 100644 --- a/packages/core/src/render3/instructions/animation.ts +++ b/packages/core/src/render3/instructions/animation.ts @@ -36,6 +36,10 @@ const areAnimationSupported = const noOpAnimationComplete = () => {}; +// Tracks the list of classes added to a DOM node from `animate.enter` calls to ensure +// we remove all of the classes in the case of animation composition via host bindings. +const enterClassMap = new WeakMap(); + /** * Instruction to handle the `animate.enter` behavior for class bindings. * @@ -76,7 +80,6 @@ export function ɵɵanimateEnter(value: string | Function): typeof ɵɵanimateEn // This also allows us to setup cancellation of animations in progress if the // gets removed early. const handleAnimationStart = (event: AnimationEvent | TransitionEvent) => { - setupAnimationCancel(event, activeClasses, renderer); const eventName = event instanceof AnimationEvent ? 'animationend' : 'transitionend'; ngZone.runOutsideAngular(() => { cleanupFns.push(renderer.listen(nativeElement, eventName, handleInAnimationEnd)); @@ -85,7 +88,7 @@ export function ɵɵanimateEnter(value: string | Function): typeof ɵɵanimateEn // When the longest animation ends, we can remove all the classes const handleInAnimationEnd = (event: AnimationEvent | TransitionEvent) => { - animationEnd(event, nativeElement, activeClasses, renderer, cleanupFns); + animationEnd(event, nativeElement, renderer, cleanupFns); }; // We only need to add these event listeners if there are actual classes to apply @@ -95,6 +98,8 @@ export function ɵɵanimateEnter(value: string | Function): typeof ɵɵanimateEn cleanupFns.push(renderer.listen(nativeElement, 'transitionstart', handleAnimationStart)); }); + trackEnterClasses(nativeElement, activeClasses); + for (const klass of activeClasses) { renderer.addClass(nativeElement as HTMLElement, klass); } @@ -103,6 +108,23 @@ export function ɵɵanimateEnter(value: string | Function): typeof ɵɵanimateEn return ɵɵanimateEnter; // For chaining } +/** + * trackEnterClasses is necessary in the case of composition where animate.enter + * is used on the same element in multiple places, like on the element and in a + * host binding. When removing classes, we need the entire list of animation classes + * added to properly remove them when the longest animation fires. + */ +function trackEnterClasses(el: HTMLElement, classes: string[]) { + const classlist = enterClassMap.get(el); + if (classlist) { + for (const klass of classes) { + classlist.push(klass); + } + } else { + enterClassMap.set(el, classes); + } +} + /** * Instruction to handle the `(animate.enter)` behavior for event bindings, aka when * a user wants to use a custom animation function rather than a class. @@ -390,10 +412,12 @@ function isLongestAnimation( function animationEnd( event: AnimationEvent | TransitionEvent, nativeElement: HTMLElement, - classList: string[] | null, renderer: Renderer, cleanupFns: Function[], ) { + const classList = enterClassMap.get(nativeElement); + if (!classList) return; + setupAnimationCancel(event, classList, renderer); const longestAnimation = getLongestAnimation(event); if (isLongestAnimation(event, nativeElement, longestAnimation)) { // Now that we've found the longest animation, there's no need @@ -401,11 +425,10 @@ function animationEnd( // other elements further up. We don't want it to inadvertently // affect any other animations on the page. event.stopImmediatePropagation(); - if (classList !== null) { - for (const klass of classList) { - renderer.removeClass(nativeElement, klass); - } + for (const klass of classList) { + renderer.removeClass(nativeElement, klass); } + enterClassMap.delete(nativeElement); for (const fn of cleanupFns) { fn(); } diff --git a/packages/core/test/acceptance/animation_spec.ts b/packages/core/test/acceptance/animation_spec.ts index 248fef1ce740..2fcc495ae264 100644 --- a/packages/core/test/acceptance/animation_spec.ts +++ b/packages/core/test/acceptance/animation_spec.ts @@ -629,7 +629,13 @@ describe('Animation', () => { cmp.show.set(true); fixture.detectChanges(); expect(cmp.show()).toBeTruthy(); + const paragraph = fixture.debugElement.query(By.css('p')); expect(cmp.el.nativeElement.outerHTML).toContain('class="slide-in"'); + paragraph.nativeElement.dispatchEvent(new AnimationEvent('animationstart')); + paragraph.nativeElement.dispatchEvent( + new AnimationEvent('animationend', {animationName: 'fade-in'}), + ); + expect(cmp.el.nativeElement.outerHTML).not.toContain('class="slide-in fade-in"'); }); it('should support string arrays', () => { @@ -671,13 +677,74 @@ describe('Animation', () => { } TestBed.configureTestingModule({animationsEnabled: true}); + const fixture = TestBed.createComponent(TestComponent); + const cmp = fixture.componentInstance; + fixture.detectChanges(); + expect(cmp.show()).toBeFalsy(); + cmp.show.set(true); + fixture.detectChanges(); + const paragraph = fixture.debugElement.query(By.css('p')); + expect(cmp.show()).toBeTruthy(); + expect(cmp.el.nativeElement.outerHTML).toContain('class="slide-in fade-in"'); + paragraph.nativeElement.dispatchEvent(new AnimationEvent('animationstart')); + paragraph.nativeElement.dispatchEvent( + new AnimationEvent('animationend', {animationName: 'fade-in'}), + ); + expect(cmp.el.nativeElement.outerHTML).not.toContain('class="slide-in fade-in"'); + }); + + it('should support multple classes as a single string separated by a space', () => { + const multiple = ` + .slide-in { + animation: slide-in 1ms; + } + .fade-in { + animation: fade-in 2ms; + } + @keyframes slide-in { + from { + transform: translateX(-10px); + } + to { + transform: translateX(0); + } + } + @keyframes fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + `; + @Component({ + selector: 'test-cmp', + styles: multiple, + template: + '
@if (show()) {

I should slide in

}
', + encapsulation: ViewEncapsulation.None, + }) + class TestComponent { + show = signal(false); + @ViewChild('el', {read: ElementRef}) el!: ElementRef; + } + TestBed.configureTestingModule({animationsEnabled: true}); + const fixture = TestBed.createComponent(TestComponent); const cmp = fixture.componentInstance; fixture.detectChanges(); cmp.show.set(true); fixture.detectChanges(); + const paragraph = fixture.debugElement.query(By.css('p')); expect(cmp.show()).toBeTruthy(); expect(cmp.el.nativeElement.outerHTML).toContain('class="slide-in fade-in"'); + fixture.detectChanges(); + paragraph.nativeElement.dispatchEvent(new AnimationEvent('animationstart')); + paragraph.nativeElement.dispatchEvent( + new AnimationEvent('animationend', {animationName: 'fade-in'}), + ); + expect(cmp.el.nativeElement.outerHTML).not.toContain('class="slide-in fade-in"'); }); it('should support multple classes as a single string separated by a space', () => { @@ -762,6 +829,12 @@ describe('Animation', () => { const fixture = TestBed.createComponent(TestComponent); fixture.detectChanges(); expect(fixture.debugElement.nativeElement.outerHTML).toContain('class="slide-in"'); + const paragraph = fixture.debugElement.query(By.css('p')); + paragraph.nativeElement.dispatchEvent(new AnimationEvent('animationstart')); + paragraph.nativeElement.dispatchEvent( + new AnimationEvent('animationend', {animationName: 'slide-in'}), + ); + expect(fixture.debugElement.nativeElement.outerHTML).toContain('class="slide-in"'); }); it('should compose class list when host binding and regular binding', () => { @@ -781,6 +854,7 @@ describe('Animation', () => { styles: styles, imports: [ChildComponent], template: '', + encapsulation: ViewEncapsulation.None, }) class TestComponent { fadeExp = 'fade-in'; @@ -793,6 +867,15 @@ describe('Animation', () => { expect(childCmp.nativeElement.className).toContain('slide-in'); expect(childCmp.nativeElement.className).toContain('fade-in'); + childCmp.nativeElement.dispatchEvent(new AnimationEvent('animationstart')); + childCmp.nativeElement.dispatchEvent( + new AnimationEvent('animationend', {animationName: 'fade-in'}), + ); + childCmp.nativeElement.dispatchEvent( + new AnimationEvent('animationend', {animationName: 'slide-in'}), + ); + expect(childCmp.nativeElement.className).not.toContain('slide-in'); + expect(childCmp.nativeElement.className).not.toContain('fade-in'); }); it('should compose class list when host binding a string and regular class strings', () => { @@ -810,6 +893,7 @@ describe('Animation', () => { styles: styles, imports: [ChildComponent], template: '', + encapsulation: ViewEncapsulation.None, }) class TestComponent {} TestBed.configureTestingModule({animationsEnabled: true}); @@ -819,6 +903,15 @@ describe('Animation', () => { const childCmp = fixture.debugElement.query(By.css('child-cmp')); expect(childCmp.nativeElement.className).toContain('slide-in fade-in'); + childCmp.nativeElement.dispatchEvent(new AnimationEvent('animationstart')); + childCmp.nativeElement.dispatchEvent( + new AnimationEvent('animationend', {animationName: 'fade-in'}), + ); + childCmp.nativeElement.dispatchEvent( + new AnimationEvent('animationend', {animationName: 'slide-in'}), + ); + fixture.detectChanges(); + expect(childCmp.nativeElement.className).not.toContain('slide-in fade-in'); }); }); });