/** * @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 {AfterContentChecked, AfterContentInit, AfterViewChecked, AfterViewInit, ApplicationRef, Component, DoCheck, Inject, Injector, Input, NgModule, NgZone, OnChanges, OnDestroy, OnInit, StaticProvider, ViewRef, destroyPlatform} from '@angular/core'; import {async, fakeAsync, tick} from '@angular/core/testing'; import {BrowserModule} from '@angular/platform-browser'; import {platformBrowserDynamic} from '@angular/platform-browser-dynamic'; import {browserDetection} from '@angular/platform-browser/testing/src/browser_util'; import {downgradeComponent, downgradeModule} from '@angular/upgrade/static'; import * as angular from '@angular/upgrade/static/src/common/angular1'; import {$ROOT_SCOPE, INJECTOR_KEY, LAZY_MODULE_REF} from '@angular/upgrade/static/src/common/constants'; import {LazyModuleRef} from '@angular/upgrade/static/src/common/util'; import {html, multiTrim, withEachNg1Version} from '../test_helpers'; withEachNg1Version(() => { [true, false].forEach(propagateDigest => { describe(`lazy-load ng2 module (propagateDigest: ${propagateDigest})`, () => { beforeEach(() => destroyPlatform()); it('should support downgrading a component and propagate inputs', async(() => { @Component( {selector: 'ng2A', template: 'a({{ value }}) | '}) class Ng2AComponent { @Input() value = -1; } @Component({selector: 'ng2B', template: 'b({{ value }})'}) class Ng2BComponent { @Input() value = -2; } @NgModule({ declarations: [Ng2AComponent, Ng2BComponent], entryComponents: [Ng2AComponent], imports: [BrowserModule], }) class Ng2Module { ngDoBootstrap() {} } const bootstrapFn = (extraProviders: StaticProvider[]) => platformBrowserDynamic(extraProviders).bootstrapModule(Ng2Module); const lazyModuleName = downgradeModule(bootstrapFn); const ng1Module = angular.module('ng1', [lazyModuleName]) .directive( 'ng2', downgradeComponent({component: Ng2AComponent, propagateDigest})) .run(($rootScope: angular.IRootScopeService) => $rootScope.value = 0); const element = html('
'); const $injector = angular.bootstrap(element, [ng1Module.name]); const $rootScope = $injector.get($ROOT_SCOPE) as angular.IRootScopeService; expect(element.textContent).toBe(''); expect(() => $injector.get(INJECTOR_KEY)).toThrowError(); $rootScope.$apply('value = 1'); expect(element.textContent).toBe(''); expect(() => $injector.get(INJECTOR_KEY)).toThrowError(); $rootScope.$apply('loadNg2 = true'); expect(element.textContent).toBe(''); expect(() => $injector.get(INJECTOR_KEY)).toThrowError(); // Wait for the module to be bootstrapped. setTimeout(() => { expect(() => $injector.get(INJECTOR_KEY)).not.toThrow(); // Wait for `$evalAsync()` to propagate inputs. setTimeout(() => expect(element.textContent).toBe('a(1) | b(1)')); }); })); it('should support using an upgraded service', async(() => { class Ng2Service { constructor(@Inject('ng1Value') private ng1Value: string) {} getValue = () => `${this.ng1Value}-bar`; } @Component({selector: 'ng2', template: '{{ value }}'}) class Ng2Component { value: string; constructor(ng2Service: Ng2Service) { this.value = ng2Service.getValue(); } } @NgModule({ declarations: [Ng2Component], entryComponents: [Ng2Component], imports: [BrowserModule], providers: [ Ng2Service, { provide: 'ng1Value', useFactory: (i: angular.IInjectorService) => i.get('ng1Value'), deps: ['$injector'], }, ], }) class Ng2Module { ngDoBootstrap() {} } const bootstrapFn = (extraProviders: StaticProvider[]) => platformBrowserDynamic(extraProviders).bootstrapModule(Ng2Module); const lazyModuleName = downgradeModule(bootstrapFn); const ng1Module = angular.module('ng1', [lazyModuleName]) .directive('ng2', downgradeComponent({component: Ng2Component, propagateDigest})) .value('ng1Value', 'foo'); const element = html('
'); const $injector = angular.bootstrap(element, [ng1Module.name]); const $rootScope = $injector.get($ROOT_SCOPE) as angular.IRootScopeService; expect(element.textContent).toBe(''); expect(() => $injector.get(INJECTOR_KEY)).toThrowError(); $rootScope.$apply('loadNg2 = true'); expect(element.textContent).toBe(''); expect(() => $injector.get(INJECTOR_KEY)).toThrowError(); // Wait for the module to be bootstrapped. setTimeout(() => { expect(() => $injector.get(INJECTOR_KEY)).not.toThrow(); // Wait for `$evalAsync()` to propagate inputs. setTimeout(() => expect(element.textContent).toBe('foo-bar')); }); })); it('should create components inside the Angular zone', async(() => { @Component({selector: 'ng2', template: 'In the zone: {{ inTheZone }}'}) class Ng2Component { private inTheZone = false; constructor() { this.inTheZone = NgZone.isInAngularZone(); } } @NgModule({ declarations: [Ng2Component], entryComponents: [Ng2Component], imports: [BrowserModule], }) class Ng2Module { ngDoBootstrap() {} } const bootstrapFn = (extraProviders: StaticProvider[]) => platformBrowserDynamic(extraProviders).bootstrapModule(Ng2Module); const lazyModuleName = downgradeModule(bootstrapFn); const ng1Module = angular.module('ng1', [lazyModuleName]) .directive( 'ng2', downgradeComponent({component: Ng2Component, propagateDigest})); const element = html(''); angular.bootstrap(element, [ng1Module.name]); // Wait for the module to be bootstrapped. setTimeout(() => { // Wait for `$evalAsync()` to propagate inputs. setTimeout(() => expect(element.textContent).toBe('In the zone: true')); }); })); it('should destroy components inside the Angular zone', async(() => { let destroyedInTheZone = false; @Component({selector: 'ng2', template: ''}) class Ng2Component implements OnDestroy { ngOnDestroy() { destroyedInTheZone = NgZone.isInAngularZone(); } } @NgModule({ declarations: [Ng2Component], entryComponents: [Ng2Component], imports: [BrowserModule], }) class Ng2Module { ngDoBootstrap() {} } const bootstrapFn = (extraProviders: StaticProvider[]) => platformBrowserDynamic(extraProviders).bootstrapModule(Ng2Module); const lazyModuleName = downgradeModule(bootstrapFn); const ng1Module = angular.module('ng1', [lazyModuleName]) .directive( 'ng2', downgradeComponent({component: Ng2Component, propagateDigest})); const element = html(''); const $injector = angular.bootstrap(element, [ng1Module.name]); const $rootScope = $injector.get($ROOT_SCOPE) as angular.IRootScopeService; // Wait for the module to be bootstrapped. setTimeout(() => { $rootScope.$apply('hideNg2 = true'); expect(destroyedInTheZone).toBe(true); }); })); it('should propagate input changes inside the Angular zone', async(() => { let ng2Component: Ng2Component; @Component({selector: 'ng2', template: ''}) class Ng2Component implements OnChanges { @Input() attrInput = 'foo'; @Input() propInput = 'foo'; constructor() { ng2Component = this; } ngOnChanges() {} } @NgModule({ declarations: [Ng2Component], entryComponents: [Ng2Component], imports: [BrowserModule], }) class Ng2Module { ngDoBootstrap() {} } const bootstrapFn = (extraProviders: StaticProvider[]) => platformBrowserDynamic(extraProviders).bootstrapModule(Ng2Module); const lazyModuleName = downgradeModule(bootstrapFn); const ng1Module = angular.module('ng1', [lazyModuleName]) .directive('ng2', downgradeComponent({component: Ng2Component, propagateDigest})) .run(($rootScope: angular.IRootScopeService) => { $rootScope.attrVal = 'bar'; $rootScope.propVal = 'bar'; }); const element = html(''); const $injector = angular.bootstrap(element, [ng1Module.name]); const $rootScope = $injector.get($ROOT_SCOPE) as angular.IRootScopeService; setTimeout(() => { // Wait for the module to be bootstrapped. setTimeout(() => { // Wait for `$evalAsync()` to propagate inputs. const expectToBeInNgZone = () => expect(NgZone.isInAngularZone()).toBe(true); const changesSpy = spyOn(ng2Component, 'ngOnChanges').and.callFake(expectToBeInNgZone); expect(ng2Component.attrInput).toBe('bar'); expect(ng2Component.propInput).toBe('bar'); $rootScope.$apply('attrVal = "baz"'); expect(ng2Component.attrInput).toBe('baz'); expect(ng2Component.propInput).toBe('bar'); expect(changesSpy).toHaveBeenCalledTimes(1); $rootScope.$apply('propVal = "qux"'); expect(ng2Component.attrInput).toBe('baz'); expect(ng2Component.propInput).toBe('qux'); expect(changesSpy).toHaveBeenCalledTimes(2); }); }); })); it('should wire up the component for change detection', async(() => { @Component( {selector: 'ng2', template: '{{ count }}'}) class Ng2Component { private count = 0; increment() { ++this.count; } } @NgModule({ declarations: [Ng2Component], entryComponents: [Ng2Component], imports: [BrowserModule], }) class Ng2Module { ngDoBootstrap() {} } const bootstrapFn = (extraProviders: StaticProvider[]) => platformBrowserDynamic(extraProviders).bootstrapModule(Ng2Module); const lazyModuleName = downgradeModule(bootstrapFn); const ng1Module = angular.module('ng1', [lazyModuleName]) .directive( 'ng2', downgradeComponent({component: Ng2Component, propagateDigest})); const element = html(''); angular.bootstrap(element, [ng1Module.name]); setTimeout(() => { // Wait for the module to be bootstrapped. setTimeout(() => { // Wait for `$evalAsync()` to propagate inputs. const button = element.querySelector('button') !; expect(element.textContent).toBe('0'); button.click(); expect(element.textContent).toBe('1'); button.click(); expect(element.textContent).toBe('2'); }); }); })); it('should run the lifecycle hooks in the correct order', async(() => { const logs: string[] = []; let rootScope: angular.IRootScopeService; @Component({ selector: 'ng2', template: ` {{ value }} ` }) class Ng2Component implements AfterContentChecked, AfterContentInit, AfterViewChecked, AfterViewInit, DoCheck, OnChanges, OnDestroy, OnInit { @Input() value = 'foo'; ngAfterContentChecked() { this.log('AfterContentChecked'); } ngAfterContentInit() { this.log('AfterContentInit'); } ngAfterViewChecked() { this.log('AfterViewChecked'); } ngAfterViewInit() { this.log('AfterViewInit'); } ngDoCheck() { this.log('DoCheck'); } ngOnChanges() { this.log('OnChanges'); } ngOnDestroy() { this.log('OnDestroy'); } ngOnInit() { this.log('OnInit'); } private log(hook: string) { logs.push(`${hook}(${this.value})`); } } @NgModule({ declarations: [Ng2Component], entryComponents: [Ng2Component], imports: [BrowserModule], }) class Ng2Module { ngDoBootstrap() {} } const bootstrapFn = (extraProviders: StaticProvider[]) => platformBrowserDynamic(extraProviders).bootstrapModule(Ng2Module); const lazyModuleName = downgradeModule(bootstrapFn); const ng1Module = angular.module('ng1', [lazyModuleName]) .directive('ng2', downgradeComponent({component: Ng2Component, propagateDigest})) .run(($rootScope: angular.IRootScopeService) => { rootScope = $rootScope; rootScope.value = 'bar'; }); const element = html('
Content
'); angular.bootstrap(element, [ng1Module.name]); setTimeout(() => { // Wait for the module to be bootstrapped. setTimeout(() => { // Wait for `$evalAsync()` to propagate inputs. const button = element.querySelector('button') !; // Once initialized. expect(multiTrim(element.textContent)).toBe('bar Content'); expect(logs).toEqual([ // `ngOnChanges()` call triggered directly through the `inputChanges` $watcher. 'OnChanges(bar)', // Initial CD triggered directly through the `detectChanges()` or `inputChanges` // $watcher (for `propagateDigest` true/false respectively). 'OnInit(bar)', 'DoCheck(bar)', 'AfterContentInit(bar)', 'AfterContentChecked(bar)', 'AfterViewInit(bar)', 'AfterViewChecked(bar)', ...(propagateDigest ? [ // CD triggered directly through the `detectChanges()` $watcher (2nd // $digest). 'DoCheck(bar)', 'AfterContentChecked(bar)', 'AfterViewChecked(bar)', ] : []), // CD triggered due to entering/leaving the NgZone (in `downgradeFn()`). 'DoCheck(bar)', 'AfterContentChecked(bar)', 'AfterViewChecked(bar)', ]); logs.length = 0; // Change inputs and run `$digest`. rootScope.$apply('value = "baz"'); expect(multiTrim(element.textContent)).toBe('baz Content'); expect(logs).toEqual([ // `ngOnChanges()` call triggered directly through the `inputChanges` $watcher. 'OnChanges(baz)', // `propagateDigest: true` (3 CD runs): // - CD triggered due to entering/leaving the NgZone (in `inputChanges` // $watcher). // - CD triggered directly through the `detectChanges()` $watcher. // - CD triggered due to entering/leaving the NgZone (in `detectChanges` // $watcher). // `propagateDigest: false` (2 CD runs): // - CD triggered directly through the `inputChanges` $watcher. // - CD triggered due to entering/leaving the NgZone (in `inputChanges` // $watcher). 'DoCheck(baz)', 'AfterContentChecked(baz)', 'AfterViewChecked(baz)', 'DoCheck(baz)', 'AfterContentChecked(baz)', 'AfterViewChecked(baz)', ...(propagateDigest ? [ 'DoCheck(baz)', 'AfterContentChecked(baz)', 'AfterViewChecked(baz)', ] : []), ]); logs.length = 0; // Run `$digest` (without changing inputs). rootScope.$digest(); expect(multiTrim(element.textContent)).toBe('baz Content'); expect(logs).toEqual( propagateDigest ? [ // CD triggered directly through the `detectChanges()` $watcher. 'DoCheck(baz)', 'AfterContentChecked(baz)', 'AfterViewChecked(baz)', // CD triggered due to entering/leaving the NgZone (in the above $watcher). 'DoCheck(baz)', 'AfterContentChecked(baz)', 'AfterViewChecked(baz)', ] : []); logs.length = 0; // Trigger change detection (without changing inputs). button.click(); expect(multiTrim(element.textContent)).toBe('qux Content'); expect(logs).toEqual([ 'DoCheck(qux)', 'AfterContentChecked(qux)', 'AfterViewChecked(qux)', ]); logs.length = 0; // Destroy the component. rootScope.$apply('hideNg2 = true'); expect(logs).toEqual([ 'OnDestroy(qux)', ]); logs.length = 0; }); }); })); it('should detach hostViews from the ApplicationRef once destroyed', async(() => { let ng2Component: Ng2Component; @Component({selector: 'ng2', template: ''}) class Ng2Component { constructor(public appRef: ApplicationRef) { ng2Component = this; spyOn(appRef, 'attachView').and.callThrough(); spyOn(appRef, 'detachView').and.callThrough(); } } @NgModule({ declarations: [Ng2Component], entryComponents: [Ng2Component], imports: [BrowserModule], }) class Ng2Module { ngDoBootstrap() {} } const bootstrapFn = (extraProviders: StaticProvider[]) => platformBrowserDynamic(extraProviders).bootstrapModule(Ng2Module); const lazyModuleName = downgradeModule(bootstrapFn); const ng1Module = angular.module('ng1', [lazyModuleName]) .directive( 'ng2', downgradeComponent({component: Ng2Component, propagateDigest})); const element = html(''); const $injector = angular.bootstrap(element, [ng1Module.name]); const $rootScope = $injector.get($ROOT_SCOPE) as angular.IRootScopeService; setTimeout(() => { // Wait for the module to be bootstrapped. setTimeout(() => { // Wait for the hostView to be attached (during the `$digest`). const hostView: ViewRef = (ng2Component.appRef.attachView as jasmine.Spy).calls.mostRecent().args[0]; expect(hostView.destroyed).toBe(false); $rootScope.$apply('hideNg2 = true'); expect(hostView.destroyed).toBe(true); expect(ng2Component.appRef.detachView).toHaveBeenCalledWith(hostView); }); }); })); it('should only retrieve the Angular zone once (and cache it for later use)', fakeAsync(() => { let count = 0; let getNgZoneCount = 0; @Component( {selector: 'ng2', template: 'Count: {{ count }} | In the zone: {{ inTheZone }}'}) class Ng2Component { private count = ++count; private inTheZone = false; constructor() { this.inTheZone = NgZone.isInAngularZone(); } } @NgModule({ declarations: [Ng2Component], entryComponents: [Ng2Component], imports: [BrowserModule], }) class Ng2Module { constructor(injector: Injector) { const originalGet = injector.get; injector.get = function(token: any) { if (token === NgZone) ++getNgZoneCount; return originalGet.apply(injector, arguments); }; } ngDoBootstrap() {} } const tickDelay = browserDetection.isIE ? 100 : 0; const bootstrapFn = (extraProviders: StaticProvider[]) => platformBrowserDynamic(extraProviders).bootstrapModule(Ng2Module); const lazyModuleName = downgradeModule(bootstrapFn); const ng1Module = angular.module('ng1', [lazyModuleName]) .directive( 'ng2', downgradeComponent({component: Ng2Component, propagateDigest})); const element = html('
'); const $injector = angular.bootstrap(element, [ng1Module.name]); const $rootScope = $injector.get($ROOT_SCOPE) as angular.IRootScopeService; $rootScope.$apply('showNg2 = true'); tick(tickDelay); // Wait for the module to be bootstrapped and `$evalAsync()` to // propagate inputs. const injector = ($injector.get(LAZY_MODULE_REF) as LazyModuleRef).injector !; const injectorGet = injector.get; spyOn(injector, 'get').and.callFake((...args: any[]) => { expect(args[0]).not.toBe(NgZone); return injectorGet.apply(injector, args); }); expect(element.textContent).toBe('Count: 1 | In the zone: true'); $rootScope.$apply('showNg2 = false'); expect(element.textContent).toBe(''); $rootScope.$apply('showNg2 = true'); tick(tickDelay); // Wait for `$evalAsync()` to propagate inputs. expect(element.textContent).toBe('Count: 2 | In the zone: true'); $rootScope.$destroy(); })); it('should give access to both injectors in the Angular module\'s constructor', async(() => { let $injectorFromNg2: angular.IInjectorService|null = null; @Component({selector: 'ng2', template: ''}) class Ng2Component { } @NgModule({ declarations: [Ng2Component], entryComponents: [Ng2Component], imports: [BrowserModule], }) class Ng2Module { constructor(injector: Injector) { $injectorFromNg2 = injector.get('$injector' as any); } ngDoBootstrap() {} } const bootstrapFn = (extraProviders: StaticProvider[]) => platformBrowserDynamic(extraProviders).bootstrapModule(Ng2Module); const lazyModuleName = downgradeModule(bootstrapFn); const ng1Module = angular.module('ng1', [lazyModuleName]) .directive( 'ng2', downgradeComponent({component: Ng2Component, propagateDigest})); const element = html(''); const $injectorFromNg1 = angular.bootstrap(element, [ng1Module.name]); // Wait for the module to be bootstrapped. setTimeout(() => expect($injectorFromNg2).toBe($injectorFromNg1)); })); }); }); });