Skip to content

Commit ad0156e

Browse files
thePunderWomanleonsenft
authored andcommitted
fix(core): fixes a regression with animate.leave and reordering
PATCH PR for #67765 This fixes a regression bug that resulted in reordered elements not getting properly removed from the DOM. Reused nodes were not being cleared out in this situation. fixes: #67728
1 parent aa3220a commit ad0156e

File tree

6 files changed

+155
-1
lines changed

6 files changed

+155
-1
lines changed

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,4 +91,23 @@ describe('Animations Integration', () => {
9191
fallbackEls = await page.$$('.fallback-el');
9292
expect(fallbackEls.length).toBe(0);
9393
});
94+
95+
it('should remove elements from DOM after reordering and removal with (animate.leave)', async () => {
96+
// Wait for the test elements to be rendered
97+
await page.waitForSelector('.test-item');
98+
99+
let items = await page.$$('.test-item');
100+
expect(items.length).toBe(2);
101+
102+
// Shuffle
103+
await page.click('#shuffle-test');
104+
await new Promise((res) => setTimeout(res, 500));
105+
106+
// Remove
107+
await page.click('#remove-test');
108+
await new Promise((res) => setTimeout(res, 500));
109+
110+
items = await page.$$('.test-item');
111+
expect(items.length).toBe(1);
112+
});
94113
});

integration/animations/src/app/app.component.html

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,11 @@
1717
}
1818

1919
<button id="hide-and-intercept" (click)="hideAndIntercept()">Hide and Intercept</button>
20+
21+
<div class="test-reorder-container">
22+
@for (item of testItems; track item) {
23+
<div class="test-item" (animate.leave)="onTestLeave($event)">Item {{ item }}</div>
24+
}
25+
</div>
26+
<button id="shuffle-test" (click)="shuffleTest()">Shuffle Test</button>
27+
<button id="remove-test" (click)="removeTest()">Remove Test</button>

integration/animations/src/app/app.component.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {Component} from '@angular/core';
1+
import {Component, AnimationCallbackEvent} from '@angular/core';
22
import {
33
CdkDragDrop,
44
CdkDropList,
@@ -23,6 +23,8 @@ export class AppComponent {
2323
'Episode III - Revenge of the Sith',
2424
];
2525

26+
testItems = ['A', 'B'];
27+
2628
showFallback = true;
2729

2830
drop(event: CdkDragDrop<string[]>) {
@@ -42,4 +44,16 @@ export class AppComponent {
4244
}
4345
this.showFallback = false;
4446
}
47+
48+
shuffleTest() {
49+
this.testItems = ['B', 'A'];
50+
}
51+
52+
removeTest() {
53+
this.testItems = ['A'];
54+
}
55+
56+
onTestLeave(event: AnimationCallbackEvent) {
57+
event.animationComplete();
58+
}
4559
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import {Component, AnimationCallbackEvent} 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+
testItems = ['A', 'B'];
24+
25+
showFallback = true;
26+
27+
drop(event: CdkDragDrop<string[]>) {
28+
moveItemInArray(this.movies, event.previousIndex, event.currentIndex);
29+
}
30+
31+
hideAndIntercept() {
32+
const el = document.querySelector('.fallback-el');
33+
if (el) {
34+
el.addEventListener(
35+
'animationend',
36+
(e) => {
37+
e.stopImmediatePropagation();
38+
},
39+
true,
40+
);
41+
}
42+
this.showFallback = false;
43+
}
44+
45+
shuffleTest() {
46+
this.testItems = ['B', 'A'];
47+
}
48+
49+
removeTest() {
50+
this.testItems = ['A'];
51+
}
52+
53+
onTestLeave(event: AnimationCallbackEvent) {
54+
event.animationComplete();
55+
}
56+
}

packages/core/src/render3/node_manipulation.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ function applyToElementOrContainer(
164164
if (parentLView?.[ANIMATIONS]?.leave?.has(tNode.index)) {
165165
trackLeavingNodes(tNode, rNode as HTMLElement);
166166
}
167+
reusedNodes.delete(rNode as HTMLElement);
167168
runLeaveAnimationsWithCallback(
168169
parentLView,
169170
tNode,
@@ -180,6 +181,7 @@ function applyToElementOrContainer(
180181
},
181182
);
182183
} else if (action === WalkTNodeTreeAction.Destroy) {
184+
reusedNodes.delete(rNode as HTMLElement);
183185
runLeaveAnimationsWithCallback(parentLView, tNode, injector, () => {
184186
renderer.destroyNode!(rNode);
185187
});

packages/core/test/acceptance/animation_spec.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {tickAnimationFrames} from '../animation_utils/tick_animation_frames';
3838
import {BrowserTestingModule, platformBrowserTesting} from '@angular/platform-browser/testing';
3939
import {NoopAnimationsModule} from '@angular/platform-browser/animations';
4040
import {ComponentRef} from '@angular/core/src/render3';
41+
import {reusedNodes} from '../../src/animation/utils';
4142

4243
@NgModule({
4344
providers: [provideZonelessChangeDetection()],
@@ -511,6 +512,60 @@ describe('Animation', () => {
511512
expect(fadeCalled).toHaveBeenCalled();
512513
}));
513514

515+
it('should remove element from DOM with (animate.leave) after list reordering', async () => {
516+
@Component({
517+
selector: 'leak-cmp',
518+
template: 'item',
519+
})
520+
class LeakComponent {}
521+
522+
@Component({
523+
selector: 'test-cmp',
524+
imports: [LeakComponent],
525+
template: `
526+
@for (item of items(); track item.id) {
527+
<leak-cmp (animate.leave)="onLeave($event)" />
528+
}
529+
`,
530+
})
531+
class TestComponent {
532+
items = signal([{id: '1'}, {id: '2'}]);
533+
534+
onLeave(event: AnimationCallbackEvent) {
535+
event.animationComplete();
536+
}
537+
538+
shuffle() {
539+
const arr = this.items();
540+
this.items.set([arr[1], arr[0]]);
541+
}
542+
543+
remove() {
544+
const arr = this.items();
545+
this.items.set([arr[1]]);
546+
}
547+
}
548+
549+
TestBed.configureTestingModule({animationsEnabled: true});
550+
const fixture = TestBed.createComponent(TestComponent);
551+
await fixture.whenStable();
552+
553+
expect(fixture.nativeElement.textContent).toContain('item');
554+
555+
fixture.componentInstance.shuffle();
556+
await fixture.whenStable();
557+
558+
const elementsBeforeRemove = fixture.debugElement.queryAll(By.css('leak-cmp'));
559+
// Element is in reused nodes before remove
560+
expect(reusedNodes.has(elementsBeforeRemove[0].nativeElement)).toBe(true);
561+
562+
fixture.componentInstance.remove();
563+
await fixture.whenStable();
564+
565+
const elements = fixture.nativeElement.querySelectorAll('leak-cmp');
566+
expect(elements.length).toBe(1);
567+
});
568+
514569
it('should compose class list when host binding and regular binding', fakeAsync(() => {
515570
const multiple = `
516571
.slide-out {

0 commit comments

Comments
 (0)