From b4ec3cc4e41f2948ad0830eb14aa05d14fa3a9ed Mon Sep 17 00:00:00 2001 From: Jessica Janiuk Date: Tue, 3 Mar 2026 08:53:54 -0800 Subject: [PATCH] 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 9e64147b731a1d9d0a36074b77c1e21e3fb49ea7) --- .../animations/e2e/src/animations.e2e-spec.ts | 19 ++++++++ .../animations/src/app/app.component.css | 13 ++++++ .../animations/src/app/app.component.html | 6 +++ .../animations/src/app/app.component.ts | 16 +++++++ packages/core/src/animation/utils.ts | 3 +- .../src/render3/instructions/animation.ts | 46 +++++++++++++++---- 6 files changed, 93 insertions(+), 10 deletions(-) diff --git a/integration/animations/e2e/src/animations.e2e-spec.ts b/integration/animations/e2e/src/animations.e2e-spec.ts index 2666209e12a..7c87aee711b 100644 --- a/integration/animations/e2e/src/animations.e2e-spec.ts +++ b/integration/animations/e2e/src/animations.e2e-spec.ts @@ -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); + }); }); diff --git a/integration/animations/src/app/app.component.css b/integration/animations/src/app/app.component.css index 4a6e2feb241..cc1d03a3c5a 100644 --- a/integration/animations/src/app/app.component.css +++ b/integration/animations/src/app/app.component.css @@ -80,3 +80,16 @@ height: 0; } } + +.short-animation { + animation: short-animation 50ms; +} + +@keyframes short-animation { + from { + opacity: 1; + } + to { + opacity: 0; + } +} diff --git a/integration/animations/src/app/app.component.html b/integration/animations/src/app/app.component.html index 232190a51fd..11b1f49aa91 100644 --- a/integration/animations/src/app/app.component.html +++ b/integration/animations/src/app/app.component.html @@ -11,3 +11,9 @@ } + +@if (showFallback) { +
Fallback Element
+} + + diff --git a/integration/animations/src/app/app.component.ts b/integration/animations/src/app/app.component.ts index 50b0d6844bd..7b48ed66fdd 100644 --- a/integration/animations/src/app/app.component.ts +++ b/integration/animations/src/app/app.component.ts @@ -23,7 +23,23 @@ export class AppComponent { 'Episode III - Revenge of the Sith', ]; + showFallback = true; + drop(event: CdkDragDrop) { 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; + } } diff --git a/packages/core/src/animation/utils.ts b/packages/core/src/animation/utils.ts index 1fc83665a74..1d6646f603c 100644 --- a/packages/core/src/animation/utils.ts +++ b/packages/core/src/animation/utils.ts @@ -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))) ); } diff --git a/packages/core/src/render3/instructions/animation.ts b/packages/core/src/render3/instructions/animation.ts index d9a01f97c0e..393dc435f3b 100644 --- a/packages/core/src/render3/instructions/animation.ts +++ b/packages/core/src/render3/instructions/animation.ts @@ -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)); } }); });