Skip to content

Commit ac50bd6

Browse files
matskochuckjaz
authored andcommitted
fix(animations): ensure inner :leave animations do not remove node when skipped (#19532)
PR Close #19532
1 parent 9130505 commit ac50bd6

File tree

2 files changed

+143
-71
lines changed

2 files changed

+143
-71
lines changed

packages/animations/browser/src/render/transition_animation_engine.ts

Lines changed: 85 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ export class AnimationTransitionNamespace {
141141
if (!triggersWithStates.hasOwnProperty(name)) {
142142
addClass(element, NG_TRIGGER_CLASSNAME);
143143
addClass(element, NG_TRIGGER_CLASSNAME + '-' + name);
144-
triggersWithStates[name] = null;
144+
triggersWithStates[name] = DEFAULT_STATE_VALUE;
145145
}
146146

147147
return () => {
@@ -305,47 +305,87 @@ export class AnimationTransitionNamespace {
305305
}
306306
}
307307

308-
private _destroyInnerNodes(rootElement: any, context: any, animate: boolean = false) {
308+
private _signalRemovalForInnerTriggers(rootElement: any, context: any, animate: boolean = false) {
309309
// emulate a leave animation for all inner nodes within this node.
310310
// If there are no animations found for any of the nodes then clear the cache
311311
// for the element.
312312
this._engine.driver.query(rootElement, NG_TRIGGER_SELECTOR, true).forEach(elm => {
313313
const namespaces = this._engine.fetchNamespacesByElement(elm);
314314
if (namespaces.size) {
315-
namespaces.forEach(ns => ns.removeNode(elm, context, true));
315+
namespaces.forEach(ns => { ns.triggerLeaveAnimation(elm, context, false, true); });
316316
} else {
317317
this.clearElementCache(elm);
318318
}
319319
});
320320
}
321321

322-
removeNode(element: any, context: any, doNotRecurse?: boolean): void {
323-
const engine = this._engine;
324-
325-
if (!doNotRecurse && element.childElementCount) {
326-
this._destroyInnerNodes(element, context, true);
327-
}
328-
329-
const triggerStates = engine.statesByElement.get(element);
322+
triggerLeaveAnimation(
323+
element: any, context: any, destroyAfterComplete?: boolean,
324+
defaultToFallback?: boolean): boolean {
325+
const triggerStates = this._engine.statesByElement.get(element);
330326
if (triggerStates) {
331327
const players: TransitionAnimationPlayer[] = [];
332328
Object.keys(triggerStates).forEach(triggerName => {
333329
// this check is here in the event that an element is removed
334330
// twice (both on the host level and the component level)
335331
if (this._triggers[triggerName]) {
336-
const player = this.trigger(element, triggerName, VOID_VALUE, false);
332+
const player = this.trigger(element, triggerName, VOID_VALUE, defaultToFallback);
337333
if (player) {
338334
players.push(player);
339335
}
340336
}
341337
});
342338

343339
if (players.length) {
344-
engine.markElementAsRemoved(this.id, element, true, context);
345-
optimizeGroupPlayer(players).onDone(() => engine.processLeaveNode(element));
346-
return;
340+
this._engine.markElementAsRemoved(this.id, element, true, context);
341+
if (destroyAfterComplete) {
342+
optimizeGroupPlayer(players).onDone(() => this._engine.processLeaveNode(element));
343+
}
344+
return true;
347345
}
348346
}
347+
return false;
348+
}
349+
350+
prepareLeaveAnimationListeners(element: any) {
351+
const listeners = this._elementListeners.get(element);
352+
if (listeners) {
353+
const visitedTriggers = new Set<string>();
354+
listeners.forEach(listener => {
355+
const triggerName = listener.name;
356+
if (visitedTriggers.has(triggerName)) return;
357+
visitedTriggers.add(triggerName);
358+
359+
const trigger = this._triggers[triggerName];
360+
const transition = trigger.fallbackTransition;
361+
const elementStates = this._engine.statesByElement.get(element) !;
362+
const fromState = elementStates[triggerName] || DEFAULT_STATE_VALUE;
363+
const toState = new StateValue(VOID_VALUE);
364+
const player = new TransitionAnimationPlayer(this.id, triggerName, element);
365+
366+
this._engine.totalQueuedPlayers++;
367+
this._queue.push({
368+
element,
369+
triggerName,
370+
transition,
371+
fromState,
372+
toState,
373+
player,
374+
isFallbackTransition: true
375+
});
376+
});
377+
}
378+
}
379+
380+
removeNode(element: any, context: any): void {
381+
const engine = this._engine;
382+
383+
if (element.childElementCount) {
384+
this._signalRemovalForInnerTriggers(element, context, true);
385+
}
386+
387+
// this means that a * => VOID animation was detected and kicked off
388+
if (this.triggerLeaveAnimation(element, context, true)) return;
349389

350390
// find the player that is animating and make sure that the
351391
// removal is delayed until that player has completed
@@ -376,33 +416,7 @@ export class AnimationTransitionNamespace {
376416
// during flush or will be picked up by a parent query. Either way
377417
// we need to fire the listeners for this element when it DOES get
378418
// removed (once the query parent animation is done or after flush)
379-
const listeners = this._elementListeners.get(element);
380-
if (listeners) {
381-
const visitedTriggers = new Set<string>();
382-
listeners.forEach(listener => {
383-
const triggerName = listener.name;
384-
if (visitedTriggers.has(triggerName)) return;
385-
visitedTriggers.add(triggerName);
386-
387-
const trigger = this._triggers[triggerName];
388-
const transition = trigger.fallbackTransition;
389-
const elementStates = engine.statesByElement.get(element) !;
390-
const fromState = elementStates[triggerName] || DEFAULT_STATE_VALUE;
391-
const toState = new StateValue(VOID_VALUE);
392-
const player = new TransitionAnimationPlayer(this.id, triggerName, element);
393-
394-
this._engine.totalQueuedPlayers++;
395-
this._queue.push({
396-
element,
397-
triggerName,
398-
transition,
399-
fromState,
400-
toState,
401-
player,
402-
isFallbackTransition: true
403-
});
404-
});
405-
}
419+
this.prepareLeaveAnimationListeners(element);
406420

407421
// whether or not a parent has an animation we need to delay the deferral of the leave
408422
// operation until we have more information (which we do after flush() has been called)
@@ -465,7 +479,7 @@ export class AnimationTransitionNamespace {
465479

466480
destroy(context: any) {
467481
this.players.forEach(p => p.destroy());
468-
this._destroyInnerNodes(this.hostElement, context);
482+
this._signalRemovalForInnerTriggers(this.hostElement, context);
469483
}
470484

471485
elementContainsData(element: any): boolean {
@@ -668,15 +682,15 @@ export class TransitionAnimationEngine {
668682
}
669683
}
670684

671-
removeNode(namespaceId: string, element: any, context: any, doNotRecurse?: boolean): void {
685+
removeNode(namespaceId: string, element: any, context: any): void {
672686
if (!isElementNode(element)) {
673687
this._onRemovalComplete(element, context);
674688
return;
675689
}
676690

677691
const ns = namespaceId ? this._fetchNamespace(namespaceId) : null;
678692
if (ns) {
679-
ns.removeNode(element, context, doNotRecurse);
693+
ns.removeNode(element, context);
680694
} else {
681695
this.markElementAsRemoved(namespaceId, element, false, context);
682696
}
@@ -708,37 +722,39 @@ export class TransitionAnimationEngine {
708722

709723
destroyInnerAnimations(containerElement: any) {
710724
let elements = this.driver.query(containerElement, NG_TRIGGER_SELECTOR, true);
711-
elements.forEach(element => {
712-
const players = this.playersByElement.get(element);
713-
if (players) {
714-
players.forEach(player => {
715-
// special case for when an element is set for destruction, but hasn't started.
716-
// in this situation we want to delay the destruction until the flush occurs
717-
// so that any event listeners attached to the player are triggered.
718-
if (player.queued) {
719-
player.markedForDestroy = true;
720-
} else {
721-
player.destroy();
722-
}
723-
});
724-
}
725-
const stateMap = this.statesByElement.get(element);
726-
if (stateMap) {
727-
Object.keys(stateMap).forEach(triggerName => stateMap[triggerName] = DELETED_STATE_VALUE);
728-
}
729-
});
725+
elements.forEach(element => this.destroyActiveAnimationsForElement(element));
730726

731727
if (this.playersByQueriedElement.size == 0) return;
732728

733729
elements = this.driver.query(containerElement, NG_ANIMATING_SELECTOR, true);
734-
if (elements.length) {
735-
elements.forEach(element => {
736-
const players = this.playersByQueriedElement.get(element);
737-
if (players) {
738-
players.forEach(player => player.finish());
730+
elements.forEach(element => this.finishActiveQueriedAnimationOnElement(element));
731+
}
732+
733+
destroyActiveAnimationsForElement(element: any) {
734+
const players = this.playersByElement.get(element);
735+
if (players) {
736+
players.forEach(player => {
737+
// special case for when an element is set for destruction, but hasn't started.
738+
// in this situation we want to delay the destruction until the flush occurs
739+
// so that any event listeners attached to the player are triggered.
740+
if (player.queued) {
741+
player.markedForDestroy = true;
742+
} else {
743+
player.destroy();
739744
}
740745
});
741746
}
747+
const stateMap = this.statesByElement.get(element);
748+
if (stateMap) {
749+
Object.keys(stateMap).forEach(triggerName => stateMap[triggerName] = DELETED_STATE_VALUE);
750+
}
751+
}
752+
753+
finishActiveQueriedAnimationOnElement(element: any) {
754+
const players = this.playersByQueriedElement.get(element);
755+
if (players) {
756+
players.forEach(player => player.finish());
757+
}
742758
}
743759

744760
whenRenderingDone(): Promise<any> {

packages/core/test/animation/animation_query_integration_spec.ts

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2380,6 +2380,62 @@ export function main() {
23802380
]);
23812381
});
23822382

2383+
it('should not cause a removal of inner @trigger DOM nodes when a parent animation occurs',
2384+
fakeAsync(() => {
2385+
@Component({
2386+
selector: 'ani-cmp',
2387+
template: `
2388+
<div @parent *ngIf="exp" class="parent">
2389+
this <div @child>child</div>
2390+
</div>
2391+
`,
2392+
animations: [
2393+
trigger(
2394+
'parent',
2395+
[
2396+
transition(
2397+
':leave',
2398+
[
2399+
style({opacity: 0}),
2400+
animate('1s', style({opacity: 1})),
2401+
]),
2402+
]),
2403+
trigger(
2404+
'child',
2405+
[
2406+
transition(
2407+
'* => something',
2408+
[
2409+
style({opacity: 0}),
2410+
animate('1s', style({opacity: 1})),
2411+
]),
2412+
]),
2413+
]
2414+
})
2415+
class Cmp {
2416+
public exp: boolean = true;
2417+
}
2418+
2419+
TestBed.configureTestingModule({declarations: [Cmp]});
2420+
2421+
const fixture = TestBed.createComponent(Cmp);
2422+
const cmp = fixture.componentInstance;
2423+
2424+
cmp.exp = true;
2425+
fixture.detectChanges();
2426+
flushMicrotasks();
2427+
2428+
cmp.exp = false;
2429+
fixture.detectChanges();
2430+
flushMicrotasks();
2431+
2432+
const players = getLog();
2433+
expect(players.length).toEqual(1);
2434+
2435+
const element = players[0] !.element;
2436+
expect(element.innerText.trim()).toMatch(/this\s+child/mg);
2437+
}));
2438+
23832439
it('should only mark outermost *directive nodes :enter and :leave when inserts and removals occur',
23842440
() => {
23852441
@Component({
@@ -2619,8 +2675,8 @@ export function main() {
26192675
fixture.detectChanges();
26202676
flushMicrotasks();
26212677
expect(cmp.log).toEqual([
2622-
'c1-start', 'c1-done', 'c2-start', 'c2-done', 'p-start', 'p-done', 'c3-start',
2623-
'c3-done'
2678+
'c1-start', 'c1-done', 'c2-start', 'c2-done', 'p-start', 'c3-start', 'c3-done',
2679+
'p-done'
26242680
]);
26252681
}));
26262682

0 commit comments

Comments
 (0)