/
githubmirror
/
angular
Обзор
Документация
Войти
/
githubmirror
/
angular
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
packages/core/test/acceptance/animation_spec.ts
2 797 строк
90 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 {NgFor} from '@angular/common'; import {ViewEncapsulation} from '@angular/compiler'; import { AfterViewInit, AnimationCallbackEvent, ApplicationRef, ChangeDetectionStrategy, ChangeDetectorRef, Component, computed, createComponent as createComponentFn, createEnvironmentInjector, Directive, ElementRef, EnvironmentInjector, ErrorHandler, inject, input, NgModule, OnDestroy, provideZonelessChangeDetection, signal, TemplateRef, ViewChild, ViewContainerRef, } from '@angular/core'; import {ComponentRef} from '@angular/core/src/render3'; import {fakeAsync, TestBed, tick} from '@angular/core/testing'; import {By} from '@angular/platform-browser'; import {NoopAnimationsModule} from '@angular/platform-browser/animations'; import {BrowserTestingModule, platformBrowserTesting} from '@angular/platform-browser/testing'; import {isNode} from '@angular/private/testing'; import {reusedNodes} from '../../src/animation/utils'; import {tickAnimationFrames} from '../animation_utils/tick_animation_frames'; @NgModule({ providers: [provideZonelessChangeDetection()], }) export class TestModule {} describe('Animation', () => { if (isNode) { it('should pass', () => expect(true).toBe(true)); return; } beforeEach(() => { TestBed.resetTestEnvironment(); TestBed.initTestEnvironment([BrowserTestingModule, TestModule], platformBrowserTesting()); }); afterEach(() => { TestBed.resetTestEnvironment(); TestBed.initTestEnvironment( [BrowserTestingModule, NoopAnimationsModule, TestModule], platformBrowserTesting(), ); }); afterAll(() => { TestBed.resetTestEnvironment(); TestBed.initTestEnvironment( [BrowserTestingModule, NoopAnimationsModule, TestModule], platformBrowserTesting(), ); }); describe('animate.leave', () => { const styles = ` .fade { animation: fade-out 1ms; } @keyframes fade-out { from { opacity: 1; } to { opacity: 0; } } `; it('should delay element removal when an animation is specified', fakeAsync(() => { const logSpy = jasmine.createSpy('logSpy'); @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'test-cmp', styles: styles, template: '<div>@if (show()) {<p animate.leave="fade" (animationend)="logMe($event)">I should fade</p>}</div>', encapsulation: ViewEncapsulation.None, }) class TestComponent { show = signal(true); logMe(event: AnimationEvent) { logSpy(); } } TestBed.configureTestingModule({animationsEnabled: true}); const fixture = TestBed.createComponent(TestComponent); const cmp = fixture.componentInstance; fixture.detectChanges(); const paragragh = fixture.debugElement.query(By.css('p')); 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(); paragragh.nativeElement.dispatchEvent( new AnimationEvent('animationend', {animationName: 'fade-out'}), ); tick(); expect(fixture.nativeElement.outerHTML).not.toContain('class="fade"'); expect(logSpy).toHaveBeenCalled(); })); it('should remove right away when animations are disabled', fakeAsync(() => { @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'test-cmp', styles: styles, template: '<div>@if (show()) {<p animate.leave="fade" #el>I should fade</p>}</div>', encapsulation: ViewEncapsulation.None, }) class TestComponent { show = signal(true); @ViewChild('el', {read: ElementRef}) el!: ElementRef<HTMLParagraphElement>; } const fixture = TestBed.createComponent(TestComponent); const cmp = fixture.componentInstance; fixture.detectChanges(); cmp.show.set(false); tickAnimationFrames(1); fixture.detectChanges(); expect(cmp.show()).toBeFalsy(); expect(cmp.el).toBeUndefined(); })); it('should remove right away when classes have no animations', fakeAsync(() => { @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'test-cmp', styles: styles, template: '<div>@if (show()) {<p animate.leave="not-a-class" #el>I should fade</p>}</div>', encapsulation: ViewEncapsulation.None, }) class TestComponent { show = signal(true); @ViewChild('el', {read: ElementRef}) el!: ElementRef<HTMLParagraphElement>; } TestBed.configureTestingModule({animationsEnabled: true}); const fixture = TestBed.createComponent(TestComponent); const cmp = fixture.componentInstance; fixture.detectChanges(); cmp.show.set(false); tickAnimationFrames(1); fixture.detectChanges(); expect(cmp.show()).toBeFalsy(); expect(cmp.el).toBeUndefined(); })); it('should support string arrays', fakeAsync(() => { const multiple = ` .slide-out { animation: slide-out 2ms; } .fade { animation: fade-out 1ms; } @keyframes slide-out { from { transform: translateX(0); } to { transform: translateX(10px); } } @keyframes fade-out { from { opacity: 1; } to { opacity: 0; } } `; @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'test-cmp', styles: multiple, template: '<div>@if (show()) {<p [animate.leave]="classArray" #el>I should slide out</p>}</div>', encapsulation: ViewEncapsulation.None, }) class TestComponent { show = signal(true); classArray = ['slide-out', 'fade']; @ViewChild('el', {read: ElementRef}) el!: ElementRef<HTMLParagraphElement>; } TestBed.configureTestingModule({animationsEnabled: true}); const fixture = TestBed.createComponent(TestComponent); const cmp = fixture.componentInstance; fixture.detectChanges(); const paragragh = fixture.debugElement.query(By.css('p')); expect(fixture.nativeElement.outerHTML).not.toContain('class="slide-out fade"'); cmp.show.set(false); fixture.detectChanges(); tickAnimationFrames(1); expect(cmp.show()).toBeFalsy(); fixture.detectChanges(); expect(fixture.nativeElement.outerHTML).toContain('class="slide-out fade"'); fixture.detectChanges(); paragragh.nativeElement.dispatchEvent( new AnimationEvent('animationend', {animationName: 'fade-out'}), ); paragragh.nativeElement.dispatchEvent( new AnimationEvent('animationend', {animationName: 'slide-out'}), ); tick(); expect(fixture.nativeElement.outerHTML).not.toContain('class="slide-out fade"'); })); it('should support binding strings with spaces', fakeAsync(() => { const multiple = ` .slide-out { animation: slide-out 2ms; } .fade { animation: fade-out 1ms; } @keyframes slide-out { from { transform: translateX(0); } to { transform: translateX(10px); } } @keyframes fade-out { from { opacity: 1; } to { opacity: 0; } } `; @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'test-cmp', styles: multiple, template: `<div> @if (show()) { <p [animate.leave]="'slide-out fade'" #el>I should slide out</p> } </div>`, encapsulation: ViewEncapsulation.None, }) class TestComponent { show = signal(true); @ViewChild('el', {read: ElementRef}) el!: ElementRef<HTMLParagraphElement>; } TestBed.configureTestingModule({animationsEnabled: true}); const fixture = TestBed.createComponent(TestComponent); const cmp = fixture.componentInstance; fixture.detectChanges(); const paragragh = fixture.debugElement.query(By.css('p')); expect(fixture.nativeElement.outerHTML).not.toContain('class="slide-out fade"'); cmp.show.set(false); fixture.detectChanges(); tickAnimationFrames(1); expect(cmp.show()).toBeFalsy(); fixture.detectChanges(); expect(fixture.nativeElement.outerHTML).toContain('class="slide-out fade"'); fixture.detectChanges(); paragragh.nativeElement.dispatchEvent( new AnimationEvent('animationend', {animationName: 'fade-out'}), ); paragragh.nativeElement.dispatchEvent( new AnimationEvent('animationend', {animationName: 'slide-out'}), ); tick(); expect(fixture.nativeElement.outerHTML).not.toContain('class="slide-out fade"'); })); it('should support multiple classes as a single string with spaces', fakeAsync(() => { const multiple = ` .slide-out { animation: slide-out 2ms; } .fade { animation: fade-out 1ms; } @keyframes slide-out { from { transform: translateX(0); } to { transform: translateX(10px); } } @keyframes fade-out { from { opacity: 1; } to { opacity: 0; } } `; @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'test-cmp', styles: multiple, template: '<div>@if (show()) {<p animate.leave="slide-out fade" #el>I should slide out</p>}</div>', encapsulation: ViewEncapsulation.None, }) class TestComponent { show = signal(true); @ViewChild('el', {read: ElementRef}) el!: ElementRef<HTMLParagraphElement>; } TestBed.configureTestingModule({animationsEnabled: true}); const fixture = TestBed.createComponent(TestComponent); const cmp = fixture.componentInstance; fixture.detectChanges(); const paragragh = fixture.debugElement.query(By.css('p')); expect(fixture.nativeElement.outerHTML).not.toContain('class="slide-out fade"'); cmp.show.set(false); fixture.detectChanges(); tickAnimationFrames(1); expect(cmp.show()).toBeFalsy(); fixture.detectChanges(); expect(fixture.nativeElement.outerHTML).toContain('class="slide-out fade"'); fixture.detectChanges(); paragragh.nativeElement.dispatchEvent( new AnimationEvent('animationend', {animationName: 'fade-out'}), ); paragragh.nativeElement.dispatchEvent( new AnimationEvent('animationend', {animationName: 'slide-out'}), ); tick(); expect(fixture.nativeElement.outerHTML).not.toContain('class="slide-out fade"'); })); it('should support function syntax', fakeAsync(() => { @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'test-cmp', styles: styles, template: '<div>@if (show()) {<p (animate.leave)="animateFn($event)" class="slide-in" #el>I should slide out</p>}</div>', encapsulation: ViewEncapsulation.None, }) class TestComponent { show = signal(true); animateFn = (event: AnimationCallbackEvent) => { event.target.classList.remove('slide-in'); }; @ViewChild('el', {read: ElementRef}) el!: ElementRef<HTMLParagraphElement>; } TestBed.configureTestingModule({animationsEnabled: true}); const fixture = TestBed.createComponent(TestComponent); const cmp = fixture.componentInstance; fixture.detectChanges(); cmp.show.set(false); fixture.detectChanges(); tickAnimationFrames(1); expect(cmp.show()).toBeFalsy(); expect(fixture.debugElement.nativeElement.outerHTML).not.toContain('class="slide-in"'); })); it('should be host bindable', fakeAsync(() => { @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'fade-cmp', host: {'animate.leave': 'fade'}, template: '<p>I should fade</p>', encapsulation: ViewEncapsulation.None, }) class FadeComponent {} @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'test-cmp', styles: styles, imports: [FadeComponent], template: '@if (show()) { <fade-cmp /> }', encapsulation: ViewEncapsulation.None, }) class TestComponent { show = signal(true); } TestBed.configureTestingModule({animationsEnabled: true}); const fixture = TestBed.createComponent(TestComponent); const cmp = fixture.componentInstance; fixture.detectChanges(); const fadeCmp = fixture.debugElement.query(By.css('fade-cmp')); expect(fixture.nativeElement.outerHTML).not.toContain('class="fade"'); cmp.show.set(false); fixture.detectChanges(); tickAnimationFrames(1); expect(cmp.show()).toBeFalsy(); expect(fixture.nativeElement.outerHTML).toContain('class="fade"'); fixture.detectChanges(); fadeCmp.nativeElement.dispatchEvent( new AnimationEvent('animationend', {animationName: 'fade-out'}), ); tick(); expect(fixture.nativeElement.outerHTML).not.toContain('class="fade"'); })); it('should be host bindable with brackets', fakeAsync(() => { @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'fade-cmp', host: {'[animate.leave]': 'fade()'}, template: '<p>I should fade</p>', encapsulation: ViewEncapsulation.None, }) class FadeComponent { fade = signal('fade'); } @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'test-cmp', styles: styles, imports: [FadeComponent], template: '@if (show()) { <fade-cmp /> }', encapsulation: ViewEncapsulation.None, }) class TestComponent { show = signal(true); } TestBed.configureTestingModule({animationsEnabled: true}); const fixture = TestBed.createComponent(TestComponent); const cmp = fixture.componentInstance; fixture.detectChanges(); const fadeCmp = fixture.debugElement.query(By.css('fade-cmp')); expect(fixture.nativeElement.outerHTML).not.toContain('class="fade"'); cmp.show.set(false); fixture.detectChanges(); tickAnimationFrames(1); expect(cmp.show()).toBeFalsy(); expect(fixture.nativeElement.outerHTML).toContain('class="fade"'); fixture.detectChanges(); fadeCmp.nativeElement.dispatchEvent( new AnimationEvent('animationend', {animationName: 'fade-out'}), ); tick(); expect(fixture.nativeElement.outerHTML).not.toContain('class="fade"'); })); it('should be host bindable with events', fakeAsync(() => { const fadeCalled = jasmine.createSpy('fadeCalled'); @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'fade-cmp', styles: styles, host: {'(animate.leave)': 'fadeIn($event)'}, template: '<p>I should fade</p>', encapsulation: ViewEncapsulation.None, }) class FadeComponent { fadeIn(event: AnimationCallbackEvent) { fadeCalled(); event.target.classList.add('fade'); event.animationComplete(); } } @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'test-cmp', imports: [FadeComponent], template: '@if (show()) { <fade-cmp /> }', encapsulation: ViewEncapsulation.None, }) class TestComponent { show = signal(true); } TestBed.configureTestingModule({animationsEnabled: true}); const fixture = TestBed.createComponent(TestComponent); const cmp = fixture.componentInstance; fixture.detectChanges(); expect(fixture.nativeElement.outerHTML).not.toContain('class="fade"'); cmp.show.set(false); fixture.detectChanges(); tickAnimationFrames(1); expect(cmp.show()).toBeFalsy(); fixture.detectChanges(); expect(fadeCalled).toHaveBeenCalled(); })); it('should remove element from DOM with (animate.leave) after list reordering', async () => { @Component({ selector: 'leak-cmp', template: 'item', }) class LeakComponent {} @Component({ selector: 'test-cmp', imports: [LeakComponent], template: ` @for (item of items(); track item.id) { <leak-cmp (animate.leave)="onLeave($event)" /> } `, }) class TestComponent { items = signal([{id: '1'}, {id: '2'}]); onLeave(event: AnimationCallbackEvent) { event.animationComplete(); } shuffle() { const arr = this.items(); this.items.set([arr[1], arr[0]]); } remove() { const arr = this.items(); this.items.set([arr[1]]); } } TestBed.configureTestingModule({animationsEnabled: true}); const fixture = TestBed.createComponent(TestComponent); await fixture.whenStable(); expect(fixture.nativeElement.textContent).toContain('item'); fixture.componentInstance.shuffle(); await fixture.whenStable(); const elementsBeforeRemove = fixture.debugElement.queryAll(By.css('leak-cmp')); // Element is in reused nodes before remove expect(reusedNodes.has(elementsBeforeRemove[0].nativeElement)).toBe(true); fixture.componentInstance.remove(); await fixture.whenStable(); const elements = fixture.nativeElement.querySelectorAll('leak-cmp'); expect(elements.length).toBe(1); }); it('should compose class list when host binding and regular binding', fakeAsync(() => { const multiple = ` .slide-out { animation: slide-out 2ms; } .fade { animation: fade-out 1ms; } @keyframes slide-out { from { transform: translateX(0); } to { transform: translateX(10px); } } @keyframes fade-out { from { opacity: 1; } to { opacity: 0; } } `; @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'child-cmp', host: {'[animate.leave]': 'slide()'}, template: '<p>I should fade</p>', encapsulation: ViewEncapsulation.None, }) class ChildComponent { slide = signal('slide-out'); } @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'test-cmp', styles: multiple, imports: [ChildComponent], template: '@if (show()) { <child-cmp [animate.leave]="fadeExp" /> }', encapsulation: ViewEncapsulation.None, }) class TestComponent { fadeExp = 'fade'; show = signal(true); } TestBed.configureTestingModule({animationsEnabled: true}); const fixture = TestBed.createComponent(TestComponent); const cmp = fixture.componentInstance; fixture.detectChanges(); const childCmp = fixture.debugElement.query(By.css('child-cmp')); expect(childCmp.nativeElement.className).not.toContain('fade'); expect(childCmp.nativeElement.className).not.toContain('slide-out'); cmp.show.set(false); fixture.detectChanges(); tickAnimationFrames(1); expect(cmp.show()).toBeFalsy(); expect(childCmp.nativeElement.className).toContain('fade'); expect(childCmp.nativeElement.className).toContain('slide-out'); childCmp.nativeElement.dispatchEvent( new AnimationEvent('animationend', {animationName: 'fade-out'}), ); childCmp.nativeElement.dispatchEvent( new AnimationEvent('animationend', {animationName: 'slide-out'}), ); tick(); expect(fixture.nativeElement.outerHTML).not.toContain('fade'); expect(fixture.nativeElement.outerHTML).not.toContain('slide-out'); expect(fixture.debugElement.query(By.css('child-cmp'))).toBeNull(); })); it('should compose class list when host binding on a directive and regular binding', fakeAsync(() => { const multiple = ` .slide-out { animation: slide-out 2ms; } .fade { animation: fade-out 1ms; } @keyframes slide-out { from { transform: translateX(0); } to { transform: translateX(10px); } } @keyframes fade-out { from { opacity: 1; } to { opacity: 0; } } `; @Directive({ selector: '[dir]', host: {'[animate.leave]': 'clazz'}, }) class StuffDirective { clazz = 'slide-out'; } @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'child-cmp', styles: multiple, template: '<p>I should fade</p>', encapsulation: ViewEncapsulation.None, }) class ChildComponent {} @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'test-cmp', styles: multiple, imports: [ChildComponent, StuffDirective], template: '@if (show()) { <child-cmp dir /> }', encapsulation: ViewEncapsulation.None, }) class TestComponent { show = signal(true); } TestBed.configureTestingModule({animationsEnabled: true}); const fixture = TestBed.createComponent(TestComponent); const cmp = fixture.componentInstance; fixture.detectChanges(); const childCmp = fixture.debugElement.query(By.css('child-cmp')); expect(childCmp.nativeElement.className).not.toContain('slide-out'); cmp.show.set(false); fixture.detectChanges(); tickAnimationFrames(1); expect(cmp.show()).toBeFalsy(); fixture.detectChanges(); expect(childCmp.nativeElement.className).toContain('slide-out'); fixture.detectChanges(); childCmp.nativeElement.dispatchEvent( new AnimationEvent('animationend', {animationName: 'slide-out'}), ); tick(); expect(fixture.nativeElement.outerHTML).not.toContain('slide-out'); expect(fixture.debugElement.query(By.css('child-cmp'))).toBeNull(); })); it('should compose class list when host binding a string and regular class strings', fakeAsync(() => { const multiple = ` .slide-out { animation: slide-out 2ms; } .fade { animation: fade-out 1ms; } @keyframes slide-out { from { transform: translateX(0); } to { transform: translateX(10px); } } @keyframes fade-out { from { opacity: 1; } to { opacity: 0; } } `; @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'child-cmp', host: {'animate.leave': 'slide-out'}, template: '<p>I should fade</p>', encapsulation: ViewEncapsulation.None, }) class ChildComponent {} @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'test-cmp', styles: multiple, imports: [ChildComponent], template: '@if (show()) { <child-cmp animate.leave="fade" /> }', encapsulation: ViewEncapsulation.None, }) class TestComponent { show = signal(true); } TestBed.configureTestingModule({animationsEnabled: true}); const fixture = TestBed.createComponent(TestComponent); const cmp = fixture.componentInstance; fixture.detectChanges(); const childCmp = fixture.debugElement.query(By.css('child-cmp')); expect(childCmp.nativeElement.className).not.toContain('slide-out fade'); cmp.show.set(false); fixture.detectChanges(); tickAnimationFrames(1); expect(cmp.show()).toBeFalsy(); fixture.detectChanges(); expect(childCmp.nativeElement.className).toContain('slide-out fade'); fixture.detectChanges(); childCmp.nativeElement.dispatchEvent( new AnimationEvent('animationend', {animationName: 'fade-out'}), ); childCmp.nativeElement.dispatchEvent( new AnimationEvent('animationend', {animationName: 'slide-out'}), ); expect(fixture.nativeElement.outerHTML).not.toContain('slide-out fade '); expect(fixture.debugElement.query(By.css('child-cmp'))).toBeNull(); })); it('should await the longest animation when multiple transitions are present', fakeAsync(() => { const multiple = ` .slide-out { grid-template-rows: 0fr; overflow: hidden; opacity: 0; translate: 0 -60px; margin-top: -16px; transition: grid-template-rows 1s ease 400ms, margin-top 1s ease 400ms, opacity 400ms ease, translate 400ms ease; } `; @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'test-cmp', styles: multiple, template: '@if (show()) { <div animate.leave="slide-out"><p>Element with text</p></div> }', encapsulation: ViewEncapsulation.None, }) class TestComponent { show = signal(true); } TestBed.configureTestingModule({animationsEnabled: true}); const fixture = TestBed.createComponent(TestComponent); const cmp = fixture.componentInstance; fixture.detectChanges(); const div = fixture.debugElement.query(By.css('div')); expect(div.nativeElement.className).not.toContain('slide-out'); cmp.show.set(false); fixture.detectChanges(); tickAnimationFrames(1); expect(cmp.show()).toBeFalsy(); fixture.detectChanges(); expect(div.nativeElement.className).toContain('slide-out'); fixture.detectChanges(); div.nativeElement.dispatchEvent( new TransitionEvent('transitionend', {propertyName: 'opacity'}), ); div.nativeElement.dispatchEvent( new TransitionEvent('transitionend', {propertyName: 'translate'}), ); expect(fixture.nativeElement.outerHTML).toContain('slide-out'); div.nativeElement.dispatchEvent( new TransitionEvent('transitionend', {propertyName: 'margin-top'}), ); div.nativeElement.dispatchEvent( new TransitionEvent('transitionend', {propertyName: 'grid-template-rows'}), ); tick(); expect(fixture.nativeElement.outerHTML).not.toContain('slide-out'); expect(fixture.debugElement.query(By.css('div'))).toBeNull(); })); describe('legacy animations compatibility', () => { beforeAll(() => { TestBed.resetTestEnvironment(); TestBed.initTestEnvironment( [BrowserTestingModule, NoopAnimationsModule, TestModule], platformBrowserTesting(), ); }); const styles = ` .fade { animation: fade-out 1ms; } @keyframes fade-out { from { opacity: 1; } to { opacity: 0; } } `; it('should have the same exact timing when AnimationsModule is present', fakeAsync(() => { const logSpy = jasmine.createSpy('logSpy'); @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'test-cmp', styles: styles, template: '<div>@if (show()) {<p animate.leave="fade" (animationend)="logMe($event)">I should fade</p>}</div>', encapsulation: ViewEncapsulation.None, }) class TestComponent { show = signal(true); logMe(event: AnimationEvent) { logSpy(); } } TestBed.configureTestingModule({animationsEnabled: true}); const fixture = TestBed.createComponent(TestComponent); const cmp = fixture.componentInstance; fixture.detectChanges(); const paragragh = fixture.debugElement.query(By.css('p')); 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(); paragragh.nativeElement.dispatchEvent( new AnimationEvent('animationend', {animationName: 'fade-out'}), ); tick(); expect(fixture.nativeElement.outerHTML).not.toContain('class="fade"'); expect(logSpy).toHaveBeenCalled(); })); }); }); describe('animate.enter', () => { const styles = ` .slide-in { animation: slide-in 1ms; } .fade-in { animation: fade-in 2ms; } @keyframes slide-in { from { transform: translateX(-10px); } to { transform: translateX(0); } } @keyframes fade-in { from { opacity: 0; } to { opacity: 1; } } `; it('should apply classes on entry when animation is specified with no control flow', fakeAsync(() => { @Component({ changeDetection: ChangeDetectionStrategy.Eager, 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 call animation function on entry when animation is specified with no control flow', fakeAsync(() => { @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'test-cmp', styles: styles, template: '<div><p (animate.enter)="slideIn($event)">I should slide in</p></div>', encapsulation: ViewEncapsulation.None, }) class TestComponent { count = signal(0); slideIn(event: AnimationCallbackEvent) { this.count.update((c) => (c += 1)); event.animationComplete(); } } TestBed.configureTestingModule({animationsEnabled: true}); const fixture = TestBed.createComponent(TestComponent); const cmp = fixture.componentInstance; fixture.detectChanges(); tickAnimationFrames(1); expect(cmp.count()).toBe(1); })); it('should call animation function only once on entry when animation is specified with control flow', fakeAsync(() => { @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'test-cmp', styles: styles, template: '<div>@if(show()) {<p (animate.enter)="slideIn($event)">I should slide in</p>}</div>', encapsulation: ViewEncapsulation.None, }) class TestComponent { count = signal(0); show = signal(false); slideIn(event: AnimationCallbackEvent) { this.count.update((c) => (c += 1)); event.animationComplete(); } } TestBed.configureTestingModule({animationsEnabled: true}); const fixture = TestBed.createComponent(TestComponent); const cmp = fixture.componentInstance; fixture.detectChanges(); tickAnimationFrames(1); expect(cmp.count()).toBe(0); cmp.show.update((s) => !s); fixture.detectChanges(); tickAnimationFrames(1); expect(cmp.count()).toBe(1); })); it('should apply classes on entry when animation is specified', fakeAsync(() => { @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'test-cmp', styles: styles, template: '<div>@if (show()) {<p animate.enter="slide-in" #el>I should slide in</p>}</div>', encapsulation: ViewEncapsulation.None, }) class TestComponent { show = signal(false); @ViewChild('el', {read: ElementRef}) el!: ElementRef<HTMLParagraphElement>; } TestBed.configureTestingModule({animationsEnabled: true}); const fixture = TestBed.createComponent(TestComponent); const cmp = fixture.componentInstance; fixture.detectChanges(); cmp.show.set(true); fixture.detectChanges(); tickAnimationFrames(1); expect(cmp.show()).toBeTruthy(); expect(cmp.el.nativeElement.outerHTML).toContain('class="slide-in"'); })); it('should support binding syntax', fakeAsync(() => { @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'test-cmp', styles: styles, template: '<div>@if (show()) {<p [animate.enter]="slide()" #el>I should slide in</p>}</div>', encapsulation: ViewEncapsulation.None, }) class TestComponent { show = signal(false); slide = signal('slide-in'); @ViewChild('el', {read: ElementRef}) el!: ElementRef<HTMLParagraphElement>; } TestBed.configureTestingModule({animationsEnabled: true}); const fixture = TestBed.createComponent(TestComponent); const cmp = fixture.componentInstance; fixture.detectChanges(); cmp.show.set(true); fixture.detectChanges(); tickAnimationFrames(1); expect(cmp.show()).toBeTruthy(); expect(cmp.el.nativeElement.outerHTML).toContain('class="slide-in"'); })); it('should remove classes when animation is done', fakeAsync(() => { @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'test-cmp', styles: styles, template: '<div>@if (show()) {<p animate.enter="slide-in" #el>I should slide in</p>}</div>', encapsulation: ViewEncapsulation.None, }) class TestComponent { show = signal(false); @ViewChild('el', {read: ElementRef}) el!: ElementRef<HTMLParagraphElement>; } TestBed.configureTestingModule({animationsEnabled: true}); const fixture = TestBed.createComponent(TestComponent); const cmp = fixture.componentInstance; fixture.detectChanges(); cmp.show.set(true); fixture.detectChanges(); tickAnimationFrames(1); const paragraph = fixture.debugElement.query(By.css('p')); paragraph.nativeElement.dispatchEvent(new AnimationEvent('animationstart')); expect(cmp.show()).toBeTruthy(); paragraph.nativeElement.dispatchEvent( new AnimationEvent('animationend', {animationName: 'slide-in'}), ); expect(cmp.el.nativeElement.outerHTML).not.toContain('class="slide-in"'); })); it('should support function syntax', fakeAsync(() => { @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'test-cmp', styles: styles, template: '<div>@if (show()) {<p (animate.enter)="animateFn($event)" #el>I should slide in</p>}</div>', encapsulation: ViewEncapsulation.None, }) class TestComponent { show = signal(false); animateFn = (event: AnimationCallbackEvent) => { event.target.classList.add('slide-in'); }; @ViewChild('el', {read: ElementRef}) el!: ElementRef<HTMLParagraphElement>; } TestBed.configureTestingModule({animationsEnabled: true}); const fixture = TestBed.createComponent(TestComponent); const cmp = fixture.componentInstance; fixture.detectChanges(); cmp.show.set(true); fixture.detectChanges(); tickAnimationFrames(1); expect(cmp.show()).toBeTruthy(); const paragraph = fixture.debugElement.query(By.css('p')); expect(cmp.el.nativeElement.outerHTML).toContain('class="slide-in"'); paragraph.nativeElement.dispatchEvent(new AnimationEvent('animationstart')); paragraph.nativeElement.dispatchEvent( new AnimationEvent('animationend', {animationName: 'fade-in'}), ); expect(cmp.el.nativeElement.outerHTML).not.toContain('class="slide-in fade-in"'); })); it('should support string arrays', fakeAsync(() => { const multiple = ` .slide-in { animation: slide-in 1ms; } .fade-in { animation: fade-in 2ms; } @keyframes slide-in { from { transform: translateX(-10px); } to { transform: translateX(0); } } @keyframes fade-in { from { opacity: 0; } to { opacity: 1; } } `; @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'test-cmp', styles: multiple, template: '<div>@if (show()) {<p [animate.enter]="classArray" #el>I should slide in</p>}</div>', encapsulation: ViewEncapsulation.None, }) class TestComponent { show = signal(false); classArray = ['slide-in', 'fade-in']; @ViewChild('el', {read: ElementRef}) el!: ElementRef<HTMLParagraphElement>; } TestBed.configureTestingModule({animationsEnabled: true}); const fixture = TestBed.createComponent(TestComponent); const cmp = fixture.componentInstance; fixture.detectChanges(); expect(cmp.show()).toBeFalsy(); cmp.show.set(true); fixture.detectChanges(); tickAnimationFrames(1); const paragraph = fixture.debugElement.query(By.css('p')); expect(cmp.show()).toBeTruthy(); expect(cmp.el.nativeElement.outerHTML).toContain('class="slide-in fade-in"'); paragraph.nativeElement.dispatchEvent(new AnimationEvent('animationstart')); paragraph.nativeElement.dispatchEvent( new AnimationEvent('animationend', {animationName: 'fade-in'}), ); expect(cmp.el.nativeElement.outerHTML).not.toContain('class="slide-in fade-in"'); })); it('should support binding to a string with a space', fakeAsync(() => { const multiple = ` .slide-in { animation: slide-in 1ms; } .fade-in { animation: fade-in 2ms; } @keyframes slide-in { from { transform: translateX(-10px); } to { transform: translateX(0); } } @keyframes fade-in { from { opacity: 0; } to { opacity: 1; } } `; @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'test-cmp', styles: multiple, template: `<div> @if (show()) { <p [animate.enter]="'slide-in fade-in'" #el>I should slide in</p> } </div>`, encapsulation: ViewEncapsulation.None, }) class TestComponent { show = signal(false); @ViewChild('el', {read: ElementRef}) el!: ElementRef<HTMLParagraphElement>; } TestBed.configureTestingModule({animationsEnabled: true}); const fixture = TestBed.createComponent(TestComponent); const cmp = fixture.componentInstance; fixture.detectChanges(); expect(cmp.show()).toBeFalsy(); cmp.show.set(true); fixture.detectChanges(); tickAnimationFrames(1); const paragraph = fixture.debugElement.query(By.css('p')); expect(cmp.show()).toBeTruthy(); expect(cmp.el.nativeElement.outerHTML).toContain('class="slide-in fade-in"'); paragraph.nativeElement.dispatchEvent(new AnimationEvent('animationstart')); paragraph.nativeElement.dispatchEvent( new AnimationEvent('animationend', {animationName: 'fade-in'}), ); expect(cmp.el.nativeElement.outerHTML).not.toContain('class="slide-in fade-in"'); })); it('should support multiple classes as a single string separated by a space', fakeAsync(() => { const multiple = ` .slide-in { animation: slide-in 1ms; } .fade-in { animation: fade-in 2ms; } @keyframes slide-in { from { transform: translateX(-10px); } to { transform: translateX(0); } } @keyframes fade-in { from { opacity: 0; } to { opacity: 1; } } `; @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'test-cmp', styles: multiple, template: '<div>@if (show()) {<p animate.enter="slide-in fade-in" #el>I should slide in</p>}</div>', encapsulation: ViewEncapsulation.None, }) class TestComponent { show = signal(false); @ViewChild('el', {read: ElementRef}) el!: ElementRef<HTMLParagraphElement>; } TestBed.configureTestingModule({animationsEnabled: true}); const fixture = TestBed.createComponent(TestComponent); const cmp = fixture.componentInstance; fixture.detectChanges(); cmp.show.set(true); fixture.detectChanges(); tickAnimationFrames(1); expect(cmp.show()).toBeTruthy(); expect(cmp.el.nativeElement.outerHTML).toContain('class="slide-in fade-in"'); const paragraph = fixture.debugElement.query(By.css('p')); paragraph.nativeElement.dispatchEvent(new AnimationEvent('animationstart')); paragraph.nativeElement.dispatchEvent( new AnimationEvent('animationend', {animationName: 'slide-in'}), ); paragraph.nativeElement.dispatchEvent( new AnimationEvent('animationend', {animationName: 'fade-in'}), ); expect(fixture.debugElement.nativeElement.className).not.toContain('fade-in'); expect(fixture.debugElement.nativeElement.className).not.toContain('slide-in'); })); it('should remove right away when animations are disabled', fakeAsync(() => { @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'test-cmp', styles: styles, template: '<div>@if (show()) {<p animate.enter="slide-in" #el>I should fade</p>}</div>', encapsulation: ViewEncapsulation.None, }) class TestComponent { show = signal(false); @ViewChild('el', {read: ElementRef}) el!: ElementRef<HTMLParagraphElement>; } const fixture = TestBed.createComponent(TestComponent); const cmp = fixture.componentInstance; fixture.detectChanges(); cmp.show.set(true); fixture.detectChanges(); expect(cmp.show()).toBeTruthy(); expect(cmp.el.nativeElement.outerHTML).not.toContain('class="slide-in"'); })); it('should remove right away when no classes have animations', fakeAsync(() => { @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'test-cmp', styles: styles, template: '<div>@if (show()) {<p animate.enter="not-a-class" #el>I should fade</p>}</div>', encapsulation: ViewEncapsulation.None, }) class TestComponent { show = signal(false); @ViewChild('el', {read: ElementRef}) el!: ElementRef<HTMLParagraphElement>; } TestBed.configureTestingModule({animationsEnabled: true}); const fixture = TestBed.createComponent(TestComponent); const cmp = fixture.componentInstance; fixture.detectChanges(); cmp.show.set(true); fixture.detectChanges(); tickAnimationFrames(1); expect(cmp.show()).toBeTruthy(); expect(cmp.el.nativeElement.outerHTML).not.toContain('class="not-a-class"'); })); it('should be host bindable', fakeAsync(() => { @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'child-cmp', host: {'animate.enter': 'slide-in'}, template: '<p>I should fade</p>', encapsulation: ViewEncapsulation.None, }) class ChildComponent {} @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'test-cmp', styles: styles, imports: [ChildComponent], host: {'animate.enter': 'slide-in'}, template: '<child-cmp />', encapsulation: ViewEncapsulation.None, }) class TestComponent {} TestBed.configureTestingModule({animationsEnabled: true}); const fixture = TestBed.createComponent(TestComponent); fixture.detectChanges(); tickAnimationFrames(1); expect(fixture.debugElement.nativeElement.outerHTML).toContain('class="slide-in"'); const paragraph = fixture.debugElement.query(By.css('p')); paragraph.nativeElement.dispatchEvent(new AnimationEvent('animationstart')); paragraph.nativeElement.dispatchEvent( new AnimationEvent('animationend', {animationName: 'slide-in'}), ); expect(fixture.debugElement.nativeElement.outerHTML).toContain('class="slide-in"'); })); it('should be host bindable with brackets', fakeAsync(() => { @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'child-cmp', host: {'[animate.enter]': 'slideIn()'}, template: '<p>I should fade</p>', encapsulation: ViewEncapsulation.None, }) class ChildComponent { slideIn = signal('slide-in'); } @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'test-cmp', styles: styles, imports: [ChildComponent], template: '<child-cmp />', encapsulation: ViewEncapsulation.None, }) class TestComponent {} TestBed.configureTestingModule({animationsEnabled: true}); const fixture = TestBed.createComponent(TestComponent); fixture.detectChanges(); tickAnimationFrames(1); expect(fixture.debugElement.nativeElement.outerHTML).toContain('class="slide-in"'); const paragraph = fixture.debugElement.query(By.css('p')); paragraph.nativeElement.dispatchEvent(new AnimationEvent('animationstart')); paragraph.nativeElement.dispatchEvent( new AnimationEvent('animationend', {animationName: 'slide-in'}), ); expect(fixture.debugElement.nativeElement.outerHTML).toContain('class="slide-in"'); })); it('should be host bindable with events', fakeAsync(() => { const slideInCalled = jasmine.createSpy('slideInCalled'); @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'child-cmp', host: {'(animate.enter)': 'slideIn($event)'}, template: '<p>I should fade</p>', encapsulation: ViewEncapsulation.None, }) class ChildComponent { slideIn(event: AnimationCallbackEvent) { slideInCalled(); event.target.classList.add('slide-in'); event.animationComplete(); } } @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'test-cmp', styles: styles, imports: [ChildComponent], template: '<child-cmp />', encapsulation: ViewEncapsulation.None, }) class TestComponent { slideIn(event: AnimationCallbackEvent) { slideInCalled(); event.target.classList.add('slide-in'); event.animationComplete(); } } TestBed.configureTestingModule({animationsEnabled: true}); const fixture = TestBed.createComponent(TestComponent); fixture.detectChanges(); expect(slideInCalled).toHaveBeenCalled(); })); it('should compose class list when host binding and regular binding', fakeAsync(() => { @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'child-cmp', host: {'[animate.enter]': 'clazz'}, template: '<p>I should fade</p>', encapsulation: ViewEncapsulation.None, }) class ChildComponent { clazz = 'slide-in'; } @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'test-cmp', styles: styles, imports: [ChildComponent], template: '<child-cmp [animate.enter]="fadeExp" />', encapsulation: ViewEncapsulation.None, }) class TestComponent { fadeExp = 'fade-in'; } TestBed.configureTestingModule({animationsEnabled: true}); const fixture = TestBed.createComponent(TestComponent); fixture.detectChanges(); const childCmp = fixture.debugElement.query(By.css('child-cmp')); expect(childCmp.nativeElement.className).toContain('slide-in'); expect(childCmp.nativeElement.className).toContain('fade-in'); tickAnimationFrames(1); childCmp.nativeElement.dispatchEvent(new AnimationEvent('animationstart')); childCmp.nativeElement.dispatchEvent( new AnimationEvent('animationend', {animationName: 'fade-in'}), ); childCmp.nativeElement.dispatchEvent( new AnimationEvent('animationend', {animationName: 'slide-in'}), ); expect(childCmp.nativeElement.className).not.toContain('slide-in'); expect(childCmp.nativeElement.className).not.toContain('fade-in'); })); it('should compose class list when host binding a string and regular class strings', fakeAsync(() => { @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'child-cmp', host: {'animate.enter': 'slide-in'}, template: '<p>I should fade</p>', encapsulation: ViewEncapsulation.None, }) class ChildComponent {} @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'test-cmp', styles: styles, imports: [ChildComponent], template: '<child-cmp animate.enter="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('slide-in fade-in'); childCmp.nativeElement.dispatchEvent(new AnimationEvent('animationstart')); childCmp.nativeElement.dispatchEvent( new AnimationEvent('animationend', {animationName: 'fade-in'}), ); childCmp.nativeElement.dispatchEvent( new AnimationEvent('animationend', {animationName: 'slide-in'}), ); fixture.detectChanges(); expect(childCmp.nativeElement.className).not.toContain('slide-in fade-in'); })); it('should reset leave animation and not duplicate node when toggled quickly', fakeAsync(() => { const animateStyles = ` .slide-in { animation: slide-in 500ms; } .fade { animation: fade-out 500ms; } @keyframes slide-in { from { transform: translateX(-10px); } to { transform: translateX(0); } } @keyframes fade-out { from { opacity: 1; } to { opacity: 0; } } `; @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'test-cmp', styles: animateStyles, template: '<div>@if (show()) {<p animate.enter="slide-in" animate.leave="fade" #el>I should slide in</p>}</div>', encapsulation: ViewEncapsulation.None, }) class TestComponent { show = signal(false); @ViewChild('el', {read: ElementRef}) el!: ElementRef<HTMLParagraphElement>; } TestBed.configureTestingModule({animationsEnabled: true}); const fixture = TestBed.createComponent(TestComponent); const cmp = fixture.componentInstance; fixture.detectChanges(); tickAnimationFrames(1); cmp.show.set(true); fixture.detectChanges(); expect(cmp.show()).toBeTruthy(); cmp.show.set(false); tickAnimationFrames(1); fixture.detectChanges(); expect(cmp.show()).toBeFalsy(); cmp.show.set(true); 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); })); it('should reset leave animation and not duplicate node when toggled quickly using event bindings', fakeAsync(() => { const animateStyles = ` .slide-in { animation: slide-in 500ms; } .fade { animation: fade-out 500ms; } @keyframes slide-in { from { transform: translateX(-10px); } to { transform: translateX(0); } } @keyframes fade-out { from { opacity: 1; } to { opacity: 0; } } `; @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'test-cmp', styles: animateStyles, template: '<div>@if (show()) {<p (animate.enter)="slideIn($event)" (animate.leave)="fade($event)" #el>I should slide in</p>}</div>', encapsulation: ViewEncapsulation.None, }) class TestComponent { show = signal(false); @ViewChild('el', {read: ElementRef}) el!: ElementRef<HTMLParagraphElement>; slideIn(event: AnimationCallbackEvent) { event.target.classList.add('slide-in'); setTimeout(() => event.animationComplete(), 500); } fade(event: AnimationCallbackEvent) { event.target.classList.add('fade'); setTimeout(() => event.animationComplete(), 500); } } TestBed.configureTestingModule({animationsEnabled: true}); const fixture = TestBed.createComponent(TestComponent); const cmp = fixture.componentInstance; fixture.detectChanges(); tickAnimationFrames(1); cmp.show.set(true); fixture.detectChanges(); tickAnimationFrames(1); expect(cmp.show()).toBeTruthy(); cmp.show.set(false); fixture.detectChanges(); tickAnimationFrames(1); expect(cmp.show()).toBeFalsy(); cmp.show.set(true); fixture.detectChanges(); tickAnimationFrames(1); expect(cmp.show()).toBeTruthy(); const paragraphs = fixture.debugElement.queryAll(By.css('p')); expect(paragraphs.length).toBe(1); })); it('should reset leave animation and not duplicate node when toggled programmatically very quickly', fakeAsync(() => { const animateStyles = ` .fade { animation: fade-out 500ms; } @keyframes fade-out { from { opacity: 1; } to { opacity: 0; } } `; @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'test-cmp', styles: animateStyles, template: '<div>@if (show()) {<p animate.leave="fade">I should fade</p>}</div>', encapsulation: ViewEncapsulation.None, }) class TestComponent { show = signal(false); cdr = inject(ChangeDetectorRef); toggle() { this.show.update((s) => !s); setTimeout(() => { this.show.update((s) => !s); this.cdr.detectChanges(); setTimeout(() => { this.show.update((s) => !s); this.cdr.detectChanges(); }); }); } } TestBed.configureTestingModule({animationsEnabled: true}); const fixture = TestBed.createComponent(TestComponent); const cmp = fixture.componentInstance; cmp.toggle(); fixture.detectChanges(); tickAnimationFrames(1); const paragraphs = fixture.debugElement.queryAll(By.css('p')); expect(paragraphs.length).toBe(1); })); it('should always run animations for `@for` loops when adding and removing quickly', fakeAsync(() => { const animateStyles = ` .slide-in { animation: slide-in 500ms; } .fade { animation: fade-out 500ms; } @keyframes slide-in { from { transform: translateX(-10px); } to { transform: translateX(0); } } @keyframes fade-out { from { opacity: 1; } to { opacity: 0; } } `; @Component({ selector: 'test-cmp', styles: animateStyles, template: ` <div> @for (item of items; track item) { <p animate.enter="slide-in" animate.leave="fade" #el>I should slide in {{ item }}.</p> } </div> `, encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.Eager, }) class TestComponent { items = [1, 2, 3]; @ViewChild('el', {read: ElementRef}) el!: ElementRef<HTMLParagraphElement>; max = 3; addremove() { this.max++; this.items.splice(this.items.length, 0, this.max); this.items.splice(0, 1); } } TestBed.configureTestingModule({animationsEnabled: true}); const fixture = TestBed.createComponent(TestComponent); const cmp = fixture.componentInstance; fixture.detectChanges(); tickAnimationFrames(1); const paragraphs = fixture.debugElement.queryAll(By.css('p')); paragraphs.forEach((p) => { p.nativeElement.dispatchEvent(new AnimationEvent('animationstart')); p.nativeElement.dispatchEvent( new AnimationEvent('animationend', {animationName: 'slide-in'}), ); }); cmp.addremove(); fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); tickAnimationFrames(1); expect(fixture.debugElement.queryAll(By.css('p.fade')).length).toBe(1); expect(fixture.debugElement.queryAll(By.css('p.slide-in')).length).toBe(1); expect(fixture.debugElement.queryAll(By.css('p')).length).toBe(4); })); it('should run leave and enter animations for `@for` loops when adding / removing simultaneously', fakeAsync(() => { const animateStyles = ` .slide-in { animation: slide-in 500ms; } .fade { animation: fade-out 500ms; } @keyframes slide-in { from { transform: translateX(-10px); } to { transform: translateX(0); } } @keyframes fade-out { from { opacity: 1; } to { opacity: 0; } } `; @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'test-cmp', styles: animateStyles, template: ` <div> @for (item of items(); track item) { <p id="item-{{ item }}" animate.enter="slide-in" animate.leave="fade"> I should slide in {{ item }}. </p> } </div> `, encapsulation: ViewEncapsulation.None, }) class TestComponent { items = signal([1, 2, 3]); addremove() { this.items.update((l) => l.slice(1).concat([l.at(-1)! + 1])); } } TestBed.configureTestingModule({animationsEnabled: true}); const fixture = TestBed.createComponent(TestComponent); const cmp = fixture.componentInstance; fixture.detectChanges(); tickAnimationFrames(1); const paragraphs = fixture.debugElement.queryAll(By.css('p')); paragraphs.forEach((p) => { p.nativeElement.dispatchEvent(new AnimationEvent('animationstart')); p.nativeElement.dispatchEvent( new AnimationEvent('animationend', {animationName: 'slide-in'}), ); }); cmp.addremove(); fixture.detectChanges(); tickAnimationFrames(1); const first = fixture.debugElement.query(By.css('p#item-1')); const last = fixture.debugElement.query(By.css('p#item-4')); first.nativeElement.dispatchEvent(new AnimationEvent('animationstart')); last.nativeElement.dispatchEvent(new AnimationEvent('animationstart')); expect(fixture.debugElement.queryAll(By.css('p.fade')).length).toBe(1); expect(fixture.debugElement.queryAll(By.css('p.slide-in')).length).toBe(1); last.nativeElement.dispatchEvent( new AnimationEvent('animationend', {animationName: 'slide-in'}), ); first.nativeElement.dispatchEvent( new AnimationEvent('animationend', {animationName: 'fade-out'}), ); fixture.detectChanges(); tickAnimationFrames(1); tick(); expect(fixture.debugElement.queryAll(By.css('p')).length).toBe(3); expect(fixture.debugElement.queryAll(By.css('p.slide-in')).length).toBe(0); expect(fixture.debugElement.queryAll(By.css('p.fade')).length).toBe(0); })); it('should run leave and enter animations for `@for` loops when adding / removing simultaneously with leave function', fakeAsync(() => { const animateStyles = ` .slide-in { animation: slide-in 500ms; } .fade { animation: fade-out 500ms; } @keyframes slide-in { from { transform: translateX(-10px); } to { transform: translateX(0); } } @keyframes fade-out { from { opacity: 1; } to { opacity: 0; } } `; @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'test-cmp', styles: animateStyles, template: ` <div> @for (item of items(); track item) { <p id="item-{{ item }}" animate.enter="slide-in" (animate.leave)="fadeOut($event)"> I should slide in {{ item }}. </p> } </div> `, encapsulation: ViewEncapsulation.None, }) class TestComponent { items = signal([1, 2, 3]); addremove() { this.items.update((l) => l.slice(1).concat([l.at(-1)! + 1])); } fadeOut(event: AnimationCallbackEvent) { event.target.classList.add('fade'); setTimeout(() => event.animationComplete(), 500); } } TestBed.configureTestingModule({animationsEnabled: true}); const fixture = TestBed.createComponent(TestComponent); const cmp = fixture.componentInstance; fixture.detectChanges(); tickAnimationFrames(1); const paragraphs = fixture.debugElement.queryAll(By.css('p')); paragraphs.forEach((p) => { p.nativeElement.dispatchEvent(new AnimationEvent('animationstart')); p.nativeElement.dispatchEvent( new AnimationEvent('animationend', {animationName: 'slide-in'}), ); }); cmp.addremove(); fixture.detectChanges(); tickAnimationFrames(1); const first = fixture.debugElement.query(By.css('p#item-1')); const last = fixture.debugElement.query(By.css('p#item-4')); first.nativeElement.dispatchEvent(new AnimationEvent('animationstart')); last.nativeElement.dispatchEvent(new AnimationEvent('animationstart')); expect(fixture.debugElement.queryAll(By.css('p.fade')).length).toBe(1); expect(fixture.debugElement.queryAll(By.css('p.slide-in')).length).toBe(1); last.nativeElement.dispatchEvent( new AnimationEvent('animationend', {animationName: 'slide-in'}), ); first.nativeElement.dispatchEvent( new AnimationEvent('animationend', {animationName: 'fade-out'}), ); fixture.detectChanges(); tickAnimationFrames(1); tick(); expect(fixture.debugElement.queryAll(By.css('p')).length).toBe(3); expect(fixture.debugElement.queryAll(By.css('p.slide-in')).length).toBe(0); expect(fixture.debugElement.queryAll(By.css('p.fade')).length).toBe(0); })); it('should always run animations for custom repeater loops when adding and removing quickly', fakeAsync(() => { const animateStyles = ` .slide-in { animation: slide-in 500ms; } .fade { animation: fade-out 500ms; } @keyframes slide-in { from { transform: translateX(-10px); } to { transform: translateX(0); } } @keyframes fade-out { from { opacity: 1; } to { opacity: 0; } } `; @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'test-cmp', styles: animateStyles, imports: [NgFor], template: ` <div> <ng-container *ngFor="let item of items; trackBy: trackByIndex; let i = index"> <p animate.enter="slide-in" animate.leave="fade" #el>I should slide in {{ item }}.</p> </ng-container> </div> `, encapsulation: ViewEncapsulation.None, }) class TestComponent { items = [1, 2, 3]; @ViewChild('el', {read: ElementRef}) el!: ElementRef<HTMLParagraphElement>; max = 3; addremove() { this.max++; this.items.splice(this.items.length, 0, this.max); this.items.splice(0, 1); } } TestBed.configureTestingModule({animationsEnabled: true}); const fixture = TestBed.createComponent(TestComponent); const cmp = fixture.componentInstance; fixture.detectChanges(); tickAnimationFrames(1); const paragraphs = fixture.debugElement.queryAll(By.css('p')); paragraphs.forEach((p) => { p.nativeElement.dispatchEvent(new AnimationEvent('animationstart')); p.nativeElement.dispatchEvent( new AnimationEvent('animationend', {animationName: 'slide-in'}), ); }); cmp.addremove(); fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); tickAnimationFrames(1); expect(fixture.debugElement.queryAll(By.css('p.fade')).length).toBe(1); expect(fixture.debugElement.queryAll(By.css('p.slide-in')).length).toBe(1); expect(fixture.debugElement.queryAll(By.css('p')).length).toBe(4); })); it('should only remove one element in reactive `@for` loops when removing the second to last item', fakeAsync(() => { const animateStyles = ` .fade { animation: fade-out 500ms; } @keyframes fade-out { from { opacity: 1; } to { opacity: 0; } } `; @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'test-cmp', styles: animateStyles, template: ` <div> @for (item of shown(); track item) { <p animate.leave="fade" #el>I should slide in {{ item }}.</p> } </div> `, encapsulation: ViewEncapsulation.None, }) class TestComponent { items = signal([1, 2, 3, 4, 5, 6]); shown = computed(() => this.items().slice(0, 3)); @ViewChild('el', {read: ElementRef}) el!: ElementRef<HTMLParagraphElement>; max = 6; removeSecondToLast() { this.items.update((old) => { const newList = [...old]; newList.splice(1, 1); return newList; }); } } TestBed.configureTestingModule({animationsEnabled: true}); const fixture = TestBed.createComponent(TestComponent); const cmp = fixture.componentInstance; fixture.detectChanges(); cmp.removeSecondToLast(); fixture.detectChanges(); tickAnimationFrames(1); expect(fixture.debugElement.queryAll(By.css('p.fade')).length).toBe(1); expect(fixture.debugElement.queryAll(By.css('p')).length).toBe(4); fixture.debugElement .query(By.css('p.fade')) .nativeElement.dispatchEvent( new AnimationEvent('animationend', {animationName: 'fade-out'}), ); tick(); expect(fixture.debugElement.queryAll(By.css('p')).length).toBe(3); })); it('should not remove elements when swapping or moving nodes', fakeAsync(() => { const animateSpy = jasmine.createSpy('animateSpy'); @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'test-cmp', template: ` <div> @for (item of items; track item.id) { <p (animate.leave)="animate($event)" #el>{{ item.id }}</p> } </div> `, encapsulation: ViewEncapsulation.None, }) class TestComponent { items = [{id: 1}, {id: 2}, {id: 3}]; private cd = inject(ChangeDetectorRef); animate(event: AnimationCallbackEvent) { animateSpy(); event.animationComplete(); } shuffle() { this.items = this.shuffleArray(this.items); this.cd.markForCheck(); } shuffleArray<T>(array: readonly T[]): T[] { return [array[1], array[2], array[0]]; } } TestBed.configureTestingModule({animationsEnabled: true}); const fixture = TestBed.createComponent(TestComponent); const cmp = fixture.componentInstance; cmp.shuffle(); fixture.detectChanges(); expect(animateSpy).not.toHaveBeenCalled(); expect(fixture.debugElement.queryAll(By.css('p')).length).toBe(3); })); it('should not remove elements when child element animations finish', fakeAsync(() => { const animateStyles = ` .fade { animation: fade-out 500ms; } .flash { animation: flash 200ms; } @keyframes flash { from { background-color: rgba(255, 255, 255, 1); } to { background-color: rgba(255, 255, 255, 0);; } } @keyframes fade-out { from { opacity: 1; } to { opacity: 0; } } `; @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'test-cmp', styles: animateStyles, template: ` <div> @if (show()) { <p animate.leave="fade" #el> I should slide in. <button [class]="buttonClass()" (click)="flash()">click me</button> </p> } </div> `, encapsulation: ViewEncapsulation.None, }) class TestComponent { show = signal(true); buttonClass = signal(''); flash() { this.show.update((val) => !val); this.buttonClass.set('flash'); } } TestBed.configureTestingModule({animationsEnabled: true}); const fixture = TestBed.createComponent(TestComponent); const cmp = fixture.componentInstance; fixture.detectChanges(); cmp.flash(); fixture.detectChanges(); tickAnimationFrames(1); fixture.debugElement .query(By.css('button')) .nativeElement.dispatchEvent( new AnimationEvent('animationend', {animationName: 'flash', bubbles: true}), ); tick(); expect(fixture.debugElement.queryAll(By.css('p')).length).toBe(1); fixture.debugElement .query(By.css('p')) .nativeElement.dispatchEvent( new AnimationEvent('animationend', {animationName: 'fade-out', bubbles: true}), ); tick(); expect(fixture.debugElement.queryAll(By.css('p')).length).toBe(0); })); }); describe('animation queue timing', () => { it('should run animations with a fresh componentRef after destroy', fakeAsync(() => { const animateStyles = ` .fade { animation: fade-out 500ms; } @keyframes fade-out { from { opacity: 1; } to { opacity: 0; } } `; @Component({ selector: 'app-control-panel', template: ` @if (step() === 0) { <p class="not-here" [animate.leave]="'fade-out'">THIS SHOULD NOT BE HERE</p> } @if (step() === 1) { <p class="all-there-is">THIS SHOULD BE ALL THERE IS</p> } `, changeDetection: ChangeDetectionStrategy.OnPush, }) class StepperComponent { readonly step = signal(0); } @Component({ selector: 'app-dynamic', template: `<ng-container #dynamicComponent></ng-container>`, changeDetection: ChangeDetectionStrategy.OnPush, }) class DynamicComponent implements AfterViewInit, OnDestroy { @ViewChild('dynamicComponent', {read: ViewContainerRef}) dynamicComponent!: ViewContainerRef; constructor() { this.componentRef = null; } protected componentRef: ComponentRef<StepperComponent> | null; ngAfterViewInit(): void { this.componentRef = this.dynamicComponent.createComponent( StepperComponent, ) as ComponentRef<StepperComponent>; this.componentRef!.changeDetectorRef.detectChanges(); this.componentRef!.instance.step.set(1); } ngOnDestroy(): void { this.componentRef?.destroy(); } } @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'test-cmp', imports: [DynamicComponent], template: ` <div> @if (show()) { <app-dynamic /> } </div> `, encapsulation: ViewEncapsulation.None, }) class TestComponent { show = signal(true); toggleOverlay() { this.show.update((show) => !show); } } TestBed.configureTestingModule({animationsEnabled: true}); const fixture = TestBed.createComponent(TestComponent); const cmp = fixture.componentInstance; fixture.detectChanges(); expect(fixture.debugElement.query(By.css('p.all-there-is'))).not.toBeNull(); expect(fixture.debugElement.query(By.css('p.not-here.fade-out'))).not.toBeNull(); // Finish the leave animation to ensure it is removed tickAnimationFrames(1); // verify element is removed post animation expect(fixture.debugElement.query(By.css('p.not-here'))).toBeNull(); cmp.toggleOverlay(); fixture.detectChanges(); // show is false. Nothing should be present. expect(fixture.debugElement.query(By.css('p.all-there-is'))).toBeNull(); expect(fixture.debugElement.query(By.css('p.not-here'))).toBeNull(); cmp.toggleOverlay(); fixture.detectChanges(); expect(fixture.debugElement.query(By.css('p.not-here'))).not.toBeNull(); tickAnimationFrames(1); // show is true. Only one element should be present. expect(fixture.debugElement.query(By.css('p.all-there-is'))).not.toBeNull(); expect(fixture.debugElement.query(By.css('p.not-here'))).toBeNull(); })); it('should not throw INJECTOR_ALREADY_DESTROYED when lView injector is destroyed before animation queue runs', fakeAsync(() => { const animateStyles = ` .fade-out { animation: fade-out 100ms; } @keyframes fade-out { from { opacity: 1; } to { opacity: 0; } } `; @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'animated-child', template: ` @if (show()) { <div class="item" animate.leave="fade-out">Item</div> } `, styles: [animateStyles], encapsulation: ViewEncapsulation.None, }) class AnimatedChild { show = signal(true); } TestBed.configureTestingModule({animationsEnabled: true}); const rootEnvInjector = TestBed.inject(EnvironmentInjector); const childEnvInjector = createEnvironmentInjector([], rootEnvInjector); const appRef = TestBed.inject(ApplicationRef); const errorHandler = TestBed.inject(ErrorHandler); spyOn(errorHandler, 'handleError'); const hostEl = document.createElement('animated-child'); const compRef = createComponentFn(AnimatedChild, { environmentInjector: childEnvInjector, hostElement: hostEl, }); appRef.attachView(compRef.hostView); appRef.tick(); tickAnimationFrames(1); expect(hostEl.querySelector('.item')).not.toBeNull(); // Trigger leave animation via local detectChanges (queues animation // without flushing the queue - afterNextRender only runs during tick) compRef.instance.show.set(false); compRef.changeDetectorRef.detectChanges(); // Destroy the child injector before the animation queue flushes. // This simulates what happens when a component's lView injector is // destroyed while leave animations are pending. childEnvInjector.destroy(); // Tick to flush the animation queue. Without the fix, the animation // function would call lView[INJECTOR].get(NgZone) which delegates to // the destroyed childEnvInjector, throwing NG0205. appRef.tick(); tickAnimationFrames(1); expect(errorHandler.handleError).not.toHaveBeenCalled(); })); it('should not wait for child component leave animations when host is inside an ng-container', fakeAsync(() => { const animateStyles = ` .fade-out { animation: fade-out 5000ms; } @keyframes fade-out { from { opacity: 1; } to { opacity: 0; } } `; @Component({ selector: 'child-cmp', template: '<div class="child" animate.leave="fade-out">Child</div>', styles: [animateStyles], }) class ChildCmp {} @Component({ selector: 'test-cmp', imports: [ChildCmp], template: ` <ng-container> @if (show()) { <child-cmp></child-cmp> } </ng-container> `, }) class TestCmp { show = signal(true); } TestBed.configureTestingModule({animationsEnabled: true}); const fixture = TestBed.createComponent(TestCmp); const cmp = fixture.componentInstance; fixture.detectChanges(); tickAnimationFrames(1); expect(fixture.debugElement.query(By.css('.child'))).not.toBeNull(); cmp.show.set(false); fixture.detectChanges(); // If the bounding logic works, the ng-container (TNodeType.ElementContainer) // containing the child component will NOT recurse into the child component's // views, so the parent host will be removed immediately without waiting // for the child's 5000ms animation. tickAnimationFrames(1); expect(fixture.debugElement.query(By.css('.child'))).toBeNull(); })); }); describe('animation element duplication', () => { it('should not duplicate elements when using dynamic components in overlay-like containers', fakeAsync(() => { const animateStyles = ` .example-menu { display: inline-flex; flex-direction: column; min-width: 180px; max-width: 280px; padding: 6px 0; } .open { animation: open 10ms; } .close { animation: open 10ms reverse; } @keyframes open { from { opacity: 0; } to { opacity: 1; } } `; @Component({ selector: 'dynamic-menu', styles: [animateStyles], template: ` <ng-template #menu> <div class="example-menu" animate.enter="open" animate.leave="close"> <div>Menu</div> </div> </ng-template> `, changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, }) class MenuComponent { @ViewChild('menu') menuTpl!: TemplateRef<unknown>; vcr = inject(ViewContainerRef); opened = false; private currentPane: HTMLElement | null = null; toggle() { if (this.opened) { this.close(); } else { this.open(); } } open() { this.opened = true; const viewRef = this.vcr.createEmbeddedView(this.menuTpl); // Simulate CDK DomPortalOutlet: after creating the view, move // the root nodes to a new "overlay pane" div, just like CDK // Overlay does with outletElement.appendChild(rootNode). const pane = document.createElement('div'); pane.className = 'overlay-pane'; document.body.appendChild(pane); for (const node of viewRef.rootNodes) { pane.appendChild(node); } this.currentPane = pane; } close() { this.opened = false; this.vcr.clear(); // CDK Overlay may or may not remove the pane immediately } } @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'test-cmp', imports: [MenuComponent], template: ` <dynamic-menu /> `, encapsulation: ViewEncapsulation.None, }) class TestComponent {} TestBed.configureTestingModule({animationsEnabled: true}); const fixture = TestBed.createComponent(TestComponent); fixture.detectChanges(); const cmp = fixture.debugElement.query(By.css('dynamic-menu')).componentInstance; // Query from document since overlay panes are appended to body const countMenus = () => document.querySelectorAll('.example-menu').length; // Helper to complete the leave animation for all leaving menu elements const finishLeaveAnimations = () => { tickAnimationFrames(1); document.querySelectorAll('.example-menu.close').forEach((el) => { el.dispatchEvent(new AnimationEvent('animationend', {animationName: 'open'})); }); tick(); }; // Simulate rapid clicking with CD between each toggle for (let i = 0; i < 20; i++) { cmp.toggle(); fixture.detectChanges(); tickAnimationFrames(1); // At no point should there be more than one menu element expect(countMenus()).toBeLessThanOrEqual(1); } // Complete any remaining leave animations finishLeaveAnimations(); fixture.detectChanges(); // 20 toggles (even) = closed = 0 elements expect(countMenus()).toBe(0); // Clean up overlay panes document.querySelectorAll('.overlay-pane').forEach((p) => p.remove()); })); it('should run animate.leave for a sibling instance when another instance of the same template enters', fakeAsync(() => { // Regression test for a case where two *separate* instances of the same // component (which therefore share a `TNode`) are toggled in the same // change-detection tick: one panel collapses (`animate.leave`) while a // sibling panel expands (`animate.enter`). Because the leaving and entering // elements live in different DOM parents (each instance has its own host), // the cross-parent de-duplication in `cancelLeavingNodes` used to rip the // leaving element out synchronously, skipping its leave animation. The two // elements belong to different declaration views, so it must be left alone. const animateStyles = ` .panel { overflow: hidden; } .enter { animation: grow 10ms; } .leave { animation: shrink 10ms forwards; } @keyframes grow { from { height: 0; } to { height: 60px; } } @keyframes shrink { from { height: 60px; } to { height: 0; } } `; @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'expandable-cmp', styles: [animateStyles], template: ` @if (open()) { <div class="panel" animate.enter="enter" animate.leave="leave">Panel {{ id }}</div> } `, encapsulation: ViewEncapsulation.None, }) class ExpandableComponent { id = ''; open = signal(false); } @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'test-cmp', imports: [ExpandableComponent], // Two instances of the SAME template; only one open at a time. template: ` <expandable-cmp #a /> <expandable-cmp #b /> `, encapsulation: ViewEncapsulation.None, }) class TestComponent { @ViewChild('a', {read: ExpandableComponent}) a!: ExpandableComponent; @ViewChild('b', {read: ExpandableComponent}) b!: ExpandableComponent; } TestBed.configureTestingModule({animationsEnabled: true}); const fixture = TestBed.createComponent(TestComponent); const cmp = fixture.componentInstance; fixture.detectChanges(); cmp.a.id = 'A'; cmp.b.id = 'B'; // Initially only A is open. cmp.a.open.set(true); fixture.detectChanges(); tickAnimationFrames(1); const panels = () => Array.from(fixture.nativeElement.querySelectorAll('.panel')); const panelByText = (text: string) => panels().find((el) => (el as HTMLElement).textContent?.includes(text)) as | HTMLElement | undefined; expect(panels().length).toBe(1); expect(panelByText('Panel A')).toBeTruthy(); const leavingPanelA = panelByText('Panel A')!; // Open B (and close A) in the same change-detection tick. A and B are two // separate instances of the same component, so their panels share a `TNode` // but live in different DOM parents (their respective hosts). cmp.a.open.set(false); cmp.b.open.set(true); fixture.detectChanges(); tickAnimationFrames(1); // B's panel should have entered. expect(panelByText('Panel B')).toBeTruthy(); // A's panel must NOT have been removed synchronously — its leave animation // should still be running, so the element stays connected to the DOM. expect(leavingPanelA.isConnected).toBe(true); expect(panelByText('Panel A')).toBeTruthy(); // Once A's leave animation completes, it is removed as usual. leavingPanelA.dispatchEvent(new AnimationEvent('animationend', {animationName: 'shrink'})); tickAnimationFrames(1); fixture.detectChanges(); expect(leavingPanelA.isConnected).toBe(false); expect(panelByText('Panel A')).toBeUndefined(); expect(panelByText('Panel B')).toBeTruthy(); })); }); describe('infinite animations', () => { const styles = ` .infinite-anim { animation: infinite-anim 1s infinite; } @keyframes infinite-anim { from { opacity: 0; } to { opacity: 1; } } `; it('should ignore infinite animations during animate.leave and remove element immediately', fakeAsync(() => { @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'test-cmp', styles: styles, template: '<div>@if (show()) {<p animate.leave="infinite-anim" #el>I should leave</p>}</div>', encapsulation: ViewEncapsulation.None, }) class TestComponent { show = signal(true); @ViewChild('el', {read: ElementRef}) el!: ElementRef<HTMLParagraphElement>; } TestBed.configureTestingModule({animationsEnabled: true}); const fixture = TestBed.createComponent(TestComponent); const cmp = fixture.componentInstance; fixture.detectChanges(); const paragraph = fixture.debugElement.query(By.css('p')); expect(paragraph).toBeTruthy(); cmp.show.set(false); fixture.detectChanges(); // Simulate a frame. If the animation is ignored, it should be removed already. // If it's NOT ignored, it will wait for the animation (forever). tickAnimationFrames(1); fixture.detectChanges(); // The element should be removed if infinite animations are ignored. expect(cmp.show()).toBeFalsy(); expect(fixture.debugElement.query(By.css('p'))).toBeNull(); })); it('should ignore infinite animations during animate.enter and remove classes immediately', fakeAsync(() => { @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'test-cmp', styles: styles, template: '<div>@if (show()) {<p animate.enter="infinite-anim" #el>I should enter</p>}</div>', encapsulation: ViewEncapsulation.None, }) class TestComponent { show = signal(false); @ViewChild('el', {read: ElementRef}) el!: ElementRef<HTMLParagraphElement>; } TestBed.configureTestingModule({animationsEnabled: true}); const fixture = TestBed.createComponent(TestComponent); const cmp = fixture.componentInstance; fixture.detectChanges(); cmp.show.set(true); fixture.detectChanges(); // Check that class is added initially (maybe?) let paragraph = fixture.debugElement.query(By.css('p')); expect(paragraph.nativeElement.classList.contains('infinite-anim')).toBe(true); // Simulate a frame. If ignored, class should be removed immediately. tickAnimationFrames(1); fixture.detectChanges(); paragraph = fixture.debugElement.query(By.css('p')); expect(paragraph.nativeElement.classList.contains('infinite-anim')).toBe(false); })); }); });