diff --git a/packages/core/src/animation/longest_animation.ts b/packages/core/src/animation/longest_animation.ts index d2f4c727a13..5776dd29420 100644 --- a/packages/core/src/animation/longest_animation.ts +++ b/packages/core/src/animation/longest_animation.ts @@ -69,6 +69,24 @@ function longestExists(longest: LongestAnimation): boolean { ); } +/** Gets an animation's total duration, including its delay and playback rate. */ +export function getAnimationDuration(animation: Animation): number | undefined { + const timing = animation.effect?.getTiming(); + if (timing === undefined) return undefined; + + // duration can be a string 'auto' or a number. + const animationDuration = typeof timing.duration === 'number' ? timing.duration : 0; + let duration = (timing.delay ?? 0) + animationDuration; + + // Account for playback rate if it is set. + const playbackRate = animation.playbackRate; + if (playbackRate !== undefined && playbackRate !== 0 && playbackRate !== 1) { + duration /= Math.abs(playbackRate); + } + + return duration; +} + /** * Determines the longest animation, but with `getComputedStyles` instead of `getAnimations`. This * is ultimately safer than getAnimations because it can be used when recalculations are in @@ -127,15 +145,7 @@ function determineLongestAnimationFromElementAnimations( if (timing?.iterations === Infinity) { continue; } - // duration can be a string 'auto' or a number. - const animDuration = typeof timing?.duration === 'number' ? timing.duration : 0; - let duration = (timing?.delay ?? 0) + animDuration; - - // Account for playback rate if it is set - const playbackRate = animation.playbackRate; - if (playbackRate !== undefined && playbackRate !== 0 && playbackRate !== 1) { - duration /= Math.abs(playbackRate); - } + const duration = getAnimationDuration(animation) ?? 0; let propertyName: string | undefined; let animationName: string | undefined; diff --git a/packages/core/src/animation/utils.ts b/packages/core/src/animation/utils.ts index 050e3a7791e..90a39b06cf2 100644 --- a/packages/core/src/animation/utils.ts +++ b/packages/core/src/animation/utils.ts @@ -21,8 +21,10 @@ import {RuntimeError, RuntimeErrorCode} from '../errors'; import {Renderer} from '../render3/interfaces/renderer'; import {RElement} from '../render3/interfaces/renderer_dom'; import {TNode} from '../render3/interfaces/node'; +import {getAnimationDuration} from './longest_animation'; const DEFAULT_ANIMATIONS_DISABLED = false; +const ANIMATION_DURATION_TOLERANCE_MS = 1; export const areAnimationSupported = (typeof ngServerMode === 'undefined' || !ngServerMode) && @@ -332,14 +334,42 @@ export function isLongestAnimation( // If we don't have any record of a longest animation, then we shouldn't // block the animationend/transitionend event from doing its work. if (longestAnimation === undefined) return true; - return ( - nativeElement === getEventTarget(event) && - ((longestAnimation.animationName !== undefined && - (event as AnimationEvent).animationName === longestAnimation.animationName) || - (longestAnimation.propertyName !== undefined && - (longestAnimation.propertyName === 'all' || - (event as TransitionEvent).propertyName === longestAnimation.propertyName))) - ); + + if (nativeElement !== getEventTarget(event)) return false; + + // Distinct CSS animations can share a name. Chrome 151 stable exposes their instance: + // https://developer.chrome.com/release-notes/151#animation_accessor_on_animation_and_transition_events + const eventAnimation = ( + event as (AnimationEvent | TransitionEvent) & {readonly animation?: Animation | null} + ).animation; + + // Compare the event animation's duration instead of retaining the Animation object. This also + // disambiguates records obtained from computed styles when getAnimations() was empty. + if (eventAnimation) { + const eventAnimationDuration = getAnimationDuration(eventAnimation); + // CSSOM can round serialized times while Web Animations retains more precision. Only reject an + // event when it is shorter by more than the tolerance so the longest event is not ignored. + if ( + eventAnimationDuration !== undefined && + eventAnimationDuration + ANIMATION_DURATION_TOLERANCE_MS < longestAnimation.duration + ) { + return false; + } + } + + // Fall back to strings for older browsers. + if (longestAnimation.animationName !== undefined) { + return (event as AnimationEvent).animationName === longestAnimation.animationName; + } + + if (longestAnimation.propertyName !== undefined) { + return ( + longestAnimation.propertyName === 'all' || + (event as TransitionEvent).propertyName === longestAnimation.propertyName + ); + } + + return false; } /** diff --git a/packages/core/test/acceptance/animation_spec.ts b/packages/core/test/acceptance/animation_spec.ts index 7751d32c429..f9f3316b660 100644 --- a/packages/core/test/acceptance/animation_spec.ts +++ b/packages/core/test/acceptance/animation_spec.ts @@ -864,6 +864,68 @@ describe('Animation', () => { expect(fixture.debugElement.query(By.css('div'))).toBeNull(); })); + it('should wait for the longest duplicate-named animation when getAnimations is empty', fakeAsync(() => { + const multiple = ` + .duplicate-animation-name { + animation: + duplicate-name 10s linear, + duplicate-name 20s linear; + } + @keyframes duplicate-name { + from { + opacity: 1; + } + to { + opacity: 0; + } + } + `; + @Component({ + changeDetection: ChangeDetectionStrategy.Eager, + selector: 'test-cmp', + styles: multiple, + template: + '@if (show()) {

Element with text

}', + encapsulation: ViewEncapsulation.None, + }) + class TestComponent { + show = signal(true); + } + + TestBed.configureTestingModule({animationsEnabled: true}); + + const fixture = TestBed.createComponent(TestComponent); + const cmp = fixture.componentInstance; + fixture.detectChanges(); + const paragraph = fixture.debugElement.query(By.css('p')); + const getAnimations = paragraph.nativeElement.getAnimations.bind(paragraph.nativeElement); + spyOn(paragraph.nativeElement, 'getAnimations').and.returnValue([]); + + expect(paragraph.nativeElement.className).not.toContain('duplicate-animation-name'); + cmp.show.set(false); + fixture.detectChanges(); + tickAnimationFrames(1); + expect(cmp.show()).toBeFalse(); + fixture.detectChanges(); + expect(paragraph.nativeElement.className).toContain('duplicate-animation-name'); + + const [shortAnimation, longAnimation] = getAnimations(); + const dispatchAnimationEnd = (animation: Animation) => { + const event = new AnimationEvent('animationend', {animationName: 'duplicate-name'}); + Object.defineProperty(event, 'animation', {value: animation}); + paragraph.nativeElement.dispatchEvent(event); + }; + + dispatchAnimationEnd(shortAnimation); + tick(); + expect(fixture.nativeElement.outerHTML).toContain('duplicate-animation-name'); + + dispatchAnimationEnd(longAnimation); + tick(); + expect(fixture.nativeElement.outerHTML).not.toContain('duplicate-animation-name'); + expect(fixture.debugElement.query(By.css('p'))).toBeNull(); + })); + describe('legacy animations compatibility', () => { beforeAll(() => { TestBed.resetTestEnvironment(); @@ -2678,8 +2740,7 @@ describe('Animation', () => { const panels = () => Array.from(fixture.nativeElement.querySelectorAll('.panel')); const panelByText = (text: string) => panels().find((el) => (el as HTMLElement).textContent?.includes(text)) as - | HTMLElement - | undefined; + HTMLElement | undefined; expect(panels().length).toBe(1); expect(panelByText('Panel A')).toBeTruthy(); diff --git a/packages/core/test/animation/longest_animation_spec.ts b/packages/core/test/animation/longest_animation_spec.ts index 76fae451e14..5668a96fb4a 100644 --- a/packages/core/test/animation/longest_animation_spec.ts +++ b/packages/core/test/animation/longest_animation_spec.ts @@ -8,6 +8,7 @@ import {determineLongestAnimation} from '../../src/animation/longest_animation'; import {LongestAnimation} from '../../src/animation/interfaces'; +import {isLongestAnimation, longestAnimations} from '../../src/animation/utils'; import {isNode} from '@angular/private/testing'; describe('determineLongestAnimation', () => { @@ -27,6 +28,33 @@ describe('determineLongestAnimation', () => { expect(el.getAnimations).not.toHaveBeenCalled(); }); + it('should tolerate duration rounding when checking the longest animation event', () => { + const el = document.createElement('div'); + longestAnimations.set(el, { + animationName: 'duplicate-name', + propertyName: undefined, + // Equivalent to calc(20s / 3) + calc(1s / 7) after CSSOM serialization. + duration: 6809.527, + }); + + const createAnimationEndEvent = (duration: number) => { + const event = new AnimationEvent('animationend', {animationName: 'duplicate-name'}); + const animation = { + playbackRate: 1, + effect: { + getTiming: () => ({duration, delay: 1000 / 7}), + }, + } as unknown as Animation; + Object.defineProperty(event, 'animation', {value: animation}); + spyOn(event, 'composedPath').and.returnValue([el]); + return event; + }; + + expect(isLongestAnimation(createAnimationEndEvent(20_000 / 3), el)).toBeTrue(); + expect(isLongestAnimation(createAnimationEndEvent(10_000 / 3), el)).toBeFalse(); + longestAnimations.delete(el); + }); + describe('with getAnimations() support', () => { it('should find the longest animation among multiple animations', () => { const el = document.createElement('div');