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 {