Skip to content

Commit 4f5b183

Browse files
fix(core): skip leave animations on view swaps
We accounted for skipping leave animations during moves, but not swaps. This accounts for the swap cases and updates how we deal with swaps and moves. Now we always queue animations and then essentially dequeue them if we attach them back in the same render pass. fixes: angular#64818 fixes: angular#64730
1 parent a0fe177 commit 4f5b183

6 files changed

Lines changed: 124 additions & 36 deletions

File tree

packages/core/src/animation/interfaces.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,5 +98,7 @@ export interface AnimationLViewData {
9898
// path during move that detaching or removing does. So to prevent
9999
// unexpected disappearing of moving nodes, we use this flag to skip
100100
// the animations in that case.
101-
skipLeaveAnimations?: boolean;
101+
detach?: boolean;
102+
103+
qeueued?: Function[];
102104
}

packages/core/src/animation/queue.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
import {afterNextRender} from '../render3/after_render/hooks';
1010
import {InjectionToken, Injector} from '../di';
11-
import {NodeAnimations} from './interfaces';
11+
import {AnimationLViewData, NodeAnimations} from './interfaces';
1212

1313
export interface AnimationQueue {
1414
queue: Set<Function>;
@@ -33,18 +33,38 @@ export const ANIMATION_QUEUE = new InjectionToken<AnimationQueue>(
3333
},
3434
);
3535

