Skip to content

fix(core): fixes empty animations when recalculating styles #63007

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,75 +5,13 @@
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {InjectionToken} from './di/injection_token';

/**
* A [DI token](api/core/InjectionToken) that enables or disables all enter and leave animations.
*/
export const ANIMATIONS_DISABLED = new InjectionToken<boolean>(
typeof ngDevMode !== 'undefined' && ngDevMode ? 'AnimationsDisabled' : '',
{
providedIn: 'root',
factory: () => false,
},
);

/**
* The event type for when `animate.enter` and `animate.leave` are used with function
* callbacks.
*
* @publicApi 20.2
*/
export type AnimationCallbackEvent = {target: Element; animationComplete: Function};

/**
* A [DI token](api/core/InjectionToken) that configures the maximum animation timeout
* before element removal. The default value mirrors from Chrome's cross document
* navigation view transition timeout. It's intended to prevent people from accidentally
* forgetting to call the removal function in their callback. Also serves as a delay
* for when stylesheets are pruned.
*
* @publicApi 20.2
*/
export const MAX_ANIMATION_TIMEOUT = new InjectionToken<number>(
typeof ngDevMode !== 'undefined' && ngDevMode ? 'MaxAnimationTimeout' : '',
{
providedIn: 'root',
factory: () => MAX_ANIMATION_TIMEOUT_DEFAULT,
},
);
const MAX_ANIMATION_TIMEOUT_DEFAULT = 4000;

/**
* The function type for `animate.enter` and `animate.leave` when they are used with
* function callbacks.
*
* @publicApi 20.2
*/
export type AnimationFunction = (event: AnimationCallbackEvent) => void;

export type AnimationEventFunction = (
el: Element,
value: AnimationFunction,
) => AnimationRemoveFunction;
export type AnimationClassFunction = (
el: Element,
value: Set<string> | null,
resolvers: Function[] | undefined,
) => AnimationRemoveFunction;
export type AnimationRemoveFunction = (removeFn: VoidFunction) => void;

export interface LongestAnimation {
animationName: string | undefined;
propertyName: string | undefined;
duration: number;
}

export interface AnimationDetails {
classes: Set<string> | null;
classFns?: Function[];
animateFn: AnimationRemoveFunction;
}
import {
AnimationClassFunction,
AnimationDetails,
AnimationEventFunction,
AnimationFunction,
} from './interfaces';

