refactor(core): track enter and leave animations in LView (#63450)

This tracks the enter and leave functions in the LView to be executed at a safe time for change detection.

PR Close #63450
This commit is contained in:
Jessica Janiuk
2025-08-27 14:23:10 +02:00
parent 4d535cfaa4
commit bf499f2fca
5 changed files with 101 additions and 13 deletions
@@ -69,3 +69,7 @@ export interface AnimationDetails {
animateFn: AnimationRemoveFunction;
isEventBinding: boolean;
}
export interface LeaveAnimation {
index: number;
animateFn: Function;
}
@@ -29,6 +29,8 @@ import {
LView,
TView,
DECLARATION_LCONTAINER,
ENTER_ANIMATIONS,
LEAVE_ANIMATIONS,
} from '../interfaces/view';
import {RuntimeError, RuntimeErrorCode} from '../../errors';
import {getNativeByTNode, storeCleanupWithContext} from '../util/view_utils';
@@ -121,7 +123,9 @@ function cancelLeavingNodes(tNode: TNode, lView: LView): void {
// this is the insertion point for the new TNode element.
// it will be inserted before the declaring containers anchor.
const beforeNode = getBeforeNodeForView(tNode.index, lContainer);
// here we need to check the previous sibling of that anchor
// here we need to check the previous sibling of that anchor. The first
// previousSibling node will be the new element added. The second
// previousSibling will be the one that's being removed.
const previousNode = beforeNode?.previousSibling;
// We really only want to cancel animations if the leaving node is the
// same as the node before where the new node will be inserted. This is
@@ -143,6 +147,20 @@ function trackLeavingNodes(tNode: TNode, el: HTMLElement): void {
}
}
function getLViewEnterAnimations(lView: LView): Function[] {
if (lView[ENTER_ANIMATIONS] === null) {
lView[ENTER_ANIMATIONS] = [];
}
return lView[ENTER_ANIMATIONS];
}
// function getLViewLeaveAnimations(lView: LView): Function[] {
// if (lView[LEAVE_ANIMATIONS] === null) {
// lView[LEAVE_ANIMATIONS] = [];
// }
// return lView[LEAVE_ANIMATIONS];
// }
/**
* Instruction to handle the `animate.enter` behavior for class bindings.
*
@@ -165,6 +183,15 @@ export function ɵɵanimateEnter(value: string | Function): typeof ɵɵanimateEn
}
const tNode = getCurrentTNode()!;
cancelLeavingNodes(tNode, lView);
getLViewEnterAnimations(lView).push(() => runEnterAnimation(lView, tNode, value));
return ɵɵanimateEnter; // For chaining
}
export function runEnterAnimation(lView: LView, tNode: TNode, value: string | Function): void {
const nativeElement = getNativeByTNode(tNode, lView) as HTMLElement;
ngDevMode && assertElementNodes(nativeElement, 'animate.enter');
@@ -200,8 +227,6 @@ export function ɵɵanimateEnter(value: string | Function): typeof ɵɵanimateEn
cleanupFns.push(renderer.listen(nativeElement, 'transitionstart', handleAnimationStart));
});
cancelLeavingNodes(tNode, lView);
trackEnterClasses(nativeElement, activeClasses, cleanupFns);
for (const klass of activeClasses) {
@@ -222,8 +247,6 @@ export function ɵɵanimateEnter(value: string | Function): typeof ɵɵanimateEn
});
});
}
return ɵɵanimateEnter; // For chaining
}
/**
@@ -268,15 +291,17 @@ export function ɵɵanimateEnterListener(value: AnimationFunction): typeof ɵɵa
if (areAnimationsDisabled(lView)) {
return ɵɵanimateEnterListener;
}
const tNode = getCurrentTNode()!;
const nativeElement = getNativeByTNode(tNode, lView) as HTMLElement;
ngDevMode && assertElementNodes(nativeElement, 'animate.enter');
cancelLeavingNodes(tNode, lView);
value.call(lView[CONTEXT], {target: nativeElement, animationComplete: noOpAnimationComplete});
getLViewEnterAnimations(lView).push(() => {
const nativeElement = getNativeByTNode(tNode, lView) as HTMLElement;
ngDevMode && assertElementNodes(nativeElement, 'animate.enter');
value.call(lView[CONTEXT], {target: nativeElement, animationComplete: noOpAnimationComplete});
});
return ɵɵanimateEnterListener;
}
@@ -25,6 +25,7 @@ import {ComponentTemplate, HostBindingsFunction, RenderFlags} from '../interface
import {
CONTEXT,
EFFECTS_TO_SCHEDULE,
ENTER_ANIMATIONS,
ENVIRONMENT,
FLAGS,
InitPhaseState,
@@ -260,6 +261,7 @@ export function refreshView<T>(
// `LView` but its declaration appears after the insertion component.
markTransplantedViewsForRefresh(lView);
}
runEnterAnimations(lView);
runEffectsInView(lView);
detectChangesInEmbeddedViews(lView, ChangeDetectionMode.Global);
@@ -372,6 +374,15 @@ export function refreshView<T>(
}
}
function runEnterAnimations(lView: LView) {
if (lView[ENTER_ANIMATIONS]) {
for (let animateFn of lView[ENTER_ANIMATIONS]) {
animateFn();
}
lView[ENTER_ANIMATIONS] = null;
}
}
/**
* Goes over embedded views (ones created through ViewContainerRef APIs) and refreshes
* them by executing an associated template function.
+10 -1
View File
@@ -6,6 +6,7 @@
* found in the LICENSE file at https://angular.dev/license
*/
import {LeaveAnimation} from '../../animation/interfaces';
import type {ChangeDetectionScheduler} from '../../change_detection/scheduling/zoneless_scheduling';
import {TDeferBlockDetails} from '../../defer/interfaces';
import type {Injector} from '../../di/injector';
@@ -69,6 +70,8 @@ export const EFFECTS_TO_SCHEDULE = 22;
export const EFFECTS = 23;
export const REACTIVE_TEMPLATE_CONSUMER = 24;
export const AFTER_RENDER_SEQUENCES_TO_ADD = 25;
export const ENTER_ANIMATIONS = 26;
export const LEAVE_ANIMATIONS = 27;
/**
* Size of LView's header. Necessary to adjust for it when setting slots.
@@ -77,7 +80,7 @@ export const AFTER_RENDER_SEQUENCES_TO_ADD = 25;
* instruction index into `LView` index. All other indexes should be in the `LView` index space and
* there should be no need to refer to `HEADER_OFFSET` anywhere else.
*/
export const HEADER_OFFSET = 26;
export const HEADER_OFFSET = 28;
// This interface replaces the real LView interface if it is an arg or a
// return value of a public instruction. This ensures we don't need to expose
@@ -367,6 +370,12 @@ export interface LView<T = unknown> extends Array<any> {
// AfterRenderSequences that need to be scheduled
[AFTER_RENDER_SEQUENCES_TO_ADD]: AfterRenderSequence[] | null;
// Enter animations that apply to nodes in this view
[ENTER_ANIMATIONS]: Function[] | null;
// Leave animations that apply to nodes in this view
[LEAVE_ANIMATIONS]: Set<LeaveAnimation> | null;
}
/**
@@ -13,6 +13,8 @@ import {
Component,
Directive,
ElementRef,
input,
InputSignal,
signal,
ViewChild,
} from '@angular/core';
@@ -933,7 +935,7 @@ describe('Animation', () => {
expect(cmp.el.nativeElement.outerHTML).not.toContain('class="slide-in fade-in"');
}));
it('should support multple classes as a single string separated by a space', fakeAsync(() => {
it('should support multiple classes as a single string separated by a space', fakeAsync(() => {
const multiple = `
.slide-in {
animation: slide-in 1ms;
@@ -988,7 +990,7 @@ describe('Animation', () => {
expect(cmp.el.nativeElement.outerHTML).not.toContain('class="slide-in fade-in"');
}));
it('should support multple classes as a single string separated by a space', fakeAsync(() => {
it('should support multiple classes as a single string separated by a space', fakeAsync(() => {
const multiple = `
.slide-in {
animation: slide-in 1ms;
@@ -1243,6 +1245,41 @@ describe('Animation', () => {
expect(childCmp.nativeElement.className).not.toContain('slide-in fade-in');
}));
xit('should support signal inputs', fakeAsync(() => {
@Component({
selector: 'child-cmp',
styles: styles,
template: '<p [animate.enter]="enterAnim()">I should fade</p>',
encapsulation: ViewEncapsulation.None,
})
class ChildComponent {
public enterAnim: InputSignal<string | string[]> = input.required<string | string[]>();
}
@Component({
selector: 'test-cmp',
styles: styles,
imports: [ChildComponent],
template: '<child-cmp enterAnim="fade-in" />',
encapsulation: ViewEncapsulation.None,
})
class TestComponent {}
TestBed.configureTestingModule({animationsEnabled: true});
const fixture = TestBed.createComponent(TestComponent);
fixture.detectChanges();
tickAnimationFrames(1);
const childCmp = fixture.debugElement.query(By.css('child-cmp'));
expect(childCmp.nativeElement.className).toContain('fade-in');
childCmp.nativeElement.dispatchEvent(new AnimationEvent('animationstart'));
childCmp.nativeElement.dispatchEvent(
new AnimationEvent('animationend', {animationName: 'fade-in'}),
);
fixture.detectChanges();
expect(childCmp.nativeElement.className).not.toContain('fade-in');
}));
it('should reset leave animation and not duplicate node when toggled quickly', fakeAsync(() => {
const animateStyles = `
.slide-in {
@@ -1297,6 +1334,8 @@ describe('Animation', () => {
fixture.detectChanges();
tickAnimationFrames(1);
expect(cmp.show()).toBeTruthy();
fixture.detectChanges();
tickAnimationFrames(1);
const paragraphs = fixture.debugElement.queryAll(By.css('p'));
expect(paragraphs.length).toBe(1);
}));