36-
export function addToAnimationQueue(injector: Injector, animationFns: Function | Function[]) {
36+
export function addToAnimationQueue(
37+
injector: Injector,
38+
animationFns: Function | Function[],
39+
animationData?: AnimationLViewData,
40+
) {
3741
const animationQueue = injector.get(ANIMATION_QUEUE);
3842
if (Array.isArray(animationFns)) {
3943
for (const animateFn of animationFns) {
4044
animationQueue.queue.add(animateFn);
45+
if (animationData?.detach) {
46+
(animationData.qeueued ??= []).push(animateFn);
47+
}
4148
}
4249
} else {
4350
animationQueue.queue.add(animationFns);
51+
if (animationData?.detach) {
52+
(animationData.qeueued ??= []).push(animationFns);
53+
}
4454
}
4555
animationQueue.scheduler && animationQueue.scheduler(injector);
4656
}
4757

58+
export function removeFromAnimationQueue(injector: Injector, animationData: AnimationLViewData) {
59+
const animationQueue = injector.get(ANIMATION_QUEUE);
60+
if (animationData.qeueued) {
61+
for (const animationFn of animationData.qeueued) {
62+
animationQueue.queue.delete(animationFn);
63+
}
64+
animationData.qeueued = undefined;
65+
}
66+
}
67+
4868
export function scheduleAnimationQueue(injector: Injector) {
4969
const animationQueue = injector.get(ANIMATION_QUEUE);
5070
// We only want to schedule the animation queue if it hasn't already been scheduled.

packages/core/src/render3/instructions/control_flow.ts

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
DECLARATION_COMPONENT_VIEW,
2929
HEADER_OFFSET,
3030
HYDRATION,
31+
INJECTOR,
3132
LView,
3233
TVIEW,
3334
TView,
@@ -48,6 +49,8 @@ import {
4849
removeLViewFromLContainer,
4950
} from '../view/container';
5051
import {declareNoDirectiveHostTemplate} from './template';
52+
import {removeFromAnimationQueue} from '../../animation/queue';
53+
import {allLeavingAnimations} from '../../animation/longest_animation';
5154

5255
/**
5356
* Creates an LContainer for an ng-template representing a root node
@@ -419,10 +422,11 @@ class LiveCollectionLContainerImpl extends LiveCollection<
419422
index,
420423
shouldAddViewToDom(this.templateTNode, dehydratedView),
421424
);
425+
clearDetachFlag(this.lContainer, index);
422426
}
423-
override detach(index: number, skipLeaveAnimations?: boolean): LView<RepeaterContext<unknown>> {
427+
override detach(index: number): LView<RepeaterContext<unknown>> {
424428
this.needsIndexUpdate ||= index !== this.length - 1;
425-
if (skipLeaveAnimations) setSkipLeaveAnimations(this.lContainer, index);
429+
setDetachFlag(this.lContainer, index);
426430
return detachExistingView<RepeaterContext<unknown>>(this.lContainer, index);
427431
}
428432
override create(index: number, value: unknown): LView<RepeaterContext<unknown>> {
@@ -570,13 +574,35 @@ function getLContainer(lView: LView, index: number): LContainer {
570574
return lContainer;
571575
}
572576

573-
function setSkipLeaveAnimations(lContainer: LContainer, index: number): void {
577+
function clearDetachFlag(lContainer: LContainer, index: number): void {
578+
if (lContainer.length <= CONTAINER_HEADER_OFFSET) return;
579+
580+
const indexInContainer = CONTAINER_HEADER_OFFSET + index;
581+
const viewToDetach = lContainer[indexInContainer];
582+
if (
583+
viewToDetach &&
584+
viewToDetach[ANIMATIONS] &&
585+
(viewToDetach[ANIMATIONS] as AnimationLViewData).detach
586+
) {
587+
const animations = viewToDetach[ANIMATIONS] as AnimationLViewData;
588+
animations.detach = false;
589+
if (animations.qeueued && animations.qeueued.length > 0) {
590+
const injector = viewToDetach[INJECTOR];
591+
removeFromAnimationQueue(injector, animations);
592+
allLeavingAnimations.delete(viewToDetach);
593+
}
594+
}
595+
}
596+
597+
function setDetachFlag(lContainer: LContainer, index: number): void {
574598
if (lContainer.length <= CONTAINER_HEADER_OFFSET) return;
575599

576600
const indexInContainer = CONTAINER_HEADER_OFFSET + index;
577601
const viewToDetach = lContainer[indexInContainer];
578602
if (viewToDetach && viewToDetach[ANIMATIONS]) {
579-
(viewToDetach[ANIMATIONS] as AnimationLViewData).skipLeaveAnimations = true;
603+
const animations = viewToDetach[ANIMATIONS] as AnimationLViewData;
604+
animations.detach = true;
605+
animations.qeueued = [];
580606
}
581607
}
582608

packages/core/src/render3/list_reconciliation.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export abstract class LiveCollection<T, V> {
2121
abstract get length(): number;
2222
abstract at(index: number): V;
2323
abstract attach(index: number, item: T): void;
24-
abstract detach(index: number, skipLeaveAnimations?: boolean): T;
24+
abstract detach(index: number): T;
2525
abstract create(index: number, value: V): T;
2626
destroy(item: T): void {
2727
// noop by default
@@ -50,7 +50,7 @@ export abstract class LiveCollection<T, V> {
5050
// DOM nodes, which would trigger `animate.leave` bindings. We need to skip
5151
// those animations in the case of a move operation so the moving elements don't
5252
// unexpectedly disappear.
53-
this.attach(newIdx, this.detach(prevIndex, true /* skipLeaveAnimations */));
53+
this.attach(newIdx, this.detach(prevIndex));
5454
}
5555
}
5656

