diff --git a/integration/animations/e2e/src/animations.e2e-spec.ts b/integration/animations/e2e/src/animations.e2e-spec.ts
index 5e5c3bc5e87..a20e046651a 100644
--- a/integration/animations/e2e/src/animations.e2e-spec.ts
+++ b/integration/animations/e2e/src/animations.e2e-spec.ts
@@ -114,4 +114,23 @@ describe('Animations Integration', () => {
'Nested child component should have been removed immediately during routing',
);
});
+
+ it('should remove elements from DOM after reordering and removal with (animate.leave)', async () => {
+ // Wait for the test elements to be rendered
+ await page.waitForSelector('.test-item');
+
+ let items = await page.$$('.test-item');
+ expect(items.length).toBe(2);
+
+ // Shuffle
+ await page.click('#shuffle-test');
+ await new Promise((res) => setTimeout(res, 500));
+
+ // Remove
+ await page.click('#remove-test');
+ await new Promise((res) => setTimeout(res, 500));
+
+ items = await page.$$('.test-item');
+ expect(items.length).toBe(1);
+ });
});
diff --git a/integration/animations/src/app/app.component.html b/integration/animations/src/app/app.component.html
index 11b1f49aa91..ec2966b9854 100644
--- a/integration/animations/src/app/app.component.html
+++ b/integration/animations/src/app/app.component.html
@@ -17,3 +17,11 @@
}
+
+
+ @for (item of testItems; track item) {
+
Item {{ item }}
+ }
+
+
+
diff --git a/integration/animations/src/app/home.component.ts b/integration/animations/src/app/home.component.ts
index 7a3deeba284..add64699498 100644
--- a/integration/animations/src/app/home.component.ts
+++ b/integration/animations/src/app/home.component.ts
@@ -1,4 +1,4 @@
-import {Component} from '@angular/core';
+import {Component, AnimationCallbackEvent} from '@angular/core';
import {
CdkDragDrop,
CdkDropList,
@@ -20,6 +20,8 @@ export class HomeComponent {
'Episode III - Revenge of the Sith',
];
+ testItems = ['A', 'B'];
+
showFallback = true;
drop(event: CdkDragDrop) {
@@ -39,4 +41,16 @@ export class HomeComponent {
}
this.showFallback = false;
}
+
+ shuffleTest() {
+ this.testItems = ['B', 'A'];
+ }
+
+ removeTest() {
+ this.testItems = ['A'];
+ }
+
+ onTestLeave(event: AnimationCallbackEvent) {
+ event.animationComplete();
+ }
}
diff --git a/packages/core/src/render3/node_manipulation.ts b/packages/core/src/render3/node_manipulation.ts
index a6f16aeebfe..ea147f04bf7 100644
--- a/packages/core/src/render3/node_manipulation.ts
+++ b/packages/core/src/render3/node_manipulation.ts
@@ -150,6 +150,7 @@ function applyToElementOrContainer(
if (parentLView?.[ANIMATIONS]?.leave?.has(tNode.index)) {
trackLeavingNodes(tNode, rNode as HTMLElement);
}
+ reusedNodes.delete(rNode as HTMLElement);
runLeaveAnimationsWithCallback(
parentLView,
tNode,
@@ -166,6 +167,7 @@ function applyToElementOrContainer(
},
);
} else if (action === WalkTNodeTreeAction.Destroy) {
+ reusedNodes.delete(rNode as HTMLElement);
runLeaveAnimationsWithCallback(parentLView, tNode, injector, () => {
renderer.destroyNode!(rNode);
});
diff --git a/packages/core/test/acceptance/animation_spec.ts b/packages/core/test/acceptance/animation_spec.ts
index 7279a98ab80..f122a557afa 100644
--- a/packages/core/test/acceptance/animation_spec.ts
+++ b/packages/core/test/acceptance/animation_spec.ts
@@ -38,6 +38,7 @@ 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';
+import {reusedNodes} from '../../src/animation/utils';
@NgModule({
providers: [provideZonelessChangeDetection()],
@@ -511,6 +512,60 @@ describe('Animation', () => {
expect(fadeCalled).toHaveBeenCalled();
}));
+ it('should remove element from DOM with (animate.leave) after list reordering', async () => {
+ @Component({
+ selector: 'leak-cmp',
+ template: 'item',
+ })
+ class LeakComponent {}
+
+ @Component({
+ selector: 'test-cmp',
+ imports: [LeakComponent],
+ template: `
+ @for (item of items(); track item.id) {
+
+ }
+ `,
+ })
+ class TestComponent {
+ items = signal([{id: '1'}, {id: '2'}]);
+
+ onLeave(event: AnimationCallbackEvent) {
+ event.animationComplete();
+ }
+
+ shuffle() {
+ const arr = this.items();
+ this.items.set([arr[1], arr[0]]);
+ }
+
+ remove() {
+ const arr = this.items();
+ this.items.set([arr[1]]);
+ }
+ }
+
+ TestBed.configureTestingModule({animationsEnabled: true});
+ const fixture = TestBed.createComponent(TestComponent);
+ await fixture.whenStable();
+
+ expect(fixture.nativeElement.textContent).toContain('item');
+
+ fixture.componentInstance.shuffle();
+ await fixture.whenStable();
+
+ const elementsBeforeRemove = fixture.debugElement.queryAll(By.css('leak-cmp'));
+ // Element is in reused nodes before remove
+ expect(reusedNodes.has(elementsBeforeRemove[0].nativeElement)).toBe(true);
+
+ fixture.componentInstance.remove();
+ await fixture.whenStable();
+
+ const elements = fixture.nativeElement.querySelectorAll('leak-cmp');
+ expect(elements.length).toBe(1);
+ });
+
it('should compose class list when host binding and regular binding', fakeAsync(() => {
const multiple = `
.slide-out {