Skip to content

Commit b4ec3cc

Browse files
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 9e64147)
1 parent 84e79f5 commit b4ec3cc

File tree

6 files changed

+93
-10
lines changed

6 files changed

+93
-10
lines changed

integration/animations/e2e/src/animations.e2e-spec.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,4 +72,23 @@ describe('Animations Integration', () => {
7272
const finalBoxes = await page.$$('.example-box');
7373
expect(finalBoxes.length).toBe(3);
7474
});
75+
76+
it('should remove element when animationend is dropped (fallback timeout)', async () => {
77+
// Wait for the fallback element to be rendered
78+
await page.waitForSelector('.fallback-el');
79+
80+
let fallbackEls = await page.$$('.fallback-el');
81+
expect(fallbackEls.length).toBe(1);
82+
83+
// Click the hide and intercept button
84+
await page.click('#hide-and-intercept');
85+
86+
// Wait for fallback to kick in (animation is 50ms, fallback is duration + 50ms)
87+
// We give it a small buffer to ensure the timeout fires
88+
await new Promise((res) => setTimeout(res, 300));
89+
90+
// Check that we have 0 items
91+
fallbackEls = await page.$$('.fallback-el');
92+
expect(fallbackEls.length).toBe(0);
93+
});
7594
});

integration/animations/src/app/app.component.css

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,3 +80,16 @@
8080
height: 0;
8181
}
8282
}
83+
84+
.short-animation {
85+
animation: short-animation 50ms;
86+
}
87+
88+
@keyframes short-animation {
89+
from {
90+
opacity: 1;
91+
}
92+
to {
93+
opacity: 0;
94+
}
95+
}

integration/animations/src/app/app.component.html

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,9 @@
1111
</div>
1212
}
1313
</div>
14+
15+
@if (showFallback) {
16+
<div class="fallback-el" animate.leave="short-animation">Fallback Element</div>
17+
}
18+
19+
<button id="hide-and-intercept" (click)="hideAndIntercept()">Hide and Intercept</button>

integration/animations/src/app/app.component.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,23 @@ export class AppComponent {
2323
'Episode III - Revenge of the Sith',
2424
];
2525

26+
showFallback = true;
27+
2628
drop(event: CdkDragDrop<string[]>) {
2729
moveItemInArray(this.movies, event.previousIndex, event.currentIndex);
2830
}
31+
32+
hideAndIntercept() {
33+
const el = document.querySelector('.fallback-el');
34+
if (el) {
35+
el.addEventListener(
36+
'animationend',
37+
(e) => {
38+
e.stopImmediatePropagation();
39+
},
40+
true,
41+
);
42+
}
43+
this.showFallback = false;
44+
}
2945
}

packages/core/src/animation/utils.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,8 @@ export function isLongestAnimation(
276276
((longestAnimation.animationName !== undefined &&
277277
(event as AnimationEvent).animationName === longestAnimation.animationName) ||
278278
(longestAnimation.propertyName !== undefined &&
279-
(event as TransitionEvent).propertyName === longestAnimation.propertyName))
279+
(longestAnimation.propertyName === 'all' ||
280+
(event as TransitionEvent).propertyName === longestAnimation.propertyName)))
280281
);
281282
}
282283

