fix(core): Fixes animations in conjunction with content projection (#63776)

Content Projected nodes are not destroyed and recreated, like every other
situation. Enter and Leave animations were ephemeral and are
expected to run once, and then be cleared. This means that for content projection
cases, the animations would only ever work the first time they were shown / hid.

In order to resolve this, we move to an animation queue that re-runs the animation
functions stored in the LView. In most cases, this animation will run once on creation.
For content projection, the enter and leave animations will fire more than once. Animations
are stored on the LView, but indexed and scheduled by whichever RNode needs to be animated.
So we only run animations for an affected RNode, rather than potentially all in the LView.

This also moves the queue to afterRender, which is safer than right after template
execution in refreshView.

fixes: #63418
fixes: #64065
fixes: #63901

PR Close #63776
This commit is contained in:
Jessica Janiuk
2025-09-12 13:51:51 -07:00
committed by Miles Malerba
parent cb56fbfddb
commit dd7c4cd9d1
15 changed files with 438 additions and 93 deletions
+23 -2
View File
@@ -18,6 +18,27 @@ export const ANIMATIONS_DISABLED = new InjectionToken<boolean>(
},
);
export interface AnimationQueue {
queue: Set<Function>;
isScheduled: boolean;
}
/**
* A [DI token](api/core/InjectionToken) for the queue of all animations.
*/
export const ANIMATION_QUEUE = new InjectionToken<AnimationQueue>(
typeof ngDevMode !== 'undefined' && ngDevMode ? 'AnimationQueue' : '',
{
providedIn: 'root',
factory: () => {
return {
queue: new Set(),
isScheduled: false,
};
},
},
);
/**
* The event type for when `animate.enter` and `animate.leave` are used with function
* callbacks.
@@ -78,10 +99,10 @@ export interface LongestAnimation {
export interface AnimationLViewData {
// Enter animations that apply to nodes in this view
enter?: Function[];
enter?: Map<number, Function[]>;
// Leave animations that apply to nodes in this view
leave?: (() => Promise<void>)[];
leave?: Map<number, Function[]>;
// Leave animations that apply to nodes in this view
// We chose to use unknown instead of PromiseSettledResult<void> to avoid requiring the type
+17 -7
View File
@@ -162,17 +162,17 @@ export function trackLeavingNodes(tNode: TNode, el: HTMLElement): void {
/**
* Retrieves the list of specified enter animations from the lView
*/
export function getLViewEnterAnimations(lView: LView): Function[] {
export function getLViewEnterAnimations(lView: LView): Map<number, Function[]> {
const animationData = (lView[ANIMATIONS] ??= {});
return (animationData.enter ??= []);
return (animationData.enter ??= new Map<number, Function[]>());
}
/**
* Retrieves the list of specified leave animations from the lView
*/
export function getLViewLeaveAnimations(lView: LView): Function[] {
export function getLViewLeaveAnimations(lView: LView): Map<number, Function[]> {
const animationData = (lView[ANIMATIONS] ??= {});
return (animationData.leave ??= []);
return (animationData.leave ??= new Map<number, Function[]>());
}
/**
@@ -244,8 +244,18 @@ export function isLongestAnimation(
}
/**
* Determines if a given tNode is a content projection root node.
* 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 isTNodeContentProjectionRoot(tNode: TNode): boolean {
return Array.isArray(tNode.projection);
export function addAnimationToLView(
animations: Map<number, Function[]>,
tNode: TNode,
fn: Function,
) {
const animationFns = animations.get(tNode.index) ?? [];
animationFns.push(fn);
animations.set(tNode.index, animationFns);
}
@@ -7,12 +7,13 @@
*/
import {
ANIMATION_QUEUE,
AnimationCallbackEvent,
AnimationFunction,
MAX_ANIMATION_TIMEOUT,
} from '../../animation/interfaces';
import {getLView, getCurrentTNode} from '../state';
import {RENDERER, INJECTOR, CONTEXT, LView} from '../interfaces/view';
import {RENDERER, INJECTOR, CONTEXT, LView, ANIMATIONS} from '../interfaces/view';
import {getNativeByTNode} from '../util/view_utils';
import {performanceMarkFeature} from '../../util/performance';
import {Renderer} from '../interfaces/renderer';
@@ -20,8 +21,11 @@ import {NgZone} from '../../zone';
import {determineLongestAnimation, allLeavingAnimations} from '../../animation/longest_animation';
import {TNode} from '../interfaces/node';
import {promiseWithResolvers} from '../../util/promise_with_resolvers';
import {Injector} from '../../di';
import {afterEveryRender} from '../after_render/hooks';
import {
addAnimationToLView,
areAnimationsDisabled,
areAnimationSupported,
assertAnimationTypes,
@@ -66,12 +70,16 @@ export function ɵɵanimateEnter(value: string | Function): typeof ɵɵanimateEn
cancelLeavingNodes(tNode, lView);
getLViewEnterAnimations(lView).push(() => runEnterAnimation(lView, tNode, value));
addAnimationToLView(getLViewEnterAnimations(lView), tNode, () =>
runEnterAnimation(lView, tNode, value),
);
queueEnterAnimations(lView);
return ɵɵanimateEnter; // For chaining
}
export function runEnterAnimation(lView: LView, tNode: TNode, value: string | Function): void {
export function runEnterAnimation(lView: LView, tNode: TNode, value: string | Function) {
const nativeElement = getNativeByTNode(tNode, lView) as HTMLElement;
ngDevMode && assertElementNodes(nativeElement, 'animate.enter');
@@ -94,7 +102,7 @@ export function runEnterAnimation(lView: LView, tNode: TNode, value: string | Fu
const eventName = event instanceof AnimationEvent ? 'animationend' : 'transitionend';
ngZone.runOutsideAngular(() => {
cleanupFns.push(renderer.listen(nativeElement, eventName, handleEnterAnimationEnd));
renderer.listen(nativeElement, eventName, handleEnterAnimationEnd);
});
};
@@ -118,6 +126,7 @@ export function runEnterAnimation(lView: LView, tNode: TNode, value: string | Fu
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.
@@ -182,7 +191,11 @@ export function ɵɵanimateEnterListener(value: AnimationFunction): typeof ɵɵa
cancelLeavingNodes(tNode, lView);
getLViewEnterAnimations(lView).push(() => runEnterAnimationFunction(lView, tNode, value));
addAnimationToLView(getLViewEnterAnimations(lView), tNode, () =>
runEnterAnimationFunction(lView, tNode, value),
);
queueEnterAnimations(lView);
return ɵɵanimateEnterListener;
}
@@ -190,7 +203,7 @@ export function ɵɵanimateEnterListener(value: AnimationFunction): typeof ɵɵa
/**
* runs enter animations when a custom function is provided
*/
function runEnterAnimationFunction(lView: LView, tNode: TNode, value: AnimationFunction) {
function runEnterAnimationFunction(lView: LView, tNode: TNode, value: AnimationFunction): void {
const nativeElement = getNativeByTNode(tNode, lView) as HTMLElement;
ngDevMode && assertElementNodes(nativeElement, 'animate.enter');
@@ -224,19 +237,16 @@ export function ɵɵanimateLeave(value: string | Function): typeof ɵɵanimateLe
const tNode = getCurrentTNode()!;
getLViewLeaveAnimations(lView).push(() =>
runLeaveAnimations(lView, tNode, value, animationsDisabled),
addAnimationToLView(getLViewLeaveAnimations(lView), tNode, () =>
runLeaveAnimations(lView, tNode, value),
);
enableAnimationQueueScheduler(lView[INJECTOR]);
return ɵɵanimateLeave; // For chaining
}
function runLeaveAnimations(
lView: LView,
tNode: TNode,
value: string | Function,
animationsDisabled: boolean,
): Promise<void> {
function runLeaveAnimations(lView: LView, tNode: TNode, value: string | Function): Promise<void> {
const {promise, resolve} = promiseWithResolvers<void>();
const nativeElement = getNativeByTNode(tNode, lView) as Element;
@@ -253,7 +263,6 @@ function runLeaveAnimations(
tNode,
activeClasses,
renderer,
animationsDisabled,
ngZone,
resolve,
);
@@ -273,17 +282,11 @@ function animateLeaveClassRunner(
tNode: TNode,
classList: string[],
renderer: Renderer,
animationsDisabled: boolean,
ngZone: NgZone,
resolver: VoidFunction,
) {
if (animationsDisabled) {
longestAnimations.delete(el);
resolver();
return;
}
cancelAnimationsIfRunning(el, renderer);
const cleanupFns: Function[] = [];
const handleOutAnimationEnd = (event: AnimationEvent | TransitionEvent | CustomEvent) => {
// this early exit case is to prevent issues with bubbling events that are from child element animations
@@ -307,11 +310,14 @@ function animateLeaveClassRunner(
}
}
resolver();
for (const fn of cleanupFns) {
fn();
}
};
ngZone.runOutsideAngular(() => {
renderer.listen(el, 'animationend', handleOutAnimationEnd);
renderer.listen(el, 'transitionend', handleOutAnimationEnd);
cleanupFns.push(renderer.listen(el, 'animationend', handleOutAnimationEnd));
cleanupFns.push(renderer.listen(el, 'transitionend', handleOutAnimationEnd));
});
trackLeavingNodes(tNode, el);
for (const item of classList) {
@@ -326,6 +332,9 @@ function animateLeaveClassRunner(
if (!longestAnimations.has(el)) {
clearLeavingNodes(tNode, el);
resolver();
for (const fn of cleanupFns) {
fn();
}
}
});
});
@@ -359,7 +368,11 @@ export function ɵɵanimateLeaveListener(value: AnimationFunction): typeof ɵɵa
const tNode = getCurrentTNode()!;
allLeavingAnimations.add(lView);
getLViewLeaveAnimations(lView).push(() => runLeaveAnimationFunction(lView, tNode, value));
addAnimationToLView(getLViewLeaveAnimations(lView), tNode, () =>
runLeaveAnimationFunction(lView, tNode, value),
);
enableAnimationQueueScheduler(lView[INJECTOR]);
return ɵɵanimateLeaveListener; // For chaining
}
@@ -416,3 +429,38 @@ function runLeaveAnimationFunction(
// Ensure cleanup if the LView is destroyed before the animation runs.
return promise;
}
function queueEnterAnimations(lView: LView) {
enableAnimationQueueScheduler(lView[INJECTOR]);
const enterAnimations = lView[ANIMATIONS]?.enter;
if (enterAnimations) {
const animationQueue = lView[INJECTOR].get(ANIMATION_QUEUE);
for (const [_, animateFns] of enterAnimations) {
for (const animateFn of animateFns) {
animationQueue.queue.add(animateFn);
}
}
}
}
function enableAnimationQueueScheduler(injector: Injector) {
const animationQueue = injector.get(ANIMATION_QUEUE);
// We only need to schedule the animation queue runner once per application.
if (!animationQueue.isScheduled) {
afterEveryRender(
() => {
runQueuedAnimations(injector);
},
{injector},
);
animationQueue.isScheduled = true;
}
}
function runQueuedAnimations(injector: Injector) {
const animationQueue = injector.get(ANIMATION_QUEUE);
for (let animateFn of animationQueue.queue) {
animateFn();
}
animationQueue.queue.clear();
}
@@ -25,7 +25,6 @@ import {ComponentTemplate, HostBindingsFunction, RenderFlags} from '../interface
import {
CONTEXT,
EFFECTS_TO_SCHEDULE,
ANIMATIONS,
ENVIRONMENT,
FLAGS,
InitPhaseState,
@@ -232,7 +231,6 @@ export function refreshView<T>(
if (templateFn !== null) {
executeTemplate(tView, lView, templateFn, RenderFlags.Update, context);
}
runEnterAnimations(lView);
const hooksInitPhaseCompleted =
(flags & LViewFlags.InitPhaseStateMask) === InitPhaseState.InitPhaseCompleted;
@@ -374,16 +372,6 @@ export function refreshView<T>(
}
}
function runEnterAnimations(lView: LView) {
const animationData = lView[ANIMATIONS];
if (animationData?.enter) {
for (const animateFn of animationData.enter) {
animateFn();
}
animationData.enter = undefined;
}
}
/**
* Goes over embedded views (ones created through ViewContainerRef APIs) and refreshes
* them by executing an associated template function.
+125 -36
View File
@@ -75,12 +75,15 @@ import {
TVIEW,
TView,
TViewType,
INJECTOR,
} from './interfaces/view';
import {assertTNodeType} from './node_assert';
import {profiler} from './profiler';
import {ProfilerEvent} from './profiler_types';
import {getLViewParent, getNativeByTNode, unwrapRNode} from './util/view_utils';
import {allLeavingAnimations} from '../animation/longest_animation';
import {ANIMATION_QUEUE} from '../animation/interfaces';
import {Injector} from '../di';
const enum WalkTNodeTreeAction {
/** node create in the native environment. Run on initial creation. */
@@ -99,6 +102,21 @@ const enum WalkTNodeTreeAction {
Destroy = 3,
}
function maybeQueueEnterAnimation(
parentLView: LView | undefined,
parent: RElement | null,
tNode: TNode,
injector: Injector,
): void {
const enterAnimations = parentLView?.[ANIMATIONS]?.enter;
if (parent !== null && enterAnimations && enterAnimations.has(tNode.index)) {
const animationQueue = injector.get(ANIMATION_QUEUE);
for (const animateFn of enterAnimations.get(tNode.index)!) {
animationQueue.queue.add(animateFn);
}
}
}
/**
* NOTE: for performance reasons, the possible actions are inlined within the function instead of
* being passed as an argument.
@@ -106,8 +124,10 @@ const enum WalkTNodeTreeAction {
function applyToElementOrContainer(
action: WalkTNodeTreeAction,
renderer: Renderer,
injector: Injector,
parent: RElement | null,
lNodeToHandle: RNode | LContainer | LView,
tNode: TNode,
beforeNode?: RNode | null,
parentLView?: LView,
) {
@@ -129,33 +149,50 @@ function applyToElementOrContainer(
lNodeToHandle = lNodeToHandle[HOST]!;
}
const rNode: RNode = unwrapRNode(lNodeToHandle);
if (action === WalkTNodeTreeAction.Create && parent !== null) {
maybeQueueEnterAnimation(parentLView, parent, tNode, injector);
if (beforeNode == null) {
nativeAppendChild(renderer, parent, rNode);
} else {
nativeInsertBefore(renderer, parent, rNode, beforeNode || null, true);
}
} else if (action === WalkTNodeTreeAction.Insert && parent !== null) {
maybeQueueEnterAnimation(parentLView, parent, tNode, injector);
nativeInsertBefore(renderer, parent, rNode, beforeNode || null, true);
} else if (action === WalkTNodeTreeAction.Detach) {
runLeaveAnimationsWithCallback(parentLView, (nodeHasLeaveAnimations: boolean) => {
// the nodeHasLeaveAnimations indicates to the renderer that the element needs to
// be removed synchronously and sets the requireSynchronousElementRemoval flag in
// the renderer.
nativeRemoveNode(renderer, rNode, isComponent, nodeHasLeaveAnimations);
});
runLeaveAnimationsWithCallback(
parentLView,
tNode,
injector,
(nodeHasLeaveAnimations: boolean) => {
// the nodeHasLeaveAnimations indicates to the renderer that the element needs to
// be removed synchronously and sets the requireSynchronousElementRemoval flag in
// the renderer.
nativeRemoveNode(renderer, rNode, isComponent, nodeHasLeaveAnimations);
},
);
} else if (action === WalkTNodeTreeAction.Destroy) {
runLeaveAnimationsWithCallback(parentLView, () => {
runLeaveAnimationsWithCallback(parentLView, tNode, injector, () => {
renderer.destroyNode!(rNode);
});
}
if (lContainer != null) {
applyContainer(renderer, action, lContainer, parent, beforeNode);
applyContainer(renderer, action, injector, lContainer, tNode, parent, beforeNode);
}
}
}
function addToAnimationQueue(injector: Injector, animationFns: Function | Function[]) {
const animationQueue = injector.get(ANIMATION_QUEUE);
if (Array.isArray(animationFns)) {
for (const animateFn of animationFns) {
animationQueue.queue.add(animateFn);
}
} else {
animationQueue.queue.add(animationFns);
}
}
/**
* Removes all DOM elements associated with a view.
*
@@ -353,34 +390,51 @@ function cleanUpView(tView: TView, lView: LView): void {
}
}
function hasLeaveAnimations(lView: LView | undefined): boolean {
return lView !== undefined && lView[ANIMATIONS] !== null && lView[ANIMATIONS].leave !== undefined;
}
function runLeaveAnimationsWithCallback(
lView: LView | undefined,
tNode: TNode,
injector: Injector,
callback: Function,
) {
const animations = lView?.[ANIMATIONS];
if (animations == null || (animations.leave && !animations.leave.has(tNode.index)))
return callback(false);
function runLeaveAnimationsWithCallback(lView: LView | undefined, callback: Function) {
if (lView && lView[ANIMATIONS] && lView[ANIMATIONS].leave) {
if (lView[ANIMATIONS].skipLeaveAnimations) {
lView[ANIMATIONS].skipLeaveAnimations = false;
} else {
const leaveAnimations = lView[ANIMATIONS].leave;
const runningAnimations = [];
for (let index = 0; index < leaveAnimations.length; index++) {
const animateFn = leaveAnimations[index];
runningAnimations.push(animateFn());
}
lView[ANIMATIONS].running = Promise.allSettled(runningAnimations);
lView[ANIMATIONS].leave = undefined;
}
// this is solely for move operations to prevent leave animations from running
// on the moved nodes, which would have deleted the node.
if (animations.skipLeaveAnimations) {
animations.skipLeaveAnimations = false;
return callback(false);
}
runAfterLeaveAnimations(lView, callback);
addToAnimationQueue(injector, () => {
// it's possible that in the time between when the leave animation was
// and the time it was executed, the data structure changed. So we need
// to be safe here.
if (animations.leave && animations.leave.has(tNode.index)) {
const leaveAnimationMap = animations.leave;
const leaveAnimations = leaveAnimationMap.get(tNode.index);
const runningAnimations = [];
if (leaveAnimations) {
for (let index = 0; index < leaveAnimations.length; index++) {
const animationFn = leaveAnimations[index];
runningAnimations.push(animationFn());
}
}
animations.running = Promise.allSettled(runningAnimations);
runAfterLeaveAnimations(lView!, callback);
} else {
if (lView) allLeavingAnimations.delete(lView);
callback(false);
}
});
}
function runAfterLeaveAnimations(lView: LView | undefined, callback: Function) {
if (lView && lView[ANIMATIONS] && lView[ANIMATIONS].running) {
lView[ANIMATIONS].running.then(() => {
if (lView[ANIMATIONS] && lView[ANIMATIONS].running) {
lView[ANIMATIONS].running = undefined;
}
function runAfterLeaveAnimations(lView: LView, callback: Function) {
const runningAnimations = lView[ANIMATIONS]?.running;
if (runningAnimations) {
runningAnimations.then(() => {
lView[ANIMATIONS]!.running = undefined;
allLeavingAnimations.delete(lView);
callback(true);
});
@@ -776,6 +830,7 @@ function applyNodes(
) {
while (tNode != null) {
ngDevMode && assertTNodeForLView(tNode, lView);
const injector = lView[INJECTOR];
// Let declarations don't have corresponding DOM nodes so we skip over them.
if (tNode.type === TNodeType.LetDeclaration) {
@@ -802,8 +857,10 @@ function applyNodes(
applyToElementOrContainer(
action,
renderer,
injector,
parentRElement,
rawSlotValue,
tNode,
beforeNode,
lView,
);
@@ -811,13 +868,24 @@ function applyNodes(
const nextRNode = icuContainerIterate(tNode as TIcuContainerNode, lView);
let rNode: RNode | null;
while ((rNode = nextRNode())) {
applyToElementOrContainer(action, renderer, parentRElement, rNode, beforeNode, lView);
applyToElementOrContainer(
action,
renderer,
injector,
parentRElement,
rNode,
tNode,
beforeNode,
lView,
);
}
applyToElementOrContainer(
action,
renderer,
injector,
parentRElement,
rawSlotValue,
tNode,
beforeNode,
lView,
);
@@ -835,8 +903,10 @@ function applyNodes(
applyToElementOrContainer(
action,
renderer,
injector,
parentRElement,
rawSlotValue,
tNode,
beforeNode,
lView,
);
@@ -956,7 +1026,16 @@ function applyProjectionRecursive(
// This should be refactored and cleaned up.
for (let i = 0; i < nodeToProjectOrRNodes.length; i++) {
const rNode = nodeToProjectOrRNodes[i];
applyToElementOrContainer(action, renderer, parentRElement, rNode, beforeNode, lView);
applyToElementOrContainer(
action,
renderer,
lView[INJECTOR],
parentRElement,
rNode,
tProjectionNode,
beforeNode,
lView,
);
}
} else {
let nodeToProject: TNode | null = nodeToProjectOrRNodes;
@@ -994,7 +1073,9 @@ function applyProjectionRecursive(
function applyContainer(
renderer: Renderer,
action: WalkTNodeTreeAction,
injector: Injector,
lContainer: LContainer,
tNode: TNode,
parentRElement: RElement | null,
beforeNode: RNode | null | undefined,
) {
@@ -1012,7 +1093,15 @@ function applyContainer(
// don't see a reason why they should be different, but they are.
//
// If they are we need to process the second anchor as well.
applyToElementOrContainer(action, renderer, parentRElement, anchor, beforeNode);
applyToElementOrContainer(
action,
renderer,
injector,
parentRElement,
anchor,
tNode,
beforeNode,
);
}
for (let i = CONTAINER_HEADER_OFFSET; i < lContainer.length; i++) {
const lView = lContainer[i] as LView;
@@ -400,9 +400,7 @@ describe('Animation', () => {
expect(fixture.nativeElement.outerHTML).not.toContain('class="fade"');
cmp.show.set(false);
fixture.detectChanges();
tickAnimationFrames(1);
expect(cmp.show()).toBeFalsy();
fixture.detectChanges();
expect(fixture.nativeElement.outerHTML).toContain('class="fade"');
fixture.detectChanges();
fadeCmp.nativeElement.dispatchEvent(
@@ -443,9 +441,7 @@ describe('Animation', () => {
expect(fixture.nativeElement.outerHTML).not.toContain('class="fade"');
cmp.show.set(false);
fixture.detectChanges();
tickAnimationFrames(1);
expect(cmp.show()).toBeFalsy();
fixture.detectChanges();
expect(fixture.nativeElement.outerHTML).toContain('class="fade"');
fixture.detectChanges();
fadeCmp.nativeElement.dispatchEvent(
@@ -809,6 +805,25 @@ describe('Animation', () => {
}
`;
it('should apply classes on entry when animation is specified with no control flow', fakeAsync(() => {
@Component({
selector: 'test-cmp',
styles: styles,
template: '<div><p animate.enter="slide-in" #el>I should slide in</p></div>',
encapsulation: ViewEncapsulation.None,
})
class TestComponent {
@ViewChild('el', {read: ElementRef}) el!: ElementRef<HTMLParagraphElement>;
}
TestBed.configureTestingModule({animationsEnabled: true});
const fixture = TestBed.createComponent(TestComponent);
const cmp = fixture.componentInstance;
fixture.detectChanges();
tickAnimationFrames(1);
expect(cmp.el.nativeElement.outerHTML).toContain('class="slide-in"');
}));
it('should apply classes on entry when animation is specified', fakeAsync(() => {
@Component({
selector: 'test-cmp',
@@ -12,9 +12,14 @@ import {
Directive,
effect,
EventEmitter,
inject,
input,
Output,
signal,
TemplateRef,
viewChild,
ViewChild,
ViewContainerRef,
} from '@angular/core';
import {SIGNAL} from '../../../primitives/signals';
import {fakeAsync, TestBed, tick} from '../../../testing';
@@ -361,5 +366,158 @@ describe('signal inputs', () => {
tick();
expect(childCmp.nativeElement.className).not.toContain('fade-in');
}));
it('should support content projection', fakeAsync(() => {
const animateStyles = `
.fade-in {
animation: fade 1ms forwards;
}
.fade-out {
animation: fade 2ms reverse;
}
@keyframes fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
`;
@Component({
selector: 'app-container',
encapsulation: ViewEncapsulation.None,
template: `
<button (click)="toggle()">{{ state() ? 'Leave' : 'Enter' }}</button>
<ng-template #template>
<ng-content></ng-content>
</ng-template>
`,
})
class Container {
private readonly viewContainerRef = inject(ViewContainerRef);
protected readonly template = viewChild.required<TemplateRef<any>>('template');
protected state = signal<boolean>(false);
protected toggle() {
this.state() ? this.destroy() : this.create();
}
protected create() {
this.state.set(true);
this.viewContainerRef.createEmbeddedView(this.template());
}
protected destroy() {
this.state.set(false);
this.viewContainerRef.clear();
}
}
@Component({
selector: 'app-content',
template: `
<ng-content></ng-content>
`,
styles: animateStyles,
host: {
'animate.leave': 'fade-out',
'animate.enter': 'fade-in',
},
encapsulation: ViewEncapsulation.None,
})
class Content {}
@Component({
selector: 'test-cmp',
template: `
<app-container>
<app-content>This content will be animated on enter/leave</app-content>
</app-container>
`,
imports: [Container, Content],
encapsulation: ViewEncapsulation.None,
})
class TestComponent {}
TestBed.configureTestingModule({animationsEnabled: true});
const fixture = TestBed.createComponent(TestComponent);
const button = fixture.nativeElement.querySelector('button');
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('app-content')).toBeNull();
expect(button).not.toBeNull();
button.click();
// show first time
fixture.detectChanges();
tickAnimationFrames(1);
const enterAppContent = fixture.nativeElement.querySelector('app-content');
expect(enterAppContent).not.toBeNull();
expect(enterAppContent.className).toEqual('fade-in');
enterAppContent.dispatchEvent(new AnimationEvent('animationstart'));
enterAppContent.dispatchEvent(new AnimationEvent('animationend', {animationName: 'fade'}));
tick();
expect(enterAppContent.className).not.toEqual('fade-in');
expect(fixture.debugElement.query(By.css('app-content'))).not.toBeNull();
button.click();
// hide first time
fixture.detectChanges();
tickAnimationFrames(1);
const leaveAppContent = fixture.nativeElement.querySelector('app-content');
expect(fixture.nativeElement.outerHTML).toContain('app-content class="fade-out"');
expect(leaveAppContent.className).toEqual('fade-out');
leaveAppContent.dispatchEvent(new AnimationEvent('animationstart'));
leaveAppContent.dispatchEvent(new AnimationEvent('animationend', {animationName: 'fade'}));
fixture.detectChanges();
tickAnimationFrames(1);
expect(fixture.debugElement.query(By.css('app-content'))).toBeNull();
button.click();
// show second time
fixture.detectChanges();
fixture.changeDetectorRef.markForCheck();
tickAnimationFrames(1);
const enterAppContent2 = fixture.nativeElement.querySelector('app-content');
expect(enterAppContent2).not.toBeNull();
expect(enterAppContent2.className).toEqual('fade-in');
const fadeInEl = fixture.nativeElement.querySelector('.fade-in');
fadeInEl.dispatchEvent(new AnimationEvent('animationstart'));
fadeInEl.dispatchEvent(new AnimationEvent('animationend', {animationName: 'fade'}));
fixture.detectChanges();
tickAnimationFrames(1);
expect(fixture.nativeElement.querySelector('app-content').className).not.toEqual('fade-in');
expect(fixture.debugElement.query(By.css('app-content'))).not.toBeNull();
button.click();
// hide second time
fixture.detectChanges();
tickAnimationFrames(1);
const leaveAppContent2 = fixture.nativeElement.querySelector('app-content');
expect(fixture.nativeElement.outerHTML).toContain('app-content class="fade-out"');
expect(leaveAppContent2.className).toEqual('fade-out');
leaveAppContent2.dispatchEvent(new AnimationEvent('animationstart'));
leaveAppContent2.dispatchEvent(new AnimationEvent('animationend', {animationName: 'fade'}));
fixture.detectChanges();
tickAnimationFrames(1);
expect(fixture.debugElement.query(By.css('app-content'))).toBeNull();
}));
});
});
@@ -48,6 +48,7 @@ import {ComponentFixture, TestBed, TestComponentRenderer} from '../../testing';
import {clearTranslations, loadTranslations} from '@angular/localize';
import {By, DomSanitizer} from '@angular/platform-browser';
import {expect} from '@angular/private/testing/matchers';
import {ANIMATION_QUEUE} from '../../src/animation/interfaces';
describe('ViewContainerRef', () => {
/**
@@ -1413,6 +1414,7 @@ describe('ViewContainerRef', () => {
{provide: Sanitizer, useValue: TestBed.inject(DomSanitizer)},
{provide: ErrorHandler, useValue: TestBed.inject(ErrorHandler)},
{provide: RendererFactory2, useValue: TestBed.inject(RendererFactory2)},
{provide: ANIMATION_QUEUE, useValue: TestBed.inject(ANIMATION_QUEUE)},
],
})
class MyAppModule {}
@@ -5,6 +5,7 @@
"ANIMATIONS",
"ANIMATION_MODULE_TYPE",
"ANIMATION_PREFIX",
"ANIMATION_QUEUE",
"ANY_STATE",
"APP_BOOTSTRAP_LISTENER",
"APP_ID",
@@ -321,6 +322,7 @@
"addAfterRenderSequencesForView",
"addClass",
"addServerStyles",
"addToAnimationQueue",
"addToEndOfViewTree",
"allLeavingAnimations",
"allocExpando",
@@ -680,6 +682,7 @@
"markViewForRefresh",
"markedFeatures",
"matchTemplateAttribute",
"maybeQueueEnterAnimation",
"maybeReturnReactiveLViewConsumer",
"maybeWrapInNotSelector",
"mergeHostAttribute",
@@ -779,7 +782,6 @@
"roundOffset",
"runAfterLeaveAnimations",
"runEffectsInView",
"runEnterAnimations",
"runInInjectionContext",
"runLeaveAnimationsWithCallback",
"runPlatformInitializers",
@@ -60,6 +60,7 @@
"lazy": [
"AFTER_RENDER_SEQUENCES_TO_ADD",
"ANIMATIONS",
"ANIMATION_QUEUE",
"APP_BOOTSTRAP_LISTENER",
"APP_ID",
"APP_INITIALIZER",
@@ -291,6 +292,7 @@
"addAfterRenderSequencesForView",
"addDepsToRegistry",
"addLViewToLContainer",
"addToAnimationQueue",
"addToArray",
"addToEndOfViewTree",
"addViewToDOM",
@@ -605,6 +607,7 @@
"markViewForRefresh",
"markedFeatures",
"matchTemplateAttribute",
"maybeQueueEnterAnimation",
"maybeReturnReactiveLViewConsumer",
"maybeWrapInNotSelector",
"mergeHostAttribute",
@@ -674,7 +677,6 @@
"retrieveHydrationInfo",
"runAfterLeaveAnimations",
"runEffectsInView",
"runEnterAnimations",
"runInInjectionContext",
"runLeaveAnimationsWithCallback",
"runPlatformInitializers",
@@ -3,6 +3,7 @@
"main": [
"AFTER_RENDER_SEQUENCES_TO_ADD",
"ANIMATIONS",
"ANIMATION_QUEUE",
"APP_BOOTSTRAP_LISTENER",
"APP_ID",
"APP_ID_ATTRIBUTE_NAME",
@@ -346,6 +347,7 @@
"addAfterRenderSequencesForView",
"addLViewToLContainer",
"addServerStyles",
"addToAnimationQueue",
"addToArray",
"addToEndOfViewTree",
"addValidators",
@@ -787,6 +789,7 @@
"matchTemplateAttribute",
"maxLengthValidator",
"maxValidator",
"maybeQueueEnterAnimation",
"maybeReturnReactiveLViewConsumer",
"maybeUnwrapEmpty",
"maybeUnwrapFn",
@@ -899,7 +902,6 @@
"retrieveHydrationInfo",
"runAfterLeaveAnimations",
"runEffectsInView",
"runEnterAnimations",
"runInInjectionContext",
"runLeaveAnimationsWithCallback",
"runPlatformInitializers",
@@ -3,6 +3,7 @@
"main": [
"AFTER_RENDER_SEQUENCES_TO_ADD",
"ANIMATIONS",
"ANIMATION_QUEUE",
"APP_BOOTSTRAP_LISTENER",
"APP_ID",
"APP_ID_ATTRIBUTE_NAME",
@@ -350,6 +351,7 @@
"addAfterRenderSequencesForView",
"addLViewToLContainer",
"addServerStyles",
"addToAnimationQueue",
"addToArray",
"addToEndOfViewTree",
"addValidators",
@@ -786,6 +788,7 @@
"markViewForRefresh",
"markedFeatures",
"matchTemplateAttribute",
"maybeQueueEnterAnimation",
"maybeReturnReactiveLViewConsumer",
"maybeUnwrapEmpty",
"maybeUnwrapFn",
@@ -897,7 +900,6 @@
"retrieveHydrationInfo",
"runAfterLeaveAnimations",
"runEffectsInView",
"runEnterAnimations",
"runInInjectionContext",
"runLeaveAnimationsWithCallback",
"runPlatformInitializers",
@@ -5,6 +5,7 @@
"AFTER_RENDER_SEQUENCES_TO_ADD",
"ALLOWED_METHODS",
"ANIMATIONS",
"ANIMATION_QUEUE",
"APP_BOOTSTRAP_LISTENER",
"APP_ID",
"APP_ID_ATTRIBUTE_NAME",
@@ -287,6 +288,7 @@
"activeConsumer",
"addAfterRenderSequencesForView",
"addServerStyles",
"addToAnimationQueue",
"addToEndOfViewTree",
"allLeavingAnimations",
"allocExpando",
@@ -636,6 +638,7 @@
"markViewDirty",
"markViewForRefresh",
"markedFeatures",
"maybeQueueEnterAnimation",
"maybeReturnReactiveLViewConsumer",
"maybeWrapInNotSelector",
"mergeHostAttribute",
@@ -715,7 +718,6 @@
"retrieveTransferredState",
"runAfterLeaveAnimations",
"runEffectsInView",
"runEnterAnimations",
"runInInjectionContext",
"runLeaveAnimationsWithCallback",
"runPlatformInitializers",
@@ -3,6 +3,7 @@
"main": [
"AFTER_RENDER_SEQUENCES_TO_ADD",
"ANIMATIONS",
"ANIMATION_QUEUE",
"APP_BASE_HREF",
"APP_BOOTSTRAP_LISTENER",
"APP_ID",
@@ -396,6 +397,7 @@
"addEmptyPathsToChildrenIfNeeded",
"addLViewToLContainer",
"addServerStyles",
"addToAnimationQueue",
"addToArray",
"addToEndOfViewTree",
"addViewToDOM",
@@ -896,6 +898,7 @@
"matchWithChecks",
"materializeViewResults",
"matrixParamsMatch",
"maybeQueueEnterAnimation",
"maybeResolveResources",
"maybeReturnReactiveLViewConsumer",
"maybeSchedule",
@@ -1014,7 +1017,6 @@
"runCanLoadGuards",
"runCanMatchGuards",
"runEffectsInView",
"runEnterAnimations",
"runInInjectionContext",
"runLeaveAnimationsWithCallback",
"runPlatformInitializers",
@@ -3,6 +3,7 @@
"main": [
"AFTER_RENDER_SEQUENCES_TO_ADD",
"ANIMATIONS",
"ANIMATION_QUEUE",
"APP_BOOTSTRAP_LISTENER",
"APP_ID",
"APP_ID_ATTRIBUTE_NAME",
@@ -231,6 +232,7 @@
"activeConsumer",
"addAfterRenderSequencesForView",
"addServerStyles",
"addToAnimationQueue",
"addToEndOfViewTree",
"allLeavingAnimations",
"allocExpando",
@@ -502,6 +504,7 @@
"markViewDirty",
"markViewForRefresh",
"markedFeatures",
"maybeQueueEnterAnimation",
"maybeReturnReactiveLViewConsumer",
"maybeWrapInNotSelector",
"mergeHostAttribute",
@@ -568,7 +571,6 @@
"retrieveHydrationInfo",
"runAfterLeaveAnimations",
"runEffectsInView",
"runEnterAnimations",
"runInInjectionContext",
"runLeaveAnimationsWithCallback",
"runPlatformInitializers",