fix(animations): properly track listeners for a removed element (#40712)
Prior to this patch, if an element was removed multiple times (due to the nature of parent/child elements), the leave listeners may have been fired for an element that was already removed. This patch adds a guard within the animations code to prevent this. PR Close #40712
This commit is contained in:
parent
d7f5755f80
commit
4ce44eac33
|
@ -370,7 +370,11 @@ export class AnimationTransitionNamespace {
|
||||||
|
|
||||||
prepareLeaveAnimationListeners(element: any) {
|
prepareLeaveAnimationListeners(element: any) {
|
||||||
const listeners = this._elementListeners.get(element);
|
const listeners = this._elementListeners.get(element);
|
||||||
if (listeners) {
|
const elementStates = this._engine.statesByElement.get(element);
|
||||||
|
|
||||||
|
// if this statement fails then it means that the element was picked up
|
||||||
|
// by an earlier flush (or there are no listeners at all to track the leave).
|
||||||
|
if (listeners && elementStates) {
|
||||||
const visitedTriggers = new Set<string>();
|
const visitedTriggers = new Set<string>();
|
||||||
listeners.forEach(listener => {
|
listeners.forEach(listener => {
|
||||||
const triggerName = listener.name;
|
const triggerName = listener.name;
|
||||||
|
@ -379,7 +383,6 @@ export class AnimationTransitionNamespace {
|
||||||
|
|
||||||
const trigger = this._triggers[triggerName];
|
const trigger = this._triggers[triggerName];
|
||||||
const transition = trigger.fallbackTransition;
|
const transition = trigger.fallbackTransition;
|
||||||
const elementStates = this._engine.statesByElement.get(element)!;
|
|
||||||
const fromState = elementStates[triggerName] || DEFAULT_STATE_VALUE;
|
const fromState = elementStates[triggerName] || DEFAULT_STATE_VALUE;
|
||||||
const toState = new StateValue(VOID_VALUE);
|
const toState = new StateValue(VOID_VALUE);
|
||||||
const player = new TransitionAnimationPlayer(this.id, triggerName, element);
|
const player = new TransitionAnimationPlayer(this.id, triggerName, element);
|
||||||
|
@ -400,7 +403,6 @@ export class AnimationTransitionNamespace {
|
||||||
|
|
||||||
removeNode(element: any, context: any): void {
|
removeNode(element: any, context: any): void {
|
||||||
const engine = this._engine;
|
const engine = this._engine;
|
||||||
|
|
||||||
if (element.childElementCount) {
|
if (element.childElementCount) {
|
||||||
this._signalRemovalForInnerTriggers(element, context);
|
this._signalRemovalForInnerTriggers(element, context);
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,12 +5,16 @@
|
||||||
* Use of this source code is governed by an MIT-style license that can be
|
* Use of this source code is governed by an MIT-style license that can be
|
||||||
* found in the LICENSE file at https://angular.io/license
|
* found in the LICENSE file at https://angular.io/license
|
||||||
*/
|
*/
|
||||||
|
import {animate, AnimationEvent, state, style, transition, trigger} from '@angular/animations';
|
||||||
|
import {AnimationDriver} from '@angular/animations/browser';
|
||||||
|
import {MockAnimationDriver, MockAnimationPlayer} from '@angular/animations/browser/testing';
|
||||||
import {CommonModule} from '@angular/common';
|
import {CommonModule} from '@angular/common';
|
||||||
import {Component, ContentChild, Directive, ElementRef, EventEmitter, HostBinding, HostListener, Input, NgModule, OnInit, Output, Pipe, QueryList, TemplateRef, ViewChild, ViewChildren, ViewContainerRef} from '@angular/core';
|
import {Component, ContentChild, Directive, ElementRef, EventEmitter, HostBinding, HostListener, Input, NgModule, OnInit, Output, Pipe, QueryList, TemplateRef, ViewChild, ViewChildren, ViewContainerRef} from '@angular/core';
|
||||||
|
import {Inject} from '@angular/core/src/di';
|
||||||
import {TVIEW} from '@angular/core/src/render3/interfaces/view';
|
import {TVIEW} from '@angular/core/src/render3/interfaces/view';
|
||||||
import {getLView} from '@angular/core/src/render3/state';
|
import {getLView} from '@angular/core/src/render3/state';
|
||||||
import {ngDevModeResetPerfCounters} from '@angular/core/src/util/ng_dev_mode';
|
import {ngDevModeResetPerfCounters} from '@angular/core/src/util/ng_dev_mode';
|
||||||
import {TestBed} from '@angular/core/testing';
|
import {fakeAsync, flushMicrotasks, TestBed} from '@angular/core/testing';
|
||||||
import {By} from '@angular/platform-browser';
|
import {By} from '@angular/platform-browser';
|
||||||
import {expect} from '@angular/platform-browser/testing/src/matchers';
|
import {expect} from '@angular/platform-browser/testing/src/matchers';
|
||||||
import {ivyEnabled, onlyInIvy} from '@angular/private/testing';
|
import {ivyEnabled, onlyInIvy} from '@angular/private/testing';
|
||||||
|
@ -2017,4 +2021,218 @@ describe('acceptance integration tests', () => {
|
||||||
assertAttrValues(elements[2], 'post-update-pass');
|
assertAttrValues(elements[2], 'post-update-pass');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('animations', () => {
|
||||||
|
it('should apply triggers for a list of items when they are sorted and reSorted',
|
||||||
|
fakeAsync(() => {
|
||||||
|
interface Item {
|
||||||
|
value: any;
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
template: `
|
||||||
|
<div *ngIf="showWarningMessage; else listOfItems">
|
||||||
|
Nooo!
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ng-template #listOfItems>
|
||||||
|
<animation-comp *ngFor="let item of items; trackBy: itemTrackFn">
|
||||||
|
{{ item.value }}
|
||||||
|
</animation-comp>
|
||||||
|
</ng-template>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
class Cmp {
|
||||||
|
showWarningMessage = false;
|
||||||
|
|
||||||
|
items: Item[] = [
|
||||||
|
{value: 1, id: 1},
|
||||||
|
{value: 2, id: 2},
|
||||||
|
{value: 3, id: 3},
|
||||||
|
{value: 4, id: 4},
|
||||||
|
{value: 5, id: 5},
|
||||||
|
];
|
||||||
|
|
||||||
|
itemTrackFn(value: Item) {
|
||||||
|
return value.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'animation-comp',
|
||||||
|
animations: [
|
||||||
|
trigger(
|
||||||
|
'host',
|
||||||
|
[
|
||||||
|
state('void', style({height: '0px'})),
|
||||||
|
transition(
|
||||||
|
'* => *',
|
||||||
|
[
|
||||||
|
animate('1s'),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
<ng-content></ng-content>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
class AnimationComp {
|
||||||
|
@HostBinding('@host') public hostState = '';
|
||||||
|
|
||||||
|
@HostListener('@host.start', ['$event'])
|
||||||
|
onLeaveStart(event: AnimationEvent) {
|
||||||
|
// we just want to register the listener
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
declarations: [Cmp, AnimationComp],
|
||||||
|
providers: [{provide: AnimationDriver, useClass: MockAnimationDriver}],
|
||||||
|
});
|
||||||
|
const fixture = TestBed.createComponent(Cmp);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
let elements = queryAll(fixture.nativeElement, 'animation-comp');
|
||||||
|
expect(elements.length).toEqual(5);
|
||||||
|
expect(elements.map(e => e.textContent?.trim())).toEqual(['1', '2', '3', '4', '5']);
|
||||||
|
|
||||||
|
const items = fixture.componentInstance.items;
|
||||||
|
arraySwap(items, 2, 0); // 3 2 1 4 5
|
||||||
|
arraySwap(items, 2, 1); // 3 1 2 4 5
|
||||||
|
const first = items.shift()!;
|
||||||
|
items.push(first); // 1 2 4 5 3
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
elements = queryAll(fixture.nativeElement, 'animation-comp');
|
||||||
|
expect(elements.length).toEqual(5);
|
||||||
|
expect(elements.map(e => e.textContent?.trim())).toEqual(['1', '2', '4', '5', '3']);
|
||||||
|
completeAnimations();
|
||||||
|
|
||||||
|
fixture.componentInstance.showWarningMessage = true;
|
||||||
|
fixture.detectChanges();
|
||||||
|
completeAnimations();
|
||||||
|
|
||||||
|
elements = queryAll(fixture.nativeElement, 'animation-comp');
|
||||||
|
expect(elements.length).toEqual(0);
|
||||||
|
expect(fixture.nativeElement.textContent.trim()).toEqual('Nooo!');
|
||||||
|
|
||||||
|
fixture.componentInstance.showWarningMessage = false;
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
elements = queryAll(fixture.nativeElement, 'animation-comp');
|
||||||
|
expect(elements.length).toEqual(5);
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('should insert and remove views in the correct order when animations are present',
|
||||||
|
fakeAsync(() => {
|
||||||
|
@Component({
|
||||||
|
animations: [
|
||||||
|
trigger('root', [transition('* => *', [])]),
|
||||||
|
trigger('outer', [transition('* => *', [])]),
|
||||||
|
trigger('inner', [transition('* => *', [])]),
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
<div *ngIf="showRoot" (@root.start)="track('root', $event)" @root>
|
||||||
|
<div *ngIf="showIfContents; else innerCompList" (@outer.start)="track('outer', $event)" @outer>
|
||||||
|
Nooo!
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ng-template #innerCompList>
|
||||||
|
<inner-comp *ngFor="let item of items; trackBy: itemTrackFn" (@inner.start)="track('inner', $event)" @inner>
|
||||||
|
{{ item.value }}
|
||||||
|
</inner-comp>
|
||||||
|
</ng-template>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
class Cmp {
|
||||||
|
showRoot = true;
|
||||||
|
showIfContents = true;
|
||||||
|
items = [1];
|
||||||
|
log: string[] = [];
|
||||||
|
|
||||||
|
track(name: string, event: AnimationEvent) {
|
||||||
|
this.log.push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'inner-comp',
|
||||||
|
animations: [
|
||||||
|
trigger('host', [transition('* => *', [])]),
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
<ng-content></ng-content>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
class InnerComp {
|
||||||
|
@HostBinding('@host') public hostState = '';
|
||||||
|
|
||||||
|
constructor(@Inject(Cmp) private parent: Cmp) {}
|
||||||
|
|
||||||
|
@HostListener('@host.start', ['$event'])
|
||||||
|
onLeaveStart(event: AnimationEvent) {
|
||||||
|
this.parent.log.push('host');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
declarations: [Cmp, InnerComp],
|
||||||
|
providers: [{provide: AnimationDriver, useClass: MockAnimationDriver}],
|
||||||
|
});
|
||||||
|
const fixture = TestBed.createComponent(Cmp);
|
||||||
|
fixture.detectChanges();
|
||||||
|
completeAnimations();
|
||||||
|
|
||||||
|
const comp = fixture.componentInstance;
|
||||||
|
expect(comp.log).toEqual([
|
||||||
|
'root', // insertion of the inner-comp content
|
||||||
|
'outer', // insertion of the default ngIf
|
||||||
|
]);
|
||||||
|
|
||||||
|
comp.log = [];
|
||||||
|
comp.showIfContents = false;
|
||||||
|
fixture.detectChanges();
|
||||||
|
completeAnimations();
|
||||||
|
|
||||||
|
expect(comp.log).toEqual([
|
||||||
|
'host', // insertion of the inner-comp content
|
||||||
|
'outer', // insertion of the template into the ngIf
|
||||||
|
'inner' // insertion of the inner comp element
|
||||||
|
]);
|
||||||
|
|
||||||
|
comp.log = [];
|
||||||
|
comp.showRoot = false;
|
||||||
|
fixture.detectChanges();
|
||||||
|
completeAnimations();
|
||||||
|
|
||||||
|
expect(comp.log).toEqual([
|
||||||
|
'root', // removal the root div container
|
||||||
|
'host', // removal of the inner-comp content
|
||||||
|
'inner' // removal of the inner comp element
|
||||||
|
]);
|
||||||
|
}));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function completeAnimations() {
|
||||||
|
flushMicrotasks();
|
||||||
|
const log = MockAnimationDriver.log as MockAnimationPlayer[];
|
||||||
|
log.forEach(player => player.finish());
|
||||||
|
flushMicrotasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
function arraySwap(arr: any[], indexA: number, indexB: number): void {
|
||||||
|
const item = arr[indexA];
|
||||||
|
arr[indexA] = arr[indexB];
|
||||||
|
arr[indexB] = item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queries the provided `root` element for sub elements by the selector and casts the result as an
|
||||||
|
* array of elements
|
||||||
|
*/
|
||||||
|
function queryAll(root: HTMLElement, selector: string): HTMLElement[] {
|
||||||
|
return Array.from(root.querySelectorAll(selector));
|
||||||
|
}
|
||||||
|
|
|
@ -8,7 +8,6 @@
|
||||||
import {animate, animateChild, AnimationEvent, AnimationOptions, AUTO_STYLE, group, keyframes, query, state, style, transition, trigger, ɵPRE_STYLE as PRE_STYLE} from '@angular/animations';
|
import {animate, animateChild, AnimationEvent, AnimationOptions, AUTO_STYLE, group, keyframes, query, state, style, transition, trigger, ɵPRE_STYLE as PRE_STYLE} from '@angular/animations';
|
||||||
import {AnimationDriver, ɵAnimationEngine, ɵNoopAnimationDriver as NoopAnimationDriver} from '@angular/animations/browser';
|
import {AnimationDriver, ɵAnimationEngine, ɵNoopAnimationDriver as NoopAnimationDriver} from '@angular/animations/browser';
|
||||||
import {MockAnimationDriver, MockAnimationPlayer} from '@angular/animations/browser/testing';
|
import {MockAnimationDriver, MockAnimationPlayer} from '@angular/animations/browser/testing';
|
||||||
import {ɵgetDOM as getDOM} from '@angular/common';
|
|
||||||
import {ChangeDetectorRef, Component, HostBinding, HostListener, Inject, RendererFactory2, ViewChild} from '@angular/core';
|
import {ChangeDetectorRef, Component, HostBinding, HostListener, Inject, RendererFactory2, ViewChild} from '@angular/core';
|
||||||
import {fakeAsync, flushMicrotasks, TestBed} from '@angular/core/testing';
|
import {fakeAsync, flushMicrotasks, TestBed} from '@angular/core/testing';
|
||||||
import {ɵDomRendererFactory2} from '@angular/platform-browser';
|
import {ɵDomRendererFactory2} from '@angular/platform-browser';
|
||||||
|
|
Loading…
Reference in New Issue