diff --git a/packages/core/src/animation/interfaces.ts b/packages/core/src/animation/interfaces.ts index 36a6fcfd6c9..d94ec0ae7e3 100644 --- a/packages/core/src/animation/interfaces.ts +++ b/packages/core/src/animation/interfaces.ts @@ -18,6 +18,27 @@ export const ANIMATIONS_DISABLED = new InjectionToken( }, ); +export interface AnimationQueue { + queue: Set; + isScheduled: boolean; +} + +/** + * A [DI token](api/core/InjectionToken) for the queue of all animations. + */ +export const ANIMATION_QUEUE = new InjectionToken( + typeof ngDevMode !== 'undefined' && ngDevMode ? 'AnimationQueue' : '', + { + providedIn: 'root', + factory: () => { + return { + queue: new Set(), + isScheduled: false, + }; + }, + }, +); + /** * The event type for when `animate.enter` and `animate.leave` are used with function * callbacks. @@ -78,10 +99,10 @@ export interface LongestAnimation { export interface AnimationLViewData { // Enter animations that apply to nodes in this view - enter?: Function[]; + enter?: Map; // Leave animations that apply to nodes in this view - leave?: (() => Promise)[]; + leave?: Map; // Leave animations that apply to nodes in this view // We chose to use unknown instead of PromiseSettledResult to avoid requiring the type diff --git a/packages/core/src/animation/utils.ts b/packages/core/src/animation/utils.ts index 986fa7570ed..15a9c9b5492 100644 --- a/packages/core/src/animation/utils.ts +++ b/packages/core/src/animation/utils.ts @@ -162,17 +162,17 @@ export function trackLeavingNodes(tNode: TNode, el: HTMLElement): void { /** * Retrieves the list of specified enter animations from the lView */ -export function getLViewEnterAnimations(lView: LView): Function[] { +export function getLViewEnterAnimations(lView: LView): Map { const animationData = (lView[ANIMATIONS] ??= {}); - return (animationData.enter ??= []); + return (animationData.enter ??= new Map()); } /** * Retrieves the list of specified leave animations from the lView */ -export function getLViewLeaveAnimations(lView: LView): Function[] { +export function getLViewLeaveAnimations(lView: LView): Map { const animationData = (lView[ANIMATIONS] ??= {}); - return (animationData.leave ??= []); + return (animationData.leave ??= new Map()); } /** @@ -244,8 +244,18 @@ export function isLongestAnimation( } /** - * Determines if a given tNode is a content projection root node. + * Stores a given animation function in the LView's animation map for later execution + * + * @param animations Either the enter or leave animation map from the LView + * @param tNode The TNode the animation is associated with + * @param fn The animation function to be called later */ -export function isTNodeContentProjectionRoot(tNode: TNode): boolean { - return Array.isArray(tNode.projection); +export function addAnimationToLView( + animations: Map, + tNode: TNode, + fn: Function, +) { + const animationFns = animations.get(tNode.index) ?? []; + animationFns.push(fn); + animations.set(tNode.index, animationFns); } diff --git a/packages/core/src/render3/instructions/animation.ts b/packages/core/src/render3/instructions/animation.ts index a409ad08e43..432d63726bb 100644 --- a/packages/core/src/render3/instructions/animation.ts +++ b/packages/core/src/render3/instructions/animation.ts @@ -7,12 +7,13 @@ */ import { + ANIMATION_QUEUE, AnimationCallbackEvent, AnimationFunction, MAX_ANIMATION_TIMEOUT, } from '../../animation/interfaces'; import {getLView, getCurrentTNode} from '../state'; -import {RENDERER, INJECTOR, CONTEXT, LView} from '../interfaces/view'; +import {RENDERER, INJECTOR, CONTEXT, LView, ANIMATIONS} from '../interfaces/view'; import {getNativeByTNode} from '../util/view_utils'; import {performanceMarkFeature} from '../../util/performance'; import {Renderer} from '../interfaces/renderer'; @@ -20,8 +21,11 @@ import {NgZone} from '../../zone'; import {determineLongestAnimation, allLeavingAnimations} from '../../animation/longest_animation'; import {TNode} from '../interfaces/node'; import {promiseWithResolvers} from '../../util/promise_with_resolvers'; +import {Injector} from '../../di'; +import {afterEveryRender} from '../after_render/hooks'; import { + addAnimationToLView, areAnimationsDisabled, areAnimationSupported, assertAnimationTypes, @@ -66,12 +70,16 @@ export function ɵɵanimateEnter(value: string | Function): typeof ɵɵanimateEn cancelLeavingNodes(tNode, lView); - getLViewEnterAnimations(lView).push(() => runEnterAnimation(lView, tNode, value)); + addAnimationToLView(getLViewEnterAnimations(lView), tNode, () => + runEnterAnimation(lView, tNode, value), + ); + + queueEnterAnimations(lView); return ɵɵanimateEnter; // For chaining } -export function runEnterAnimation(lView: LView, tNode: TNode, value: string | Function): void { +export function runEnterAnimation(lView: LView, tNode: TNode, value: string | Function) { const nativeElement = getNativeByTNode(tNode, lView) as HTMLElement; ngDevMode && assertElementNodes(nativeElement, 'animate.enter'); @@ -94,7 +102,7 @@ export function runEnterAnimation(lView: LView, tNode: TNode, value: string | Fu const eventName = event instanceof AnimationEvent ? 'animationend' : 'transitionend'; ngZone.runOutsideAngular(() => { - cleanupFns.push(renderer.listen(nativeElement, eventName, handleEnterAnimationEnd)); + renderer.listen(nativeElement, eventName, handleEnterAnimationEnd); }); }; @@ -118,6 +126,7 @@ export function runEnterAnimation(lView: LView, tNode: TNode, value: string | Fu for (const klass of activeClasses) { renderer.addClass(nativeElement, klass); } + // In the case that the classes added have no animations, we need to remove // the classes right away. This could happen because someone is intentionally // preventing an animation via selector specificity. @@ -182,7 +191,11 @@ export function ɵɵanimateEnterListener(value: AnimationFunction): typeof ɵɵa cancelLeavingNodes(tNode, lView); - getLViewEnterAnimations(lView).push(() => runEnterAnimationFunction(lView, tNode, value)); + addAnimationToLView(getLViewEnterAnimations(lView), tNode, () => + runEnterAnimationFunction(lView, tNode, value), + ); + + queueEnterAnimations(lView); return ɵɵanimateEnterListener; } @@ -190,7 +203,7 @@ export function ɵɵanimateEnterListener(value: AnimationFunction): typeof ɵɵa /** * runs enter animations when a custom function is provided */ -function runEnterAnimationFunction(lView: LView, tNode: TNode, value: AnimationFunction) { +function runEnterAnimationFunction(lView: LView, tNode: TNode, value: AnimationFunction): void { const nativeElement = getNativeByTNode(tNode, lView) as HTMLElement; ngDevMode && assertElementNodes(nativeElement, 'animate.enter'); @@ -224,19 +237,16 @@ export function ɵɵanimateLeave(value: string | Function): typeof ɵɵanimateLe const tNode = getCurrentTNode()!; - getLViewLeaveAnimations(lView).push(() => - runLeaveAnimations(lView, tNode, value, animationsDisabled), + addAnimationToLView(getLViewLeaveAnimations(lView), tNode, () => + runLeaveAnimations(lView, tNode, value), ); + enableAnimationQueueScheduler(lView[INJECTOR]); + return ɵɵanimateLeave; // For chaining } -function runLeaveAnimations( - lView: LView, - tNode: TNode, - value: string | Function, - animationsDisabled: boolean, -): Promise { +function runLeaveAnimations(lView: LView, tNode: TNode, value: string | Function): Promise { const {promise, resolve} = promiseWithResolvers(); const nativeElement = getNativeByTNode(tNode, lView) as Element; @@ -253,7 +263,6 @@ function runLeaveAnimations( tNode, activeClasses, renderer, - animationsDisabled, ngZone, resolve, ); @@ -273,17 +282,11 @@ function animateLeaveClassRunner( tNode: TNode, classList: string[], renderer: Renderer, - animationsDisabled: boolean, ngZone: NgZone, resolver: VoidFunction, ) { - if (animationsDisabled) { - longestAnimations.delete(el); - resolver(); - return; - } - cancelAnimationsIfRunning(el, renderer); + const cleanupFns: Function[] = []; const handleOutAnimationEnd = (event: AnimationEvent | TransitionEvent | CustomEvent) => { // this early exit case is to prevent issues with bubbling events that are from child element animations @@ -307,11 +310,14 @@ function animateLeaveClassRunner( } } resolver(); + for (const fn of cleanupFns) { + fn(); + } }; ngZone.runOutsideAngular(() => { - renderer.listen(el, 'animationend', handleOutAnimationEnd); - renderer.listen(el, 'transitionend', handleOutAnimationEnd); + cleanupFns.push(renderer.listen(el, 'animationend', handleOutAnimationEnd)); + cleanupFns.push(renderer.listen(el, 'transitionend', handleOutAnimationEnd)); }); trackLeavingNodes(tNode, el); for (const item of classList) { @@ -326,6 +332,9 @@ function animateLeaveClassRunner( if (!longestAnimations.has(el)) { clearLeavingNodes(tNode, el); resolver(); + for (const fn of cleanupFns) { + fn(); + } } }); }); @@ -359,7 +368,11 @@ export function ɵɵanimateLeaveListener(value: AnimationFunction): typeof ɵɵa const tNode = getCurrentTNode()!; allLeavingAnimations.add(lView); - getLViewLeaveAnimations(lView).push(() => runLeaveAnimationFunction(lView, tNode, value)); + addAnimationToLView(getLViewLeaveAnimations(lView), tNode, () => + runLeaveAnimationFunction(lView, tNode, value), + ); + + enableAnimationQueueScheduler(lView[INJECTOR]); return ɵɵanimateLeaveListener; // For chaining } @@ -416,3 +429,38 @@ function runLeaveAnimationFunction( // Ensure cleanup if the LView is destroyed before the animation runs. return promise; } + +function queueEnterAnimations(lView: LView) { + enableAnimationQueueScheduler(lView[INJECTOR]); + const enterAnimations = lView[ANIMATIONS]?.enter; + if (enterAnimations) { + const animationQueue = lView[INJECTOR].get(ANIMATION_QUEUE); + for (const [_, animateFns] of enterAnimations) { + for (const animateFn of animateFns) { + animationQueue.queue.add(animateFn); + } + } + } +} + +function enableAnimationQueueScheduler(injector: Injector) { + const animationQueue = injector.get(ANIMATION_QUEUE); + // We only need to schedule the animation queue runner once per application. + if (!animationQueue.isScheduled) { + afterEveryRender( + () => { + runQueuedAnimations(injector); + }, + {injector}, + ); + animationQueue.isScheduled = true; + } +} + +function runQueuedAnimations(injector: Injector) { + const animationQueue = injector.get(ANIMATION_QUEUE); + for (let animateFn of animationQueue.queue) { + animateFn(); + } + animationQueue.queue.clear(); +} diff --git a/packages/core/src/render3/instructions/change_detection.ts b/packages/core/src/render3/instructions/change_detection.ts index 53941e04ce3..ec853d9c1f1 100644 --- a/packages/core/src/render3/instructions/change_detection.ts +++ b/packages/core/src/render3/instructions/change_detection.ts @@ -25,7 +25,6 @@ import {ComponentTemplate, HostBindingsFunction, RenderFlags} from '../interface import { CONTEXT, EFFECTS_TO_SCHEDULE, - ANIMATIONS, ENVIRONMENT, FLAGS, InitPhaseState, @@ -232,7 +231,6 @@ export function refreshView( if (templateFn !== null) { executeTemplate(tView, lView, templateFn, RenderFlags.Update, context); } - runEnterAnimations(lView); const hooksInitPhaseCompleted = (flags & LViewFlags.InitPhaseStateMask) === InitPhaseState.InitPhaseCompleted; @@ -374,16 +372,6 @@ export function refreshView( } } -function runEnterAnimations(lView: LView) { - const animationData = lView[ANIMATIONS]; - if (animationData?.enter) { - for (const animateFn of animationData.enter) { - animateFn(); - } - animationData.enter = undefined; - } -} - /** * Goes over embedded views (ones created through ViewContainerRef APIs) and refreshes * them by executing an associated template function. diff --git a/packages/core/src/render3/node_manipulation.ts b/packages/core/src/render3/node_manipulation.ts index 08a6f87ec9a..bbe1156243f 100644 --- a/packages/core/src/render3/node_manipulation.ts +++ b/packages/core/src/render3/node_manipulation.ts @@ -75,12 +75,15 @@ import { TVIEW, TView, TViewType, + INJECTOR, } from './interfaces/view'; import {assertTNodeType} from './node_assert'; import {profiler} from './profiler'; import {ProfilerEvent} from './profiler_types'; import {getLViewParent, getNativeByTNode, unwrapRNode} from './util/view_utils'; import {allLeavingAnimations} from '../animation/longest_animation'; +import {ANIMATION_QUEUE} from '../animation/interfaces'; +import {Injector} from '../di'; const enum WalkTNodeTreeAction { /** node create in the native environment. Run on initial creation. */ @@ -99,6 +102,21 @@ const enum WalkTNodeTreeAction { Destroy = 3, } +function maybeQueueEnterAnimation( + parentLView: LView | undefined, + parent: RElement | null, + tNode: TNode, + injector: Injector, +): void { + const enterAnimations = parentLView?.[ANIMATIONS]?.enter; + if (parent !== null && enterAnimations && enterAnimations.has(tNode.index)) { + const animationQueue = injector.get(ANIMATION_QUEUE); + for (const animateFn of enterAnimations.get(tNode.index)!) { + animationQueue.queue.add(animateFn); + } + } +} + /** * NOTE: for performance reasons, the possible actions are inlined within the function instead of * being passed as an argument. @@ -106,8 +124,10 @@ const enum WalkTNodeTreeAction { function applyToElementOrContainer( action: WalkTNodeTreeAction, renderer: Renderer, + injector: Injector, parent: RElement | null, lNodeToHandle: RNode | LContainer | LView, + tNode: TNode, beforeNode?: RNode | null, parentLView?: LView, ) { @@ -129,33 +149,50 @@ function applyToElementOrContainer( lNodeToHandle = lNodeToHandle[HOST]!; } const rNode: RNode = unwrapRNode(lNodeToHandle); - if (action === WalkTNodeTreeAction.Create && parent !== null) { + maybeQueueEnterAnimation(parentLView, parent, tNode, injector); if (beforeNode == null) { nativeAppendChild(renderer, parent, rNode); } else { nativeInsertBefore(renderer, parent, rNode, beforeNode || null, true); } } else if (action === WalkTNodeTreeAction.Insert && parent !== null) { + maybeQueueEnterAnimation(parentLView, parent, tNode, injector); nativeInsertBefore(renderer, parent, rNode, beforeNode || null, true); } else if (action === WalkTNodeTreeAction.Detach) { - runLeaveAnimationsWithCallback(parentLView, (nodeHasLeaveAnimations: boolean) => { - // the nodeHasLeaveAnimations indicates to the renderer that the element needs to - // be removed synchronously and sets the requireSynchronousElementRemoval flag in - // the renderer. - nativeRemoveNode(renderer, rNode, isComponent, nodeHasLeaveAnimations); - }); + runLeaveAnimationsWithCallback( + parentLView, + tNode, + injector, + (nodeHasLeaveAnimations: boolean) => { + // the nodeHasLeaveAnimations indicates to the renderer that the element needs to + // be removed synchronously and sets the requireSynchronousElementRemoval flag in + // the renderer. + nativeRemoveNode(renderer, rNode, isComponent, nodeHasLeaveAnimations); + }, + ); } else if (action === WalkTNodeTreeAction.Destroy) { - runLeaveAnimationsWithCallback(parentLView, () => { + runLeaveAnimationsWithCallback(parentLView, tNode, injector, () => { renderer.destroyNode!(rNode); }); } if (lContainer != null) { - applyContainer(renderer, action, lContainer, parent, beforeNode); + applyContainer(renderer, action, injector, lContainer, tNode, parent, beforeNode); } } } +function addToAnimationQueue(injector: Injector, animationFns: Function | Function[]) { + const animationQueue = injector.get(ANIMATION_QUEUE); + if (Array.isArray(animationFns)) { + for (const animateFn of animationFns) { + animationQueue.queue.add(animateFn); + } + } else { + animationQueue.queue.add(animationFns); + } +} + /** * Removes all DOM elements associated with a view. * @@ -353,34 +390,51 @@ function cleanUpView(tView: TView, lView: LView): void { } } -function hasLeaveAnimations(lView: LView | undefined): boolean { - return lView !== undefined && lView[ANIMATIONS] !== null && lView[ANIMATIONS].leave !== undefined; -} +function runLeaveAnimationsWithCallback( + lView: LView | undefined, + tNode: TNode, + injector: Injector, + callback: Function, +) { + const animations = lView?.[ANIMATIONS]; + if (animations == null || (animations.leave && !animations.leave.has(tNode.index))) + return callback(false); -function runLeaveAnimationsWithCallback(lView: LView | undefined, callback: Function) { - if (lView && lView[ANIMATIONS] && lView[ANIMATIONS].leave) { - if (lView[ANIMATIONS].skipLeaveAnimations) { - lView[ANIMATIONS].skipLeaveAnimations = false; - } else { - const leaveAnimations = lView[ANIMATIONS].leave; - const runningAnimations = []; - for (let index = 0; index < leaveAnimations.length; index++) { - const animateFn = leaveAnimations[index]; - runningAnimations.push(animateFn()); - } - lView[ANIMATIONS].running = Promise.allSettled(runningAnimations); - lView[ANIMATIONS].leave = undefined; - } + // this is solely for move operations to prevent leave animations from running + // on the moved nodes, which would have deleted the node. + if (animations.skipLeaveAnimations) { + animations.skipLeaveAnimations = false; + return callback(false); } - runAfterLeaveAnimations(lView, callback); + + addToAnimationQueue(injector, () => { + // it's possible that in the time between when the leave animation was + // and the time it was executed, the data structure changed. So we need + // to be safe here. + if (animations.leave && animations.leave.has(tNode.index)) { + const leaveAnimationMap = animations.leave; + const leaveAnimations = leaveAnimationMap.get(tNode.index); + const runningAnimations = []; + if (leaveAnimations) { + for (let index = 0; index < leaveAnimations.length; index++) { + const animationFn = leaveAnimations[index]; + runningAnimations.push(animationFn()); + } + } + animations.running = Promise.allSettled(runningAnimations); + runAfterLeaveAnimations(lView!, callback); + } else { + if (lView) allLeavingAnimations.delete(lView); + callback(false); + } + }); } -function runAfterLeaveAnimations(lView: LView | undefined, callback: Function) { - if (lView && lView[ANIMATIONS] && lView[ANIMATIONS].running) { - lView[ANIMATIONS].running.then(() => { - if (lView[ANIMATIONS] && lView[ANIMATIONS].running) { - lView[ANIMATIONS].running = undefined; - } +function runAfterLeaveAnimations(lView: LView, callback: Function) { + const runningAnimations = lView[ANIMATIONS]?.running; + if (runningAnimations) { + runningAnimations.then(() => { + lView[ANIMATIONS]!.running = undefined; allLeavingAnimations.delete(lView); callback(true); }); @@ -776,6 +830,7 @@ function applyNodes( ) { while (tNode != null) { ngDevMode && assertTNodeForLView(tNode, lView); + const injector = lView[INJECTOR]; // Let declarations don't have corresponding DOM nodes so we skip over them. if (tNode.type === TNodeType.LetDeclaration) { @@ -802,8 +857,10 @@ function applyNodes( applyToElementOrContainer( action, renderer, + injector, parentRElement, rawSlotValue, + tNode, beforeNode, lView, ); @@ -811,13 +868,24 @@ function applyNodes( const nextRNode = icuContainerIterate(tNode as TIcuContainerNode, lView); let rNode: RNode | null; while ((rNode = nextRNode())) { - applyToElementOrContainer(action, renderer, parentRElement, rNode, beforeNode, lView); + applyToElementOrContainer( + action, + renderer, + injector, + parentRElement, + rNode, + tNode, + beforeNode, + lView, + ); } applyToElementOrContainer( action, renderer, + injector, parentRElement, rawSlotValue, + tNode, beforeNode, lView, ); @@ -835,8 +903,10 @@ function applyNodes( applyToElementOrContainer( action, renderer, + injector, parentRElement, rawSlotValue, + tNode, beforeNode, lView, ); @@ -956,7 +1026,16 @@ function applyProjectionRecursive( // This should be refactored and cleaned up. for (let i = 0; i < nodeToProjectOrRNodes.length; i++) { const rNode = nodeToProjectOrRNodes[i]; - applyToElementOrContainer(action, renderer, parentRElement, rNode, beforeNode, lView); + applyToElementOrContainer( + action, + renderer, + lView[INJECTOR], + parentRElement, + rNode, + tProjectionNode, + beforeNode, + lView, + ); } } else { let nodeToProject: TNode | null = nodeToProjectOrRNodes; @@ -994,7 +1073,9 @@ function applyProjectionRecursive( function applyContainer( renderer: Renderer, action: WalkTNodeTreeAction, + injector: Injector, lContainer: LContainer, + tNode: TNode, parentRElement: RElement | null, beforeNode: RNode | null | undefined, ) { @@ -1012,7 +1093,15 @@ function applyContainer( // don't see a reason why they should be different, but they are. // // If they are we need to process the second anchor as well. - applyToElementOrContainer(action, renderer, parentRElement, anchor, beforeNode); + applyToElementOrContainer( + action, + renderer, + injector, + parentRElement, + anchor, + tNode, + beforeNode, + ); } for (let i = CONTAINER_HEADER_OFFSET; i < lContainer.length; i++) { const lView = lContainer[i] as LView; diff --git a/packages/core/test/acceptance/animation_spec.ts b/packages/core/test/acceptance/animation_spec.ts index 333fe6ee200..32761bd2010 100644 --- a/packages/core/test/acceptance/animation_spec.ts +++ b/packages/core/test/acceptance/animation_spec.ts @@ -400,9 +400,7 @@ describe('Animation', () => { expect(fixture.nativeElement.outerHTML).not.toContain('class="fade"'); cmp.show.set(false); fixture.detectChanges(); - tickAnimationFrames(1); expect(cmp.show()).toBeFalsy(); - fixture.detectChanges(); expect(fixture.nativeElement.outerHTML).toContain('class="fade"'); fixture.detectChanges(); fadeCmp.nativeElement.dispatchEvent( @@ -443,9 +441,7 @@ describe('Animation', () => { expect(fixture.nativeElement.outerHTML).not.toContain('class="fade"'); cmp.show.set(false); fixture.detectChanges(); - tickAnimationFrames(1); expect(cmp.show()).toBeFalsy(); - fixture.detectChanges(); expect(fixture.nativeElement.outerHTML).toContain('class="fade"'); fixture.detectChanges(); fadeCmp.nativeElement.dispatchEvent( @@ -809,6 +805,25 @@ describe('Animation', () => { } `; + it('should apply classes on entry when animation is specified with no control flow', fakeAsync(() => { + @Component({ + selector: 'test-cmp', + styles: styles, + template: '

I should slide in

', + encapsulation: ViewEncapsulation.None, + }) + class TestComponent { + @ViewChild('el', {read: ElementRef}) el!: ElementRef; + } + TestBed.configureTestingModule({animationsEnabled: true}); + + const fixture = TestBed.createComponent(TestComponent); + const cmp = fixture.componentInstance; + fixture.detectChanges(); + tickAnimationFrames(1); + expect(cmp.el.nativeElement.outerHTML).toContain('class="slide-in"'); + })); + it('should apply classes on entry when animation is specified', fakeAsync(() => { @Component({ selector: 'test-cmp', diff --git a/packages/core/test/acceptance/authoring/signal_inputs_spec.ts b/packages/core/test/acceptance/authoring/signal_inputs_spec.ts index 8559214a2b4..3a33b4fb21b 100644 --- a/packages/core/test/acceptance/authoring/signal_inputs_spec.ts +++ b/packages/core/test/acceptance/authoring/signal_inputs_spec.ts @@ -12,9 +12,14 @@ import { Directive, effect, EventEmitter, + inject, input, Output, + signal, + TemplateRef, + viewChild, ViewChild, + ViewContainerRef, } from '@angular/core'; import {SIGNAL} from '../../../primitives/signals'; import {fakeAsync, TestBed, tick} from '../../../testing'; @@ -361,5 +366,158 @@ describe('signal inputs', () => { tick(); expect(childCmp.nativeElement.className).not.toContain('fade-in'); })); + + it('should support content projection', fakeAsync(() => { + const animateStyles = ` + .fade-in { + animation: fade 1ms forwards; + } + .fade-out { + animation: fade 2ms reverse; + } + @keyframes fade { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + + `; + + @Component({ + selector: 'app-container', + encapsulation: ViewEncapsulation.None, + template: ` + + + + + + `, + }) + class Container { + private readonly viewContainerRef = inject(ViewContainerRef); + + protected readonly template = viewChild.required>('template'); + + protected state = signal(false); + + protected toggle() { + this.state() ? this.destroy() : this.create(); + } + + protected create() { + this.state.set(true); + this.viewContainerRef.createEmbeddedView(this.template()); + } + + protected destroy() { + this.state.set(false); + this.viewContainerRef.clear(); + } + } + + @Component({ + selector: 'app-content', + template: ` + + `, + styles: animateStyles, + host: { + 'animate.leave': 'fade-out', + 'animate.enter': 'fade-in', + }, + encapsulation: ViewEncapsulation.None, + }) + class Content {} + + @Component({ + selector: 'test-cmp', + template: ` + + This content will be animated on enter/leave + + `, + imports: [Container, Content], + encapsulation: ViewEncapsulation.None, + }) + class TestComponent {} + TestBed.configureTestingModule({animationsEnabled: true}); + + const fixture = TestBed.createComponent(TestComponent); + const button = fixture.nativeElement.querySelector('button'); + + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('app-content')).toBeNull(); + expect(button).not.toBeNull(); + + button.click(); + + // show first time + fixture.detectChanges(); + tickAnimationFrames(1); + const enterAppContent = fixture.nativeElement.querySelector('app-content'); + + expect(enterAppContent).not.toBeNull(); + expect(enterAppContent.className).toEqual('fade-in'); + enterAppContent.dispatchEvent(new AnimationEvent('animationstart')); + enterAppContent.dispatchEvent(new AnimationEvent('animationend', {animationName: 'fade'})); + tick(); + expect(enterAppContent.className).not.toEqual('fade-in'); + expect(fixture.debugElement.query(By.css('app-content'))).not.toBeNull(); + + button.click(); + + // hide first time + fixture.detectChanges(); + tickAnimationFrames(1); + const leaveAppContent = fixture.nativeElement.querySelector('app-content'); + + expect(fixture.nativeElement.outerHTML).toContain('app-content class="fade-out"'); + expect(leaveAppContent.className).toEqual('fade-out'); + leaveAppContent.dispatchEvent(new AnimationEvent('animationstart')); + leaveAppContent.dispatchEvent(new AnimationEvent('animationend', {animationName: 'fade'})); + fixture.detectChanges(); + tickAnimationFrames(1); + + expect(fixture.debugElement.query(By.css('app-content'))).toBeNull(); + + button.click(); + + // show second time + fixture.detectChanges(); + fixture.changeDetectorRef.markForCheck(); + tickAnimationFrames(1); + const enterAppContent2 = fixture.nativeElement.querySelector('app-content'); + + expect(enterAppContent2).not.toBeNull(); + expect(enterAppContent2.className).toEqual('fade-in'); + const fadeInEl = fixture.nativeElement.querySelector('.fade-in'); + fadeInEl.dispatchEvent(new AnimationEvent('animationstart')); + fadeInEl.dispatchEvent(new AnimationEvent('animationend', {animationName: 'fade'})); + fixture.detectChanges(); + tickAnimationFrames(1); + + expect(fixture.nativeElement.querySelector('app-content').className).not.toEqual('fade-in'); + expect(fixture.debugElement.query(By.css('app-content'))).not.toBeNull(); + + button.click(); + + // hide second time + fixture.detectChanges(); + tickAnimationFrames(1); + const leaveAppContent2 = fixture.nativeElement.querySelector('app-content'); + + expect(fixture.nativeElement.outerHTML).toContain('app-content class="fade-out"'); + expect(leaveAppContent2.className).toEqual('fade-out'); + leaveAppContent2.dispatchEvent(new AnimationEvent('animationstart')); + leaveAppContent2.dispatchEvent(new AnimationEvent('animationend', {animationName: 'fade'})); + fixture.detectChanges(); + tickAnimationFrames(1); + + expect(fixture.debugElement.query(By.css('app-content'))).toBeNull(); + })); }); }); diff --git a/packages/core/test/acceptance/view_container_ref_spec.ts b/packages/core/test/acceptance/view_container_ref_spec.ts index cae99f769a9..47f02ef9fd4 100644 --- a/packages/core/test/acceptance/view_container_ref_spec.ts +++ b/packages/core/test/acceptance/view_container_ref_spec.ts @@ -48,6 +48,7 @@ import {ComponentFixture, TestBed, TestComponentRenderer} from '../../testing'; import {clearTranslations, loadTranslations} from '@angular/localize'; import {By, DomSanitizer} from '@angular/platform-browser'; import {expect} from '@angular/private/testing/matchers'; +import {ANIMATION_QUEUE} from '../../src/animation/interfaces'; describe('ViewContainerRef', () => { /** @@ -1413,6 +1414,7 @@ describe('ViewContainerRef', () => { {provide: Sanitizer, useValue: TestBed.inject(DomSanitizer)}, {provide: ErrorHandler, useValue: TestBed.inject(ErrorHandler)}, {provide: RendererFactory2, useValue: TestBed.inject(RendererFactory2)}, + {provide: ANIMATION_QUEUE, useValue: TestBed.inject(ANIMATION_QUEUE)}, ], }) class MyAppModule {} diff --git a/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json b/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json index 6fd5a68cf6e..be2b1c80a5a 100644 --- a/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json +++ b/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json @@ -5,6 +5,7 @@ "ANIMATIONS", "ANIMATION_MODULE_TYPE", "ANIMATION_PREFIX", + "ANIMATION_QUEUE", "ANY_STATE", "APP_BOOTSTRAP_LISTENER", "APP_ID", @@ -321,6 +322,7 @@ "addAfterRenderSequencesForView", "addClass", "addServerStyles", + "addToAnimationQueue", "addToEndOfViewTree", "allLeavingAnimations", "allocExpando", @@ -680,6 +682,7 @@ "markViewForRefresh", "markedFeatures", "matchTemplateAttribute", + "maybeQueueEnterAnimation", "maybeReturnReactiveLViewConsumer", "maybeWrapInNotSelector", "mergeHostAttribute", @@ -779,7 +782,6 @@ "roundOffset", "runAfterLeaveAnimations", "runEffectsInView", - "runEnterAnimations", "runInInjectionContext", "runLeaveAnimationsWithCallback", "runPlatformInitializers", diff --git a/packages/core/test/bundling/defer/bundle.golden_symbols.json b/packages/core/test/bundling/defer/bundle.golden_symbols.json index ec9109c2f6b..2d6e2740dbb 100644 --- a/packages/core/test/bundling/defer/bundle.golden_symbols.json +++ b/packages/core/test/bundling/defer/bundle.golden_symbols.json @@ -60,6 +60,7 @@ "lazy": [ "AFTER_RENDER_SEQUENCES_TO_ADD", "ANIMATIONS", + "ANIMATION_QUEUE", "APP_BOOTSTRAP_LISTENER", "APP_ID", "APP_INITIALIZER", @@ -291,6 +292,7 @@ "addAfterRenderSequencesForView", "addDepsToRegistry", "addLViewToLContainer", + "addToAnimationQueue", "addToArray", "addToEndOfViewTree", "addViewToDOM", @@ -605,6 +607,7 @@ "markViewForRefresh", "markedFeatures", "matchTemplateAttribute", + "maybeQueueEnterAnimation", "maybeReturnReactiveLViewConsumer", "maybeWrapInNotSelector", "mergeHostAttribute", @@ -674,7 +677,6 @@ "retrieveHydrationInfo", "runAfterLeaveAnimations", "runEffectsInView", - "runEnterAnimations", "runInInjectionContext", "runLeaveAnimationsWithCallback", "runPlatformInitializers", diff --git a/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json b/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json index 3b3ee6e9967..8388aa4ba65 100644 --- a/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json +++ b/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json @@ -3,6 +3,7 @@ "main": [ "AFTER_RENDER_SEQUENCES_TO_ADD", "ANIMATIONS", + "ANIMATION_QUEUE", "APP_BOOTSTRAP_LISTENER", "APP_ID", "APP_ID_ATTRIBUTE_NAME", @@ -346,6 +347,7 @@ "addAfterRenderSequencesForView", "addLViewToLContainer", "addServerStyles", + "addToAnimationQueue", "addToArray", "addToEndOfViewTree", "addValidators", @@ -787,6 +789,7 @@ "matchTemplateAttribute", "maxLengthValidator", "maxValidator", + "maybeQueueEnterAnimation", "maybeReturnReactiveLViewConsumer", "maybeUnwrapEmpty", "maybeUnwrapFn", @@ -899,7 +902,6 @@ "retrieveHydrationInfo", "runAfterLeaveAnimations", "runEffectsInView", - "runEnterAnimations", "runInInjectionContext", "runLeaveAnimationsWithCallback", "runPlatformInitializers", diff --git a/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json index 9a243ee5ee4..52a38be16ba 100644 --- a/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json +++ b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json @@ -3,6 +3,7 @@ "main": [ "AFTER_RENDER_SEQUENCES_TO_ADD", "ANIMATIONS", + "ANIMATION_QUEUE", "APP_BOOTSTRAP_LISTENER", "APP_ID", "APP_ID_ATTRIBUTE_NAME", @@ -350,6 +351,7 @@ "addAfterRenderSequencesForView", "addLViewToLContainer", "addServerStyles", + "addToAnimationQueue", "addToArray", "addToEndOfViewTree", "addValidators", @@ -786,6 +788,7 @@ "markViewForRefresh", "markedFeatures", "matchTemplateAttribute", + "maybeQueueEnterAnimation", "maybeReturnReactiveLViewConsumer", "maybeUnwrapEmpty", "maybeUnwrapFn", @@ -897,7 +900,6 @@ "retrieveHydrationInfo", "runAfterLeaveAnimations", "runEffectsInView", - "runEnterAnimations", "runInInjectionContext", "runLeaveAnimationsWithCallback", "runPlatformInitializers", diff --git a/packages/core/test/bundling/hydration/bundle.golden_symbols.json b/packages/core/test/bundling/hydration/bundle.golden_symbols.json index 69ba78b4f8b..824f5eef5cf 100644 --- a/packages/core/test/bundling/hydration/bundle.golden_symbols.json +++ b/packages/core/test/bundling/hydration/bundle.golden_symbols.json @@ -5,6 +5,7 @@ "AFTER_RENDER_SEQUENCES_TO_ADD", "ALLOWED_METHODS", "ANIMATIONS", + "ANIMATION_QUEUE", "APP_BOOTSTRAP_LISTENER", "APP_ID", "APP_ID_ATTRIBUTE_NAME", @@ -287,6 +288,7 @@ "activeConsumer", "addAfterRenderSequencesForView", "addServerStyles", + "addToAnimationQueue", "addToEndOfViewTree", "allLeavingAnimations", "allocExpando", @@ -636,6 +638,7 @@ "markViewDirty", "markViewForRefresh", "markedFeatures", + "maybeQueueEnterAnimation", "maybeReturnReactiveLViewConsumer", "maybeWrapInNotSelector", "mergeHostAttribute", @@ -715,7 +718,6 @@ "retrieveTransferredState", "runAfterLeaveAnimations", "runEffectsInView", - "runEnterAnimations", "runInInjectionContext", "runLeaveAnimationsWithCallback", "runPlatformInitializers", diff --git a/packages/core/test/bundling/router/bundle.golden_symbols.json b/packages/core/test/bundling/router/bundle.golden_symbols.json index e1d8697ecb1..02961d4a791 100644 --- a/packages/core/test/bundling/router/bundle.golden_symbols.json +++ b/packages/core/test/bundling/router/bundle.golden_symbols.json @@ -3,6 +3,7 @@ "main": [ "AFTER_RENDER_SEQUENCES_TO_ADD", "ANIMATIONS", + "ANIMATION_QUEUE", "APP_BASE_HREF", "APP_BOOTSTRAP_LISTENER", "APP_ID", @@ -396,6 +397,7 @@ "addEmptyPathsToChildrenIfNeeded", "addLViewToLContainer", "addServerStyles", + "addToAnimationQueue", "addToArray", "addToEndOfViewTree", "addViewToDOM", @@ -896,6 +898,7 @@ "matchWithChecks", "materializeViewResults", "matrixParamsMatch", + "maybeQueueEnterAnimation", "maybeResolveResources", "maybeReturnReactiveLViewConsumer", "maybeSchedule", @@ -1014,7 +1017,6 @@ "runCanLoadGuards", "runCanMatchGuards", "runEffectsInView", - "runEnterAnimations", "runInInjectionContext", "runLeaveAnimationsWithCallback", "runPlatformInitializers", diff --git a/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json b/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json index 815afaeddea..13e175c0dd1 100644 --- a/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json +++ b/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json @@ -3,6 +3,7 @@ "main": [ "AFTER_RENDER_SEQUENCES_TO_ADD", "ANIMATIONS", + "ANIMATION_QUEUE", "APP_BOOTSTRAP_LISTENER", "APP_ID", "APP_ID_ATTRIBUTE_NAME", @@ -231,6 +232,7 @@ "activeConsumer", "addAfterRenderSequencesForView", "addServerStyles", + "addToAnimationQueue", "addToEndOfViewTree", "allLeavingAnimations", "allocExpando", @@ -502,6 +504,7 @@ "markViewDirty", "markViewForRefresh", "markedFeatures", + "maybeQueueEnterAnimation", "maybeReturnReactiveLViewConsumer", "maybeWrapInNotSelector", "mergeHostAttribute", @@ -568,7 +571,6 @@ "retrieveHydrationInfo", "runAfterLeaveAnimations", "runEffectsInView", - "runEnterAnimations", "runInInjectionContext", "runLeaveAnimationsWithCallback", "runPlatformInitializers",