fix(core): update animation scheduling (#64441)

In some rare cases, it seems the animation queue disappears despite being afterEveryRender. This updates the animation scheduler to be afterNextRender instead and only schedules it when we need to.

fixes: #64423

PR Close #64441
This commit is contained in:
Jessica Janiuk 2025-10-15 08:54:18 -07:00
parent 80907b20c1
commit 911d6822cb
13 changed files with 225 additions and 81 deletions

View file

@ -18,27 +18,6 @@ export const ANIMATIONS_DISABLED = new InjectionToken<boolean>(
},
);
export interface AnimationQueue {
queue: Set<Function>;
isScheduled: boolean;
}
/**
* A [DI token](api/core/InjectionToken) for the queue of all animations.
*/
export const ANIMATION_QUEUE = new InjectionToken<AnimationQueue>(
typeof ngDevMode !== 'undefined' && ngDevMode ? 'AnimationQueue' : '',
{
providedIn: 'root',
factory: () => {
return {
queue: new Set(),
isScheduled: false,
};
},
},
);
/**
* The event type for when `animate.enter` and `animate.leave` are used with function
* callbacks.

View file

@ -0,0 +1,79 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {afterNextRender} from '../render3/after_render/hooks';
import {InjectionToken, Injector} from '../di';
import {NodeAnimations} from './interfaces';
export interface AnimationQueue {
queue: Set<Function>;
isScheduled: boolean;
scheduler: Function | null;
}
/**
* A [DI token](api/core/InjectionToken) for the queue of all animations.
*/
export const ANIMATION_QUEUE = new InjectionToken<AnimationQueue>(
typeof ngDevMode !== 'undefined' && ngDevMode ? 'AnimationQueue' : '',
{
providedIn: 'root',
factory: () => {
return {
queue: new Set(),
isScheduled: false,
scheduler: null,
};
},
},
);
export function addToAnimationQueue(injector: Injector, animationFns: Function | Function[]) {
const animationQueue = injector.get(ANIMATION_QUEUE);
if (Array.isArray(animationFns)) {
for (const animateFn of animationFns) {
animationQueue.queue.add(animateFn);
}
} else {
animationQueue.queue.add(animationFns);
}
animationQueue.scheduler && animationQueue.scheduler(injector);
}
export function scheduleAnimationQueue(injector: Injector) {
const animationQueue = injector.get(ANIMATION_QUEUE);
// We only want to schedule the animation queue if it hasn't already been scheduled.
if (!animationQueue.isScheduled) {
afterNextRender(
() => {
animationQueue.isScheduled = false;
for (let animateFn of animationQueue.queue) {
animateFn();
}
animationQueue.queue.clear();
},
{injector},
);
animationQueue.isScheduled = true;
}
}
export function initializeAnimationQueueScheduler(injector: Injector) {
const animationQueue = injector.get(ANIMATION_QUEUE);
animationQueue.scheduler = scheduleAnimationQueue;
animationQueue.scheduler(injector);
}
export function queueEnterAnimations(
injector: Injector,
enterAnimations: Map<number, NodeAnimations>,
) {
for (const [_, nodeAnimations] of enterAnimations) {
addToAnimationQueue(injector, nodeAnimations.animateFns);
}
}

View file

