Skip to content

Commit df659b8

Browse files
thePunderWomanmattrbeck
authored andcommitted
feat(core): re-introduce nested leave animations scoped to component boundaries
This commit re-introduces support for nested leave animations with a critical adjustment to prevent cross-component blocking. Wait for nested inner `animate.leave` transitions natively only when they exist within the same component's view or its embedded tracking structures (like `@if` and `@for`). This resolves the issue where route navigations and parental destruction would excessively stall by traversing down into child component architectures to wait for their distinct leaf animations. BREAKING CHANGE: Leave animations are no longer limited to the element being removed. Fixes #67633
1 parent 412788f commit df659b8

File tree

18 files changed

+515
-113
lines changed

18 files changed

+515
-113
lines changed

integration/animations/e2e/src/animations.e2e-spec.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,4 +91,27 @@ describe('Animations Integration', () => {
9191
fallbackEls = await page.$$('.fallback-el');
9292
expect(fallbackEls.length).toBe(0);
9393
});
94+
95+
it('should immediately remove routed component without waiting for its inner component leave animations', async () => {
96+
// Navigate to the nested route
97+
await page.click('#nested-link');
98+
await page.waitForSelector('.nested-parent');
99+
100+
let childEls = await page.$$('.child-target');
101+
expect(childEls.length).toBe(2);
102+
103+
// Trigger a route navigation back to home
104+
await page.click('#home-link');
105+
106+
// Given the child animation is 800ms long, if we don't wait for it across component boundaries,
107+
// the previous component should be destroyed almost immediately.
108+
// Wait just a short amount (100ms) and verify the nested component and its children are completely gone.
109+
await new Promise((res) => setTimeout(res, 100));
110+
111+
childEls = await page.$$('.child-target');
112+
expect(childEls.length).toBe(
113+
0,
114+
'Nested child component should have been removed immediately during routing',
115+
);
116+
});
94117
});
Lines changed: 10 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,15 @@
11
import {Component} from '@angular/core';
2-
import {
3-
CdkDragDrop,
4-
CdkDropList,
5-
CdkDrag,
6-
CdkDragPlaceholder,
7-
moveItemInArray,
8-
} from '@angular/cdk/drag-drop';
9-
10-
// We want to verify that dragging an item does not result in any items disappearing
11-
// when they have an enter/leave animation.
2+
import {RouterLink, RouterOutlet} from '@angular/router';
123

