mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
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:
parent
84e79f5add
commit
b4ec3cc4e4
6 changed files with 93 additions and 10 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -80,3 +80,16 @@
|
|||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.short-animation {
|
||||
animation: short-animation 50ms;
|
||||
}
|
||||
|
||||
@keyframes short-animation {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue