/
githubmirror
/
angular
Обзор
Документация
Войти
/
githubmirror
/
angular
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
packages/core/src/render3/instructions/animation.ts
526 строк
18 KB
Jessica Janiuk
Revert "refactor(core): Ensure determineLongestAnimation is run synchronously after style applies"
18 мар 2026, 20:30
18 мар 2026, 20:30
890c973
Код
Авторство
О чём код?
/** * @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 { AnimationClassBindingFn, AnimationCallbackEvent, AnimationFunction, MAX_ANIMATION_TIMEOUT, } from '../../animation/interfaces'; import {getLView, getCurrentTNode} from '../state'; import {RENDERER, INJECTOR, CONTEXT, LView, ID} from '../interfaces/view'; import {getNativeByTNode} from '../util/view_utils'; import {performanceMarkFeature} from '../../util/performance'; import {Renderer} from '../interfaces/renderer'; import {NgZone} from '../../zone'; import {determineLongestAnimation, allLeavingAnimations} from '../../animation/longest_animation'; import {TNode} from '../interfaces/node'; import {promiseWithResolvers} from '../../util/promise_with_resolvers'; import { addAnimationToLView, areAnimationsDisabled, areAnimationSupported, assertAnimationTypes, assertElementNodes, cancelAnimationsIfRunning, cleanupAfterLeaveAnimations, cleanupEnterClassData, clearLeavingNodes, clearLViewNodeAnimationResolvers, enterClassMap, getClassListFromValue, getEventTarget, getLViewEnterAnimations, getLViewLeaveAnimations, isLongestAnimation, leaveAnimationFunctionCleanup, longestAnimations, noOpAnimationComplete, trackEnterClasses, trackLeavingNodes, } from '../../animation/utils'; import {initializeAnimationQueueScheduler, queueEnterAnimations} from '../../animation/queue'; /** * Instruction to handle the `animate.enter` behavior for class bindings. * * @param value The value bound to `animate.enter`, which is a string or a function. * @returns This function returns itself so that it may be chained. * * @codeGenApi */ export function ɵɵanimateEnter(value: string | AnimationClassBindingFn): typeof ɵɵanimateEnter { performanceMarkFeature('NgAnimateEnter'); if ((typeof ngServerMode !== 'undefined' && ngServerMode) || !areAnimationSupported) { return ɵɵanimateEnter; } ngDevMode && assertAnimationTypes(value, 'animate.enter'); const lView = getLView(); if (areAnimationsDisabled(lView)) { return ɵɵanimateEnter; } const tNode = getCurrentTNode()!; // Capture NgZone eagerly while the injector is still valid. The animation // function runs later from the queue, at which point the lView injector // may have been destroyed. const ngZone = lView[INJECTOR]!.get(NgZone); addAnimationToLView(getLViewEnterAnimations(lView), tNode, () => runEnterAnimation(lView, tNode, value, ngZone), ); initializeAnimationQueueScheduler(lView[INJECTOR]); // We have to queue here due to the animation instruction being invoked after the element // instruction. The DOM node has to exist before we can queue an animation. Any node that // is not inside of control flow needs to get queued here. For nodes inside of control // flow, those are queued in node_manipulation.ts and are deduped by a Set in the animation // queue. queueEnterAnimations(lView[INJECTOR], getLViewEnterAnimations(lView)); return ɵɵanimateEnter; // For chaining } export function runEnterAnimation( lView: LView, tNode: TNode, value: string | AnimationClassBindingFn, ngZone: NgZone, ): void { const nativeElement = getNativeByTNode(tNode, lView) as HTMLElement; ngDevMode && assertElementNodes(nativeElement, 'animate.enter'); const renderer = lView[RENDERER]; // Retrieve the actual class list from the value. This will resolve any resolver functions from // bindings. const activeClasses = getClassListFromValue(value); const cleanupFns: VoidFunction[] = []; let hasCompleted = false; // In the case where multiple animations are happening on the element, we need // to get the longest animation to ensure we don't complete animations early. // This also allows us to setup cancellation of animations in progress if the // gets removed early. const handleEnterAnimationStart = (event: AnimationEvent | TransitionEvent) => { // this early exit case is to prevent issues with bubbling events that are from child element animations if (getEventTarget(event) !== nativeElement) return; const eventName = event instanceof AnimationEvent ? 'animationend' : 'transitionend'; ngZone.runOutsideAngular(() => { renderer.listen(nativeElement, eventName, handleEnterAnimationEnd); }); }; // When the longest animation ends, we can remove all the classes const handleEnterAnimationEnd = (event: AnimationEvent | TransitionEvent) => { // this early exit case is to prevent issues with bubbling events that are from child element animations if (getEventTarget(event) !== nativeElement) return; if (isLongestAnimation(event, nativeElement)) { hasCompleted = true; } enterAnimationEnd(event, nativeElement, renderer); }; // We only need to add these event listeners if there are actual classes to apply if (activeClasses && activeClasses.length > 0) { ngZone.runOutsideAngular(() => { cleanupFns.push(renderer.listen(nativeElement, 'animationstart', handleEnterAnimationStart)); cleanupFns.push(renderer.listen(nativeElement, 'transitionstart', handleEnterAnimationStart)); }); trackEnterClasses(nativeElement, activeClasses, cleanupFns); for (const klass of activeClasses) { renderer.addClass(nativeElement, klass); } // In the case that the classes added have no animations, we need to remove // the classes right away. This could happen because someone is intentionally // preventing an animation via selector specificity. ngZone.runOutsideAngular(() => { requestAnimationFrame(() => { if (hasCompleted) return; determineLongestAnimation(nativeElement, longestAnimations, areAnimationSupported); if (!longestAnimations.has(nativeElement)) { for (const klass of activeClasses) { renderer.removeClass(nativeElement, klass); } cleanupEnterClassData(nativeElement); } }); }); } } function enterAnimationEnd( event: AnimationEvent | TransitionEvent, nativeElement: HTMLElement, renderer: Renderer, ) { const elementData = enterClassMap.get(nativeElement); // this event.target check is to prevent issues with bubbling events that are from child element animations if (getEventTarget(event) !== nativeElement || !elementData) return; if (isLongestAnimation(event, nativeElement)) { // Now that we've found the longest animation, there's no need // to keep bubbling up this event as it's not going to apply to // other elements further up. We don't want it to inadvertently // affect any other animations on the page. event.stopPropagation(); for (const klass of elementData.classList) { renderer.removeClass(nativeElement, klass); } cleanupEnterClassData(nativeElement); } } /** * Instruction to handle the `(animate.enter)` behavior for event bindings, aka when * a user wants to use a custom animation function rather than a class. * * @param value The value bound to `(animate.enter)`, an AnimationFunction. * @returns This function returns itself so that it may be chained. * * @codeGenApi */ export function ɵɵanimateEnterListener(value: AnimationFunction): typeof ɵɵanimateEnterListener { performanceMarkFeature('NgAnimateEnter'); if ((typeof ngServerMode !== 'undefined' && ngServerMode) || !areAnimationSupported) { return ɵɵanimateEnterListener; } ngDevMode && assertAnimationTypes(value, 'animate.enter'); const lView = getLView(); if (areAnimationsDisabled(lView)) { return ɵɵanimateEnterListener; } const tNode = getCurrentTNode()!; addAnimationToLView(getLViewEnterAnimations(lView), tNode, () => runEnterAnimationFunction(lView, tNode, value), ); initializeAnimationQueueScheduler(lView[INJECTOR]); // We have to queue here due to the animation instruction being invoked after the element // instruction. The DOM node has to exist before we can queue an animation. Any node that // is not inside of control flow needs to get queued here. For nodes inside of control // flow, those are queued in node_manipulation.ts and are deduped by a Set in the animation // queue. queueEnterAnimations(lView[INJECTOR], getLViewEnterAnimations(lView)); return ɵɵanimateEnterListener; } /** * runs enter animations when a custom function is provided */ function runEnterAnimationFunction(lView: LView, tNode: TNode, value: AnimationFunction): void { const nativeElement = getNativeByTNode(tNode, lView) as HTMLElement; ngDevMode && assertElementNodes(nativeElement, 'animate.enter'); value.call(lView[CONTEXT], {target: nativeElement, animationComplete: noOpAnimationComplete}); } /** * Instruction to handle the `animate.leave` behavior for class animations. * It creates a leave animation function that's tracked in the LView to * be run before DOM node removal and cleanup. * * @param value The value bound to `animate.leave`, which can be a string or a function. * @returns This function returns itself so that it may be chained. * * @codeGenApi */ export function ɵɵanimateLeave(value: string | AnimationClassBindingFn): typeof ɵɵanimateLeave { performanceMarkFeature('NgAnimateLeave'); if ((typeof ngServerMode !== 'undefined' && ngServerMode) || !areAnimationSupported) { return ɵɵanimateLeave; } ngDevMode && assertAnimationTypes(value, 'animate.leave'); const lView = getLView(); const animationsDisabled = areAnimationsDisabled(lView); if (animationsDisabled) { return ɵɵanimateLeave; } const tNode = getCurrentTNode()!; // Capture NgZone eagerly while the injector is still valid. The animation // function runs later from the queue, at which point the lView injector // may have been destroyed. const ngZone = lView[INJECTOR]!.get(NgZone); addAnimationToLView(getLViewLeaveAnimations(lView), tNode, () => runLeaveAnimations(lView, tNode, value, ngZone), ); initializeAnimationQueueScheduler(lView[INJECTOR]); return ɵɵanimateLeave; // For chaining } function runLeaveAnimations( lView: LView, tNode: TNode, value: string | AnimationClassBindingFn, ngZone: NgZone, ): {promise: Promise<void>; resolve: VoidFunction} { const {promise, resolve} = promiseWithResolvers<void>(); const nativeElement = getNativeByTNode(tNode, lView) as Element; ngDevMode && assertElementNodes(nativeElement, 'animate.leave'); const renderer = lView[RENDERER]; allLeavingAnimations.add(lView[ID]); (getLViewLeaveAnimations(lView).get(tNode.index)!.resolvers ??= []).push(resolve); const activeClasses = getClassListFromValue(value); if (activeClasses && activeClasses.length > 0) { animateLeaveClassRunner( nativeElement as HTMLElement, tNode, lView, activeClasses, renderer, ngZone, ); } else { resolve(); } return {promise, resolve}; } /** * This function actually adds the classes that animate element that's leaving the DOM. * Once it finishes, it calls the remove function that was provided by the DOM renderer. */ function animateLeaveClassRunner( el: HTMLElement, tNode: TNode, lView: LView, classList: string[], renderer: Renderer, ngZone: NgZone, ) { cancelAnimationsIfRunning(el, renderer); const cleanupFns: VoidFunction[] = []; const componentResolvers = getLViewLeaveAnimations(lView).get(tNode.index)?.resolvers; let fallbackTimeoutId: number | undefined; let hasCompleted = false; const handleOutAnimationEnd = (event: AnimationEvent | TransitionEvent | CustomEvent) => { const target = getEventTarget(event as Event); // Custom fallback events don't have a target, so we bypass this check for them. if (target !== el && event.type !== 'animation-fallback') return; if ( event.type === 'animation-fallback' || isLongestAnimation(event as TransitionEvent | AnimationEvent, el) ) { hasCompleted = true; // Now that we've found the longest animation, there's no need // to keep bubbling up this event as it's not going to apply to // other elements further up. We don't want it to inadvertently // affect any other animations on the page. if (fallbackTimeoutId) clearTimeout(fallbackTimeoutId); if (event.type !== 'animation-fallback') event.stopPropagation(); longestAnimations.delete(el); clearLeavingNodes(tNode, el); if (Array.isArray(tNode.projection)) { // in the content projection case, the element is not destroyed. // So we need to remove the class at the end so that it isn't left // behind for whenever the item shows up again. for (const item of classList) { renderer.removeClass(el, item); } } cleanupAfterLeaveAnimations(componentResolvers, cleanupFns); clearLViewNodeAnimationResolvers(lView, tNode); } }; ngZone.runOutsideAngular(() => { cleanupFns.push(renderer.listen(el, 'animationend', handleOutAnimationEnd)); cleanupFns.push(renderer.listen(el, 'transitionend', handleOutAnimationEnd)); }); trackLeavingNodes(tNode, el); for (const item of classList) { renderer.addClass(el, item); } // Force a reflow to ensure the browser registers the class addition and triggers the transition // eslint-disable-next-line @typescript-eslint/no-unused-vars const _reflow = el.offsetWidth; // In the case that the classes added have no animations, we need to remove // the element right away. This could happen because someone is intentionally // preventing an animation via selector specificity. ngZone.runOutsideAngular(() => { requestAnimationFrame(() => { if (hasCompleted) return; determineLongestAnimation(el, longestAnimations, areAnimationSupported); const longest = longestAnimations.get(el); if (!longest) { clearLeavingNodes(tNode, el); cleanupAfterLeaveAnimations(componentResolvers, cleanupFns); clearLViewNodeAnimationResolvers(lView, tNode); } else { // Fallback cleanup if the browser drops the transitionend/animationend event // entirely due to off-screen optimizations or rapid DOM teardown. fallbackTimeoutId = setTimeout(() => { handleOutAnimationEnd(new CustomEvent('animation-fallback')); }, longest.duration + 50) as unknown as number; cleanupFns.push(() => clearTimeout(fallbackTimeoutId)); } }); }); } /** * Instruction to handle the `(animate.leave)` behavior for event bindings, aka when * a user wants to use a custom animation function rather than a class. It registers * a leave animation function in the LView to be run at right before removal from the * DOM. * * @param value The value bound to `(animate.leave)`, an AnimationFunction. * @returns This function returns itself so that it may be chained. * * @codeGenApi */ export function ɵɵanimateLeaveListener(value: AnimationFunction): typeof ɵɵanimateLeaveListener { performanceMarkFeature('NgAnimateLeave'); if ((typeof ngServerMode !== 'undefined' && ngServerMode) || !areAnimationSupported) { return ɵɵanimateLeaveListener; } ngDevMode && assertAnimationTypes(value, 'animate.leave'); // Even when animations are disabled, we still need to register the element for removal // to ensure proper cleanup and allow developers to handle element removal in tests // So we don't have an early return here. const lView = getLView(); const tNode = getCurrentTNode()!; allLeavingAnimations.add(lView[ID]); // Capture NgZone and MAX_ANIMATION_TIMEOUT eagerly while the injector is // still valid. The animation function runs later from the queue, at which // point the lView injector may have been destroyed. const ngZone = lView[INJECTOR]!.get(NgZone); const maxAnimationTimeout = lView[INJECTOR]!.get(MAX_ANIMATION_TIMEOUT); addAnimationToLView(getLViewLeaveAnimations(lView), tNode, () => runLeaveAnimationFunction(lView, tNode, value, ngZone, maxAnimationTimeout), ); initializeAnimationQueueScheduler(lView[INJECTOR]); return ɵɵanimateLeaveListener; // For chaining } /** * runs leave animations when a custom function is provided */ function runLeaveAnimationFunction( lView: LView, tNode: TNode, value: AnimationFunction, ngZone: NgZone, maxAnimationTimeout: number, ): {promise: Promise<void>; resolve: VoidFunction} { const {promise, resolve} = promiseWithResolvers<void>(); const nativeElement = getNativeByTNode(tNode, lView) as Element; ngDevMode && assertElementNodes(nativeElement, 'animate.leave'); const cleanupFns: VoidFunction[] = []; const renderer = lView[RENDERER]; const animationsDisabled = areAnimationsDisabled(lView); (getLViewLeaveAnimations(lView).get(tNode.index)!.resolvers ??= []).push(resolve); const resolvers = getLViewLeaveAnimations(lView).get(tNode.index)?.resolvers; if (animationsDisabled) { leaveAnimationFunctionCleanup( lView, tNode, nativeElement as HTMLElement, resolvers, cleanupFns, ); } else { const timeoutId = setTimeout( () => leaveAnimationFunctionCleanup( lView, tNode, nativeElement as HTMLElement, resolvers, cleanupFns, ), maxAnimationTimeout, ); const event: AnimationCallbackEvent = { target: nativeElement, animationComplete: () => { leaveAnimationFunctionCleanup( lView, tNode, nativeElement as HTMLElement, resolvers, cleanupFns, ); clearTimeout(timeoutId); }, }; trackLeavingNodes(tNode, nativeElement as HTMLElement); ngZone.runOutsideAngular(() => { cleanupFns.push( renderer.listen( nativeElement, 'animationend', () => { leaveAnimationFunctionCleanup( lView, tNode, nativeElement as HTMLElement, resolvers, cleanupFns, ); clearTimeout(timeoutId); }, {once: true}, ), ); }); value.call(lView[CONTEXT], event); } // Ensure cleanup if the LView is destroyed before the animation runs. return {promise, resolve}; }