mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
feat(core): add support for nested animations
Now if there's a leave animation nested within a template, those will animate before the parent element is removed. This makes animating leaving elements a bit easier than before and adds a lot more flexibility to how animations can be structured. fixes: #66476
This commit is contained in:
committed by
Andrew Kushnir
parent
24c0c5a180
commit
ea2016a6dc
@@ -24,11 +24,14 @@ export const ANIMATION_QUEUE = new InjectionToken<AnimationQueue>(
|
||||
typeof ngDevMode !== 'undefined' && ngDevMode ? 'AnimationQueue' : '',
|
||||
{
|
||||
factory: () => {
|
||||
const injector = inject(EnvironmentInjector);
|
||||
const queue = new Set<VoidFunction>();
|
||||
injector.onDestroy(() => queue.clear());
|
||||
return {
|
||||
queue: new Set(),
|
||||
queue,
|
||||
isScheduled: false,
|
||||
scheduler: null,
|
||||
injector: inject(EnvironmentInjector), // should be the root injector
|
||||
injector,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* @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 {
|
||||
RunLeaveAnimationFn,
|
||||
LeaveNodeAnimations,
|
||||
AnimationLViewData,
|
||||
} from '../animation/interfaces';
|
||||
import {allLeavingAnimations} from '../animation/longest_animation';
|
||||
import {
|
||||
queueEnterAnimations,
|
||||
addToAnimationQueue,
|
||||
removeAnimationsFromQueue,
|
||||
} from '../animation/queue';
|
||||
import {Injector, INJECTOR} from '../di';
|
||||
import {CONTAINER_HEADER_OFFSET} from './interfaces/container';
|
||||
import {TNode, TNodeType} from './interfaces/node';
|
||||
import {RElement} from './interfaces/renderer_dom';
|
||||
import {isComponentHost, isLContainer} from './interfaces/type_checks';
|
||||
import {ANIMATIONS, ID, LView, TVIEW} from './interfaces/view';
|
||||
import {getComponentLViewByIndex} from './util/view_utils';
|
||||
|
||||
export function maybeQueueEnterAnimation(
|
||||
parentLView: LView | undefined,
|
||||
parent: RElement | null,
|
||||
tNode: TNode,
|
||||
injector: Injector,
|
||||
): void {
|
||||
const enterAnimations = parentLView?.[ANIMATIONS]?.enter;
|
||||
if (parent !== null && enterAnimations && enterAnimations.has(tNode.index)) {
|
||||
queueEnterAnimations(injector, enterAnimations);
|
||||
}
|
||||
}
|
||||
|
||||
export function runLeaveAnimationsWithCallback(
|
||||
lView: LView | undefined,
|
||||
tNode: TNode,
|
||||
injector: Injector,
|
||||
callback: Function,
|
||||
) {
|
||||
// It's possible that the AppRef has been destroyed, which would also destroy
|
||||
// the injector tree. If this happens, we will get an error when we try to
|
||||
// get the injector, so we catch it here and avoid the error and return
|
||||
// safely.
|
||||
try {
|
||||
injector.get(INJECTOR);
|
||||
} catch {
|
||||
return callback(false);
|
||||
}
|
||||
|
||||
const animations = lView?.[ANIMATIONS];
|
||||
|
||||
// regarding the TNode index to see if it is the same element.
|
||||
if (animations?.enter?.has(tNode.index)) {
|
||||
removeAnimationsFromQueue(injector, animations.enter.get(tNode.index)!.animateFns);
|
||||
}
|
||||
|
||||
// get all nodes in the current view that are descendants of tNode and have leave animations
|
||||
const nodesWithExitAnimations = aggregateDescendantAnimations(lView, tNode, animations);
|
||||
|
||||
if (nodesWithExitAnimations.size === 0) {
|
||||
let hasNestedAnimations = false;
|
||||
if (lView) {
|
||||
const nestedPromises: Promise<unknown>[] = [];
|
||||
collectNestedViewAnimations(lView, tNode, nestedPromises);
|
||||
hasNestedAnimations = nestedPromises.length > 0;
|
||||
}
|
||||
|
||||
if (!hasNestedAnimations) {
|
||||
return callback(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (lView) allLeavingAnimations.add(lView[ID]);
|
||||
|
||||
addToAnimationQueue(
|
||||
injector,
|
||||
() =>
|
||||
executeLeaveAnimations(
|
||||
lView,
|
||||
tNode,
|
||||
animations || undefined,
|
||||
nodesWithExitAnimations,
|
||||
callback,
|
||||
),
|
||||
animations || undefined,
|
||||
);
|
||||
}
|
||||
|
||||
// Identifies all elements that are descendants of `tNode` *within the same component view*
|
||||
// (LView) and have active leave animations. Since `tNode` is being removed, its descendants
|
||||
// will also be removed. We must execute their leave animations and wait for them to finish
|
||||
// before physically removing `tNode` from the DOM.
|
||||
//
|
||||
// Instead of performing a potentially expensive downward traversal of the
|
||||
// entire `tNode` subtree to find animated descendants, we iterate over the `leaveAnimations`
|
||||
// map. This map contains all pending leave animations in the current LView and is typically
|
||||
// very small.
|
||||
//
|
||||
// Note: Animations across LView boundaries (e.g., in child components or embedded views)
|
||||
// are collected separately via `collectNestedViewAnimations`.
|
||||
function aggregateDescendantAnimations(
|
||||
lView: LView | undefined,
|
||||
tNode: TNode,
|
||||
animations: AnimationLViewData | null | undefined,
|
||||
): Map<number, LeaveNodeAnimations> {
|
||||
const nodesWithExitAnimations = new Map<number, LeaveNodeAnimations>();
|
||||
const leaveAnimations = animations?.leave;
|
||||
|
||||
if (leaveAnimations && leaveAnimations.has(tNode.index)) {
|
||||
nodesWithExitAnimations.set(tNode.index, leaveAnimations.get(tNode.index)!);
|
||||
}
|
||||
|
||||
if (lView && leaveAnimations) {
|
||||
for (const [index, animationData] of leaveAnimations) {
|
||||
if (nodesWithExitAnimations.has(index)) continue;
|
||||
|
||||
// Get the tNode for the animation. This node might be a descendant of the tNode we are removing.
|
||||
// If so, we need to run its leave animation as well.
|
||||
const nestedTNode = lView[TVIEW].data[index] as TNode;
|
||||
let parent = nestedTNode.parent;
|
||||
|
||||
// Traverse upward to check if `tNode` is an ancestor of `nestedTNode`
|
||||
// For each animation in the map, we retrieve its corresponding TNode (`nestedTNode`) and
|
||||
// traverse UP the tree using parent pointers. If we encounter `tNode` during this upward
|
||||
// traversal, we know the animated element is a descendant, and we add its animation data
|
||||
// to `nodesWithExitAnimations`.
|
||||
while (parent) {
|
||||
if (parent === tNode) {
|
||||
nodesWithExitAnimations.set(index, animationData);
|
||||
break;
|
||||
}
|
||||
parent = parent.parent;
|
||||
}
|
||||
}
|
||||
}
|
||||
return nodesWithExitAnimations;
|
||||
}
|
||||
|
||||
function executeLeaveAnimations(
|
||||
lView: LView | undefined,
|
||||
tNode: TNode,
|
||||
animations: AnimationLViewData | undefined,
|
||||
nodesWithExitAnimations: Map<number, LeaveNodeAnimations>,
|
||||
callback: Function,
|
||||
) {
|
||||
// it's possible that in the time between when the leave animation was
|
||||
// and the time it was executed, the data structure changed. So we need
|
||||
// to be safe here.
|
||||
const runningAnimations: Promise<unknown>[] = [];
|
||||
|
||||
if (animations && animations.leave) {
|
||||
for (const [index] of nodesWithExitAnimations) {
|
||||
if (!animations.leave.has(index)) continue;
|
||||
|
||||
const currentAnimationData = animations.leave.get(index)!;
|
||||
for (const animationFn of currentAnimationData.animateFns) {
|
||||
const {promise} = animationFn() as ReturnType<RunLeaveAnimationFn>;
|
||||
runningAnimations.push(promise);
|
||||
}
|
||||
animations.detachedLeaveAnimationFns = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Also add nested view animations
|
||||
if (lView) {
|
||||
collectNestedViewAnimations(lView, tNode, runningAnimations);
|
||||
}
|
||||
|
||||
if (runningAnimations.length > 0) {
|
||||
const currentAnimations = animations || lView?.[ANIMATIONS];
|
||||
if (currentAnimations) {
|
||||
const prevRunning = currentAnimations.running;
|
||||
if (prevRunning) {
|
||||
runningAnimations.push(prevRunning);
|
||||
}
|
||||
currentAnimations.running = Promise.allSettled(runningAnimations);
|
||||
runAfterLeaveAnimations(lView!, currentAnimations.running, callback);
|
||||
} else {
|
||||
Promise.allSettled(runningAnimations).then(() => {
|
||||
if (lView) allLeavingAnimations.delete(lView[ID]);
|
||||
callback(true);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (lView) allLeavingAnimations.delete(lView[ID]);
|
||||
callback(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects leave animations from nested views (components and containers)
|
||||
* starting from the given TNode's children.
|
||||
*/
|
||||
function collectNestedViewAnimations(
|
||||
lView: LView,
|
||||
tNode: TNode,
|
||||
collectedPromises: Promise<unknown>[],
|
||||
) {
|
||||
if (isComponentHost(tNode)) {
|
||||
const componentView = getComponentLViewByIndex(tNode.index, lView);
|
||||
collectAllViewLeaveAnimations(componentView, collectedPromises);
|
||||
} else if (tNode.type & TNodeType.AnyContainer) {
|
||||
const lContainer = lView[tNode.index];
|
||||
if (isLContainer(lContainer)) {
|
||||
for (let i = CONTAINER_HEADER_OFFSET; i < lContainer.length; i++) {
|
||||
const subView = lContainer[i] as LView;
|
||||
collectAllViewLeaveAnimations(subView, collectedPromises);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let child = tNode.child;
|
||||
while (child) {
|
||||
collectNestedViewAnimations(lView, child, collectedPromises);
|
||||
child = child.next;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively collects all leave animations from a view and its children.
|
||||
*/
|
||||
function collectAllViewLeaveAnimations(view: LView, collectedPromises: Promise<unknown>[]) {
|
||||
const animations = view[ANIMATIONS];
|
||||
if (animations && animations.leave) {
|
||||
for (const animationData of animations.leave.values()) {
|
||||
for (const animationFn of animationData.animateFns) {
|
||||
// We interpret the animation function to get the promise
|
||||
const {promise} = animationFn() as ReturnType<RunLeaveAnimationFn>;
|
||||
collectedPromises.push(promise);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let child = view[TVIEW].firstChild;
|
||||
while (child) {
|
||||
collectNestedViewAnimations(view, child, collectedPromises);
|
||||
child = child.next;
|
||||
}
|
||||
}
|
||||
|
||||
function runAfterLeaveAnimations(
|
||||
lView: LView,
|
||||
runningAnimations: Promise<unknown>,
|
||||
callback: Function,
|
||||
) {
|
||||
runningAnimations.then(() => {
|
||||
// We only want to clear the running flag and the allLeavingAnimations set if
|
||||
// the current running animation is the same as the one we just waited for.
|
||||
// If it's different, it means another animation started while we were waiting,
|
||||
// and that other animation is now responsible for clearing the flag.
|
||||
if (lView[ANIMATIONS]?.running === runningAnimations) {
|
||||
lView[ANIMATIONS]!.running = undefined;
|
||||
allLeavingAnimations.delete(lView[ID]);
|
||||
}
|
||||
callback(true);
|
||||
});
|
||||
}
|
||||
@@ -82,14 +82,8 @@ import {assertTNodeType} from './node_assert';
|
||||
import {profiler} from './profiler';
|
||||
import {ProfilerEvent} from '../../primitives/devtools';
|
||||
import {getLViewParent, getNativeByTNode, unwrapRNode} from './util/view_utils';
|
||||
import {allLeavingAnimations} from '../animation/longest_animation';
|
||||
import {Injector} from '../di';
|
||||
import {
|
||||
addToAnimationQueue,
|
||||
queueEnterAnimations,
|
||||
removeAnimationsFromQueue,
|
||||
} from '../animation/queue';
|
||||
import {RunLeaveAnimationFn} from '../animation/interfaces';
|
||||
import {maybeQueueEnterAnimation, runLeaveAnimationsWithCallback} from './node_animations';
|
||||
|
||||
const enum WalkTNodeTreeAction {
|
||||
/** node create in the native environment. Run on initial creation. */
|
||||
@@ -108,18 +102,6 @@ const enum WalkTNodeTreeAction {
|
||||
Destroy = 3,
|
||||
}
|
||||
|
||||
function maybeQueueEnterAnimation(
|
||||
parentLView: LView | undefined,
|
||||
parent: RElement | null,
|
||||
tNode: TNode,
|
||||
injector: Injector,
|
||||
): void {
|
||||
const enterAnimations = parentLView?.[ANIMATIONS]?.enter;
|
||||
if (parent !== null && enterAnimations && enterAnimations.has(tNode.index)) {
|
||||
queueEnterAnimations(injector, enterAnimations);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NOTE: for performance reasons, the possible actions are inlined within the function instead of
|
||||
* being passed as an argument.
|
||||
@@ -382,64 +364,6 @@ function cleanUpView(tView: TView, lView: LView): void {
|
||||
}
|
||||
}
|
||||
|
||||
function runLeaveAnimationsWithCallback(
|
||||
lView: LView | undefined,
|
||||
tNode: TNode,
|
||||
injector: Injector,
|
||||
callback: Function,
|
||||
) {
|
||||
const animations = lView?.[ANIMATIONS];
|
||||
if (animations?.enter?.has(tNode.index)) {
|
||||
removeAnimationsFromQueue(injector, animations.enter.get(tNode.index)!.animateFns);
|
||||
}
|
||||
|
||||
if (animations == null || animations.leave == undefined || !animations.leave.has(tNode.index))
|
||||
return callback(false);
|
||||
|
||||
if (lView) allLeavingAnimations.add(lView[ID]);
|
||||
|
||||
addToAnimationQueue(
|
||||
injector,
|
||||
() => {
|
||||
// it's possible that in the time between when the leave animation was
|
||||
// and the time it was executed, the data structure changed. So we need
|
||||
// to be safe here.
|
||||
if (animations.leave && animations.leave.has(tNode.index)) {
|
||||
const leaveAnimationMap = animations.leave;
|
||||
const leaveAnimations = leaveAnimationMap.get(tNode.index);
|
||||
const runningAnimations = [];
|
||||
if (leaveAnimations) {
|
||||
for (let index = 0; index < leaveAnimations.animateFns.length; index++) {
|
||||
const animationFn = leaveAnimations.animateFns[index];
|
||||
const {promise} = animationFn() as ReturnType<RunLeaveAnimationFn>;
|
||||
runningAnimations.push(promise);
|
||||
}
|
||||
animations.detachedLeaveAnimationFns = undefined;
|
||||
}
|
||||
animations.running = Promise.allSettled(runningAnimations);
|
||||
runAfterLeaveAnimations(lView!, callback);
|
||||
} else {
|
||||
if (lView) allLeavingAnimations.delete(lView[ID]);
|
||||
callback(false);
|
||||
}
|
||||
},
|
||||
animations,
|
||||
);
|
||||
}
|
||||
|
||||
function runAfterLeaveAnimations(lView: LView, callback: Function) {
|
||||
const runningAnimations = lView[ANIMATIONS]?.running;
|
||||
if (runningAnimations) {
|
||||
runningAnimations.then(() => {
|
||||
lView[ANIMATIONS]!.running = undefined;
|
||||
allLeavingAnimations.delete(lView[ID]);
|
||||
callback(true);
|
||||
});
|
||||
return;
|
||||
}
|
||||
callback(false);
|
||||
}
|
||||
|
||||
/** Removes listeners and unsubscribes from output subscriptions */
|
||||
function processCleanups(tView: TView, lView: LView): void {
|
||||
ngDevMode && assertNotReactive(processCleanups.name);
|
||||
|
||||
@@ -652,5 +652,71 @@ describe('signal inputs', () => {
|
||||
fixture.detectChanges(); // Detect changes after animation completes and element is removed
|
||||
expect(fixture.nativeElement.querySelector('notification')).toBeNull(); // Verify element is removed
|
||||
}));
|
||||
|
||||
it('should support nested animate.leave with signal inputs via host binding', fakeAsync(() => {
|
||||
const styles = `
|
||||
.fade-out {
|
||||
animation: fade-out 500ms;
|
||||
}
|
||||
@keyframes fade-out {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
`;
|
||||
|
||||
@Component({
|
||||
selector: 'child-cmp',
|
||||
template: '<div class="target">Content</div>',
|
||||
host: {
|
||||
'[animate.leave]': 'animClass()',
|
||||
},
|
||||
})
|
||||
class ChildCmp {
|
||||
animClass = input.required<string>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'test-cmp',
|
||||
styles: [styles],
|
||||
imports: [ChildCmp],
|
||||
template: `
|
||||
@if (show()) {
|
||||
<div class="parent">
|
||||
<child-cmp [animClass]="'fade-out'" class="child-comp" />
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
})
|
||||
class TestCmp {
|
||||
show = signal(true);
|
||||
}
|
||||
|
||||
TestBed.configureTestingModule({animationsEnabled: true});
|
||||
const fixture = TestBed.createComponent(TestCmp);
|
||||
const cmp = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
|
||||
const target = fixture.nativeElement.querySelector('.child-comp');
|
||||
expect(target).toBeTruthy();
|
||||
|
||||
cmp.show.set(false);
|
||||
fixture.detectChanges();
|
||||
tickAnimationFrames(1);
|
||||
|
||||
const targetAfter = fixture.nativeElement.querySelector('.child-comp');
|
||||
expect(targetAfter).withContext('Nested component should persist').not.toBeNull();
|
||||
|
||||
if (targetAfter) {
|
||||
expect(targetAfter.classList.contains('fade-out'))
|
||||
.withContext('Should have animation class from signal input host binding')
|
||||
.toBeTruthy();
|
||||
|
||||
targetAfter.dispatchEvent(new AnimationEvent('animationend', {animationName: 'fade-out'}));
|
||||
tick();
|
||||
|
||||
expect(fixture.nativeElement.querySelector('.child-comp')).toBeNull();
|
||||
}
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* @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 {Component, signal, ViewEncapsulation, OnDestroy, destroyPlatform} from '@angular/core';
|
||||
import {TestBed, fakeAsync, tick} from '@angular/core/testing';
|
||||
import {BrowserTestingModule, platformBrowserTesting} from '@angular/platform-browser/testing';
|
||||
import {NoopAnimationsModule} from '@angular/platform-browser/animations';
|
||||
import {tickAnimationFrames} from '../animation_utils/tick_animation_frames';
|
||||
|
||||
describe('Nested animate.leave', () => {
|
||||
beforeEach(() => {
|
||||
destroyPlatform();
|
||||
TestBed.resetTestEnvironment();
|
||||
TestBed.initTestEnvironment(
|
||||
[BrowserTestingModule, NoopAnimationsModule],
|
||||
platformBrowserTesting(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should wait for nested animations inside @if', fakeAsync(() => {
|
||||
const styles = `
|
||||
.fade {
|
||||
animation: fade-out 500ms;
|
||||
}
|
||||
@keyframes fade-out {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
`;
|
||||
|
||||
@Component({
|
||||
selector: 'test-cmp',
|
||||
template: `
|
||||
<div>
|
||||
@if (show()) {
|
||||
<div class="parent">
|
||||
<div animate.leave="fade" class="child">Child</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [styles],
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
})
|
||||
class TestCmp {
|
||||
show = signal(true);
|
||||
}
|
||||
|
||||
TestBed.configureTestingModule({animationsEnabled: true});
|
||||
const fixture = TestBed.createComponent(TestCmp);
|
||||
const cmp = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
|
||||
const child = fixture.nativeElement.querySelector('.child');
|
||||
expect(child).toBeTruthy();
|
||||
|
||||
cmp.show.set(false);
|
||||
fixture.detectChanges();
|
||||
tickAnimationFrames(1);
|
||||
|
||||
// In current Angular, this likely fails immediately because parent is removed, taking child with it.
|
||||
// We expect child to remain if we support nested animations.
|
||||
const childAfter = fixture.nativeElement.querySelector('.child');
|
||||
|
||||
// This expectation confirms if it works or fails.
|
||||
// If it works, childAfter is NOT null.
|
||||
// If it fails (current behavior), childAfter IS null.
|
||||
|
||||
// We want to prove it fails currently, so we expect rejection or we just assert strict truth.
|
||||
// I will use strict truth to demonstrate failure.
|
||||
expect(childAfter).withContext('Child element should persist during animation').not.toBeNull();
|
||||
if (childAfter) {
|
||||
expect(childAfter.classList.contains('fade'))
|
||||
.withContext('Child should get animation class')
|
||||
.toBeTruthy();
|
||||
|
||||
// Simulate end
|
||||
childAfter.dispatchEvent(new AnimationEvent('animationend', {animationName: 'fade-out'}));
|
||||
tick();
|
||||
}
|
||||
}));
|
||||
|
||||
it('should support nested host bindings', fakeAsync(() => {
|
||||
const styles = `
|
||||
.fade {
|
||||
animation: fade-out 500ms;
|
||||
}
|
||||
@keyframes fade-out {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
`;
|
||||
|
||||
@Component({
|
||||
selector: 'fade-cmp',
|
||||
host: {'animate.leave': 'fade'},
|
||||
template: '<p>I should fade</p>',
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
})
|
||||
class FadeComponent {}
|
||||
|
||||
@Component({
|
||||
selector: 'test-cmp',
|
||||
styles: [styles],
|
||||
imports: [FadeComponent],
|
||||
template: `
|
||||
<div>
|
||||
@if (show()) {
|
||||
<div class="parent">
|
||||
<fade-cmp class="child" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
})
|
||||
class TestCmp {
|
||||
show = signal(true);
|
||||
}
|
||||
|
||||
TestBed.configureTestingModule({animationsEnabled: true});
|
||||
const fixture = TestBed.createComponent(TestCmp);
|
||||
const cmp = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
|
||||
const child = fixture.nativeElement.querySelector('.child');
|
||||
expect(child).toBeTruthy();
|
||||
|
||||
cmp.show.set(false);
|
||||
fixture.detectChanges();
|
||||
tickAnimationFrames(1);
|
||||
|
||||
const childAfter = fixture.nativeElement.querySelector('.child');
|
||||
expect(childAfter).withContext('Child element should persist during animation').not.toBeNull();
|
||||
|
||||
if (childAfter) {
|
||||
expect(childAfter.classList.contains('fade'))
|
||||
.withContext('Child should get animation class from host binding')
|
||||
.toBeTruthy();
|
||||
|
||||
childAfter.dispatchEvent(new AnimationEvent('animationend', {animationName: 'fade-out'}));
|
||||
tick();
|
||||
|
||||
const childFinal = fixture.nativeElement.querySelector('.child');
|
||||
expect(childFinal).toBeNull();
|
||||
}
|
||||
}));
|
||||
|
||||
it('should support nested function syntax', fakeAsync(() => {
|
||||
let completeFn: Function | undefined;
|
||||
@Component({
|
||||
selector: 'test-cmp',
|
||||
template: `
|
||||
<div>
|
||||
@if (show()) {
|
||||
<div class="parent">
|
||||
<div (animate.leave)="animateFn($event)" class="child">Child</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
})
|
||||
class TestCmp {
|
||||
show = signal(true);
|
||||
animateFn = (event: any) => {
|
||||
event.target.classList.add('custom-anim');
|
||||
completeFn = event.animationComplete;
|
||||
};
|
||||
}
|
||||
|
||||
TestBed.configureTestingModule({animationsEnabled: true});
|
||||
const fixture = TestBed.createComponent(TestCmp);
|
||||
const cmp = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
|
||||
cmp.show.set(false);
|
||||
fixture.detectChanges();
|
||||
tickAnimationFrames(1);
|
||||
|
||||
const childAfter = fixture.nativeElement.querySelector('.child');
|
||||
expect(childAfter).withContext('Child element should persist during animation').not.toBeNull();
|
||||
|
||||
if (childAfter) {
|
||||
expect(childAfter.classList.contains('custom-anim'))
|
||||
.withContext('Child should get class from animation function')
|
||||
.toBeTruthy();
|
||||
|
||||
expect(completeFn).withContext('animationComplete should be captured').toBeDefined();
|
||||
completeFn!();
|
||||
|
||||
tick();
|
||||
const childFinal = fixture.nativeElement.querySelector('.child');
|
||||
expect(childFinal).toBeNull();
|
||||
}
|
||||
}));
|
||||
|
||||
it('should support nested animate.leave with component host binding (GitHub #66476)', fakeAsync(() => {
|
||||
// Reproduction from https://github.com/angular/angular/issues/66476
|
||||
// Structure: Host -> @if -> Child -> Popup (with host animation)
|
||||
|
||||
const styles = `
|
||||
.popup-overlay {
|
||||
animation: fade-in 0.3s;
|
||||
}
|
||||
.popup-overlay-leave {
|
||||
animation: fade-out 0.3s;
|
||||
}
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes fade-out {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
`;
|
||||
|
||||
@Component({
|
||||
selector: 'app-popup',
|
||||
template: `
|
||||
<div class="popup-overlay" animate.leave="popup-overlay-leave">
|
||||
<div class="popup">
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [styles],
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
host: {
|
||||
'(animate.leave)': 'animateLeave($event)',
|
||||
},
|
||||
})
|
||||
class PopupComponent {
|
||||
animateLeave(event: any) {
|
||||
// In the issue, they use setTimeout to call animationComplete.
|
||||
// We simulate this async completion.
|
||||
setTimeout(() => event.animationComplete(), 300);
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-child',
|
||||
template: ` <app-popup> Projected content. </app-popup> `,
|
||||
imports: [PopupComponent],
|
||||
})
|
||||
class ChildComponent {}
|
||||
|
||||
@Component({
|
||||
selector: 'app-host',
|
||||
template: `
|
||||
@if (showChild()) {
|
||||
<app-child></app-child>
|
||||
}
|
||||
`,
|
||||
imports: [ChildComponent],
|
||||
})
|
||||
class HostComponent {
|
||||
showChild = signal(true);
|
||||
}
|
||||
|
||||
TestBed.configureTestingModule({animationsEnabled: true});
|
||||
const fixture = TestBed.createComponent(HostComponent);
|
||||
const host = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
|
||||
const popup = fixture.nativeElement.querySelector('app-popup');
|
||||
expect(popup).toBeTruthy();
|
||||
|
||||
host.showChild.set(false);
|
||||
fixture.detectChanges();
|
||||
tickAnimationFrames(1);
|
||||
|
||||
const popupAfter = fixture.nativeElement.querySelector('app-popup');
|
||||
expect(popupAfter).withContext('Popup should persist due to host animation').not.toBeNull();
|
||||
|
||||
// Advance time for the setTimeout in animateLeave
|
||||
tick(300);
|
||||
|
||||
// The nested animation needs to complete as well.
|
||||
// Query the nested element (popup overlay) that has the animation.
|
||||
const popupOverlay = fixture.nativeElement.querySelector('.popup-overlay');
|
||||
if (popupOverlay) {
|
||||
popupOverlay.dispatchEvent(
|
||||
new AnimationEvent('animationend', {animationName: 'fade-out', bubbles: true}),
|
||||
);
|
||||
tick();
|
||||
}
|
||||
|
||||
// Attempt to verify removal
|
||||
const popupFinal = fixture.nativeElement.querySelector('app-popup');
|
||||
expect(popupFinal).toBeFalsy();
|
||||
}));
|
||||
|
||||
it('should not throw "Injector destroyed" when a parent with leave animations is destroyed with nested components', async () => {
|
||||
@Component({
|
||||
selector: 'child-comp',
|
||||
standalone: true,
|
||||
template: '<div>Child</div>',
|
||||
})
|
||||
class ChildComp implements OnDestroy {
|
||||
ngOnDestroy() {}
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'parent-comp',
|
||||
standalone: true,
|
||||
imports: [ChildComp],
|
||||
template: `
|
||||
<div animate.leave="leaving">
|
||||
<child-comp />
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
class ParentComp {
|
||||
leaving = () => ({promise: Promise.resolve()});
|
||||
}
|
||||
|
||||
TestBed.configureTestingModule({});
|
||||
|
||||
const fixture = TestBed.createComponent(ParentComp);
|
||||
fixture.detectChanges();
|
||||
|
||||
// Before the fix, the framework would recursively dive into ChildComp's
|
||||
// internal view to collect animations. During a full destruction of the
|
||||
// component tree, this could lead to starting animations or async tasks
|
||||
// that resolve after the component's injector is already gone.
|
||||
fixture.destroy();
|
||||
|
||||
// If we reach here without an exception, the regression is prevented.
|
||||
});
|
||||
|
||||
it('should still support nested animations within the same template', () => {
|
||||
// This test ensures that while we stopped recursing into component views,
|
||||
// we still support nested elements within the same view having animations.
|
||||
|
||||
@Component({
|
||||
selector: 'nested-view-comp',
|
||||
standalone: true,
|
||||
template: `
|
||||
<div animate.leave="leaving">
|
||||
@if (showNested) {
|
||||
<div animate.leave="leaving">Nested</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
class NestedViewComp {
|
||||
showNested = true;
|
||||
leaving = () => ({promise: Promise.resolve()});
|
||||
}
|
||||
|
||||
TestBed.configureTestingModule({});
|
||||
|
||||
const fixture = TestBed.createComponent(NestedViewComp);
|
||||
fixture.detectChanges();
|
||||
|
||||
// This should not throw and correctly handle the nested animations.
|
||||
fixture.destroy();
|
||||
});
|
||||
});
|
||||
@@ -323,6 +323,7 @@
|
||||
"addServerStyles",
|
||||
"addToAnimationQueue",
|
||||
"addToEndOfViewTree",
|
||||
"aggregateDescendantAnimations",
|
||||
"allLeavingAnimations",
|
||||
"allocExpando",
|
||||
"allocLFrame",
|
||||
@@ -369,8 +370,10 @@
|
||||
"cleanUpView",
|
||||
"cloakAndComputeStyles",
|
||||
"cloakElement",
|
||||
"collectAllViewLeaveAnimations",
|
||||
"collectNativeNodes",
|
||||
"collectNativeNodesInLContainer",
|
||||
"collectNestedViewAnimations",
|
||||
"computeStaticStyling",
|
||||
"computeStyle",
|
||||
"concatStringsWithSpace",
|
||||
@@ -461,6 +464,7 @@
|
||||
"executeCheckHooks",
|
||||
"executeContentQueries",
|
||||
"executeInitAndCheckHooks",
|
||||
"executeLeaveAnimations",
|
||||
"executeOnDestroys",
|
||||
"executeTemplate",
|
||||
"executeViewQueryFn",
|
||||
|
||||
@@ -253,6 +253,7 @@
|
||||
"addToArray",
|
||||
"addToEndOfViewTree",
|
||||
"addViewToDOM",
|
||||
"aggregateDescendantAnimations",
|
||||
"allLeavingAnimations",
|
||||
"allocExpando",
|
||||
"allocLFrame",
|
||||
@@ -286,8 +287,10 @@
|
||||
"captureError",
|
||||
"checkStable",
|
||||
"cleanUpView",
|
||||
"collectAllViewLeaveAnimations",
|
||||
"collectNativeNodes",
|
||||
"collectNativeNodesInLContainer",
|
||||
"collectNestedViewAnimations",
|
||||
"computeStaticStyling",
|
||||
"concatStringsWithSpace",
|
||||
"config",
|
||||
@@ -369,6 +372,7 @@
|
||||
"executeCheckHooks",
|
||||
"executeContentQueries",
|
||||
"executeInitAndCheckHooks",
|
||||
"executeLeaveAnimations",
|
||||
"executeListenerWithErrorHandling",
|
||||
"executeOnDestroys",
|
||||
"executeTemplate",
|
||||
|
||||
@@ -296,6 +296,7 @@
|
||||
"addToArray",
|
||||
"addToEndOfViewTree",
|
||||
"addViewToDOM",
|
||||
"aggregateDescendantAnimations",
|
||||
"allLeavingAnimations",
|
||||
"allocExpando",
|
||||
"allocLFrame",
|
||||
@@ -330,8 +331,10 @@
|
||||
"checkStable",
|
||||
"classIndexOf",
|
||||
"cleanUpView",
|
||||
"collectAllViewLeaveAnimations",
|
||||
"collectNativeNodes",
|
||||
"collectNativeNodesInLContainer",
|
||||
"collectNestedViewAnimations",
|
||||
"computeStaticStyling",
|
||||
"concatStringsWithSpace",
|
||||
"config",
|
||||
@@ -410,6 +413,7 @@
|
||||
"executeCheckHooks",
|
||||
"executeContentQueries",
|
||||
"executeInitAndCheckHooks",
|
||||
"executeLeaveAnimations",
|
||||
"executeOnDestroys",
|
||||
"executeTemplate",
|
||||
"executeViewQueryFn",
|
||||
|
||||
@@ -352,6 +352,7 @@
|
||||
"addToEndOfViewTree",
|
||||
"addValidators",
|
||||
"addViewToDOM",
|
||||
"aggregateDescendantAnimations",
|
||||
"allLeavingAnimations",
|
||||
"allocExpando",
|
||||
"allocLFrame",
|
||||
@@ -400,8 +401,10 @@
|
||||
"cleanUpView",
|
||||
"coerceToAsyncValidator",
|
||||
"coerceToValidator",
|
||||
"collectAllViewLeaveAnimations",
|
||||
"collectNativeNodes",
|
||||
"collectNativeNodesInLContainer",
|
||||
"collectNestedViewAnimations",
|
||||
"collectResidual",
|
||||
"collectStylingFromDirectives",
|
||||
"collectStylingFromTAttrs",
|
||||
@@ -507,6 +510,7 @@
|
||||
"executeCheckHooks",
|
||||
"executeContentQueries",
|
||||
"executeInitAndCheckHooks",
|
||||
"executeLeaveAnimations",
|
||||
"executeListenerWithErrorHandling",
|
||||
"executeOnDestroys",
|
||||
"executeSchedule",
|
||||
|
||||
@@ -356,6 +356,7 @@
|
||||
"addToEndOfViewTree",
|
||||
"addValidators",
|
||||
"addViewToDOM",
|
||||
"aggregateDescendantAnimations",
|
||||
"allLeavingAnimations",
|
||||
"allocExpando",
|
||||
"allocLFrame",
|
||||
@@ -402,8 +403,10 @@
|
||||
"cleanUpView",
|
||||
"coerceToAsyncValidator",
|
||||
"coerceToValidator",
|
||||
"collectAllViewLeaveAnimations",
|
||||
"collectNativeNodes",
|
||||
"collectNativeNodesInLContainer",
|
||||
"collectNestedViewAnimations",
|
||||
"collectResidual",
|
||||
"collectStylingFromDirectives",
|
||||
"collectStylingFromTAttrs",
|
||||
@@ -509,6 +512,7 @@
|
||||
"executeCheckHooks",
|
||||
"executeContentQueries",
|
||||
"executeInitAndCheckHooks",
|
||||
"executeLeaveAnimations",
|
||||
"executeListenerWithErrorHandling",
|
||||
"executeOnDestroys",
|
||||
"executeSchedule",
|
||||
|
||||
@@ -292,6 +292,7 @@
|
||||
"addServerStyles",
|
||||
"addToAnimationQueue",
|
||||
"addToEndOfViewTree",
|
||||
"aggregateDescendantAnimations",
|
||||
"allLeavingAnimations",
|
||||
"allocExpando",
|
||||
"allocLFrame",
|
||||
@@ -334,8 +335,10 @@
|
||||
"cleanupLView",
|
||||
"cleanupMatchingDehydratedViews",
|
||||
"clearElementContents",
|
||||
"collectAllViewLeaveAnimations",
|
||||
"collectNativeNodes",
|
||||
"collectNativeNodesInLContainer",
|
||||
"collectNestedViewAnimations",
|
||||
"computeStaticStyling",
|
||||
"concatStringsWithSpace",
|
||||
"config",
|
||||
@@ -425,6 +428,7 @@
|
||||
"executeCheckHooks",
|
||||
"executeContentQueries",
|
||||
"executeInitAndCheckHooks",
|
||||
"executeLeaveAnimations",
|
||||
"executeOnDestroys",
|
||||
"executeSchedule",
|
||||
"executeTemplate",
|
||||
|
||||
@@ -408,6 +408,7 @@
|
||||
"addViewToDOM",
|
||||
"advanceActivatedRoute",
|
||||
"afterNextNavigation",
|
||||
"aggregateDescendantAnimations",
|
||||
"allLeavingAnimations",
|
||||
"allocExpando",
|
||||
"allocLFrame",
|
||||
@@ -450,8 +451,10 @@
|
||||
"checkStable",
|
||||
"classIndexOf",
|
||||
"cleanUpView",
|
||||
"collectAllViewLeaveAnimations",
|
||||
"collectNativeNodes",
|
||||
"collectNativeNodesInLContainer",
|
||||
"collectNestedViewAnimations",
|
||||
"collectQueryResults",
|
||||
"combineLatest",
|
||||
"combineLatestInit",
|
||||
@@ -601,6 +604,7 @@
|
||||
"executeCheckHooks",
|
||||
"executeContentQueries",
|
||||
"executeInitAndCheckHooks",
|
||||
"executeLeaveAnimations",
|
||||
"executeListenerWithErrorHandling",
|
||||
"executeOnDestroys",
|
||||
"executeSchedule",
|
||||
|
||||
@@ -233,6 +233,7 @@
|
||||
"addServerStyles",
|
||||
"addToAnimationQueue",
|
||||
"addToEndOfViewTree",
|
||||
"aggregateDescendantAnimations",
|
||||
"allLeavingAnimations",
|
||||
"allocExpando",
|
||||
"allocLFrame",
|
||||
@@ -265,8 +266,10 @@
|
||||
"captureError",
|
||||
"checkStable",
|
||||
"cleanUpView",
|
||||
"collectAllViewLeaveAnimations",
|
||||
"collectNativeNodes",
|
||||
"collectNativeNodesInLContainer",
|
||||
"collectNestedViewAnimations",
|
||||
"computeStaticStyling",
|
||||
"concatStringsWithSpace",
|
||||
"config",
|
||||
@@ -340,6 +343,7 @@
|
||||
"executeCheckHooks",
|
||||
"executeContentQueries",
|
||||
"executeInitAndCheckHooks",
|
||||
"executeLeaveAnimations",
|
||||
"executeOnDestroys",
|
||||
"executeTemplate",
|
||||
"executeViewQueryFn",
|
||||
|
||||
Reference in New Issue
Block a user