@ -7,13 +7,12 @@
*/
import {
ANIMATION_QUEUE,
AnimationCallbackEvent,
AnimationFunction,
MAX_ANIMATION_TIMEOUT,
} from '../../animation/interfaces';
import {getLView, getCurrentTNode} from '../state';
import {RENDERER, INJECTOR, CONTEXT, LView, ANIMATIONS} from '../interfaces/view';
import {RENDERER, INJECTOR, CONTEXT, LView} from '../interfaces/view';
import {getNativeByTNode} from '../util/view_utils';
import {performanceMarkFeature} from '../../util/performance';
import {Renderer} from '../interfaces/renderer';
@ -21,8 +20,6 @@ import {NgZone} from '../../zone';
import {determineLongestAnimation, allLeavingAnimations} from '../../animation/longest_animation';
import {TNode} from '../interfaces/node';
import {promiseWithResolvers} from '../../util/promise_with_resolvers';
import {Injector} from '../../di';
import {afterEveryRender} from '../after_render/hooks';
import {
addAnimationToLView,
@ -47,6 +44,7 @@ import {
trackEnterClasses,
trackLeavingNodes,
} from '../../animation/utils';
import {initializeAnimationQueueScheduler, queueEnterAnimations} from '../../animation/queue';
/**
* Instruction to handle the `animate.enter` behavior for class bindings.
@ -77,7 +75,11 @@ export function ɵɵanimateEnter(value: string | Function): typeof ɵɵanimateEn
runEnterAnimation(lView, tNode, value),
);
queueEnterAnimations(lView);
initializeAnimationQueueScheduler(lView[INJECTOR]);
// TODO(thePunderWoman): it's unclear why we need to queue animations here, but without this,
// animating through host bindings fails
queueEnterAnimations(lView[INJECTOR], getLViewEnterAnimations(lView));
return ɵɵanimateEnter; // For chaining
}
@ -198,7 +200,11 @@ export function ɵɵanimateEnterListener(value: AnimationFunction): typeof ɵɵa
runEnterAnimationFunction(lView, tNode, value),
);
queueEnterAnimations(lView);
initializeAnimationQueueScheduler(lView[INJECTOR]);
// TODO(thePunderWoman): it's unclear why we need to queue animations here, but without this,
// animating through host bindings fails
queueEnterAnimations(lView[INJECTOR], getLViewEnterAnimations(lView));
return ɵɵanimateEnterListener;
}
@ -244,7 +250,7 @@ export function ɵɵanimateLeave(value: string | Function): typeof ɵɵanimateLe
runLeaveAnimations(lView, tNode, value),
);
enableAnimationQueueScheduler(lView[INJECTOR]);
initializeAnimationQueueScheduler(lView[INJECTOR]);
return ɵɵanimateLeave; // For chaining
}
@ -377,7 +383,7 @@ export function ɵɵanimateLeaveListener(value: AnimationFunction): typeof ɵɵa
runLeaveAnimationFunction(lView, tNode, value),
);
enableAnimationQueueScheduler(lView[INJECTOR]);
initializeAnimationQueueScheduler(lView[INJECTOR]);
return ɵɵanimateLeaveListener; // For chaining
}
@ -465,38 +471,3 @@ function runLeaveAnimationFunction(
// Ensure cleanup if the LView is destroyed before the animation runs.
return {promise, resolve};
}
function queueEnterAnimations(lView: LView) {
enableAnimationQueueScheduler(lView[INJECTOR]);
const enterAnimations = lView[ANIMATIONS]?.enter;
if (enterAnimations) {
const animationQueue = lView[INJECTOR].get(ANIMATION_QUEUE);
for (const [_, nodeAnimations] of enterAnimations) {
for (const animateFn of nodeAnimations.animateFns) {
animationQueue.queue.add(animateFn);
}
}
}
}
function enableAnimationQueueScheduler(injector: Injector) {
const animationQueue = injector.get(ANIMATION_QUEUE);
// We only need to schedule the animation queue runner once per application.
if (!animationQueue.isScheduled) {
afterEveryRender(
() => {
runQueuedAnimations(injector);
},
{injector},
);
animationQueue.isScheduled = true;
}
}
function runQueuedAnimations(injector: Injector) {
const animationQueue = injector.get(ANIMATION_QUEUE);
for (let animateFn of animationQueue.queue) {
animateFn();
}
animationQueue.queue.clear();
}

View file

@ -82,8 +82,8 @@ import {profiler} from './profiler';
import {ProfilerEvent} from './profiler_types';
import {getLViewParent, getNativeByTNode, unwrapRNode} from './util/view_utils';
import {allLeavingAnimations} from '../animation/longest_animation';
import {ANIMATION_QUEUE} from '../animation/interfaces';
import {Injector} from '../di';
import {addToAnimationQueue, queueEnterAnimations} from '../animation/queue';
const enum WalkTNodeTreeAction {
/** node create in the native environment. Run on initial creation. */
@ -110,10 +110,7 @@ function maybeQueueEnterAnimation(
): void {
const enterAnimations = parentLView?.[ANIMATIONS]?.enter;
if (parent !== null && enterAnimations && enterAnimations.has(tNode.index)) {
const animationQueue = injector.get(ANIMATION_QUEUE);
for (const animateFn of enterAnimations.get(tNode.index)!.animateFns) {
animationQueue.queue.add(animateFn);
}
queueEnterAnimations(injector, enterAnimations);
}
}
@ -182,17 +179,6 @@ function applyToElementOrContainer(
}
}
function addToAnimationQueue(injector: Injector, animationFns: Function | Function[]) {
const animationQueue = injector.get(ANIMATION_QUEUE);
if (Array.isArray(animationFns)) {
for (const animateFn of animationFns) {
animationQueue.queue.add(animateFn);
}
} else {
animationQueue.queue.add(animationFns);
}
}
/**
* Removes all DOM elements associated with a view.
*

View file

@ -9,15 +9,19 @@
import {NgFor} from '@angular/common';
import {ViewEncapsulation} from '@angular/compiler';
import {
AfterViewInit,
AnimationCallbackEvent,
ChangeDetectionStrategy,
Component,
computed,
Directive,
ElementRef,
NgModule,
OnDestroy,
provideZonelessChangeDetection,
signal,
ViewChild,
ViewContainerRef,
} from '@angular/core';
import {fakeAsync, TestBed, tick} from '@angular/core/testing';
import {By} from '@angular/platform-browser';
@ -25,6 +29,7 @@ import {isNode} from '@angular/private/testing';
import {tickAnimationFrames} from '../animation_utils/tick_animation_frames';
import {BrowserTestingModule, platformBrowserTesting} from '@angular/platform-browser/testing';
import {NoopAnimationsModule} from '@angular/platform-browser/animations';
import {ComponentRef} from '@angular/core/src/render3';
@NgModule({
providers: [provideZonelessChangeDetection()],
@ -2022,4 +2027,121 @@ describe('Animation', () => {
expect(fixture.debugElement.queryAll(By.css('p')).length).toBe(0);
}));
});
describe('animation queue timing', () => {
it('should run animations with a fresh componentRef after destroy', fakeAsync(() => {
const animateStyles = `
.fade {
animation: fade-out 500ms;
}
@keyframes fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
`;
@Component({
selector: 'app-control-panel',
template: `
@if (step() === 0) {
<p class="not-here" [animate.leave]="'fade-out'">
THIS SHOULD NOT BE HERE
</p>
}
@if (step() === 1) {
<p class="all-there-is">THIS SHOULD BE ALL THERE IS</p>
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
class StepperComponent {
readonly step = signal(0);
}
@Component({
selector: 'app-dynamic',
template: `<ng-container #dynamicComponent></ng-container>`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
class DynamicComponent implements AfterViewInit, OnDestroy {
@ViewChild('dynamicComponent', {read: ViewContainerRef})
dynamicComponent!: ViewContainerRef;
constructor() {
this.componentRef = null;
}
protected componentRef: ComponentRef<StepperComponent> | null;
ngAfterViewInit(): void {
this.componentRef = this.dynamicComponent.createComponent(
StepperComponent,
) as ComponentRef<StepperComponent>;
this.componentRef!.changeDetectorRef.detectChanges();
this.componentRef!.instance.step.set(1);
}
ngOnDestroy(): void {
this.componentRef?.destroy();
}
}
@Component({
selector: 'test-cmp',
imports: [DynamicComponent],
template: `
<div>
@if (show()) {
<app-dynamic/>
}
</div>
`,
encapsulation: ViewEncapsulation.None,
})
class TestComponent {
show = signal(true);
toggleOverlay() {
this.show.update((show) => !show);
}
}
TestBed.configureTestingModule({animationsEnabled: true});
const fixture = TestBed.createComponent(TestComponent);
const cmp = fixture.componentInstance;
fixture.detectChanges();
expect(fixture.debugElement.query(By.css('p.all-there-is'))).not.toBeNull();
expect(fixture.debugElement.query(By.css('p.not-here.fade-out'))).not.toBeNull();
// Finish the leave animation to ensure it is removed
tickAnimationFrames(1);
// verify element is removed post animation
expect(fixture.debugElement.query(By.css('p.not-here'))).toBeNull();
cmp.toggleOverlay();
fixture.detectChanges();
// show is false. Nothing should be present.
expect(fixture.debugElement.query(By.css('p.all-there-is'))).toBeNull();
expect(fixture.debugElement.query(By.css('p.not-here'))).toBeNull();
cmp.toggleOverlay();
fixture.detectChanges();
expect(fixture.debugElement.query(By.css('p.not-here'))).not.toBeNull();
tickAnimationFrames(1);
// show is true. Only one element should be present.
expect(fixture.debugElement.query(By.css('p.all-there-is'))).not.toBeNull();
expect(fixture.debugElement.query(By.css('p.not-here'))).toBeNull();
}));
});
});

View file

@ -48,7 +48,7 @@ import {ComponentFixture, TestBed, TestComponentRenderer} from '../../testing';
import {clearTranslations, loadTranslations} from '@angular/localize';
import {By, DomSanitizer} from '@angular/platform-browser';
import {expect} from '@angular/private/testing/matchers';
import {ANIMATION_QUEUE} from '../../src/animation/interfaces';
import {ANIMATION_QUEUE} from '../../src/animation/queue';
describe('ViewContainerRef', () => {
/**

View file

@ -748,6 +748,7 @@
"providerToFactory",
"providerToRecord",
"publishSignalConfiguration",
"queueEnterAnimations",
"refreshContentQueries",
"refreshView",
"registerFailed",

View file

@ -650,6 +650,7 @@
"providerToFactory",
"providerToRecord",
"publishSignalConfiguration",
"queueEnterAnimations",
"refreshContentQueries",
"refreshView",
"registerHostBindingOpCodes",

View file

@ -870,6 +870,7 @@
"providerToRecord",
"providersResolver",
"publishSignalConfiguration",
"queueEnterAnimations",
"readableStreamLikeToAsyncGenerator",
"refreshContentQueries",
"refreshView",

View file

@ -869,6 +869,7 @@
"providerToRecord",
"providersResolver",
"publishSignalConfiguration",
"queueEnterAnimations",
"readableStreamLikeToAsyncGenerator",
"refreshContentQueries",
"refreshView",

View file

@ -688,6 +688,7 @@
"providerToFactory",
"providerToRecord",
"publishSignalConfiguration",
"queueEnterAnimations",
"readableStreamLikeToAsyncGenerator",
"refreshContentQueries",
"refreshView",

View file

@ -973,6 +973,7 @@
"providerToFactory",
"providerToRecord",
"publishSignalConfiguration",
"queueEnterAnimations",
"readableStreamLikeToAsyncGenerator",
"recognize",
"recognize",

View file

@ -545,6 +545,7 @@
"providerToFactory",
"providerToRecord",
"publishSignalConfiguration",
"queueEnterAnimations",
"refreshContentQueries",
"refreshView",
"registerHostBindingOpCodes",