Skip to content

Commit bd2868e

Browse files
mattlewis92AndrewKushnir
authored andcommitted
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.
1 parent c6ca1cd commit bd2868e

File tree

2 files changed

+94
-7
lines changed

2 files changed

+94
-7
lines changed

packages/core/src/render3/instructions/animation.ts

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,13 @@ export function ɵɵanimateEnter(value: string | AnimationClassBindingFn): typeo
7171
const tNode = getCurrentTNode()!;
7272
cancelLeavingNodes(tNode, lView);
7373

74+
// Capture NgZone eagerly while the injector is still valid. The animation
75+
// function runs later from the queue, at which point the lView injector
76+
// may have been destroyed.
77+
const ngZone = lView[INJECTOR]!.get(NgZone);
78+
7479
addAnimationToLView(getLViewEnterAnimations(lView), tNode, () =>
75-
runEnterAnimation(lView, tNode, value),
80+
runEnterAnimation(lView, tNode, value, ngZone),
7681
);
7782

7883
initializeAnimationQueueScheduler(lView[INJECTOR]);
@@ -91,13 +96,13 @@ export function runEnterAnimation(
9196
lView: LView,
9297
tNode: TNode,
9398
value: string | AnimationClassBindingFn,
99+
ngZone: NgZone,
94100
): void {
95101
const nativeElement = getNativeByTNode(tNode, lView) as HTMLElement;
96102

97103
ngDevMode && assertElementNodes(nativeElement, 'animate.enter');
98104

99105
const renderer = lView[RENDERER];
100-
const ngZone = lView[INJECTOR]!.get(NgZone);
101106

102107
// Retrieve the actual class list from the value. This will resolve any resolver functions from
103108
// bindings.
@@ -256,8 +261,13 @@ export function ɵɵanimateLeave(value: string | AnimationClassBindingFn): typeo
256261
const tNode = getCurrentTNode()!;
257262
cancelLeavingNodes(tNode, lView);
258263

264+
// Capture NgZone eagerly while the injector is still valid. The animation
265+
// function runs later from the queue, at which point the lView injector
266+
// may have been destroyed.
267+
const ngZone = lView[INJECTOR]!.get(NgZone);
268+
259269
addAnimationToLView(getLViewLeaveAnimations(lView), tNode, () =>
260-
runLeaveAnimations(lView, tNode, value),
270+
runLeaveAnimations(lView, tNode, value, ngZone),
261271
);
262272

263273
initializeAnimationQueueScheduler(lView[INJECTOR]);
@@ -269,14 +279,14 @@ function runLeaveAnimations(
269279
lView: LView,
270280
tNode: TNode,
271281
value: string | AnimationClassBindingFn,
282+
ngZone: NgZone,
272283
): {promise: Promise<void>; resolve: VoidFunction} {
273284
const {promise, resolve} = promiseWithResolvers<void>();
274285
const nativeElement = getNativeByTNode(tNode, lView) as Element;
275286

276287
ngDevMode && assertElementNodes(nativeElement, 'animate.leave');
277288

278289
const renderer = lView[RENDERER];
279-
const ngZone = lView[INJECTOR].get(NgZone);
280290
allLeavingAnimations.add(lView[ID]);
281291
(getLViewLeaveAnimations(lView).get(tNode.index)!.resolvers ??= []).push(resolve);
282292

@@ -391,8 +401,14 @@ export function ɵɵanimateLeaveListener(value: AnimationFunction): typeof ɵɵa
391401

392402
allLeavingAnimations.add(lView[ID]);
393403

404+
// Capture NgZone and MAX_ANIMATION_TIMEOUT eagerly while the injector is
405+
// still valid. The animation function runs later from the queue, at which
406+
// point the lView injector may have been destroyed.
407+
const ngZone = lView[INJECTOR]!.get(NgZone);
408+
const maxAnimationTimeout = lView[INJECTOR]!.get(MAX_ANIMATION_TIMEOUT);
409+
394410
addAnimationToLView(getLViewLeaveAnimations(lView), tNode, () =>
395-
runLeaveAnimationFunction(lView, tNode, value),
411+
runLeaveAnimationFunction(lView, tNode, value, ngZone, maxAnimationTimeout),
396412
);
397413

398414
initializeAnimationQueueScheduler(lView[INJECTOR]);
@@ -407,6 +423,8 @@ function runLeaveAnimationFunction(
407423
lView: LView,
408424
tNode: TNode,
409425
value: AnimationFunction,
426+
ngZone: NgZone,
427+
maxAnimationTimeout: number,
410428
): {promise: Promise<void>; resolve: VoidFunction} {
411429
const {promise, resolve} = promiseWithResolvers<void>();
412430
const nativeElement = getNativeByTNode(tNode, lView) as Element;
@@ -416,8 +434,6 @@ function runLeaveAnimationFunction(
416434
const cleanupFns: VoidFunction[] = [];
417435
const renderer = lView[RENDERER];
418436
const animationsDisabled = areAnimationsDisabled(lView);
419-
const ngZone = lView[INJECTOR]!.get(NgZone);
420-
const maxAnimationTimeout = lView[INJECTOR]!.get(MAX_ANIMATION_TIMEOUT);
421437

422438
(getLViewLeaveAnimations(lView).get(tNode.index)!.resolvers ??= []).push(resolve);
423439
const resolvers = getLViewLeaveAnimations(lView).get(tNode.index)?.resolvers;

packages/core/test/acceptance/animation_spec.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,17 @@ import {ViewEncapsulation} from '@angular/compiler';
1111
import {
1212
AfterViewInit,
1313
AnimationCallbackEvent,
14+
ApplicationRef,
1415
ChangeDetectionStrategy,
1516
ChangeDetectorRef,
1617
Component,
1718
computed,
19+
createComponent as createComponentFn,
20+
createEnvironmentInjector,
1821
Directive,
1922
ElementRef,
23+
EnvironmentInjector,
24+
ErrorHandler,
2025
inject,
2126
NgModule,
2227
OnDestroy,
@@ -2299,6 +2304,72 @@ describe('Animation', () => {
22992304
expect(fixture.debugElement.query(By.css('p.all-there-is'))).not.toBeNull();
23002305
expect(fixture.debugElement.query(By.css('p.not-here'))).toBeNull();
23012306
}));
2307+
2308+
it('should not throw INJECTOR_ALREADY_DESTROYED when lView injector is destroyed before animation queue runs', fakeAsync(() => {
2309+
const animateStyles = `
2310+
.fade-out {
2311+
animation: fade-out 100ms;
2312+
}
2313+
@keyframes fade-out {
2314+
from {
2315+
opacity: 1;
2316+
}
2317+
to {
2318+
opacity: 0;
2319+
}
2320+
}
2321+
`;
2322+
2323+
@Component({
2324+
selector: 'animated-child',
2325+
template: `
2326+
@if (show()) {
2327+
<div class="item" animate.leave="fade-out">Item</div>
2328+
}
2329+
`,
2330+
styles: [animateStyles],
2331+
encapsulation: ViewEncapsulation.None,
2332+
})
2333+
class AnimatedChild {
2334+
show = signal(true);
2335+
}
2336+
2337+
TestBed.configureTestingModule({animationsEnabled: true});
2338+
const rootEnvInjector = TestBed.inject(EnvironmentInjector);
2339+
const childEnvInjector = createEnvironmentInjector([], rootEnvInjector);
2340+
const appRef = TestBed.inject(ApplicationRef);
2341+
const errorHandler = TestBed.inject(ErrorHandler);
2342+
spyOn(errorHandler, 'handleError');
2343+
2344+
const hostEl = document.createElement('animated-child');
2345+
const compRef = createComponentFn(AnimatedChild, {
2346+
environmentInjector: childEnvInjector,
2347+
hostElement: hostEl,
2348+
});
2349+
appRef.attachView(compRef.hostView);
2350+
appRef.tick();
2351+
tickAnimationFrames(1);
2352+
2353+
expect(hostEl.querySelector('.item')).not.toBeNull();
2354+
2355+
// Trigger leave animation via local detectChanges (queues animation
2356+
// without flushing the queue - afterNextRender only runs during tick)
2357+
compRef.instance.show.set(false);
2358+
compRef.changeDetectorRef.detectChanges();
2359+
2360+
// Destroy the child injector before the animation queue flushes.
2361+
// This simulates what happens when a component's lView injector is
2362+
// destroyed while leave animations are pending.
2363+
childEnvInjector.destroy();
2364+
2365+
// Tick to flush the animation queue. Without the fix, the animation
2366+
// function would call lView[INJECTOR].get(NgZone) which delegates to
2367+
// the destroyed childEnvInjector, throwing NG0205.
2368+
appRef.tick();
2369+
tickAnimationFrames(1);
2370+
2371+
expect(errorHandler.handleError).not.toHaveBeenCalled();
2372+
}));
23022373
});
23032374

23042375
describe('animation element duplication', () => {

0 commit comments

Comments
 (0)