Skip to content

Commit cc6e168

Browse files
fix(core): fixes empty animations when recalculating styles
Any time something causes styles to recalculate, `element.getAnimations()` will be empty. This updates `animate.enter` and `animate.leave` to rely on `getComputedStyles` to determine the longest animation instead. fixes: #63006
1 parent 5802f80 commit cc6e168

File tree

10 files changed

+284
-147
lines changed

10 files changed

+284
-147
lines changed

packages/core/src/animation.ts renamed to packages/core/src/animation/element_removal_registry.ts

Lines changed: 6 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -5,75 +5,13 @@
55
* Use of this source code is governed by an MIT-style license that can be
66
* found in the LICENSE file at https://angular.dev/license
77
*/
8-
import {InjectionToken} from './di/injection_token';
98

10-
/**
11-
* A [DI token](api/core/InjectionToken) that enables or disables all enter and leave animations.
12-
*/
13-
export const ANIMATIONS_DISABLED = new InjectionToken<boolean>(
14-
typeof ngDevMode !== 'undefined' && ngDevMode ? 'AnimationsDisabled' : '',
15-
{
16-
providedIn: 'root',
17-
factory: () => false,
18-
},
19-
);
20-
21-
/**
22-
* The event type for when `animate.enter` and `animate.leave` are used with function
23-
* callbacks.
24-
*
25-
* @publicApi 20.2
26-
*/
27-
export type AnimationCallbackEvent = {target: Element; animationComplete: Function};
28-
29-
/**
30-
* A [DI token](api/core/InjectionToken) that configures the maximum animation timeout
31-
* before element removal. The default value mirrors from Chrome's cross document
32-
* navigation view transition timeout. It's intended to prevent people from accidentally
33-
* forgetting to call the removal function in their callback. Also serves as a delay
34-
* for when stylesheets are pruned.
35-
*
36-
* @publicApi 20.2
37-
*/
38-
export const MAX_ANIMATION_TIMEOUT = new InjectionToken<number>(
39-
typeof ngDevMode !== 'undefined' && ngDevMode ? 'MaxAnimationTimeout' : '',
40-
{
41-
providedIn: 'root',
42-
factory: () => MAX_ANIMATION_TIMEOUT_DEFAULT,
43-
},
44-
);
45-
const MAX_ANIMATION_TIMEOUT_DEFAULT = 4000;
46-
47-
/**
48-
* The function type for `animate.enter` and `animate.leave` when they are used with
49-
* function callbacks.
50-
*
51-
* @publicApi 20.2
52-
*/
53-
export type AnimationFunction = (event: AnimationCallbackEvent) => void;
54-
55-
export type AnimationEventFunction = (
56-
el: Element,
57-
value: AnimationFunction,
58-
) => AnimationRemoveFunction;
59-
export type AnimationClassFunction = (
60-
el: Element,
61-
value: Set<string> | null,
62-
resolvers: Function[] | undefined,
63-
) => AnimationRemoveFunction;
64-
export type AnimationRemoveFunction = (removeFn: VoidFunction) => void;
65-
66-
export interface LongestAnimation {
67-
animationName: string | undefined;
68-
propertyName: string | undefined;
69-
duration: number;
70-
}
71-
72-
export interface AnimationDetails {
73-
classes: Set<string> | null;
74-
classFns?: Function[];
75-
animateFn: AnimationRemoveFunction;
76-
}
9+
import {
10+
AnimationClassFunction,
11+
AnimationDetails,
12+
AnimationEventFunction,
13+
AnimationFunction,
14+
} from './interfaces';
7715

