Skip to content

Commit d8ab83c

Browse files
fix(core): run animation queue in environment injector context
In the case that a component injector is destroyed before the animation queue runs, the animation queue would fail to run because it was using a destroyed injector. This commit changes the animation queue to run in the context of the EnvironmentInjector, which is not destroyed until the app is destroyed. fixes: #65628
1 parent f35b2ef commit d8ab83c

File tree

2 files changed

+139
-2
lines changed

2 files changed

+139
-2
lines changed

packages/core/src/animation/queue.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,14 @@
77
*/
88

99
import {afterNextRender} from '../render3/after_render/hooks';
10-
import {InjectionToken, Injector} from '../di';
10+
import {InjectionToken, EnvironmentInjector, Injector, inject} from '../di';
1111
import {AnimationLViewData, EnterNodeAnimations} from './interfaces';
1212

1313
export interface AnimationQueue {
1414
queue: Set<VoidFunction>;
1515
isScheduled: boolean;
1616
scheduler: typeof initializeAnimationQueueScheduler | null;
17+
injector: EnvironmentInjector;
1718
}
1819

1920
/**
@@ -27,6 +28,7 @@ export const ANIMATION_QUEUE = new InjectionToken<AnimationQueue>(
2728
queue: new Set(),
2829
isScheduled: false,
2930
scheduler: null,
31+
injector: inject(EnvironmentInjector), // should be the root injector
3032
};
3133
},
3234
},
@@ -78,7 +80,7 @@ export function scheduleAnimationQueue(injector: Injector) {
7880
}
7981
animationQueue.queue.clear();
8082
},
81-
{injector},
83+
{injector: animationQueue.injector},
8284
);
8385
animationQueue.isScheduled = true;
8486
}

packages/core/test/acceptance/authoring/signal_inputs_spec.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,19 @@
88

99
import {
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';
2732
import {By} from '@angular/platform-browser';
2833
import {tickAnimationFrames} from '../../animation_utils/tick_animation_frames';
2934
import {isNode} from '@angular/private/testing';
35+
import {Subscription} from 'rxjs';
3036

3137
describe('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

Comments
 (0)