Skip to content

Commit de3a0c5

Browse files
thePunderWomancrisbeto
authored andcommitted
fix(core): Fix animate.enter class removal when composing classes (#62981)
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. PR Close #62981
1 parent d00b3fe commit de3a0c5

File tree

2 files changed

+123
-7
lines changed

2 files changed

+123
-7
lines changed

packages/core/src/render3/instructions/animation.ts

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ const areAnimationSupported =
3636

3737
const noOpAnimationComplete = () => {};
3838

39+
// Tracks the list of classes added to a DOM node from `animate.enter` calls to ensure
40+
// we remove all of the classes in the case of animation composition via host bindings.
41+
const enterClassMap = new WeakMap<HTMLElement, string[]>();
42+
3943
/**
4044
* Instruction to handle the `animate.enter` behavior for class bindings.
4145
*
@@ -76,7 +80,6 @@ export function ɵɵanimateEnter(value: string | Function): typeof ɵɵanimateEn
7680
// This also allows us to setup cancellation of animations in progress if the
7781
// gets removed early.
7882
const handleAnimationStart = (event: AnimationEvent | TransitionEvent) => {
79-
setupAnimationCancel(event, activeClasses, renderer);
8083
const eventName = event instanceof AnimationEvent ? 'animationend' : 'transitionend';
8184
ngZone.runOutsideAngular(() => {
8285
cleanupFns.push(renderer.listen(nativeElement, eventName, handleInAnimationEnd));
@@ -85,7 +88,7 @@ export function ɵɵanimateEnter(value: string | Function): typeof ɵɵanimateEn
8588

8689
// When the longest animation ends, we can remove all the classes
8790
const handleInAnimationEnd = (event: AnimationEvent | TransitionEvent) => {
88-
animationEnd(event, nativeElement, activeClasses, renderer, cleanupFns);
91+
animationEnd(event, nativeElement, renderer, cleanupFns);
8992
};
9093

9194
// 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
9598
cleanupFns.push(renderer.listen(nativeElement, 'transitionstart', handleAnimationStart));
9699
});
97100

101+
trackEnterClasses(nativeElement, activeClasses);
102+
98103
for (const klass of activeClasses) {
99104
renderer.addClass(nativeElement as HTMLElement, klass);
100105
}
@@ -103,6 +108,23 @@ export function ɵɵanimateEnter(value: string | Function): typeof ɵɵanimateEn
103108
return ɵɵanimateEnter; // For chaining
104109
}
105110

111+
/**
112+
* trackEnterClasses is necessary in the case of composition where animate.enter
113+
* is used on the same element in multiple places, like on the element and in a
114+
* host binding. When removing classes, we need the entire list of animation classes
115+
* added to properly remove them when the longest animation fires.
116+
*/
117+
function trackEnterClasses(el: HTMLElement, classes: string[]) {
118+
const classlist = enterClassMap.get(el);
119+
if (classlist) {
120+
for (const klass of classes) {
121+
classlist.push(klass);
122+
}
123+
} else {
124+
enterClassMap.set(el, classes);
125+
}
126+
}
127+
106128
/**
107129
* Instruction to handle the `(animate.enter)` behavior for event bindings, aka when
108130
* a user wants to use a custom animation function rather than a class.
@@ -390,22 +412,23 @@ function isLongestAnimation(
390412
function animationEnd(
391413
event: AnimationEvent | TransitionEvent,
392414
nativeElement: HTMLElement,
393-
classList: string[] | null,
394415
renderer: Renderer,
395416
cleanupFns: Function[],
396417
) {
418+
const classList = enterClassMap.get(nativeElement);
419+
if (!classList) return;
420+
setupAnimationCancel(event, classList, renderer);
397421
const longestAnimation = getLongestAnimation(event);
398422
if (isLongestAnimation(event, nativeElement, longestAnimation)) {
399423
// Now that we've found the longest animation, there's no need
400424
// to keep bubbling up this event as it's not going to apply to
401425
// other elements further up. We don't want it to inadvertently
402426
// affect any other animations on the page.
403427
event.stopImmediatePropagation();
404-
if (classList !== null) {
405-
for (const klass of classList) {
406-
renderer.removeClass(nativeElement, klass);
407-
}
428+
for (const klass of classList) {
429+
renderer.removeClass(nativeElement, klass);
408430
}
431+
enterClassMap.delete(nativeElement);
409432
for (const fn of cleanupFns) {
410433
fn();
411434
}

packages/core/test/acceptance/animation_spec.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -629,7 +629,13 @@ describe('Animation', () => {
629629
cmp.show.set(true);
630630
fixture.detectChanges();
631631
expect(cmp.show()).toBeTruthy();
632+
const paragraph = fixture.debugElement.query(By.css('p'));
632633
expect(cmp.el.nativeElement.outerHTML).toContain('class="slide-in"');
634+
paragraph.nativeElement.dispatchEvent(new AnimationEvent('animationstart'));
635+
paragraph.nativeElement.dispatchEvent(
636+
new AnimationEvent('animationend', {animationName: 'fade-in'}),
637+
);
638+
expect(cmp.el.nativeElement.outerHTML).not.toContain('class="slide-in fade-in"');
633639
});
634640

635641
it('should support string arrays', () => {
@@ -671,13 +677,74 @@ describe('Animation', () => {
671677
}
672678
TestBed.configureTestingModule({animationsEnabled: true});
673679

680+
const fixture = TestBed.createComponent(TestComponent);
681+
const cmp = fixture.componentInstance;
682+
fixture.detectChanges();
683+
expect(cmp.show()).toBeFalsy();
684+
cmp.show.set(true);
685+
fixture.detectChanges();
686+
const paragraph = fixture.debugElement.query(By.css('p'));
687+
expect(cmp.show()).toBeTruthy();
688+
expect(cmp.el.nativeElement.outerHTML).toContain('class="slide-in fade-in"');
689+
paragraph.nativeElement.dispatchEvent(new AnimationEvent('animationstart'));
690+
paragraph.nativeElement.dispatchEvent(
691+
new AnimationEvent('animationend', {animationName: 'fade-in'}),
692+
);
693+
expect(cmp.el.nativeElement.outerHTML).not.toContain('class="slide-in fade-in"');
694+
});
695+
696+
it('should support multple classes as a single string separated by a space', () => {
697+
const multiple = `
698+
.slide-in {
699+
animation: slide-in 1ms;
700+
}
701+
.fade-in {
702+
animation: fade-in 2ms;
703+
}
704+
@keyframes slide-in {
705+
from {
706+
transform: translateX(-10px);
707+
}
708+
to {
709+
transform: translateX(0);
710+
}
711+
}
712+
@keyframes fade-in {
713+
from {
714+
opacity: 0;
715+
}
716+
to {
717+
opacity: 1;
718+
}
719+
}
720+
`;
721+
@Component({
722+
selector: 'test-cmp',
723+
styles: multiple,
724+
template:
725+
'<div>@if (show()) {<p animate.enter="slide-in fade-in" #el>I should slide in</p>}</div>',
726+
encapsulation: ViewEncapsulation.None,
727+
})
728+
class TestComponent {
729+
show = signal(false);
730+
@ViewChild('el', {read: ElementRef}) el!: ElementRef<HTMLParagraphElement>;
731+
}
732+
TestBed.configureTestingModule({animationsEnabled: true});
733+
674734
const fixture = TestBed.createComponent(TestComponent);
675735
const cmp = fixture.componentInstance;
676736
fixture.detectChanges();
677737
cmp.show.set(true);
678738
fixture.detectChanges();
739+
const paragraph = fixture.debugElement.query(By.css('p'));
679740
expect(cmp.show()).toBeTruthy();
680741
expect(cmp.el.nativeElement.outerHTML).toContain('class="slide-in fade-in"');
742+
fixture.detectChanges();
743+
paragraph.nativeElement.dispatchEvent(new AnimationEvent('animationstart'));
744+
paragraph.nativeElement.dispatchEvent(
745+
new AnimationEvent('animationend', {animationName: 'fade-in'}),
746+
);
747+
expect(cmp.el.nativeElement.outerHTML).not.toContain('class="slide-in fade-in"');
681748
});
682749

683750
it('should support multple classes as a single string separated by a space', () => {
@@ -762,6 +829,12 @@ describe('Animation', () => {
762829
const fixture = TestBed.createComponent(TestComponent);
763830
fixture.detectChanges();
764831
expect(fixture.debugElement.nativeElement.outerHTML).toContain('class="slide-in"');
832+
const paragraph = fixture.debugElement.query(By.css('p'));
833+
paragraph.nativeElement.dispatchEvent(new AnimationEvent('animationstart'));
834+
paragraph.nativeElement.dispatchEvent(
835+
new AnimationEvent('animationend', {animationName: 'slide-in'}),
836+
);
837+
expect(fixture.debugElement.nativeElement.outerHTML).toContain('class="slide-in"');
765838
});
766839

767840
it('should compose class list when host binding and regular binding', () => {
@@ -781,6 +854,7 @@ describe('Animation', () => {
781854
styles: styles,
782855
imports: [ChildComponent],
783856
template: '<child-cmp [animate.enter]="fadeExp" />',
857+
encapsulation: ViewEncapsulation.None,
784858
})
785859
class TestComponent {
786860
fadeExp = 'fade-in';
@@ -793,6 +867,15 @@ describe('Animation', () => {
793867

794868
expect(childCmp.nativeElement.className).toContain('slide-in');
795869
expect(childCmp.nativeElement.className).toContain('fade-in');
870+
childCmp.nativeElement.dispatchEvent(new AnimationEvent('animationstart'));
871+
childCmp.nativeElement.dispatchEvent(
872+
new AnimationEvent('animationend', {animationName: 'fade-in'}),
873+
);
874+
childCmp.nativeElement.dispatchEvent(
875+
new AnimationEvent('animationend', {animationName: 'slide-in'}),
876+
);
877+
expect(childCmp.nativeElement.className).not.toContain('slide-in');
878+
expect(childCmp.nativeElement.className).not.toContain('fade-in');
796879
});
797880

798881
it('should compose class list when host binding a string and regular class strings', () => {
@@ -810,6 +893,7 @@ describe('Animation', () => {
810893
styles: styles,
811894
imports: [ChildComponent],
812895
template: '<child-cmp animate.enter="fade-in" />',
896+
encapsulation: ViewEncapsulation.None,
813897
})
814898
class TestComponent {}
815899
TestBed.configureTestingModule({animationsEnabled: true});
@@ -819,6 +903,15 @@ describe('Animation', () => {
819903
const childCmp = fixture.debugElement.query(By.css('child-cmp'));
820904

821905
expect(childCmp.nativeElement.className).toContain('slide-in fade-in');
906+
childCmp.nativeElement.dispatchEvent(new AnimationEvent('animationstart'));
907+
childCmp.nativeElement.dispatchEvent(
908+
new AnimationEvent('animationend', {animationName: 'fade-in'}),
909+
);
910+
childCmp.nativeElement.dispatchEvent(
911+
new AnimationEvent('animationend', {animationName: 'slide-in'}),
912+
);
913+
fixture.detectChanges();
914+
expect(childCmp.nativeElement.className).not.toContain('slide-in fade-in');
822915
});
823916
});
824917
});

0 commit comments

Comments
 (0)