@@ -131,6 +131,7 @@ export function reconcile<T, V>(
131131
// compare from the beginning
132132
const liveStartValue = liveCollection.at(liveStartIdx);
133133
const newStartValue = newCollection[liveStartIdx];
134+
const newLastValue = newCollection[newEndIdx];
134135

135136
if (ngDevMode) {
136137
recordDuplicateKeys(duplicateKeys!, trackByFn(liveStartIdx, newStartValue), liveStartIdx);
@@ -180,6 +181,7 @@ export function reconcile<T, V>(
180181
const liveStartKey = trackByFn(liveStartIdx, liveStartValue);
181182
const liveEndKey = trackByFn(liveEndIdx, liveEndValue);
182183
const newStartKey = trackByFn(liveStartIdx, newStartValue);
184+
183185
if (Object.is(newStartKey, liveEndKey)) {
184186
const newEndKey = trackByFn(newEndIdx, newEndValue);
185187
// detect swap on both ends;

packages/core/src/render3/node_manipulation.ts

Lines changed: 24 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -386,37 +386,34 @@ function runLeaveAnimationsWithCallback(
386386
if (animations == null || animations.leave == undefined || !animations.leave.has(tNode.index))
387387
return callback(false);
388388

389-
// this is solely for move operations to prevent leave animations from running
390-
// on the moved nodes, which would have deleted the node.
391-
if (animations.skipLeaveAnimations) {
392-
animations.skipLeaveAnimations = false;
393-
return callback(false);
394-
}
395-
396389
if (lView) allLeavingAnimations.add(lView);
397390

398-
addToAnimationQueue(injector, () => {
399-
// it's possible that in the time between when the leave animation was
400-
// and the time it was executed, the data structure changed. So we need
401-
// to be safe here.
402-
if (animations.leave && animations.leave.has(tNode.index)) {
403-
const leaveAnimationMap = animations.leave;
404-
const leaveAnimations = leaveAnimationMap.get(tNode.index);
405-
const runningAnimations = [];
406-
if (leaveAnimations) {
407-
for (let index = 0; index < leaveAnimations.animateFns.length; index++) {
408-
const animationFn = leaveAnimations.animateFns[index];
409-
const {promise} = animationFn();
410-
runningAnimations.push(promise);
391+
addToAnimationQueue(
392+
injector,
393+
() => {
394+
// it's possible that in the time between when the leave animation was
395+
// and the time it was executed, the data structure changed. So we need
396+
// to be safe here.
397+
if (animations.leave && animations.leave.has(tNode.index)) {
398+
const leaveAnimationMap = animations.leave;
399+
const leaveAnimations = leaveAnimationMap.get(tNode.index);
400+
const runningAnimations = [];
401+
if (leaveAnimations) {
402+
for (let index = 0; index < leaveAnimations.animateFns.length; index++) {
403+
const animationFn = leaveAnimations.animateFns[index];
404+
const {promise} = animationFn();
405+
runningAnimations.push(promise);
406+
}
411407
}
408+
animations.running = Promise.allSettled(runningAnimations);
409+
runAfterLeaveAnimations(lView!, callback);
410+
} else {
411+
if (lView) allLeavingAnimations.delete(lView);
412+
callback(false);
412413
}
413-
animations.running = Promise.allSettled(runningAnimations);
414-
runAfterLeaveAnimations(lView!, callback);
415-
} else {
416-
if (lView) allLeavingAnimations.delete(lView);
417-
callback(false);
418-
}
419-
});
414+
},
415+
animations,
416+
);
420417
}
421418

422419
function runAfterLeaveAnimations(lView: LView, callback: Function) {

packages/core/test/acceptance/animation_spec.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2057,6 +2057,47 @@ describe('Animation', () => {
20572057
expect(fixture.debugElement.queryAll(By.css('p')).length).toBe(3);
20582058
}));
20592059

2060+
it('should not remove elements when swapping or moving nodes', fakeAsync(() => {
2061+
const animateSpy = jasmine.createSpy('animateSpy');
2062+
@Component({
2063+
selector: 'test-cmp',
2064+
template: `
2065+
<div>
2066+
@for (item of items; track item.id) {
2067+
<p (animate.leave)="animate($event)" #el>{{ item.id }}</p>
2068+
}
2069+
</div>
2070+
`,
2071+
encapsulation: ViewEncapsulation.None,
2072+
})
2073+
class TestComponent {
2074+
items = [{id: 1}, {id: 2}, {id: 3}];
2075+
private cd = inject(ChangeDetectorRef);
2076+
2077+
animate(event: AnimationCallbackEvent) {
2078+
animateSpy();
2079+
event.animationComplete();
2080+
}
2081+
2082+
shuffle() {
2083+
this.items = this.shuffleArray(this.items);
2084+
this.cd.markForCheck();
2085+
}
2086+
2087+
shuffleArray<T>(array: readonly T[]): T[] {
2088+
return [array[1], array[2], array[0]];
2089+
}
2090+
}
2091+
TestBed.configureTestingModule({animationsEnabled: true});
2092+
2093+
const fixture = TestBed.createComponent(TestComponent);
2094+
const cmp = fixture.componentInstance;
2095+
cmp.shuffle();
2096+
fixture.detectChanges();
2097+
expect(animateSpy).not.toHaveBeenCalled();
2098+
expect(fixture.debugElement.queryAll(By.css('p')).length).toBe(3);
2099+
}));
2100+
20602101
it('should not remove elements when child element animations finish', fakeAsync(() => {
20612102
const animateStyles = `
20622103
.fade {

0 commit comments

Comments
 (0)