Skip to content

Commit e5157bd

Browse files
thePunderWomankirjs
authored andcommitted
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
1 parent 36c4b3f commit e5157bd

File tree

2 files changed

+84
-1
lines changed

2 files changed

+84
-1
lines changed

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,9 @@ export function runEnterAnimation(lView: LView, tNode: TNode, value: string | Fu
8989
// This also allows us to setup cancellation of animations in progress if the
9090
// gets removed early.
9191
const handleEnterAnimationStart = (event: AnimationEvent | TransitionEvent) => {
92+
// this early exit case is to prevent issues with bubbling events that are from child element animations
93+
if (event.target !== nativeElement) return;
94+
9295
const eventName = event instanceof AnimationEvent ? 'animationend' : 'transitionend';
9396
ngZone.runOutsideAngular(() => {
9497
cleanupFns.push(renderer.listen(nativeElement, eventName, handleEnterAnimationEnd));
@@ -97,6 +100,9 @@ export function runEnterAnimation(lView: LView, tNode: TNode, value: string | Fu
97100

98101
// When the longest animation ends, we can remove all the classes
99102
const handleEnterAnimationEnd = (event: AnimationEvent | TransitionEvent) => {
103+
// this early exit case is to prevent issues with bubbling events that are from child element animations
104+
if (event.target !== nativeElement) return;
105+
100106
enterAnimationEnd(event, nativeElement, renderer);
101107
};
102108

@@ -135,7 +141,8 @@ function enterAnimationEnd(
135141
renderer: Renderer,
136142
) {
137143
const elementData = enterClassMap.get(nativeElement);
138-
if (!elementData) return;
144+
// this event.target check is to prevent issues with bubbling events that are from child element animations
145+
if (event.target !== nativeElement || !elementData) return;
139146
if (isLongestAnimation(event, nativeElement)) {
140147
// Now that we've found the longest animation, there's no need
141148
// to keep bubbling up this event as it's not going to apply to
@@ -279,6 +286,8 @@ function animateLeaveClassRunner(
279286
cancelAnimationsIfRunning(el, renderer);
280287

281288
const handleOutAnimationEnd = (event: AnimationEvent | TransitionEvent | CustomEvent) => {
289+
// this early exit case is to prevent issues with bubbling events that are from child element animations
290+
if (event.target !== el) return;
282291
if (event instanceof CustomEvent || isLongestAnimation(event, el)) {
283292
// Now that we've found the longest animation, there's no need
284293
// to keep bubbling up this event as it's not going to apply to

packages/core/test/acceptance/animation_spec.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1673,5 +1673,79 @@ describe('Animation', () => {
16731673
tick();
16741674
expect(fixture.debugElement.queryAll(By.css('p')).length).toBe(3);
16751675
}));
1676+
1677+
it('should not remove elements when child element animations finish', fakeAsync(() => {
1678+
const animateStyles = `
1679+
.fade {
1680+
animation: fade-out 500ms;
1681+
}
1682+
.flash {
1683+
animation: flash 200ms;
1684+
}
1685+
@keyframes flash {
1686+
from {
1687+
background-color: rgba(255, 255, 255, 1);
1688+
}
1689+
to {
1690+
background-color: rgba(255, 255, 255, 0);;
1691+
}
1692+
}
1693+
@keyframes fade-out {
1694+
from {
1695+
opacity: 1;
1696+
}
1697+
to {
1698+
opacity: 0;
1699+
}
1700+
}
1701+
`;
1702+
1703+
@Component({
1704+
selector: 'test-cmp',
1705+
styles: animateStyles,
1706+
template: `
1707+
<div>
1708+
@if (show()) {
1709+
<p animate.leave="fade" #el>
1710+
I should slide in.
1711+
<button [class]="buttonClass()" (click)="flash()">click me</button>
1712+
</p>
1713+
}
1714+
</div>
1715+
`,
1716+
encapsulation: ViewEncapsulation.None,
1717+
})
1718+
class TestComponent {
1719+
show = signal(true);
1720+
buttonClass = signal('');
1721+
flash() {
1722+
this.show.update((val) => !val);
1723+
this.buttonClass.set('flash');
1724+
}
1725+
}
1726+
TestBed.configureTestingModule({animationsEnabled: true});
1727+
1728+
const fixture = TestBed.createComponent(TestComponent);
1729+
const cmp = fixture.componentInstance;
1730+
fixture.detectChanges();
1731+
cmp.flash();
1732+
fixture.detectChanges();
1733+
tickAnimationFrames(1);
1734+
1735+
fixture.debugElement
1736+
.query(By.css('button'))
1737+
.nativeElement.dispatchEvent(
1738+
new AnimationEvent('animationend', {animationName: 'flash', bubbles: true}),
1739+
);
1740+
tick();
1741+
expect(fixture.debugElement.queryAll(By.css('p')).length).toBe(1);
1742+
fixture.debugElement
1743+
.query(By.css('p'))
1744+
.nativeElement.dispatchEvent(
1745+
new AnimationEvent('animationend', {animationName: 'fade-out', bubbles: true}),
1746+
);
1747+
tick();
1748+
expect(fixture.debugElement.queryAll(By.css('p')).length).toBe(0);
1749+
}));
16761750
});
16771751
});

0 commit comments

Comments
 (0)