/
githubmirror
/
angular
Обзор
Документация
Войти
/
githubmirror
/
angular
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
packages/core/src/animation/utils.ts
391 строка
15 KB
Kai Guo
fix(core): preserve leave animation for sibling instances sharing a TNode
11 июн 2026, 20:38
11 июн 2026, 20:38
6b5616b
Код
Авторство
О чём код?
/** * @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 {stringify} from '../util/stringify'; // Adjust imports as per actual location import { ANIMATIONS_DISABLED, RunEnterAnimationFn, RunLeaveAnimationFn, LongestAnimation, EnterNodeAnimations, LeaveNodeAnimations, AnimationClassBindingFn, } from './interfaces'; import {INJECTOR, LView, ANIMATIONS, DECLARATION_VIEW} from '../render3/interfaces/view'; import {RuntimeError, RuntimeErrorCode} from '../errors'; import {Renderer} from '../render3/interfaces/renderer'; import {RElement} from '../render3/interfaces/renderer_dom'; import {TNode} from '../render3/interfaces/node'; const DEFAULT_ANIMATIONS_DISABLED = false; export const areAnimationSupported = (typeof ngServerMode === 'undefined' || !ngServerMode) && typeof document !== 'undefined' && // tslint:disable-next-line:no-toplevel-property-access typeof document?.documentElement?.getAnimations === 'function'; /** * Helper function to check if animations are disabled via injection token */ export function areAnimationsDisabled(lView: LView): boolean { const injector = lView[INJECTOR]!; return injector.get(ANIMATIONS_DISABLED, DEFAULT_ANIMATIONS_DISABLED); } /** * Asserts a value passed in is actually an animation type and not something else */ export function assertAnimationTypes(value: string | Function, instruction: string) { if (value == null || (typeof value !== 'string' && typeof value !== 'function')) { throw new RuntimeError( RuntimeErrorCode.ANIMATE_INVALID_VALUE, `'${instruction}' value must be a string of CSS classes or an animation function, got ${stringify(value)}`, ); } } /** * Asserts a given native element is an actual Element node and not something like a comment node. */ export function assertElementNodes(nativeElement: Element, instruction: string) { if ((nativeElement as Node).nodeType !== Node.ELEMENT_NODE) { throw new RuntimeError( RuntimeErrorCode.ANIMATE_INVALID_VALUE, `'${instruction}' can only be used on an element node, got ${stringify((nativeElement as Node).nodeType)}`, ); } } /** * trackEnterClasses is necessary in the case of composition where animate.enter * is used on the same element in multiple places, like on the element and in a * host binding. When removing classes, we need the entire list of animation classes * added to properly remove them when the longest animation fires. */ export function trackEnterClasses( el: HTMLElement, classList: string[], cleanupFns: VoidFunction[], ) { const elementData = enterClassMap.get(el); if (elementData) { for (const klass of classList) { elementData.classList.push(klass); } for (const fn of cleanupFns) { elementData.cleanupFns.push(fn); } } else { enterClassMap.set(el, {classList, cleanupFns}); } } /** * Helper function to cleanup enterClassMap data safely */ export function cleanupEnterClassData(element: HTMLElement): void { const elementData = enterClassMap.get(element); if (elementData) { for (const fn of elementData.cleanupFns) { fn(); } enterClassMap.delete(element); } longestAnimations.delete(element); } export const noOpAnimationComplete = () => {}; // Tracks the list of classes added to a DOM node from `animate.enter` calls to ensure // we remove all of the classes in the case of animation composition via host bindings. export const enterClassMap = new WeakMap< HTMLElement, {classList: string[]; cleanupFns: VoidFunction[]} >(); export const longestAnimations = new WeakMap<HTMLElement, LongestAnimation>(); /** * A node that is currently animating away, tracked alongside the view that * declares its template. The declaration view lets us tell apart two distinct * instances of the same template (which share a `TNode`) from the same logical * view being re-rendered. */ interface LeavingNode { el: HTMLElement; // The view that declares the template the leaving element belongs to. For two // separate component instances of the same template this differs; for the same // logical view re-rendered (e.g. a dynamic component repeatedly created from the // same `ViewContainerRef`) it is identical. `null` when no owning view is known. declarationView: LView | null; } // Tracks nodes that are animating away for the duration of the animation. This is // used to prevent duplicate nodes from showing up when nodes have been toggled quickly // from an `@if` or `@for`. export const leavingNodes = new WeakMap<TNode, LeavingNode[]>(); /** * Resolves the view that declares the template a node belongs to. We use the * declaration view (rather than the rendered `LView` itself, which is a fresh * object on every re-render) so that the same logical insertion point compares * equal across re-renders, while distinct instances of a shared template compare * unequal. */ function getDeclarationView(lView: LView | undefined): LView | null { if (!lView) return null; return lView[DECLARATION_VIEW] ?? lView; } // Tracks nodes that have scheduled leave animations but were re-inserted into the DOM // before the animation completed, thus rescuing them from being physically removed. export const reusedNodes = new WeakSet<HTMLElement>(); /** * This actually removes the leaving HTML Element in the TNode */ export function clearLeavingNodes(tNode: TNode, el: HTMLElement): void { const nodes = leavingNodes.get(tNode); if (nodes && nodes.length > 0) { const ix = nodes.findIndex((node) => node.el === el); if (ix > -1) nodes.splice(ix, 1); } if (nodes?.length === 0) { leavingNodes.delete(tNode); } } /** * In the case that we have an existing node that's animating away in a * different DOM parent (e.g. a CDK overlay menu that renders each instance * in its own overlay pane), we need to end the animation for the former * node and remove it right away to prevent duplicate nodes showing up. * * Leaving elements in the same parent are left alone — their leave * animation will complete naturally and remove them from the DOM. * * @param tNode The `TNode` of the entering element. * @param newElement The element being inserted. * @param newLView The view the entering element is being rendered into. Used to * tell apart two separate instances of the same template (which share a * `TNode`) from the same logical view being re-rendered into a new DOM parent. */ export function cancelLeavingNodes(tNode: TNode, newElement: HTMLElement, newLView?: LView): void { const nodes = leavingNodes.get(tNode); if (!nodes || nodes.length === 0) return; const newParent = newElement.parentNode; const prevSibling = newElement.previousSibling; const newDeclarationView = getDeclarationView(newLView); for (let i = nodes.length - 1; i >= 0; i--) { const {el: leavingEl, declarationView: leavingDeclarationView} = nodes[i]; const leavingParent = leavingEl.parentNode; // Cancel if the leaving element is: // - The direct previousSibling of the new element. This is reliable // because Angular inserts new elements at the same position (before // the container anchor) where the leaving element was, making them // always adjacent. Covers @if toggling and same-VCR toggling. // - The leaving element IS the new element. This happens when a node is moved // (e.g., drag-and-drop reordering). We must cancel its pending leave animation // and ensure it's not physically removed from the DOM by marking it as reused. if (leavingEl === newElement) { nodes.splice(i, 1); reusedNodes.add(leavingEl); leavingEl.dispatchEvent(new CustomEvent('animationend', {detail: {cancel: true}})); } else if (prevSibling && leavingEl === prevSibling) { nodes.splice(i, 1); leavingEl.dispatchEvent(new CustomEvent('animationend', {detail: {cancel: true}})); leavingEl.parentNode?.removeChild(leavingEl); } else if (leavingParent && newParent && leavingParent !== newParent) { // The leaving element is in a different DOM parent than the entering one. // This is ambiguous: it can be the same logical view re-rendered into a new // container (e.g. a dynamic component re-created in a fresh CDK overlay // pane), which must be de-duplicated by removing it immediately; or it can // be a *distinct* instance of the same template (accordions, exclusive- // expansion menus, master/detail nav) that merely shares this `TNode` and // is legitimately leaving in its own parent. Only force-remove when the // entering element belongs to the SAME declaration view as the leaving one // — i.e. a true re-render of the same logical view. Distinct instances are // left alone so their `animate.leave` runs to completion. const sameLogicalView = newDeclarationView === null || leavingDeclarationView === null || newDeclarationView === leavingDeclarationView; if (sameLogicalView) { nodes.splice(i, 1); leavingEl.dispatchEvent(new CustomEvent('animationend', {detail: {cancel: true}})); leavingEl.parentNode?.removeChild(leavingEl); } } } } /** * Tracks the nodes list of nodes that are leaving the DOM so we can cancel any leave animations * and remove the node before adding a new entering instance of the DOM node. This prevents * duplicates from showing up on screen mid-animation. */ export function trackLeavingNodes(tNode: TNode, el: HTMLElement, lView?: LView): void { // We need to track this tNode's element just to be sure we don't add // a new RNode for this TNode while this one is still animating away. // once the animation is complete, we remove this reference. // The declaration view is recorded so `cancelLeavingNodes` can tell apart two // separate instances of the same template from the same view re-rendered. const declarationView = getDeclarationView(lView); const nodes = leavingNodes.get(tNode); if (nodes) { if (!nodes.some((node) => node.el === el)) { nodes.push({el, declarationView}); } } else { leavingNodes.set(tNode, [{el, declarationView}]); } } /** * Retrieves the list of specified enter animations from the lView */ export function getLViewEnterAnimations(lView: LView): Map<number, EnterNodeAnimations> { const animationData = (lView[ANIMATIONS] ??= {}); return (animationData.enter ??= new Map()); } /** * Retrieves the list of specified leave animations from the lView */ export function getLViewLeaveAnimations(lView: LView): Map<number, LeaveNodeAnimations> { const animationData = (lView[ANIMATIONS] ??= {}); return (animationData.leave ??= new Map()); } /** * Gets the list of classes from a passed in value */ export function getClassListFromValue(value: string | AnimationClassBindingFn): string[] | null { const classes = typeof value === 'function' ? value() : value; let classList: string[] | null = Array.isArray(classes) ? classes : null; if (typeof classes === 'string') { classList = classes .trim() .split(/\s+/) .filter((k) => k); } return classList; } /** * Cancels any running enter animations on a given element to prevent them from interfering * with leave animations. */ export function cancelAnimationsIfRunning(element: HTMLElement, renderer: Renderer): void { if (!areAnimationSupported) return; const elementData = enterClassMap.get(element); if ( elementData && elementData.classList.length > 0 && elementHasClassList(element, elementData.classList) ) { for (const klass of elementData.classList) { renderer.removeClass(element as unknown as RElement, klass); } } // We need to prevent any enter animation listeners from firing if they exist. cleanupEnterClassData(element); } /** * Checks if a given element contains the classes is a provided list */ export function elementHasClassList(element: HTMLElement, classList: string[]): boolean { for (const className of classList) { if (element.classList.contains(className)) return true; } return false; } /** Gets the target of an event while accounting for Shadow DOM. */ export function getEventTarget<T extends EventTarget>(event: Event): T | null { // If an event is bound outside the Shadow DOM, the `event.target` will // point to the shadow root so we have to use `composedPath` instead. return (event.composedPath ? event.composedPath()[0] : event.target) as T | null; } /** * Determines if the animation or transition event is currently the expected longest animation * based on earlier determined data in `longestAnimations` * * @param event * @param nativeElement * @returns */ export function isLongestAnimation( event: AnimationEvent | TransitionEvent, nativeElement: HTMLElement, ): boolean { const longestAnimation = longestAnimations.get(nativeElement); // 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))) ); } /** * Stores a given animation function in the LView's animation map for later execution * * @param animations Either the enter or leave animation map from the LView * @param tNode The TNode the animation is associated with * @param fn The animation function to be called later */ export function addAnimationToLView( animations: Map<number, EnterNodeAnimations | LeaveNodeAnimations>, tNode: TNode, fn: RunEnterAnimationFn | RunLeaveAnimationFn, ) { const nodeAnimations = animations.get(tNode.index) ?? {animateFns: []}; nodeAnimations.animateFns.push(fn); animations.set(tNode.index, nodeAnimations); } export function cleanupAfterLeaveAnimations( resolvers: VoidFunction[] | undefined, cleanupFns: VoidFunction[], ): void { if (resolvers) { for (const fn of resolvers) { fn(); } } for (const fn of cleanupFns) { fn(); } } export function clearLViewNodeAnimationResolvers(lView: LView, tNode: TNode) { const nodeAnimations = getLViewLeaveAnimations(lView).get(tNode.index); if (nodeAnimations) nodeAnimations.resolvers = undefined; } export function leaveAnimationFunctionCleanup( lView: LView, tNode: TNode, nativeElement: HTMLElement, resolvers: VoidFunction[] | undefined, cleanupFns: VoidFunction[], ) { clearLeavingNodes(tNode, nativeElement as HTMLElement); cleanupAfterLeaveAnimations(resolvers, cleanupFns); clearLViewNodeAnimationResolvers(lView, tNode); }