From bd2868e915e78fb60583c00a11c778e3abf3ed8d Mon Sep 17 00:00:00 2001 From: Matt Lewis Date: Tue, 10 Feb 2026 18:35:27 +0000 Subject: [PATCH] fix(core): capture animation dependencies eagerly to avoid destroyed injector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/render3/instructions/animation.ts | 30 ++++++-- .../core/test/acceptance/animation_spec.ts | 71 +++++++++++++++++++ 2 files changed, 94 insertions(+), 7 deletions(-) diff --git a/packages/core/src/render3/instructions/animation.ts b/packages/core/src/render3/instructions/animation.ts index a85677095ca..2e9846c6d4d 100644 --- a/packages/core/src/render3/instructions/animation.ts +++ b/packages/core/src/render3/instructions/animation.ts @@ -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; resolve: VoidFunction} { const {promise, resolve} = promiseWithResolvers(); 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; resolve: VoidFunction} { const {promise, resolve} = promiseWithResolvers(); 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; diff --git a/packages/core/test/acceptance/animation_spec.ts b/packages/core/test/acceptance/animation_spec.ts index 92d25836d56..d62964deb4b 100644 --- a/packages/core/test/acceptance/animation_spec.ts +++ b/packages/core/test/acceptance/animation_spec.ts @@ -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()) { +
Item
+ } + `, + 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', () => {