fix(core): fixes a regression with animate.leave and reordering

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
This commit is contained in:
Jessica Janiuk 2026-03-19 10:18:42 -07:00 committed by Leon Senft
parent 41b1410cb8
commit dfa149dc68
5 changed files with 99 additions and 1 deletions

View file

@ -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);
});
});

View file

@ -17,3 +17,11 @@
}
<button id="hide-and-intercept" (click)="hideAndIntercept()">Hide and Intercept</button>
<div class="test-reorder-container">
@for (item of testItems; track item) {
<div class="test-item" (animate.leave)="onTestLeave($event)">Item {{ item }}</div>
}
</div>
<button id="shuffle-test" (click)="shuffleTest()">Shuffle Test</button>
<button id="remove-test" (click)="removeTest()">Remove Test</button>

View file

@ -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<string[]>) {
@ -39,4 +41,16 @@ export class HomeComponent {
}
this.showFallback = false;
}
shuffleTest() {
this.testItems = ['B', 'A'];
}
removeTest() {
this.testItems = ['A'];
}
onTestLeave(event: AnimationCallbackEvent) {
event.animationComplete();
}
}

View file

@ -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);
});

View file

@ -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) {
<leak-cmp (animate.leave)="onLeave($event)" />
}
`,
})
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 {