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)); } }); });