diff --git a/modules/@angular/compiler/src/compiler_util/render_util.ts b/modules/@angular/compiler/src/compiler_util/render_util.ts index 632e03e0bc..ecdaf4c2f1 100644 --- a/modules/@angular/compiler/src/compiler_util/render_util.ts +++ b/modules/@angular/compiler/src/compiler_util/render_util.ts @@ -91,8 +91,8 @@ function sanitizedValue( export function triggerAnimation( view: o.Expression, componentView: o.Expression, boundProp: BoundElementPropertyAst, - eventListener: o.Expression, renderElement: o.Expression, renderValue: o.Expression, - lastRenderValue: o.Expression) { + boundOutputs: BoundEventAst[], eventListener: o.Expression, renderElement: o.Expression, + renderValue: o.Expression, lastRenderValue: o.Expression) { const detachStmts: o.Statement[] = []; const updateStmts: o.Statement[] = []; @@ -121,23 +121,32 @@ export function triggerAnimation( .set(animationFnExpr.callFn([view, renderElement, lastRenderValue, emptyStateValue])) .toDeclStmt()); - const registerStmts = [ - animationTransitionVar - .callMethod( - 'onStart', - [eventListener.callMethod( - o.BuiltinMethod.Bind, - [view, o.literal(BoundEventAst.calcFullName(animationName, null, 'start'))])]) - .toStmt(), - animationTransitionVar - .callMethod( - 'onDone', - [eventListener.callMethod( - o.BuiltinMethod.Bind, - [view, o.literal(BoundEventAst.calcFullName(animationName, null, 'done'))])]) - .toStmt(), + const registerStmts: o.Statement[] = []; + const animationStartMethodExists = boundOutputs.find( + event => event.isAnimation && event.name == animationName && event.phase == 'start'); + if (animationStartMethodExists) { + registerStmts.push( + animationTransitionVar + .callMethod( + 'onStart', + [eventListener.callMethod( + o.BuiltinMethod.Bind, + [view, o.literal(BoundEventAst.calcFullName(animationName, null, 'start'))])]) + .toStmt()); + } - ]; + const animationDoneMethodExists = boundOutputs.find( + event => event.isAnimation && event.name == animationName && event.phase == 'done'); + if (animationDoneMethodExists) { + registerStmts.push( + animationTransitionVar + .callMethod( + 'onDone', + [eventListener.callMethod( + o.BuiltinMethod.Bind, + [view, o.literal(BoundEventAst.calcFullName(animationName, null, 'done'))])]) + .toStmt()); + } updateStmts.push(...registerStmts); detachStmts.push(...registerStmts); diff --git a/modules/@angular/compiler/src/directive_wrapper_compiler.ts b/modules/@angular/compiler/src/directive_wrapper_compiler.ts index daa3c0fb8b..d919a62b56 100644 --- a/modules/@angular/compiler/src/directive_wrapper_compiler.ts +++ b/modules/@angular/compiler/src/directive_wrapper_compiler.ts @@ -70,7 +70,7 @@ export class DirectiveWrapperCompiler { addCheckInputMethod(inputFieldName, builder); }); addNgDoCheckMethod(builder); - addCheckHostMethod(hostParseResult.hostProps, builder); + addCheckHostMethod(hostParseResult.hostProps, hostParseResult.hostListeners, builder); addHandleEventMethod(hostParseResult.hostListeners, builder); addSubscribeMethod(dirMeta, builder); @@ -235,7 +235,8 @@ function addCheckInputMethod(input: string, builder: DirectiveWrapperBuilder) { } function addCheckHostMethod( - hostProps: BoundElementPropertyAst[], builder: DirectiveWrapperBuilder) { + hostProps: BoundElementPropertyAst[], hostEvents: BoundEventAst[], + builder: DirectiveWrapperBuilder) { const stmts: o.Statement[] = []; const methodParams: o.FnParam[] = [ new o.FnParam( @@ -262,7 +263,7 @@ function addCheckHostMethod( let checkBindingStmts: o.Statement[]; if (hostProp.isAnimation) { const {updateStmts, detachStmts} = triggerAnimation( - VIEW_VAR, COMPONENT_VIEW_VAR, hostProp, + VIEW_VAR, COMPONENT_VIEW_VAR, hostProp, hostEvents, o.THIS_EXPR.prop(EVENT_HANDLER_FIELD_NAME) .or(o.importExpr(createIdentifier(Identifiers.noop))), RENDER_EL_VAR, evalResult.currValExpr, field.expression); diff --git a/modules/@angular/compiler/src/view_compiler/property_binder.ts b/modules/@angular/compiler/src/view_compiler/property_binder.ts index f420ee36ca..ea58067ce7 100644 --- a/modules/@angular/compiler/src/view_compiler/property_binder.ts +++ b/modules/@angular/compiler/src/view_compiler/property_binder.ts @@ -17,7 +17,7 @@ import {Identifiers, createIdentifier} from '../identifiers'; import * as o from '../output/output_ast'; import {isDefaultChangeDetectionStrategy} from '../private_import_core'; import {ElementSchemaRegistry} from '../schema/element_schema_registry'; -import {BoundElementPropertyAst, BoundTextAst, DirectiveAst, PropertyBindingType} from '../template_parser/template_ast'; +import {BoundElementPropertyAst, BoundEventAst, BoundTextAst, DirectiveAst, PropertyBindingType} from '../template_parser/template_ast'; import {CompileElement, CompileNode} from './compile_element'; import {CompileView} from './compile_view'; import {DetectChangesVars} from './constants'; @@ -41,7 +41,8 @@ export function bindRenderText( } export function bindRenderInputs( - boundProps: BoundElementPropertyAst[], hasEvents: boolean, compileElement: CompileElement) { + boundProps: BoundElementPropertyAst[], boundOutputs: BoundEventAst[], hasEvents: boolean, + compileElement: CompileElement) { const view = compileElement.view; const renderNode = compileElement.renderNode; @@ -67,7 +68,7 @@ export function bindRenderInputs( case PropertyBindingType.Animation: compileMethod = view.animationBindingsMethod; const {updateStmts, detachStmts} = triggerAnimation( - o.THIS_EXPR, o.THIS_EXPR, boundProp, + o.THIS_EXPR, o.THIS_EXPR, boundProp, boundOutputs, (hasEvents ? o.THIS_EXPR.prop(getHandleEventMethodName(compileElement.nodeIndex)) : o.importExpr(createIdentifier(Identifiers.noop))) .callMethod(o.BuiltinMethod.Bind, [o.THIS_EXPR]), diff --git a/modules/@angular/compiler/src/view_compiler/view_binder.ts b/modules/@angular/compiler/src/view_compiler/view_binder.ts index e7661a654c..bd4b41f77d 100644 --- a/modules/@angular/compiler/src/view_compiler/view_binder.ts +++ b/modules/@angular/compiler/src/view_compiler/view_binder.ts @@ -44,7 +44,7 @@ class ViewBinderVisitor implements TemplateAstVisitor { visitElement(ast: ElementAst, parent: CompileElement): any { const compileElement = this.view.nodes[this._nodeIndex++]; const hasEvents = bindOutputs(ast.outputs, ast.directives, compileElement, true); - bindRenderInputs(ast.inputs, hasEvents, compileElement); + bindRenderInputs(ast.inputs, ast.outputs, hasEvents, compileElement); ast.directives.forEach((directiveAst, dirIndex) => { const directiveWrapperInstance = compileElement.directiveWrapperInstance.get(directiveAst.directive.type.reference); diff --git a/modules/@angular/core/src/animation/animation_queue.ts b/modules/@angular/core/src/animation/animation_queue.ts index d10888af05..41d78b99a5 100644 --- a/modules/@angular/core/src/animation/animation_queue.ts +++ b/modules/@angular/core/src/animation/animation_queue.ts @@ -5,30 +5,47 @@ * 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 */ - +import {Injectable} from '../di/metadata'; +import {NgZone} from '../zone/ng_zone'; import {AnimationPlayer} from './animation_player'; -let _queuedAnimations: AnimationPlayer[] = []; +@Injectable() +export class AnimationQueue { + public entries: AnimationPlayer[] = []; -/** @internal */ -export function queueAnimation(player: AnimationPlayer) { - _queuedAnimations.push(player); -} + constructor(private _zone: NgZone) {} -/** @internal */ -export function triggerQueuedAnimations() { - // this code is wrapped into a single promise such that the - // onStart and onDone player callbacks are triggered outside - // of the digest cycle of animations - if (_queuedAnimations.length) { - Promise.resolve(null).then(_triggerAnimations); + enqueue(player: AnimationPlayer) { this.entries.push(player); } + + flush() { + // given that each animation player may set aside + // microtasks and rely on DOM-based events, this + // will cause Angular to run change detection after + // each request. This sidesteps the issue. If a user + // hooks into an animation via (@anim.start) or (@anim.done) + // then those methods will automatically trigger change + // detection by wrapping themselves inside of a zone + if (this.entries.length) { + this._zone.runOutsideAngular(() => { + // this code is wrapped into a single promise such that the + // onStart and onDone player callbacks are triggered outside + // of the digest cycle of animations + Promise.resolve(null).then(() => this._triggerAnimations()); + }); + } + } + + private _triggerAnimations() { + NgZone.assertNotInAngularZone(); + + while (this.entries.length) { + const player = this.entries.shift(); + // in the event that an animation throws an error then we do + // not want to re-run animations on any previous animations + // if they have already been kicked off beforehand + if (!player.hasStarted()) { + player.play(); + } + } } } - -function _triggerAnimations() { - for (let i = 0; i < _queuedAnimations.length; i++) { - const player = _queuedAnimations[i]; - player.play(); - } - _queuedAnimations = []; -} diff --git a/modules/@angular/core/src/animation/animation_transition.ts b/modules/@angular/core/src/animation/animation_transition.ts index 103427dd98..eaea2efb2c 100644 --- a/modules/@angular/core/src/animation/animation_transition.ts +++ b/modules/@angular/core/src/animation/animation_transition.ts @@ -23,12 +23,14 @@ export class AnimationTransition { } onStart(callback: (event: AnimationTransitionEvent) => any): void { - const event = this._createEvent('start'); - this._player.onStart(() => callback(event)); + const fn = + <() => void>Zone.current.wrap(() => callback(this._createEvent('start')), 'player.onStart'); + this._player.onStart(fn); } onDone(callback: (event: AnimationTransitionEvent) => any): void { - const event = this._createEvent('done'); - this._player.onDone(() => callback(event)); + const fn = + <() => void>Zone.current.wrap(() => callback(this._createEvent('done')), 'player.onDone'); + this._player.onDone(fn); } } diff --git a/modules/@angular/core/src/application_module.ts b/modules/@angular/core/src/application_module.ts index 507e105b04..39eb8ee63b 100644 --- a/modules/@angular/core/src/application_module.ts +++ b/modules/@angular/core/src/application_module.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ +import {AnimationQueue} from './animation/animation_queue'; import {ApplicationInitStatus} from './application_init'; import {ApplicationRef, ApplicationRef_} from './application_ref'; import {APP_ID_RANDOM_PROVIDER} from './application_tokens'; @@ -37,6 +38,7 @@ export function _keyValueDiffersFactory() { Compiler, APP_ID_RANDOM_PROVIDER, ViewUtils, + AnimationQueue, {provide: IterableDiffers, useFactory: _iterableDiffersFactory}, {provide: KeyValueDiffers, useFactory: _keyValueDiffersFactory}, {provide: LOCALE_ID, useValue: 'en-US'}, diff --git a/modules/@angular/core/src/linker/animation_view_context.ts b/modules/@angular/core/src/linker/animation_view_context.ts index 51aa2b1e51..33a1d7c593 100644 --- a/modules/@angular/core/src/linker/animation_view_context.ts +++ b/modules/@angular/core/src/linker/animation_view_context.ts @@ -7,14 +7,15 @@ */ import {AnimationGroupPlayer} from '../animation/animation_group_player'; import {AnimationPlayer} from '../animation/animation_player'; -import {queueAnimation as queueAnimationGlobally} from '../animation/animation_queue'; +import {AnimationQueue} from '../animation/animation_queue'; import {AnimationSequencePlayer} from '../animation/animation_sequence_player'; import {ViewAnimationMap} from '../animation/view_animation_map'; -import {ListWrapper} from '../facade/collection'; export class AnimationViewContext { private _players = new ViewAnimationMap(); + constructor(private _animationQueue: AnimationQueue) {} + onAllActiveAnimationsDone(callback: () => any): void { const activeAnimationPlayers = this._players.getAllPlayers(); // we check for the length to avoid having GroupAnimationPlayer @@ -27,7 +28,7 @@ export class AnimationViewContext { } queueAnimation(element: any, animationName: string, player: AnimationPlayer): void { - queueAnimationGlobally(player); + this._animationQueue.enqueue(player); this._players.set(element, animationName, player); player.onDone(() => this._players.remove(element, animationName, player)); } diff --git a/modules/@angular/core/src/linker/view.ts b/modules/@angular/core/src/linker/view.ts index fc8ab9adb2..68fa533884 100644 --- a/modules/@angular/core/src/linker/view.ts +++ b/modules/@angular/core/src/linker/view.ts @@ -63,7 +63,7 @@ export abstract class AppView { public viewUtils: ViewUtils, public parentView: AppView, public parentIndex: number, public parentElement: any, public cdMode: ChangeDetectorStatus, public declaredViewContainer: ViewContainer = null) { - this.ref = new ViewRef_(this); + this.ref = new ViewRef_(this, viewUtils.animationQueue); if (type === ViewType.COMPONENT || type === ViewType.HOST) { this.renderer = viewUtils.renderComponent(componentType); } else { @@ -74,7 +74,7 @@ export abstract class AppView { get animationContext(): AnimationViewContext { if (!this._animationContext) { - this._animationContext = new AnimationViewContext(); + this._animationContext = new AnimationViewContext(this.viewUtils.animationQueue); } return this._animationContext; } diff --git a/modules/@angular/core/src/linker/view_ref.ts b/modules/@angular/core/src/linker/view_ref.ts index 0d1313a5c9..0264627b97 100644 --- a/modules/@angular/core/src/linker/view_ref.ts +++ b/modules/@angular/core/src/linker/view_ref.ts @@ -6,14 +6,12 @@ * found in the LICENSE file at https://angular.io/license */ -import {triggerQueuedAnimations} from '../animation/animation_queue'; +import {AnimationQueue} from '../animation/animation_queue'; import {ChangeDetectorRef} from '../change_detection/change_detector_ref'; import {ChangeDetectorStatus} from '../change_detection/constants'; import {unimplemented} from '../facade/errors'; - import {AppView} from './view'; - /** * @stable */ @@ -92,7 +90,7 @@ export class ViewRef_ implements EmbeddedViewRef, ChangeDetectorRef { /** @internal */ _originalMode: ChangeDetectorStatus; - constructor(private _view: AppView) { + constructor(private _view: AppView, public animationQueue: AnimationQueue) { this._view = _view; this._originalMode = this._view.cdMode; } @@ -109,7 +107,7 @@ export class ViewRef_ implements EmbeddedViewRef, ChangeDetectorRef { detach(): void { this._view.cdMode = ChangeDetectorStatus.Detached; } detectChanges(): void { this._view.detectChanges(false); - triggerQueuedAnimations(); + this.animationQueue.flush(); } checkNoChanges(): void { this._view.detectChanges(true); } reattach(): void { diff --git a/modules/@angular/core/src/linker/view_utils.ts b/modules/@angular/core/src/linker/view_utils.ts index b42f371b65..1dffff2e6f 100644 --- a/modules/@angular/core/src/linker/view_utils.ts +++ b/modules/@angular/core/src/linker/view_utils.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ +import {AnimationQueue} from '../animation/animation_queue'; import {SimpleChange, devModeEqual} from '../change_detection/change_detection'; import {UNINITIALIZED} from '../change_detection/change_detection_util'; import {Inject, Injectable} from '../di'; @@ -14,17 +15,21 @@ import {ViewEncapsulation} from '../metadata/view'; import {RenderComponentType, RenderDebugInfo, Renderer, RootRenderer} from '../render/api'; import {Sanitizer} from '../security'; import {VERSION} from '../version'; +import {NgZone} from '../zone/ng_zone'; import {ExpressionChangedAfterItHasBeenCheckedError} from './errors'; import {AppView} from './view'; -import {ViewContainer} from './view_container'; @Injectable() export class ViewUtils { sanitizer: Sanitizer; private _nextCompTypeId: number = 0; - constructor(private _renderer: RootRenderer, sanitizer: Sanitizer) { this.sanitizer = sanitizer; } + constructor( + private _renderer: RootRenderer, sanitizer: Sanitizer, + public animationQueue: AnimationQueue) { + this.sanitizer = sanitizer; + } /** @internal */ renderComponent(renderComponentType: RenderComponentType): Renderer { diff --git a/modules/@angular/core/test/animation/animation_integration_spec.ts b/modules/@angular/core/test/animation/animation_integration_spec.ts index 97cacd2c6b..3354eaad78 100644 --- a/modules/@angular/core/test/animation/animation_integration_spec.ts +++ b/modules/@angular/core/test/animation/animation_integration_spec.ts @@ -1493,6 +1493,36 @@ function declareTests({useJit}: {useJit: boolean}) { expect(message).toMatch(/Couldn't find an animation entry for "something"/); }); + it('should throw an error if an animation output is referenced that is not bound to as a property on the same element', + () => { + TestBed.overrideComponent(DummyLoadingCmp, { + set: { + template: ` + + ` + } + }); + TestBed.overrideComponent(DummyIfCmp, { + set: { + template: ` +
+ `, + animations: [trigger('trigger', [transition('one => two', [animate(1000)])])] + } + }); + + let message = ''; + try { + const fixture = TestBed.createComponent(DummyIfCmp); + fixture.detectChanges(); + } catch (e) { + message = e.message; + } + + expect(message).toMatch( + /Unable to listen on \(@trigger.done\) because the animation trigger \[@trigger\] isn't being used on the same element/); + }); + it('should throw an error if an animation output is referenced that is not bound to as a property on the same element', () => { TestBed.overrideComponent(DummyIfCmp, { @@ -2177,6 +2207,23 @@ function declareTests({useJit}: {useJit: boolean}) { ]; }); + function assertStatus(value: string) { + const text = getDOM().getText(el); + const regexp = new RegExp(`Animation Status: ${value}`); + expect(text).toMatch(regexp); + } + + function assertTime(value: number) { + const text = getDOM().getText(el); + const regexp = new RegExp(`Animation Time: ${value}`); + expect(text).toMatch(regexp); + } + + function finishAnimation(player: WebAnimationsPlayer, cb: () => any) { + getDOM().dispatchEvent(player.domPlayer, getDOM().createEvent('finish')); + Promise.resolve(null).then(cb); + } + afterEach(() => { destroyPlatform(); }); it('should automatically run change detection when the animation done callback code updates any bindings', @@ -2187,24 +2234,82 @@ function declareTests({useJit}: {useJit: boolean}) { appRef.components.find(cmp => cmp.componentType === AnimationAppCmp).instance; const driver: ExtendedWebAnimationsDriver = ref.injector.get(AnimationDriver); const zone: NgZone = ref.injector.get(NgZone); - let text = ''; zone.run(() => { - text = getDOM().getText(el); - expect(text).toMatch(/Animation Status: pending/); - expect(text).toMatch(/Animation Time: 0/); + assertStatus('pending'); + assertTime(0); appCmp.animationStatus = 'on'; - setTimeout(() => { - text = getDOM().getText(el); - expect(text).toMatch(/Animation Status: started/); - expect(text).toMatch(/Animation Time: 555/); - const player = driver.players.pop().domPlayer; - getDOM().dispatchEvent(player, getDOM().createEvent('finish')); + zone.runOutsideAngular(() => { setTimeout(() => { - text = getDOM().getText(el); - expect(text).toMatch(/Animation Status: done/); - expect(text).toMatch(/Animation Time: 555/); - asyncDone(); + assertStatus('started'); + assertTime(555); + const player = driver.players.pop(); + finishAnimation(player, () => { + assertStatus('done'); + assertTime(555); + asyncDone(); + }); }, 0); + }); + }); + }); + }); + + it('should not run change detection for an animation that contains multiple steps until a callback is fired', + (asyncDone: Function) => { + bootstrap(AnimationAppCmp, testProviders).then(ref => { + const appRef = ref.injector.get(ApplicationRef); + const appCmpDetails: any = + appRef.components.find(cmp => cmp.componentType === AnimationAppCmp); + const appCD = appCmpDetails.changeDetectorRef; + const appCmp: AnimationAppCmp = appCmpDetails.instance; + const driver: ExtendedWebAnimationsDriver = ref.injector.get(AnimationDriver); + const zone: NgZone = ref.injector.get(NgZone); + + let player: WebAnimationsPlayer; + let onDoneCalls: string[] = []; + function onDoneFn(value: string) { + return () => { + NgZone.assertNotInAngularZone(); + onDoneCalls.push(value); + appCmp.status = value; + }; + }; + + zone.run(() => { + assertStatus('pending'); + appCmp.animationWithSteps = 'on'; + + setTimeout(() => { + expect(driver.players.length).toEqual(3); + assertStatus('started'); + + zone.runOutsideAngular(() => { + setTimeout(() => { + assertStatus('started'); + player = driver.players.shift(); + player.onDone(onDoneFn('1')); + + // step 1 => 2 + finishAnimation(player, () => { + assertStatus('started'); + player = driver.players.shift(); + player.onDone(onDoneFn('2')); + + // step 2 => 3 + finishAnimation(player, () => { + assertStatus('started'); + player = driver.players.shift(); + player.onDone(onDoneFn('3')); + + // step 3 => done + finishAnimation(player, () => { + assertStatus('done'); + asyncDone(); + }); + }); + }); + }, 0); + }); }, 0); }); }); @@ -2295,7 +2400,17 @@ class _NaiveElementSchema extends DomElementSchemaRegistry { @Component({ selector: 'animation-app', - animations: [trigger('animationStatus', [transition('off => on', animate(555))])], + animations: [ + trigger('animationStatus', [transition('off => on', animate(555))]), + trigger( + 'animationWithSteps', + [transition( + '* => on', + [ + style({height: '0px'}), animate(100, style({height: '100px'})), + animate(100, style({height: '200px'})), animate(100, style({height: '300px'})) + ])]) + ], template: ` Animation Time: {{ time }} Animation Status: {{ status }} @@ -2309,7 +2424,7 @@ class AnimationAppCmp { animationStatus = 'off'; @HostListener('@animationStatus.start', ['$event']) - onStart(event: AnimationTransitionEvent) { + onAnimationStartDone(event: AnimationTransitionEvent) { if (event.toState == 'on') { this.time = event.totalTime; this.status = 'started'; @@ -2317,7 +2432,26 @@ class AnimationAppCmp { } @HostListener('@animationStatus.done', ['$event']) - onDone(event: AnimationTransitionEvent) { + onAnimationStatusDone(event: AnimationTransitionEvent) { + if (event.toState == 'on') { + this.time = event.totalTime; + this.status = 'done'; + } + } + + @HostBinding('@animationWithSteps') + animationWithSteps = 'off'; + + @HostListener('@animationWithSteps.start', ['$event']) + onAnimationWithStepsStart(event: AnimationTransitionEvent) { + if (event.toState == 'on') { + this.time = event.totalTime; + this.status = 'started'; + } + } + + @HostListener('@animationWithSteps.done', ['$event']) + onAnimationWithStepsDone(event: AnimationTransitionEvent) { if (event.toState == 'on') { this.time = event.totalTime; this.status = 'done'; diff --git a/modules/@angular/core/test/animation/animation_queue_spec.ts b/modules/@angular/core/test/animation/animation_queue_spec.ts new file mode 100644 index 0000000000..067ff68e25 --- /dev/null +++ b/modules/@angular/core/test/animation/animation_queue_spec.ts @@ -0,0 +1,143 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * 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 + */ +import {AnimationQueue} from '@angular/core/src/animation/animation_queue'; + +import {NgZone} from '../../src/zone/ng_zone'; +import {TestBed, fakeAsync, flushMicrotasks} from '../../testing'; +import {MockAnimationPlayer} from '../../testing/mock_animation_player'; +import {beforeEach, describe, expect, it} from '../../testing/testing_internal'; + +export function main() { + describe('AnimationQueue', function() { + beforeEach(() => { TestBed.configureTestingModule({declarations: [], imports: []}); }); + + it('should queue animation players and run when flushed, but only as the next scheduled microtask', + fakeAsync(() => { + const zone = TestBed.get(NgZone); + const queue = new AnimationQueue(zone); + + const log: string[] = []; + const p1 = new MockAnimationPlayer(); + const p2 = new MockAnimationPlayer(); + const p3 = new MockAnimationPlayer(); + + p1.onStart(() => log.push('1')); + p2.onStart(() => log.push('2')); + p3.onStart(() => log.push('3')); + + queue.enqueue(p1); + queue.enqueue(p2); + queue.enqueue(p3); + expect(log).toEqual([]); + + queue.flush(); + expect(log).toEqual([]); + + flushMicrotasks(); + expect(log).toEqual(['1', '2', '3']); + })); + + it('should always run each of the animation players outside of the angular zone on start', + fakeAsync(() => { + const zone = TestBed.get(NgZone); + const queue = new AnimationQueue(zone); + + const player = new MockAnimationPlayer(); + let eventHasRun = false; + player.onStart(() => { + NgZone.assertNotInAngularZone(); + eventHasRun = true; + }); + + zone.run(() => { + NgZone.assertInAngularZone(); + queue.enqueue(player); + queue.flush(); + flushMicrotasks(); + }); + + expect(eventHasRun).toBe(true); + })); + + it('should always run each of the animation players outside of the angular zone on done', + fakeAsync(() => { + const zone = TestBed.get(NgZone); + const queue = new AnimationQueue(zone); + + const player = new MockAnimationPlayer(); + let eventHasRun = false; + player.onDone(() => { + NgZone.assertNotInAngularZone(); + eventHasRun = true; + }); + + zone.run(() => { + NgZone.assertInAngularZone(); + queue.enqueue(player); + queue.flush(); + flushMicrotasks(); + }); + + expect(eventHasRun).toBe(false); + player.finish(); + expect(eventHasRun).toBe(true); + })); + + it('should not run animations again incase an animation midway fails', fakeAsync(() => { + const zone = TestBed.get(NgZone); + const queue = new AnimationQueue(zone); + + const log: string[] = []; + const p1 = new PlayerThatFails(false); + const p2 = new PlayerThatFails(true); + const p3 = new PlayerThatFails(false); + + p1.onStart(() => log.push('1')); + p2.onStart(() => log.push('2')); + p3.onStart(() => log.push('3')); + + queue.enqueue(p1); + queue.enqueue(p2); + queue.enqueue(p3); + + queue.flush(); + + expect(() => flushMicrotasks()).toThrowError(); + + expect(log).toEqual(['1', '2']); + + // let's reset this so that it gets triggered again + p2.reset(); + p2.onStart(() => log.push('2')); + + queue.flush(); + + expect(() => flushMicrotasks()).not.toThrowError(); + + expect(log).toEqual(['1', '2', '3']); + })); + }); +} + +class PlayerThatFails extends MockAnimationPlayer { + private _animationStarted = false; + + constructor(public doFail: boolean) { super(); } + + play() { + super.play(); + this._animationStarted = true; + if (this.doFail) { + throw new Error('Oh nooooo'); + } + } + + reset() { this._animationStarted = false; } + + hasStarted() { return this._animationStarted; } +} diff --git a/modules/@angular/core/test/linker/animation_view_context_spec.ts b/modules/@angular/core/test/linker/animation_view_context_spec.ts index c9e87a12c4..d8288a0023 100644 --- a/modules/@angular/core/test/linker/animation_view_context_spec.ts +++ b/modules/@angular/core/test/linker/animation_view_context_spec.ts @@ -9,53 +9,54 @@ import {el} from '@angular/platform-browser/testing/browser_util'; import {NoOpAnimationPlayer} from '../../src/animation/animation_player'; +import {AnimationQueue} from '../../src/animation/animation_queue'; import {AnimationViewContext} from '../../src/linker/animation_view_context'; -import {fakeAsync, flushMicrotasks} from '../../testing'; +import {TestBed, fakeAsync, flushMicrotasks} from '../../testing'; import {describe, expect, iit, it} from '../../testing/testing_internal'; export function main() { describe('AnimationViewContext', function() { - let viewContext: AnimationViewContext; let elm: any; - beforeEach(() => { - viewContext = new AnimationViewContext(); - elm = el('
'); - }); + beforeEach(() => { elm = el('
'); }); - function getPlayers() { return viewContext.getAnimationPlayers(elm); } + function getPlayers(vc: any) { return vc.getAnimationPlayers(elm); } it('should remove the player from the registry once the animation is complete', fakeAsync(() => { const player = new NoOpAnimationPlayer(); + const animationQueue = TestBed.get(AnimationQueue) as AnimationQueue; + const vc = new AnimationViewContext(animationQueue); - expect(getPlayers().length).toEqual(0); - viewContext.queueAnimation(elm, 'someAnimation', player); - expect(getPlayers().length).toEqual(1); + expect(getPlayers(vc).length).toEqual(0); + vc.queueAnimation(elm, 'someAnimation', player); + expect(getPlayers(vc).length).toEqual(1); player.finish(); - expect(getPlayers().length).toEqual(0); + expect(getPlayers(vc).length).toEqual(0); })); it('should not remove a follow-up player from the registry if another player is queued', fakeAsync(() => { const player1 = new NoOpAnimationPlayer(); const player2 = new NoOpAnimationPlayer(); + const animationQueue = TestBed.get(AnimationQueue) as AnimationQueue; + const vc = new AnimationViewContext(animationQueue); - viewContext.queueAnimation(elm, 'someAnimation', player1); - expect(getPlayers().length).toBe(1); - expect(getPlayers()[0]).toBe(player1); + vc.queueAnimation(elm, 'someAnimation', player1); + expect(getPlayers(vc).length).toBe(1); + expect(getPlayers(vc)[0]).toBe(player1); - viewContext.queueAnimation(elm, 'someAnimation', player2); - expect(getPlayers().length).toBe(1); - expect(getPlayers()[0]).toBe(player2); + vc.queueAnimation(elm, 'someAnimation', player2); + expect(getPlayers(vc).length).toBe(1); + expect(getPlayers(vc)[0]).toBe(player2); player1.finish(); - expect(getPlayers().length).toBe(1); - expect(getPlayers()[0]).toBe(player2); + expect(getPlayers(vc).length).toBe(1); + expect(getPlayers(vc)[0]).toBe(player2); player2.finish(); - expect(getPlayers().length).toBe(0); + expect(getPlayers(vc).length).toBe(0); })); }); } diff --git a/modules/@angular/core/testing/test_bed.ts b/modules/@angular/core/testing/test_bed.ts index efaa9e395b..263e1e4f5e 100644 --- a/modules/@angular/core/testing/test_bed.ts +++ b/modules/@angular/core/testing/test_bed.ts @@ -6,7 +6,8 @@ * found in the LICENSE file at https://angular.io/license */ -import {CompilerOptions, Component, Directive, Injector, ModuleWithComponentFactories, NgModule, NgModuleRef, NgZone, OpaqueToken, Pipe, PlatformRef, Provider, SchemaMetadata, Type} from '@angular/core'; +import {CompilerOptions, Component, Directive, Injector, ModuleWithComponentFactories, NgModule, NgModuleRef, NgZone, OpaqueToken, Pipe, PlatformRef, Provider, ReflectiveInjector, SchemaMetadata, Type} from '@angular/core'; + import {AsyncTestCompleter} from './async_test_completer'; import {ComponentFixture} from './component_fixture'; import {stringify} from './facade/lang'; @@ -268,8 +269,10 @@ export class TestBed implements Injector { } } } - this._moduleRef = - this._moduleWithComponentFactories.ngModuleFactory.create(this.platform.injector); + const ngZone = new NgZone({enableLongStackTrace: true}); + const ngZoneInjector = ReflectiveInjector.resolveAndCreate( + [{provide: NgZone, useValue: ngZone}], this.platform.injector); + this._moduleRef = this._moduleWithComponentFactories.ngModuleFactory.create(ngZoneInjector); this._instantiated = true; } diff --git a/modules/benchmarks/src/tree/ng2_ftl/app.ngfactory.ts b/modules/benchmarks/src/tree/ng2_ftl/app.ngfactory.ts index 8a020f7c84..7d0bd47440 100644 --- a/modules/benchmarks/src/tree/ng2_ftl/app.ngfactory.ts +++ b/modules/benchmarks/src/tree/ng2_ftl/app.ngfactory.ts @@ -6,6 +6,7 @@ import * as import2 from '@angular/common/src/common_module'; import * as import5 from '@angular/common/src/localization'; +import * as import34 from '@angular/core/src/animation/animation_queue'; import * as import6 from '@angular/core/src/application_init'; import * as import3 from '@angular/core/src/application_module'; import * as import8 from '@angular/core/src/application_ref'; @@ -70,6 +71,7 @@ class AppModuleInjector extends import0.NgModuleInjector { __SharedStylesHost_26: any; __Title_27: import16.Title; __TRANSLATIONS_FORMAT_28: any; + __AnimationQueue_29: import34.AnimationQueue; constructor(parent: import17.Injector) { super(parent, [import18.TreeComponentNgFactory], [import18.TreeComponentNgFactory]); } @@ -173,10 +175,17 @@ class AppModuleInjector extends import0.NgModuleInjector { } get _ViewUtils_23(): import15.ViewUtils { if ((this.__ViewUtils_23 == (null as any))) { - (this.__ViewUtils_23 = new import15.ViewUtils(this._RootRenderer_20, this._Sanitizer_22)); + (this.__ViewUtils_23 = new import15.ViewUtils( + this._RootRenderer_20, this._Sanitizer_22, this._AnimationQueue_29)); } return this.__ViewUtils_23; } + get _AnimationQueue_29(): import34.AnimationQueue { + if ((this.__AnimationQueue_29 == (null as any))) { + (this.__AnimationQueue_29 = new import34.AnimationQueue(this.parent.get(import22.NgZone))); + } + return this.__AnimationQueue_29; + } get _IterableDiffers_24(): any { if ((this.__IterableDiffers_24 == (null as any))) { (this.__IterableDiffers_24 = import3._iterableDiffersFactory()); @@ -316,4 +325,4 @@ class AppModuleInjector extends import0.NgModuleInjector { destroyInternal(): void { this._ApplicationRef__9.ngOnDestroy(); } } export const AppModuleNgFactory: import0.NgModuleFactory = - new import0.NgModuleFactory(AppModuleInjector, import1.AppModule); \ No newline at end of file + new import0.NgModuleFactory(AppModuleInjector, import1.AppModule); diff --git a/modules/benchmarks/src/tree/ng2_static_ftl/app.ngfactory.ts b/modules/benchmarks/src/tree/ng2_static_ftl/app.ngfactory.ts index 7f62fd4854..23ffbc1b9c 100644 --- a/modules/benchmarks/src/tree/ng2_static_ftl/app.ngfactory.ts +++ b/modules/benchmarks/src/tree/ng2_static_ftl/app.ngfactory.ts @@ -6,6 +6,7 @@ import * as import2 from '@angular/common/src/common_module'; import * as import5 from '@angular/common/src/localization'; +import * as import34 from '@angular/core/src/animation/animation_queue'; import * as import6 from '@angular/core/src/application_init'; import * as import3 from '@angular/core/src/application_module'; import * as import8 from '@angular/core/src/application_ref'; @@ -70,6 +71,7 @@ class AppModuleInjector extends import0.NgModuleInjector { __SharedStylesHost_26: any; __Title_27: import16.Title; __TRANSLATIONS_FORMAT_28: any; + __AnimationQueue_29: import34.AnimationQueue; constructor(parent: import17.Injector) { super(parent, [import18.TreeRootComponentNgFactory], [import18.TreeRootComponentNgFactory]); } @@ -173,10 +175,17 @@ class AppModuleInjector extends import0.NgModuleInjector { } get _ViewUtils_23(): import15.ViewUtils { if ((this.__ViewUtils_23 == (null as any))) { - (this.__ViewUtils_23 = new import15.ViewUtils(this._RootRenderer_20, this._Sanitizer_22)); + (this.__ViewUtils_23 = new import15.ViewUtils( + this._RootRenderer_20, this._Sanitizer_22, this._AnimationQueue_29)); } return this.__ViewUtils_23; } + get _AnimationQueue_29(): import34.AnimationQueue { + if ((this.__AnimationQueue_29 == (null as any))) { + (this.__AnimationQueue_29 = new import34.AnimationQueue(this.parent.get(import22.NgZone))); + } + return this.__AnimationQueue_29; + } get _IterableDiffers_24(): any { if ((this.__IterableDiffers_24 == (null as any))) { (this.__IterableDiffers_24 = import3._iterableDiffersFactory()); @@ -316,4 +325,4 @@ class AppModuleInjector extends import0.NgModuleInjector { destroyInternal(): void { this._ApplicationRef__9.ngOnDestroy(); } } export const AppModuleNgFactory: import0.NgModuleFactory = - new import0.NgModuleFactory(AppModuleInjector, import1.AppModule); \ No newline at end of file + new import0.NgModuleFactory(AppModuleInjector, import1.AppModule);