Skip to content

Commit dd7c4cd

Browse files
thePunderWomanmmalerba
authored andcommitted
fix(core): Fixes animations in conjunction with content projection (angular#63776)
Content Projected nodes are not destroyed and recreated, like every other situation. Enter and Leave animations were ephemeral and are expected to run once, and then be cleared. This means that for content projection cases, the animations would only ever work the first time they were shown / hid. In order to resolve this, we move to an animation queue that re-runs the animation functions stored in the LView. In most cases, this animation will run once on creation. For content projection, the enter and leave animations will fire more than once. Animations are stored on the LView, but indexed and scheduled by whichever RNode needs to be animated. So we only run animations for an affected RNode, rather than potentially all in the LView. This also moves the queue to afterRender, which is safer than right after template execution in refreshView. fixes: angular#63418 fixes: angular#64065 fixes: angular#63901 PR Close angular#63776
1 parent cb56fbf commit dd7c4cd

File tree

15 files changed

+436
-91
lines changed

15 files changed

+436
-91
lines changed

packages/core/src/animation/interfaces.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,27 @@ export const ANIMATIONS_DISABLED = new InjectionToken<boolean>(
1818
},
1919
);
2020

21+
export interface AnimationQueue {
22+
queue: Set<Function>;
23+
isScheduled: boolean;
24+
}
25+
26+
/**
27+
* A [DI token](api/core/InjectionToken) for the queue of all animations.
28+
*/
29+
export const ANIMATION_QUEUE = new InjectionToken<AnimationQueue>(
30+
typeof ngDevMode !== 'undefined' && ngDevMode ? 'AnimationQueue' : '',
31+
{
32+
providedIn: 'root',
33+
factory: () => {
34+
return {
35+
queue: new Set(),
36+
isScheduled: false,
37+
};
38+
},
39+
},
40+
);
41+
2142
/**
2243
* The event type for when `animate.enter` and `animate.leave` are used with function
2344
* callbacks.
@@ -78,10 +99,10 @@ export interface LongestAnimation {
7899

79100
export interface AnimationLViewData {
80101
// Enter animations that apply to nodes in this view
81-
enter?: Function[];
102+
enter?: Map<number, Function[]>;
82103

83104
// Leave animations that apply to nodes in this view
84-
leave?: (() => Promise<void>)[];
105+
leave?: Map<number, Function[]>;
85106

86107
// Leave animations that apply to nodes in this view
87108
// We chose to use unknown instead of PromiseSettledResult<void> to avoid requiring the type

packages/core/src/animation/utils.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -162,17 +162,17 @@ export function trackLeavingNodes(tNode: TNode, el: HTMLElement): void {
162162
/**
163163
* Retrieves the list of specified enter animations from the lView
164164
*/
165-
export function getLViewEnterAnimations(lView: LView): Function[] {
165+
export function getLViewEnterAnimations(lView: LView): Map<number, Function[]> {
166166
const animationData = (lView[ANIMATIONS] ??= {});
167-
return (animationData.enter ??= []);
167+
return (animationData.enter ??= new Map<number, Function[]>());
168168
}
169169

170170
/**
171171
* Retrieves the list of specified leave animations from the lView
172172
*/
173-
export function getLViewLeaveAnimations(lView: LView): Function[] {
173+
export function getLViewLeaveAnimations(lView: LView): Map<number, Function[]> {
174174
const animationData = (lView[ANIMATIONS] ??= {});
175-
return (animationData.leave ??= []);
175+
return (animationData.leave ??= new Map<number, Function[]>());
176176
}
177177

178178
/**
@@ -244,8 +244,18 @@ export function isLongestAnimation(
244244
}
245245

246246
/**
247-
* Determines if a given tNode is a content projection root node.
247+
* Stores a given animation function in the LView's animation map for later execution
248+
*
249+
* @param animations Either the enter or leave animation map from the LView
250+
* @param tNode The TNode the animation is associated with
251+
* @param fn The animation function to be called later
248252
*/
249-
export function isTNodeContentProjectionRoot(tNode: TNode): boolean {
250-
return Array.isArray(tNode.projection);
253+
export function addAnimationToLView(
254+
animations: Map<number, Function[]>,
255+
tNode: TNode,
256+
fn: Function,
257+
) {
258+
const animationFns = animations.get(tNode.index) ?? [];
259+
animationFns.push(fn);
260+
animations.set(tNode.index, animationFns);
251261
}

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

Lines changed: 73 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,25 @@
77
*/
88

99
import {
10+
ANIMATION_QUEUE,
1011
AnimationCallbackEvent,
1112
AnimationFunction,
1213
MAX_ANIMATION_TIMEOUT,
1314
} from '../../animation/interfaces';
1415
import {getLView, getCurrentTNode} from '../state';
15-
import {RENDERER, INJECTOR, CONTEXT, LView} from '../interfaces/view';
16+
import {RENDERER, INJECTOR, CONTEXT, LView, ANIMATIONS} from '../interfaces/view';
1617
import {getNativeByTNode} from '../util/view_utils';
1718
import {performanceMarkFeature} from '../../util/performance';
1819
import {Renderer} from '../interfaces/renderer';
1920
import {NgZone} from '../../zone';
2021
import {determineLongestAnimation, allLeavingAnimations} from '../../animation/longest_animation';
2122
import {TNode} from '../interfaces/node';
2223
import {promiseWithResolvers} from '../../util/promise_with_resolvers';
24+
import {Injector} from '../../di';
25+
import {afterEveryRender} from '../after_render/hooks';
2326

2427
import {
28+
addAnimationToLView,
2529
areAnimationsDisabled,
2630
areAnimationSupported,
2731
assertAnimationTypes,
@@ -66,12 +70,16 @@ export function ɵɵanimateEnter(value: string | Function): typeof ɵɵanimateEn
6670

6771
cancelLeavingNodes(tNode, lView);
6872

69-
getLViewEnterAnimations(lView).push(() => runEnterAnimation(lView, tNode, value));
73+
addAnimationToLView(getLViewEnterAnimations(lView), tNode, () =>
74+
runEnterAnimation(lView, tNode, value),
75+
);
76+
77+
queueEnterAnimations(lView);
7078

7179
return ɵɵanimateEnter; // For chaining
7280
}
7381

74-
export function runEnterAnimation(lView: LView, tNode: TNode, value: string | Function): void {
82+
export function runEnterAnimation(lView: LView, tNode: TNode, value: string | Function) {
7583
const nativeElement = getNativeByTNode(tNode, lView) as HTMLElement;
7684

7785
ngDevMode && assertElementNodes(nativeElement, 'animate.enter');
@@ -94,7 +102,7 @@ export function runEnterAnimation(lView: LView, tNode: TNode, value: string | Fu
94102

95103
const eventName = event instanceof AnimationEvent ? 'animationend' : 'transitionend';
96104
ngZone.runOutsideAngular(() => {
97-
cleanupFns.push(renderer.listen(nativeElement, eventName, handleEnterAnimationEnd));
105+
renderer.listen(nativeElement, eventName, handleEnterAnimationEnd);
98106
});
99107
};
100108

@@ -118,6 +126,7 @@ export function runEnterAnimation(lView: LView, tNode: TNode, value: string | Fu
118126
for (const klass of activeClasses) {
119127
renderer.addClass(nativeElement, klass);
120128
}
129+
121130
// In the case that the classes added have no animations, we need to remove
122131
// the classes right away. This could happen because someone is intentionally
123132
// preventing an animation via selector specificity.
@@ -182,15 +191,19 @@ export function ɵɵanimateEnterListener(value: AnimationFunction): typeof ɵɵa
182191

183192
cancelLeavingNodes(tNode, lView);
184193

185-
getLViewEnterAnimations(lView).push(() => runEnterAnimationFunction(lView, tNode, value));
194+
addAnimationToLView(getLViewEnterAnimations(lView), tNode, () =>
195+
runEnterAnimationFunction(lView, tNode, value),
196+
);
197+
198+
queueEnterAnimations(lView);
186199

187200
return ɵɵanimateEnterListener;
188201
}
189202

190203
/**
191204
* runs enter animations when a custom function is provided
192205
*/
193-
function runEnterAnimationFunction(lView: LView, tNode: TNode, value: AnimationFunction) {
206+
function runEnterAnimationFunction(lView: LView, tNode: TNode, value: AnimationFunction): void {
194207
const nativeElement = getNativeByTNode(tNode, lView) as HTMLElement;
195208
ngDevMode && assertElementNodes(nativeElement, 'animate.enter');
196209

@@ -224,19 +237,16 @@ export function ɵɵanimateLeave(value: string | Function): typeof ɵɵanimateLe
224237

225238
const tNode = getCurrentTNode()!;
226239

227-
getLViewLeaveAnimations(lView).push(() =>
228-
runLeaveAnimations(lView, tNode, value, animationsDisabled),
240+
addAnimationToLView(getLViewLeaveAnimations(lView), tNode, () =>
241+
runLeaveAnimations(lView, tNode, value),
229242
);
230243

244+
enableAnimationQueueScheduler(lView[INJECTOR]);
245+
231246
return ɵɵanimateLeave; // For chaining
232247
}
233248

234-
function runLeaveAnimations(
235-
lView: LView,
236-
tNode: TNode,
237-
value: string | Function,
238-
animationsDisabled: boolean,
239-
): Promise<void> {
249+
function runLeaveAnimations(lView: LView, tNode: TNode, value: string | Function): Promise<void> {
240250
const {promise, resolve} = promiseWithResolvers<void>();
241251
const nativeElement = getNativeByTNode(tNode, lView) as Element;
242252

@@ -253,7 +263,6 @@ function runLeaveAnimations(
253263
tNode,
254264
activeClasses,
255265
renderer,
256-
animationsDisabled,
257266
ngZone,
258267
resolve,
259268
);
@@ -273,17 +282,11 @@ function animateLeaveClassRunner(
273282
tNode: TNode,
274283
classList: string[],
275284
renderer: Renderer,
276-
animationsDisabled: boolean,
277285
ngZone: NgZone,
278286
resolver: VoidFunction,
279287
) {
280-
if (animationsDisabled) {
281-
longestAnimations.delete(el);
282-
resolver();
283-
return;
284-
}
285-
286288
cancelAnimationsIfRunning(el, renderer);
289+
const cleanupFns: Function[] = [];
287290

288291
const handleOutAnimationEnd = (event: AnimationEvent | TransitionEvent | CustomEvent) => {
289292
// this early exit case is to prevent issues with bubbling events that are from child element animations
@@ -307,11 +310,14 @@ function animateLeaveClassRunner(
307310
}
308311
}
309312
resolver();
313+
for (const fn of cleanupFns) {
314+
fn();
315+
}
310316
};
311317

312318
ngZone.runOutsideAngular(() => {
313-
renderer.listen(el, 'animationend', handleOutAnimationEnd);
314-
renderer.listen(el, 'transitionend', handleOutAnimationEnd);
319+
cleanupFns.push(renderer.listen(el, 'animationend', handleOutAnimationEnd));
320+
cleanupFns.push(renderer.listen(el, 'transitionend', handleOutAnimationEnd));
315321
});
316322
trackLeavingNodes(tNode, el);
317323
for (const item of classList) {
@@ -326,6 +332,9 @@ function animateLeaveClassRunner(
326332
if (!longestAnimations.has(el)) {
327333
clearLeavingNodes(tNode, el);
328334
resolver();
335+
for (const fn of cleanupFns) {
336+
fn();
337+
}
329338
}
330339
});
331340
});
@@ -359,7 +368,11 @@ export function ɵɵanimateLeaveListener(value: AnimationFunction): typeof ɵɵa
359368
const tNode = getCurrentTNode()!;
360369
allLeavingAnimations.add(lView);
361370

362-
getLViewLeaveAnimations(lView).push(() => runLeaveAnimationFunction(lView, tNode, value));
371+
addAnimationToLView(getLViewLeaveAnimations(lView), tNode, () =>
372+
runLeaveAnimationFunction(lView, tNode, value),
373+
);
374+
375+
enableAnimationQueueScheduler(lView[INJECTOR]);
363376

364377
return ɵɵanimateLeaveListener; // For chaining
365378
}
@@ -416,3 +429,38 @@ function runLeaveAnimationFunction(
416429
// Ensure cleanup if the LView is destroyed before the animation runs.
417430
return promise;
418431
}
432+
433+
function queueEnterAnimations(lView: LView) {
434+
enableAnimationQueueScheduler(lView[INJECTOR]);
435+
const enterAnimations = lView[ANIMATIONS]?.enter;
436+
if (enterAnimations) {
437+
const animationQueue = lView[INJECTOR].get(ANIMATION_QUEUE);
438+
for (const [_, animateFns] of enterAnimations) {
439+
for (const animateFn of animateFns) {
440+
animationQueue.queue.add(animateFn);
441+
}
442+
}
443+
}
444+
}
445+
446+
function enableAnimationQueueScheduler(injector: Injector) {
447+
const animationQueue = injector.get(ANIMATION_QUEUE);
448+
// We only need to schedule the animation queue runner once per application.
449+
if (!animationQueue.isScheduled) {
450+
afterEveryRender(
451+
() => {
452+
runQueuedAnimations(injector);
453+
},
454+
{injector},
455+
);
456+
animationQueue.isScheduled = true;
457+
}
458+
}
459+
460+
function runQueuedAnimations(injector: Injector) {
461+
const animationQueue = injector.get(ANIMATION_QUEUE);
462+
for (let animateFn of animationQueue.queue) {
463+
animateFn();
464+
}
465+
animationQueue.queue.clear();
466+
}

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

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ import {ComponentTemplate, HostBindingsFunction, RenderFlags} from '../interface
2525
import {
2626
CONTEXT,
2727
EFFECTS_TO_SCHEDULE,
28-
ANIMATIONS,
2928
ENVIRONMENT,
3029
FLAGS,
3130
InitPhaseState,
@@ -232,7 +231,6 @@ export function refreshView<T>(
232231
if (templateFn !== null) {
233232
executeTemplate(tView, lView, templateFn, RenderFlags.Update, context);
234233
}
235-
runEnterAnimations(lView);
236234

237235
const hooksInitPhaseCompleted =
238236
(flags & LViewFlags.InitPhaseStateMask) === InitPhaseState.InitPhaseCompleted;
@@ -374,16 +372,6 @@ export function refreshView<T>(
374372
}
375373
}
376374

377-
function runEnterAnimations(lView: LView) {
378-
const animationData = lView[ANIMATIONS];
379-
if (animationData?.enter) {
380-
for (const animateFn of animationData.enter) {
381-
animateFn();
382-
}
383-
animationData.enter = undefined;
384-
}
385-
}
386-
387375
/**
388376
* Goes over embedded views (ones created through ViewContainerRef APIs) and refreshes
389377
* them by executing an associated template function.

0 commit comments

Comments
 (0)