88
99import {
1010 Component ,
11+ ComponentRef ,
1112 computed ,
1213 Directive ,
1314 effect ,
1415 EventEmitter ,
1516 inject ,
1617 input ,
18+ OnChanges ,
19+ OnDestroy ,
20+ output ,
1721 Output ,
1822 signal ,
23+ SimpleChanges ,
1924 TemplateRef ,
2025 viewChild ,
2126 ViewChild ,
@@ -27,6 +32,7 @@ import {ViewEncapsulation} from '@angular/compiler';
2732import { By } from '@angular/platform-browser' ;
2833import { tickAnimationFrames } from '../../animation_utils/tick_animation_frames' ;
2934import { isNode } from '@angular/private/testing' ;
35+ import { Subscription } from 'rxjs' ;
3036
3137describe ( 'signal inputs' , ( ) => {
3238 beforeEach ( ( ) =>
@@ -519,5 +525,134 @@ describe('signal inputs', () => {
519525
520526 expect ( fixture . debugElement . query ( By . css ( 'app-content' ) ) ) . toBeNull ( ) ;
521527 } ) ) ;
528+
529+ it ( 'should run animations using the root injector so that the animation queue still runs when the component is destroyed before afterNextRender occurs' , fakeAsync ( ( ) => {
530+ const animateStyles = `
531+ .fade-out {
532+ animation: fade-out 100ms;
533+ }
534+ @keyframes fade-out {
535+ from {
536+ opacity: 1;
537+ }
538+ to {
539+ opacity: 0;
540+ }
541+ }
542+ ` ;
543+
544+ @Component ( {
545+ selector : 'notification' ,
546+ styles : animateStyles ,
547+ template : ` <div (click)="close()">{{ item?.id }}</div> ` ,
548+ host : {
549+ 'animate.leave' : 'fade-out' ,
550+ } ,
551+ encapsulation : ViewEncapsulation . None ,
552+ } )
553+ class Notification {
554+ public item : any ;
555+ public closed : EventEmitter < any > = new EventEmitter < any > ( ) ;
556+ public close ( ) : void {
557+ this . closed . emit ( this . item ?. id ) ;
558+ }
559+ }
560+
561+ @Directive ( { selector : '[messageRenderer]' } )
562+ class MessageRendererDirective implements OnChanges , OnDestroy {
563+ protected componentRef : ComponentRef < Notification > | null = null ;
564+ protected closedSubscription : Subscription | undefined | null ;
565+
566+ notification = input . required < any > ( { alias : 'messageRenderer' } ) ;
567+ closed = output < string > ( ) ;
568+
569+ protected get component ( ) : any | null {
570+ if ( ! this . componentRef ) {
571+ return null ;
572+ }
573+ return this . componentRef . instance ;
574+ }
575+
576+ constructor ( protected viewContainerRef : ViewContainerRef ) { }
577+
578+ public ngOnChanges ( changes : SimpleChanges ) : void {
579+ // Clean up old subscription before clearing view and creating new component
580+
581+ this . closedSubscription ?. unsubscribe ( ) ;
582+ this . closedSubscription = null ; // Ensure it's nullified
583+ this . viewContainerRef . clear ( ) ;
584+
585+ if ( 'notification' in changes && changes [ 'notification' ] . currentValue ) {
586+ const injector = this . viewContainerRef . injector ;
587+ this . componentRef = this . viewContainerRef . createComponent ( Notification , {
588+ injector,
589+ } ) ;
590+
591+ // Assign input immediately after creation
592+ if ( this . component ) {
593+ this . component . item = this . notification ( ) ;
594+ }
595+
596+ // Manually trigger change detection for the newly created component
597+ // to process its inputs and render its initial state.
598+ this . componentRef . changeDetectorRef . detectChanges ( ) ;
599+ this . closedSubscription = this . component ?. closed . subscribe ( this . closedEmit ) ;
600+ }
601+ }
602+
603+ public ngOnDestroy ( ) : void {
604+ this . closedSubscription ?. unsubscribe ( ) ;
605+ this . closedSubscription = null ;
606+ this . componentRef ?. destroy ( ) ; // Explicitly destroy the dynamically created component
607+ }
608+
609+ protected closedEmit = ( id : string ) : void => {
610+ this . closed . emit ( id ) ;
611+ } ;
612+ }
613+
614+ @Component ( {
615+ template : `
616+ @for(itm of list(); track itm) {
617+ <ng-template [messageRenderer]="itm" (closed)="removeItem($event)" />
618+ }
619+ ` ,
620+ imports : [ MessageRendererDirective ] ,
621+ } )
622+ class TestComponent {
623+ list = signal ( [ { id : '1' } ] ) ;
624+
625+ removeItem ( id : string ) {
626+ this . list . update ( ( l ) => [ ...l . filter ( ( i ) => i . id !== id ) ] ) ;
627+ }
628+ }
629+
630+ TestBed . configureTestingModule ( { animationsEnabled : true } ) ;
631+ const fixture = TestBed . createComponent ( TestComponent ) ;
632+ fixture . detectChanges ( ) ;
633+
634+ const notification = fixture . nativeElement . querySelector ( 'notification' ) ;
635+
636+ expect ( notification ) . not . toBeNull ( ) ;
637+ expect ( notification . classList . contains ( 'fade-out' ) ) . toBe ( false ) ; // Initially no fade-out class
638+
639+ // remove the item from the list
640+ fixture . componentInstance . removeItem ( fixture . componentInstance . list ( ) [ 0 ] . id ) ;
641+ fixture . detectChanges ( ) ; // Detect changes for TestComponent to trigger leave animation
642+ tickAnimationFrames ( 1 ) ; // Allow animation to start (will add 'fade-out' class)
643+
644+ const fadingOut = fixture . nativeElement . querySelector ( 'notification' ) ;
645+
646+ expect ( fadingOut ) . not . toBeNull ( ) ;
647+ expect ( fadingOut . classList . contains ( 'fade-out' ) ) . toBe ( true ) ;
648+
649+ // Trigger animation end to remove the element
650+ notification . dispatchEvent (
651+ new AnimationEvent ( 'animationend' , { animationName : 'fade-out' , bubbles : true } ) ,
652+ ) ;
653+ tick ( 300 ) ; // Advance timers by animation duration (0.5s)
654+ fixture . detectChanges ( ) ; // Detect changes after animation completes and element is removed
655+ expect ( fixture . nativeElement . querySelector ( 'notification' ) ) . toBeNull ( ) ; // Verify element is removed
656+ } ) ) ;
522657 } ) ;
523658} ) ;
0 commit comments