export interface AnimationRemovalRegistry {
elements: ElementRegistry | undefined;
Expand Down
70 changes: 70 additions & 0 deletions packages/core/src/animation/interfaces.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {InjectionToken} from '../di/injection_token';

/**
* A [DI token](api/core/InjectionToken) that enables or disables all enter and leave animations.
*/
export const ANIMATIONS_DISABLED = new InjectionToken<boolean>(
typeof ngDevMode !== 'undefined' && ngDevMode ? 'AnimationsDisabled' : '',
{
providedIn: 'root',
factory: () => false,
},
);

/**
* The event type for when `animate.enter` and `animate.leave` are used with function
* callbacks.
*
* @publicApi 20.2
*/
export type AnimationCallbackEvent = {target: Element; animationComplete: Function};

/**
* A [DI token](api/core/InjectionToken) that configures the maximum animation timeout
* before element removal. The default value mirrors from Chrome's cross document
* navigation view transition timeout. It's intended to prevent people from accidentally
* forgetting to call the removal function in their callback. Also serves as a delay
* for when stylesheets are pruned.
*
* @publicApi 20.2
*/
export const MAX_ANIMATION_TIMEOUT = new InjectionToken<number>(
typeof ngDevMode !== 'undefined' && ngDevMode ? 'MaxAnimationTimeout' : '',
{
providedIn: 'root',
factory: () => MAX_ANIMATION_TIMEOUT_DEFAULT,
},
);
const MAX_ANIMATION_TIMEOUT_DEFAULT = 4000;

/**
* The function type for `animate.enter` and `animate.leave` when they are used with
* function callbacks.
*
* @publicApi 20.2
*/
export type AnimationFunction = (event: AnimationCallbackEvent) => void;

export type AnimationEventFunction = (
el: Element,
value: AnimationFunction,
) => AnimationRemoveFunction;
export type AnimationClassFunction = (
el: Element,
value: Set<string> | null,
resolvers: Function[] | undefined,
) => AnimationRemoveFunction;
export type AnimationRemoveFunction = (removeFn: VoidFunction) => void;

export interface AnimationDetails {
classes: Set<string> | null;
classFns?: Function[];
animateFn: AnimationRemoveFunction;
}
137 changes: 137 additions & 0 deletions packages/core/src/animation/longest_animation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/

export interface LongestAnimation {
animationName: string | undefined;
propertyName: string | undefined;
duration: number;
}

/** Parses a CSS time value to milliseconds. */
function parseCssTimeUnitsToMs(value: string): number {
// Some browsers will return it in seconds, whereas others will return milliseconds.
const multiplier = value.toLowerCase().indexOf('ms') > -1 ? 1 : 1000;
return parseFloat(value) * multiplier;
}

/** Parses out multiple values from a computed style into an array. */
function parseCssPropertyValue(computedStyle: CSSStyleDeclaration, name: string): string[] {
const value = computedStyle.getPropertyValue(name);
return value.split(',').map((part) => part.trim());
}

/** Gets the transform transition duration, including the delay, of an element in milliseconds. */
function getLongestComputedTransition(computedStyle: CSSStyleDeclaration): LongestAnimation {
const transitionedProperties = parseCssPropertyValue(computedStyle, 'transition-property');
const rawDurations = parseCssPropertyValue(computedStyle, 'transition-duration');
const rawDelays = parseCssPropertyValue(computedStyle, 'transition-delay');
const longest = {propertyName: '', duration: 0, animationName: undefined};
for (let i = 0; i < transitionedProperties.length; i++) {
const duration = parseCssTimeUnitsToMs(rawDelays[i]) + parseCssTimeUnitsToMs(rawDurations[i]);
if (duration > longest.duration) {
longest.propertyName = transitionedProperties[i];
longest.duration = duration;
}
}
return longest;
}

function getLongestComputedAnimation(computedStyle: CSSStyleDeclaration): LongestAnimation {
const rawNames = parseCssPropertyValue(computedStyle, 'animation-name');
const rawDelays = parseCssPropertyValue(computedStyle, 'animation-delay');
const rawDurations = parseCssPropertyValue(computedStyle, 'animation-duration');
const longest: LongestAnimation = {animationName: '', propertyName: undefined, duration: 0};
for (let i = 0; i < rawNames.length; i++) {
const duration = parseCssTimeUnitsToMs(rawDelays[i]) + parseCssTimeUnitsToMs(rawDurations[i]);
if (duration > longest.duration) {
longest.animationName = rawNames[i];
longest.duration = duration;
}
}
return longest;
}

/**
* 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
* progress. `getAnimations()` will be empty in that case.
*/
function determineLongestAnimationFromComputedStyles(
el: HTMLElement,
animationsMap: WeakMap<HTMLElement, LongestAnimation>,
): void {
const computedStyle = getComputedStyle(el);

const longestAnimation = getLongestComputedAnimation(computedStyle);
const longestTransition = getLongestComputedTransition(computedStyle);

const longest =
longestAnimation.duration > longestTransition.duration ? longestAnimation : longestTransition;
if (animationsMap.has(el) && animationsMap.get(el)!.duration > longest.duration) {
return;
}
animationsMap.set(el, longest);
}

/**
* Multiple animations can be set on an element. This grabs an element and
* determines which of those will be the longest duration. If we didn't do
* this, elements would be removed whenever the first animation completes.
* This ensures we get the longest running animation and only remove when
* that animation completes.
*/
export function determineLongestAnimation(
event: AnimationEvent | TransitionEvent,
el: HTMLElement,
animationsMap: WeakMap<HTMLElement, LongestAnimation>,
areAnimationSupported: boolean,
): void {
if (!areAnimationSupported || !(event.target instanceof Element) || event.target !== el) return;
const animations = el.getAnimations();
return animations.length === 0
? // fallback to computed styles if getAnimations is empty. This would happen if styles are
// currently recalculating due to a reflow happening elsewhere.
determineLongestAnimationFromComputedStyles(el, animationsMap)
: determineLongestAnimationFromElementAnimations(el, animationsMap, animations);
}

function determineLongestAnimationFromElementAnimations(
el: HTMLElement,
animationsMap: WeakMap<HTMLElement, LongestAnimation>,
animations: Animation[],
): void {
let currentLongest: LongestAnimation = {
animationName: undefined,
propertyName: undefined,
duration: 0,
};
for (const animation of animations) {
const timing = animation.effect?.getTiming();
// duration can be a string 'auto' or a number.
const animDuration = typeof timing?.duration === 'number' ? timing.duration : 0;
let duration = (timing?.delay ?? 0) + animDuration;

let propertyName: string | undefined;
let animationName: string | undefined;

if ((animation as CSSAnimation).animationName) {
animationName = (animation as CSSAnimation).animationName;
} else {
// Check for CSSTransition specific property
propertyName = (animation as CSSTransition).transitionProperty;
}

if (duration >= currentLongest.duration) {
currentLongest = {animationName, propertyName, duration};
}
}
if (animationsMap.has(el) && animationsMap.get(el)!.duration > currentLongest.duration) {
return;
}
animationsMap.set(el, currentLongest);
}
6 changes: 5 additions & 1 deletion packages/core/src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,11 @@ export {booleanAttribute, numberAttribute} from './util/coercion';
export {REQUEST, REQUEST_CONTEXT, RESPONSE_INIT} from './application/platform_tokens';
export {DOCUMENT} from './document';
export {provideNgReflectAttributes} from './ng_reflect';
export {AnimationCallbackEvent, AnimationFunction, MAX_ANIMATION_TIMEOUT} from './animation';
export {
AnimationCallbackEvent,
AnimationFunction,
MAX_ANIMATION_TIMEOUT,
} from './animation/interfaces';

import {global} from './util/global';
if (typeof ngDevMode !== 'undefined' && ngDevMode) {
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/core_private_export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,5 +175,5 @@ export {ɵassertType} from './type_checking';
export {
ElementRegistry as ɵElementRegistry,
AnimationRemovalRegistry as ɵAnimationRemovalRegistry,
ANIMATIONS_DISABLED as ɵANIMATIONS_DISABLED,
} from './animation';
} from './animation/element_removal_registry';
export {ANIMATIONS_DISABLED as ɵANIMATIONS_DISABLED} from './animation/interfaces';
2 changes: 1 addition & 1 deletion packages/core/src/render3/features/animations_feature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.dev/license
*/

import {ElementRegistry} from '../../animation';
import {ElementRegistry} from '../../animation/element_removal_registry';
import {setAnimationElementRemovalRegistry} from '../state';

/**
Expand Down
Loading