From bf499f2fcaa56c73872896811759c50a083dfa0f Mon Sep 17 00:00:00 2001 From: Jessica Janiuk Date: Wed, 27 Aug 2025 14:23:10 +0200 Subject: [PATCH] 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 --- packages/core/src/animation/interfaces.ts | 4 ++ .../src/render3/instructions/animation.ts | 45 ++++++++++++++----- .../render3/instructions/change_detection.ts | 11 +++++ packages/core/src/render3/interfaces/view.ts | 11 ++++- .../core/test/acceptance/animation_spec.ts | 43 +++++++++++++++++- 5 files changed, 101 insertions(+), 13 deletions(-) diff --git a/packages/core/src/animation/interfaces.ts b/packages/core/src/animation/interfaces.ts index ae91b9623c5..268ab62af18 100644 --- a/packages/core/src/animation/interfaces.ts +++ b/packages/core/src/animation/interfaces.ts @@ -69,3 +69,7 @@ export interface AnimationDetails { animateFn: AnimationRemoveFunction; isEventBinding: boolean; } +export interface LeaveAnimation { + index: number; + animateFn: Function; +} diff --git a/packages/core/src/render3/instructions/animation.ts b/packages/core/src/render3/instructions/animation.ts index 2746aa8dc7d..9c19323f098 100644 --- a/packages/core/src/render3/instructions/animation.ts +++ b/packages/core/src/render3/instructions/animation.ts @@ -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; } diff --git a/packages/core/src/render3/instructions/change_detection.ts b/packages/core/src/render3/instructions/change_detection.ts index ec853d9c1f1..a1bb5b1a30d 100644 --- a/packages/core/src/render3/instructions/change_detection.ts +++ b/packages/core/src/render3/instructions/change_detection.ts @@ -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( // `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( } } +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. diff --git a/packages/core/src/render3/interfaces/view.ts b/packages/core/src/render3/interfaces/view.ts index bdc77b46004..267c3a77ef0 100644 --- a/packages/core/src/render3/interfaces/view.ts +++ b/packages/core/src/render3/interfaces/view.ts @@ -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 extends Array { // 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 | null; } /** diff --git a/packages/core/test/acceptance/animation_spec.ts b/packages/core/test/acceptance/animation_spec.ts index 979097a479b..9ea8c3e47b6 100644 --- a/packages/core/test/acceptance/animation_spec.ts +++ b/packages/core/test/acceptance/animation_spec.ts @@ -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: '

I should fade

', + encapsulation: ViewEncapsulation.None, + }) + class ChildComponent { + public enterAnim: InputSignal = input.required(); + } + + @Component({ + selector: 'test-cmp', + styles: styles, + imports: [ChildComponent], + template: '', + 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); }));