@@ -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