134
@Component({
145
selector: 'app-root',
15-
imports: [CdkDropList, CdkDrag, CdkDragPlaceholder],
16-
templateUrl: './app.component.html',
17-
styleUrl: './app.component.css',
6+
imports: [RouterOutlet, RouterLink],
7+
template: `
8+
<nav>
9+
<a routerLink="/" id="home-link">Home</a> |
10+
<a routerLink="/nested" id="nested-link">Nested Animations</a>
11+
</nav>
12+
<router-outlet></router-outlet>
13+
`,
1814
})
19-
export class AppComponent {
20-
movies = [
21-
'Episode I - The Phantom Menace',
22-
'Episode II - Attack of the Clones',
23-
'Episode III - Revenge of the Sith',
24-
];
25-
26-
showFallback = true;
27-
28-
drop(event: CdkDragDrop<string[]>) {
29-
moveItemInArray(this.movies, event.previousIndex, event.currentIndex);
30-
}
31-
32-
hideAndIntercept() {
33-
const el = document.querySelector('.fallback-el');
34-
if (el) {
35-
el.addEventListener(
36-
'animationend',
37-
(e) => {
38-
e.stopImmediatePropagation();
39-
},
40-
true,
41-
);
42-
}
43-
this.showFallback = false;
44-
}
45-
}
15+
export class AppComponent {}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import {Routes} from '@angular/router';
2+
import {HomeComponent} from './home.component';
3+
import {NestedComponent} from './nested.component';
4+
5+
export const routes: Routes = [
6+
{path: '', component: HomeComponent},
7+
{path: 'nested', component: NestedComponent},
8+
];
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import {Component} from '@angular/core';
2+
import {
3+
CdkDragDrop,
4+
CdkDropList,
5+
CdkDrag,
6+
CdkDragPlaceholder,
7+
moveItemInArray,
8+
} from '@angular/cdk/drag-drop';
9+
10+
@Component({
11+
selector: 'app-home',
12+
imports: [CdkDropList, CdkDrag, CdkDragPlaceholder],
13+
templateUrl: './app.component.html',
14+
styleUrl: './app.component.css',
15+
})
16+
export class HomeComponent {
17+
movies = [
18+
'Episode I - The Phantom Menace',
19+
'Episode II - Attack of the Clones',
20+
'Episode III - Revenge of the Sith',
21+
];
22+
23+
showFallback = true;
24+
25+
drop(event: CdkDragDrop<string[]>) {
26+
moveItemInArray(this.movies, event.previousIndex, event.currentIndex);
27+
}
28+
29+
hideAndIntercept() {
30+
const el = document.querySelector('.fallback-el');
31+
if (el) {
32+
el.addEventListener(
33+
'animationend',
34+
(e) => {
35+
e.stopImmediatePropagation();
36+
},
37+
true,
38+
);
39+
}
40+
this.showFallback = false;
41+
}
42+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import {Component, signal, ViewEncapsulation} from '@angular/core';
2+
3+
@Component({
4+
selector: 'app-nested-child',
5+
template: `<div class="child-target" animate.leave="child-leave">Child</div>`,
6+
styles: [
7+
`
8+
.child-leave {
9+
animation: test-leave 800ms;
10+
}
11+
`,
12+
],
13+
encapsulation: ViewEncapsulation.None,
14+
})
15+
export class NestedChildComponent {}
16+
17+
@Component({
18+
selector: 'app-nested',
19+
imports: [NestedChildComponent],
20+
template: `
21+
<h2>Nested Animations</h2>
22+
@if (show()) {
23+
<div class="nested-parent">
24+
<app-nested-child></app-nested-child>
25+
</div>
26+
}
27+
<ng-container>
28+
<app-nested-child></app-nested-child>
29+
</ng-container>
30+
<button id="toggle-nested" (click)="show.set(!show())">Toggle</button>
31+
`,
32+
styles: [
33+
`
34+
@keyframes test-leave {
35+
from {
36+
opacity: 1;
37+
}
38+
to {
39+
opacity: 0;
40+
}
41+
}
42+
`,
43+
],
44+
encapsulation: ViewEncapsulation.None,
45+
})
46+
export class NestedComponent {
47+
show = signal(true);
48+
}

integration/animations/src/main.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
import {bootstrapApplication} from '@angular/platform-browser';
22
import {AppComponent} from './app/app.component';
3+
import {provideRouter} from '@angular/router';
4+
import {routes} from './app/app.routes';
35

4-
bootstrapApplication(AppComponent).catch((err) => console.error(err));
6+
bootstrapApplication(AppComponent, {
7+
providers: [provideRouter(routes)],
8+
}).catch((err) => console.error(err));

packages/core/src/animation/queue.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,14 @@ export const ANIMATION_QUEUE = new InjectionToken<AnimationQueue>(
2424
typeof ngDevMode !== 'undefined' && ngDevMode ? 'AnimationQueue' : '',
2525
{
2626
factory: () => {
27+
const injector = inject(EnvironmentInjector);
28+
const queue = new Set<VoidFunction>();
29+
injector.onDestroy(() => queue.clear());
2730
return {
28-
queue: new Set(),
31+
queue,
2932
isScheduled: false,
3033
scheduler: null,
31-
injector: inject(EnvironmentInjector), // should be the root injector
34+
injector, // should be the root injector
3235
};
3336
},
3437
},
@@ -58,6 +61,20 @@ export function addToAnimationQueue(
5861
animationQueue.scheduler && animationQueue.scheduler(injector);
5962
}
6063

64+
export function removeAnimationsFromQueue(
65+
injector: Injector,
66+
animationFns: VoidFunction | VoidFunction[],
67+
) {
68+
const animationQueue = injector.get(ANIMATION_QUEUE);
69+
if (Array.isArray(animationFns)) {
70+
for (const animateFn of animationFns) {
71+
animationQueue.queue.delete(animateFn);
72+
}
73+
} else {
74+
animationQueue.queue.delete(animationFns);
75+
}
76+
}
77+
6178
export function removeFromAnimationQueue(injector: Injector, animationData: AnimationLViewData) {
6279
const animationQueue = injector.get(ANIMATION_QUEUE);
6380
if (animationData.detachedLeaveAnimationFns) {

0 commit comments

Comments
 (0)