fix(core): prevent child animation elements from being orphaned

When routing between two different routes, child animations were not finishing, causing elements to be left behind in the dom. The fix ensures the proper fallback is handled to avoid automatically cancelled custom events. This ensures the animation-fallback cancelling the animation actually completes, and ensures the element is removed.

fixes: #67400
(cherry picked from commit 9e64147b73)
This commit is contained in:
Jessica Janiuk 2026-03-03 08:53:54 -08:00
parent 84e79f5add
commit b4ec3cc4e4
6 changed files with 93 additions and 10 deletions

View file

@ -72,4 +72,23 @@ describe('Animations Integration', () => {
const finalBoxes = await page.$$('.example-box');
expect(finalBoxes.length).toBe(3);
});
it('should remove element when animationend is dropped (fallback timeout)', async () => {
// Wait for the fallback element to be rendered
await page.waitForSelector('.fallback-el');
let fallbackEls = await page.$$('.fallback-el');
expect(fallbackEls.length).toBe(1);
// Click the hide and intercept button
await page.click('#hide-and-intercept');
// Wait for fallback to kick in (animation is 50ms, fallback is duration + 50ms)
// We give it a small buffer to ensure the timeout fires
await new Promise((res) => setTimeout(res, 300));
// Check that we have 0 items
fallbackEls = await page.$$('.fallback-el');
expect(fallbackEls.length).toBe(0);
});
});

View file

@ -80,3 +80,16 @@
height: 0;
}
}
.short-animation {
animation: short-animation 50ms;
}
@keyframes short-animation {
from {
opacity: 1;
}
to {
opacity: 0;
}
}

View file

@ -11,3 +11,9 @@
</div>
}
</div>
@if (showFallback) {
<div class="fallback-el" animate.leave="short-animation">Fallback Element</div>
}
<button id="hide-and-intercept" (click)="hideAndIntercept()">Hide and Intercept</button>

View file

@ -23,7 +23,23 @@ export class AppComponent {
'Episode III - Revenge of the Sith',
];
showFallback = true;
drop(event: CdkDragDrop<string[]>) {
moveItemInArray(this.movies, event.previousIndex, event.currentIndex);
}
hideAndIntercept() {
const el = document.querySelector('.fallback-el');
if (el) {
el.addEventListener(
'animationend',
(e) => {
e.stopImmediatePropagation();
},
true,
);
}
this.showFallback = false;
}
}

View file

@ -276,7 +276,8 @@ export function isLongestAnimation(
((longestAnimation.animationName !== undefined &&
(event as AnimationEvent).animationName === longestAnimation.animationName) ||
(longestAnimation.propertyName !== undefined &&
(event as TransitionEvent).propertyName === longestAnimation.propertyName))
(longestAnimation.propertyName === 'all' ||
(event as TransitionEvent).propertyName === longestAnimation.propertyName)))
);
}

View file

@ -106,6 +106,7 @@ export function runEnterAnimation(
// bindings.
const activeClasses = getClassListFromValue(value);
const cleanupFns: VoidFunction[] = [];
let hasCompleted = false;
// In the case where multiple animations are happening on the element, we need
// to get the longest animation to ensure we don't complete animations early.
@ -126,6 +127,9 @@ export function runEnterAnimation(
// this early exit case is to prevent issues with bubbling events that are from child element animations
if (event.target !== nativeElement) return;
if (isLongestAnimation(event, nativeElement)) {
hasCompleted = true;
}
enterAnimationEnd(event, nativeElement, renderer);
};
@ -147,6 +151,7 @@ export function runEnterAnimation(
// preventing an animation via selector specificity.
ngZone.runOutsideAngular(() => {
requestAnimationFrame(() => {
if (hasCompleted) return;
determineLongestAnimation(nativeElement, longestAnimations, areAnimationSupported);
if (!longestAnimations.has(nativeElement)) {
for (const klass of activeClasses) {
@ -172,7 +177,7 @@ function enterAnimationEnd(
// to keep bubbling up this event as it's not going to apply to
// other elements further up. We don't want it to inadvertently
// affect any other animations on the page.
event.stopImmediatePropagation();
event.stopPropagation();
for (const klass of elementData.classList) {
renderer.removeClass(nativeElement, klass);
}
@ -317,17 +322,25 @@ function animateLeaveClassRunner(
) {
cancelAnimationsIfRunning(el, renderer);
const cleanupFns: VoidFunction[] = [];
const resolvers = getLViewLeaveAnimations(lView).get(tNode.index)?.resolvers;
const componentResolvers = getLViewLeaveAnimations(lView).get(tNode.index)?.resolvers;
let fallbackTimeoutId: number | undefined;
let hasCompleted = false;
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)) {
// Custom fallback events don't have a target, so we bypass this check for them.
if (event.target !== el && event.type !== 'animation-fallback') return;
if (
event.type === 'animation-fallback' ||
isLongestAnimation(event as TransitionEvent | AnimationEvent, el)
) {
hasCompleted = true;
// 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
// other elements further up. We don't want it to inadvertently
// affect any other animations on the page.
event.stopImmediatePropagation();
if (fallbackTimeoutId) clearTimeout(fallbackTimeoutId);
if (event.type !== 'animation-fallback') event.stopPropagation();
longestAnimations.delete(el);
clearLeavingNodes(tNode, el);
@ -339,7 +352,7 @@ function animateLeaveClassRunner(
renderer.removeClass(el, item);
}
}
cleanupAfterLeaveAnimations(resolvers, cleanupFns);
cleanupAfterLeaveAnimations(componentResolvers, cleanupFns);
clearLViewNodeAnimationResolvers(lView, tNode);
}
};
@ -349,19 +362,34 @@ function animateLeaveClassRunner(
cleanupFns.push(renderer.listen(el, 'transitionend', handleOutAnimationEnd));
});
trackLeavingNodes(tNode, el);
for (const item of classList) {
renderer.addClass(el, item);
}
// Force a reflow to ensure the browser registers the class addition and triggers the transition
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _reflow = el.offsetWidth;
// In the case that the classes added have no animations, we need to remove
// the element right away. This could happen because someone is intentionally
// preventing an animation via selector specificity.
ngZone.runOutsideAngular(() => {
requestAnimationFrame(() => {
if (hasCompleted) return;
determineLongestAnimation(el, longestAnimations, areAnimationSupported);
if (!longestAnimations.has(el)) {
const longest = longestAnimations.get(el);
if (!longest) {
clearLeavingNodes(tNode, el);
cleanupAfterLeaveAnimations(resolvers, cleanupFns);
cleanupAfterLeaveAnimations(componentResolvers, cleanupFns);
clearLViewNodeAnimationResolvers(lView, tNode);
} else {
// Fallback cleanup if the browser drops the transitionend/animationend event
// entirely due to off-screen optimizations or rapid DOM teardown.
fallbackTimeoutId = setTimeout(() => {
handleOutAnimationEnd(new CustomEvent('animation-fallback'));
}, longest.duration + 50) as unknown as number;
cleanupFns.push(() => clearTimeout(fallbackTimeoutId));
}
});
});