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