fix(core): capture animation dependencies eagerly to avoid destroyed injector

Animation runner functions (runEnterAnimation, runLeaveAnimations,
runLeaveAnimationFunction) execute asynchronously from the animation
queue via afterNextRender. By that time the lView injector may have
been destroyed, causing lView[INJECTOR].get(NgZone) to throw NG0205.

Move the NgZone and MAX_ANIMATION_TIMEOUT lookups into the setup
instructions (ɵɵanimateEnter, ɵɵanimateLeave, ɵɵanimateLeaveListener)
which run synchronously during template processing when the injector
is guaranteed to be valid, and pass them through the closures.
This commit is contained in:
Matt Lewis
2026-02-10 18:35:27 +00:00
committed by Andrew Kushnir
parent c6ca1cd725
commit bd2868e915
2 changed files with 94 additions and 7 deletions
@@ -71,8 +71,13 @@ export function ɵɵanimateEnter(value: string | AnimationClassBindingFn): typeo
const tNode = getCurrentTNode()!;
cancelLeavingNodes(tNode, lView);
// Capture NgZone eagerly while the injector is still valid. The animation
// function runs later from the queue, at which point the lView injector
// may have been destroyed.
const ngZone = lView[INJECTOR]!.get(NgZone);
addAnimationToLView(getLViewEnterAnimations(lView), tNode, () =>
runEnterAnimation(lView, tNode, value),
runEnterAnimation(lView, tNode, value, ngZone),
);
initializeAnimationQueueScheduler(lView[INJECTOR]);
@@ -91,13 +96,13 @@ export function runEnterAnimation(
lView: LView,
tNode: TNode,
value: string | AnimationClassBindingFn,
ngZone: NgZone,
): void {
const nativeElement = getNativeByTNode(tNode, lView) as HTMLElement;
ngDevMode && assertElementNodes(nativeElement, 'animate.enter');
const renderer = lView[RENDERER];
const ngZone = lView[INJECTOR]!.get(NgZone);
// Retrieve the actual class list from the value. This will resolve any resolver functions from
// bindings.
@@ -256,8 +261,13 @@ export function ɵɵanimateLeave(value: string | AnimationClassBindingFn): typeo
const tNode = getCurrentTNode()!;
cancelLeavingNodes(tNode, lView);
// Capture NgZone eagerly while the injector is still valid. The animation
// function runs later from the queue, at which point the lView injector
// may have been destroyed.
const ngZone = lView[INJECTOR]!.get(NgZone);
addAnimationToLView(getLViewLeaveAnimations(lView), tNode, () =>
runLeaveAnimations(lView, tNode, value),
runLeaveAnimations(lView, tNode, value, ngZone),
);
initializeAnimationQueueScheduler(lView[INJECTOR]);
@@ -269,6 +279,7 @@ function runLeaveAnimations(
lView: LView,
tNode: TNode,
value: string | AnimationClassBindingFn,
ngZone: NgZone,
): {promise: Promise<void>; resolve: VoidFunction} {
const {promise, resolve} = promiseWithResolvers<void>();
const nativeElement = getNativeByTNode(tNode, lView) as Element;
@@ -276,7 +287,6 @@ function runLeaveAnimations(
ngDevMode && assertElementNodes(nativeElement, 'animate.leave');
const renderer = lView[RENDERER];
const ngZone = lView[INJECTOR].get(NgZone);
allLeavingAnimations.add(lView[ID]);
(getLViewLeaveAnimations(lView).get(tNode.index)!.resolvers ??= []).push(resolve);
@@ -391,8 +401,14 @@ export function ɵɵanimateLeaveListener(value: AnimationFunction): typeof ɵɵa
allLeavingAnimations.add(lView[ID]);
// Capture NgZone and MAX_ANIMATION_TIMEOUT eagerly while the injector is
// still valid. The animation function runs later from the queue, at which
// point the lView injector may have been destroyed.
const ngZone = lView[INJECTOR]!.get(NgZone);
const maxAnimationTimeout = lView[INJECTOR]!.get(MAX_ANIMATION_TIMEOUT);
addAnimationToLView(getLViewLeaveAnimations(lView), tNode, () =>
runLeaveAnimationFunction(lView, tNode, value),
runLeaveAnimationFunction(lView, tNode, value, ngZone, maxAnimationTimeout),
);
initializeAnimationQueueScheduler(lView[INJECTOR]);
@@ -407,6 +423,8 @@ function runLeaveAnimationFunction(
lView: LView,
tNode: TNode,
value: AnimationFunction,
ngZone: NgZone,
maxAnimationTimeout: number,
): {promise: Promise<void>; resolve: VoidFunction} {
const {promise, resolve} = promiseWithResolvers<void>();
const nativeElement = getNativeByTNode(tNode, lView) as Element;
@@ -416,8 +434,6 @@ function runLeaveAnimationFunction(
const cleanupFns: VoidFunction[] = [];
const renderer = lView[RENDERER];
const animationsDisabled = areAnimationsDisabled(lView);
const ngZone = lView[INJECTOR]!.get(NgZone);
const maxAnimationTimeout = lView[INJECTOR]!.get(MAX_ANIMATION_TIMEOUT);
(getLViewLeaveAnimations(lView).get(tNode.index)!.resolvers ??= []).push(resolve);
const resolvers = getLViewLeaveAnimations(lView).get(tNode.index)?.resolvers;
@@ -11,12 +11,17 @@ import {ViewEncapsulation} from '@angular/compiler';
import {
AfterViewInit,
AnimationCallbackEvent,
ApplicationRef,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
computed,
createComponent as createComponentFn,
createEnvironmentInjector,
Directive,
ElementRef,
EnvironmentInjector,
ErrorHandler,
inject,
NgModule,
OnDestroy,
@@ -2299,6 +2304,72 @@ describe('Animation', () => {
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({
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();
}));
});
describe('animation element duplication', () => {