7816
export interface AnimationRemovalRegistry {
7917
elements: ElementRegistry | undefined;
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
import {InjectionToken} from '../di/injection_token';
9+
10+
/**
11+
* A [DI token](api/core/InjectionToken) that enables or disables all enter and leave animations.
12+
*/
13+
export const ANIMATIONS_DISABLED = new InjectionToken<boolean>(
14+
typeof ngDevMode !== 'undefined' && ngDevMode ? 'AnimationsDisabled' : '',
15+
{
16+
providedIn: 'root',
17+
factory: () => false,
18+
},
19+
);
20+
21+
/**
22+
* The event type for when `animate.enter` and `animate.leave` are used with function
23+
* callbacks.
24+
*
25+
* @publicApi 20.2
26+
*/
27+
export type AnimationCallbackEvent = {target: Element; animationComplete: Function};
28+
29+
/**
30+
* A [DI token](api/core/InjectionToken) that configures the maximum animation timeout
31+
* before element removal. The default value mirrors from Chrome's cross document
32+
* navigation view transition timeout. It's intended to prevent people from accidentally
33+
* forgetting to call the removal function in their callback. Also serves as a delay
34+
* for when stylesheets are pruned.
35+
*
36+
* @publicApi 20.2
37+
*/
38+
export const MAX_ANIMATION_TIMEOUT = new InjectionToken<number>(
39+
typeof ngDevMode !== 'undefined' && ngDevMode ? 'MaxAnimationTimeout' : '',
40+
{
41+
providedIn: 'root',
42+
factory: () => MAX_ANIMATION_TIMEOUT_DEFAULT,
43+
},
44+
);
45+
const MAX_ANIMATION_TIMEOUT_DEFAULT = 4000;
46+
47+
/**
48+
* The function type for `animate.enter` and `animate.leave` when they are used with
49+
* function callbacks.
50+
*
51+
* @publicApi 20.2
52+
*/
53+
export type AnimationFunction = (event: AnimationCallbackEvent) => void;
54+
55+
export type AnimationEventFunction = (
56+
el: Element,
57+
value: AnimationFunction,
58+
) => AnimationRemoveFunction;
59+
export type AnimationClassFunction = (
60+
el: Element,
61+
value: Set<string> | null,
62+
resolvers: Function[] | undefined,
63+
) => AnimationRemoveFunction;
64+
export type AnimationRemoveFunction = (removeFn: VoidFunction) => void;
65+
66+
export interface AnimationDetails {
67+
classes: Set<string> | null;
68+
classFns?: Function[];
69+
animateFn: AnimationRemoveFunction;
70+
}
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
export interface LongestAnimation {
10+
animationName: string | undefined;
11+
propertyName: string | undefined;
12+
duration: number;
13+
}
14+
15+
/** Parses a CSS time value to milliseconds. */
16+
function parseCssTimeUnitsToMs(value: string): number {
17+
// Some browsers will return it in seconds, whereas others will return milliseconds.
18+
const multiplier = value.toLowerCase().indexOf('ms') > -1 ? 1 : 1000;
19+
return parseFloat(value) * multiplier;
20+
}
21+
22+
/** Parses out multiple values from a computed style into an array. */
23+
function parseCssPropertyValue(computedStyle: CSSStyleDeclaration, name: string): string[] {
24+
const value = computedStyle.getPropertyValue(name);
25+
return value.split(',').map((part) => part.trim());
26+
}
27+
28+
/** Gets the transform transition duration, including the delay, of an element in milliseconds. */
29+
function getLongestComputedTransition(computedStyle: CSSStyleDeclaration): LongestAnimation {
30+
const transitionedProperties = parseCssPropertyValue(computedStyle, 'transition-property');
31+
const rawDurations = parseCssPropertyValue(computedStyle, 'transition-duration');
32+
const rawDelays = parseCssPropertyValue(computedStyle, 'transition-delay');
33+
const longest = {propertyName: '', duration: 0, animationName: undefined};
34+
for (let i = 0; i < transitionedProperties.length; i++) {
35+
const duration = parseCssTimeUnitsToMs(rawDelays[i]) + parseCssTimeUnitsToMs(rawDurations[i]);
36+
if (duration > longest.duration) {
37+
longest.propertyName = transitionedProperties[i];
38+
longest.duration = duration;
39+
}
40+
}
41+
return longest;
42+
}
43+
44+
function getLongestComputedAnimation(computedStyle: CSSStyleDeclaration): LongestAnimation {
45+
const rawNames = parseCssPropertyValue(computedStyle, 'animation-name');
46+
const rawDelays = parseCssPropertyValue(computedStyle, 'animation-delay');
47+
const rawDurations = parseCssPropertyValue(computedStyle, 'animation-duration');
48+
const longest: LongestAnimation = {animationName: '', propertyName: undefined, duration: 0};
49+
for (let i = 0; i < rawNames.length; i++) {
50+
const duration = parseCssTimeUnitsToMs(rawDelays[i]) + parseCssTimeUnitsToMs(rawDurations[i]);
51+
if (duration > longest.duration) {
52+
longest.animationName = rawNames[i];
53+
longest.duration = duration;
54+
}
55+
}
56+
return longest;
57+
}
58+
59+
/**
60+
* Determines the longest animation, but with `getComputedStyles` instead of `getAnimations`. This
61+
* is ultimately safer than getAnimations because it can be used when recalculations are in
62+
* progress. `getAnimations()` will be empty in that case.
63+
*/
64+
function determineLongestAnimationFromComputedStyles(
65+
el: HTMLElement,
66+
animationsMap: WeakMap<HTMLElement, LongestAnimation>,
67+
): void {
68+
const computedStyle = getComputedStyle(el);
69+
70+
const longestAnimation = getLongestComputedAnimation(computedStyle);
71+
const longestTransition = getLongestComputedTransition(computedStyle);
72+
73+
const longest =
74+
longestAnimation.duration > longestTransition.duration ? longestAnimation : longestTransition;
75+
if (animationsMap.has(el) && animationsMap.get(el)!.duration > longest.duration) {
76+
return;
77+
}
78+
animationsMap.set(el, longest);
79+
}
80+
81+
/**
82+
* Multiple animations can be set on an element. This grabs an element and
83+
* determines which of those will be the longest duration. If we didn't do
84+
* this, elements would be removed whenever the first animation completes.
85+
* This ensures we get the longest running animation and only remove when
86+
* that animation completes.
87+
*/
88+
export function determineLongestAnimation(
89+
event: AnimationEvent | TransitionEvent,
90+
el: HTMLElement,
91+
animationsMap: WeakMap<HTMLElement, LongestAnimation>,
92+
areAnimationSupported: boolean,
93+
): void {
94+
if (!areAnimationSupported || !(event.target instanceof Element) || event.target !== el) return;
95+
const animations = el.getAnimations();
96+
return animations.length === 0
97+
? // fallback to computed styles if getAnimations is empty. This would happen if styles are
98+
// currently recalculating due to a reflow happening elsewhere.
99+
determineLongestAnimationFromComputedStyles(el, animationsMap)
100+
: determineLongestAnimationFromElementAnimations(el, animationsMap, animations);
101+
}
102+
103+
function determineLongestAnimationFromElementAnimations(
104+
el: HTMLElement,
105+
animationsMap: WeakMap<HTMLElement, LongestAnimation>,
106+
animations: Animation[],
107+
): void {
108+
let currentLongest: LongestAnimation = {
109+
animationName: undefined,
110+
propertyName: undefined,
111+
duration: 0,
112+
};
113+
for (const animation of animations) {
114+
const timing = animation.effect?.getTiming();
115+
// duration can be a string 'auto' or a number.
116+
const animDuration = typeof timing?.duration === 'number' ? timing.duration : 0;
117+
let duration = (timing?.delay ?? 0) + animDuration;
118+
119+
let propertyName: string | undefined;
120+
let animationName: string | undefined;
121+
122+
if ((animation as CSSAnimation).animationName) {
123+
animationName = (animation as CSSAnimation).animationName;
124+
} else {
125+
// Check for CSSTransition specific property
126+
propertyName = (animation as CSSTransition).transitionProperty;
127+
}
128+
129+
if (duration >= currentLongest.duration) {
130+
currentLongest = {animationName, propertyName, duration};
131+
}
132+
}
133+
if (animationsMap.has(el) && animationsMap.get(el)!.duration > currentLongest.duration) {
134+
return;
135+
}
136+
animationsMap.set(el, currentLongest);
137+
}

packages/core/src/core.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,11 @@ export {booleanAttribute, numberAttribute} from './util/coercion';
121121
export {REQUEST, REQUEST_CONTEXT, RESPONSE_INIT} from './application/platform_tokens';
122122
export {DOCUMENT} from './document';
123123
export {provideNgReflectAttributes} from './ng_reflect';
124-
export {AnimationCallbackEvent, AnimationFunction, MAX_ANIMATION_TIMEOUT} from './animation';
124+
export {
125+
AnimationCallbackEvent,
126+
AnimationFunction,
127+
MAX_ANIMATION_TIMEOUT,
128+
} from './animation/interfaces';
125129

126130
import {global} from './util/global';
127131
if (typeof ngDevMode !== 'undefined' && ngDevMode) {

packages/core/src/core_private_export.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -175,5 +175,5 @@ export {ɵassertType} from './type_checking';
175175
export {
176176
ElementRegistry as ɵElementRegistry,
177177
AnimationRemovalRegistry as ɵAnimationRemovalRegistry,
178-
ANIMATIONS_DISABLED as ɵANIMATIONS_DISABLED,
179-
} from './animation';
178+
} from './animation/element_removal_registry';
179+
export {ANIMATIONS_DISABLED as ɵANIMATIONS_DISABLED} from './animation/interfaces';

packages/core/src/render3/features/animations_feature.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import {ElementRegistry} from '../../animation';
9+
import {ElementRegistry} from '../../animation/element_removal_registry';
1010
import {setAnimationElementRemovalRegistry} from '../state';
1111

1212
/**

0 commit comments

Comments
 (0)