packages/core/src/render3/instructions/animation.ts

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ export function runEnterAnimation(
106106
// bindings.
107107
const activeClasses = getClassListFromValue(value);
108108
const cleanupFns: VoidFunction[] = [];
109+
let hasCompleted = false;
109110

110111
// In the case where multiple animations are happening on the element, we need
111112
// to get the longest animation to ensure we don't complete animations early.
@@ -126,6 +127,9 @@ export function runEnterAnimation(
126127
// this early exit case is to prevent issues with bubbling events that are from child element animations
127128
if (event.target !== nativeElement) return;
128129

130+
if (isLongestAnimation(event, nativeElement)) {
131+
hasCompleted = true;
132+
}
129133
enterAnimationEnd(event, nativeElement, renderer);
130134
};
131135

@@ -147,6 +151,7 @@ export function runEnterAnimation(
147151
// preventing an animation via selector specificity.
148152
ngZone.runOutsideAngular(() => {
149153
requestAnimationFrame(() => {
154+
if (hasCompleted) return;
150155
determineLongestAnimation(nativeElement, longestAnimations, areAnimationSupported);
151156
if (!longestAnimations.has(nativeElement)) {
152157
for (const klass of activeClasses) {
@@ -172,7 +177,7 @@ function enterAnimationEnd(
172177
// to keep bubbling up this event as it's not going to apply to
173178
// other elements further up. We don't want it to inadvertently
174179
// affect any other animations on the page.
175-
event.stopImmediatePropagation();
180+
event.stopPropagation();
176181
for (const klass of elementData.classList) {
177182
renderer.removeClass(nativeElement, klass);
178183
}
@@ -317,17 +322,25 @@ function animateLeaveClassRunner(
317322
) {
318323
cancelAnimationsIfRunning(el, renderer);
319324
const cleanupFns: VoidFunction[] = [];
320-
const resolvers = getLViewLeaveAnimations(lView).get(tNode.index)?.resolvers;
325+
const componentResolvers = getLViewLeaveAnimations(lView).get(tNode.index)?.resolvers;
326+
let fallbackTimeoutId: number | undefined;
327+
let hasCompleted = false;
321328

322329
const handleOutAnimationEnd = (event: AnimationEvent | TransitionEvent | CustomEvent) => {
323-
// this early exit case is to prevent issues with bubbling events that are from child element animations
324-
if (event.target !== el) return;
325-
if (event instanceof CustomEvent || isLongestAnimation(event, el)) {
330+
// Custom fallback events don't have a target, so we bypass this check for them.
331+
if (event.target !== el && event.type !== 'animation-fallback') return;
332+
333+
if (
334+
event.type === 'animation-fallback' ||
335+
isLongestAnimation(event as TransitionEvent | AnimationEvent, el)
336+
) {
337+
hasCompleted = true;
326338
// Now that we've found the longest animation, there's no need
327339
// to keep bubbling up this event as it's not going to apply to
328340
// other elements further up. We don't want it to inadvertently
329341
// affect any other animations on the page.
330-
event.stopImmediatePropagation();
342+
if (fallbackTimeoutId) clearTimeout(fallbackTimeoutId);
343+
if (event.type !== 'animation-fallback') event.stopPropagation();
331344
longestAnimations.delete(el);
332345
clearLeavingNodes(tNode, el);
333346

@@ -339,7 +352,7 @@ function animateLeaveClassRunner(
339352
renderer.removeClass(el, item);
340353
}
341354
}
342-
cleanupAfterLeaveAnimations(resolvers, cleanupFns);
355+
cleanupAfterLeaveAnimations(componentResolvers, cleanupFns);
343356
clearLViewNodeAnimationResolvers(lView, tNode);
344357
}
345358
};
@@ -349,19 +362,34 @@ function animateLeaveClassRunner(
349362
cleanupFns.push(renderer.listen(el, 'transitionend', handleOutAnimationEnd));
350363
});
351364
trackLeavingNodes(tNode, el);
365+
352366
for (const item of classList) {
353367
renderer.addClass(el, item);
354368
}
369+
370+
// Force a reflow to ensure the browser registers the class addition and triggers the transition
371+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
372+
const _reflow = el.offsetWidth;
373+
355374
// In the case that the classes added have no animations, we need to remove
356375
// the element right away. This could happen because someone is intentionally
357376
// preventing an animation via selector specificity.
358377
ngZone.runOutsideAngular(() => {
359378
requestAnimationFrame(() => {
379+
if (hasCompleted) return;
360380
determineLongestAnimation(el, longestAnimations, areAnimationSupported);
361-
if (!longestAnimations.has(el)) {
381+
const longest = longestAnimations.get(el);
382+
if (!longest) {
362383
clearLeavingNodes(tNode, el);
363-
cleanupAfterLeaveAnimations(resolvers, cleanupFns);
384+
cleanupAfterLeaveAnimations(componentResolvers, cleanupFns);
364385
clearLViewNodeAnimationResolvers(lView, tNode);
386+
} else {
387+
// Fallback cleanup if the browser drops the transitionend/animationend event
388+
// entirely due to off-screen optimizations or rapid DOM teardown.
389+
fallbackTimeoutId = setTimeout(() => {
390+
handleOutAnimationEnd(new CustomEvent('animation-fallback'));
391+
}, longest.duration + 50) as unknown as number;
392+
cleanupFns.push(() => clearTimeout(fallbackTimeoutId));
365393
}
366394
});
367395
});

0 commit comments

Comments
 (0)