feat(UpgradeComponent): add/improve support for lifecycle hooks

Add support for the `$postDigest()` and `$onDestroy()` lifecycle hooks.
Better align the behavior of the `$onChanges()` and `$onInit()` lifecycle hooks
with Angular 1.x:

- Call `$onInit()` before pre-linking.
- Always instantiate the controller before calling `$onChanges()`.
This commit is contained in:
Georgios Kalpakas 2016-10-20 13:42:57 +03:00 committed by vikerman
parent f0cdb428f5
commit 469010ea8e
3 changed files with 793 additions and 47 deletions

View File

@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/
import {DoCheck, ElementRef, EventEmitter, Injector, OnChanges, OnInit, SimpleChanges} from '@angular/core';
import {DoCheck, ElementRef, EventEmitter, Injector, OnChanges, OnDestroy, OnInit, SimpleChanges} from '@angular/core';
import * as angular from '../angular_js';
import {looseIdentical} from '../facade/lang';
@ -34,13 +34,17 @@ interface IBindingDestination {
}
interface IControllerInstance extends IBindingDestination {
$onDestroy?: () => void;
$onInit?: () => void;
$postLink?: () => void;
}
type LifecycleHook = '$onChanges' | '$onDestroy' | '$onInit' | '$postLink';
/**
* @experimental
*/
export class UpgradeComponent implements OnInit, OnChanges, DoCheck {
export class UpgradeComponent implements OnInit, OnChanges, DoCheck, OnDestroy {
private $injector: angular.IInjectorService;
private $compile: angular.ICompileService;
private $templateCache: angular.ITemplateCacheService;
@ -80,33 +84,27 @@ export class UpgradeComponent implements OnInit, OnChanges, DoCheck {
this.$componentScope = $parentScope.$new(!!this.directive.scope);
const controllerType = this.directive.controller;
// QUESTION: shouldn't we be building the controller in any case?
if (this.directive.bindToController) {
if (controllerType) {
this.bindingDestination = this.controllerInstance = this.buildController(
controllerType, this.$componentScope, this.$element, this.directive.controllerAs);
} else {
throw new Error(
`Upgraded directive '${name}' specifies 'bindToController' but no controller.`);
}
} else {
this.bindingDestination = this.$componentScope;
const bindToController = this.directive.bindToController;
if (controllerType) {
this.controllerInstance = this.buildController(
controllerType, this.$componentScope, this.$element, this.directive.controllerAs);
} else if (bindToController) {
throw new Error(
`Upgraded directive '${name}' specifies 'bindToController' but no controller.`);
}
this.bindingDestination = bindToController ? this.controllerInstance : this.$componentScope;
this.setupOutputs();
}
ngOnInit() {
// QUESTION: why not just use $compile instead of reproducing parts of it
if (!this.directive.bindToController && this.directive.controller) {
this.controllerInstance = this.buildController(
this.directive.controller, this.$componentScope, this.$element,
this.directive.controllerAs);
}
const attrs: angular.IAttributes = NOT_SUPPORTED;
const transcludeFn: angular.ITranscludeFunction = NOT_SUPPORTED;
const linkController = this.resolveRequired(this.$element, this.directive.require);
this.callLifecycleHook('$onInit', this.controllerInstance);
const link = this.directive.link;
const preLink = (typeof link == 'object') && (link as angular.IDirectivePrePost).pre;
const postLink = (typeof link == 'object') ? (link as angular.IDirectivePrePost).post : link;
@ -131,19 +129,15 @@ export class UpgradeComponent implements OnInit, OnChanges, DoCheck {
postLink(this.$componentScope, this.$element, attrs, linkController, transcludeFn);
}
if (this.controllerInstance && this.controllerInstance.$onInit) {
this.controllerInstance.$onInit();
}
this.callLifecycleHook('$postLink', this.controllerInstance);
}
ngOnChanges(changes: SimpleChanges) {
// Forward input changes to `bindingDestination`
Object.keys(changes).forEach(
propName => { this.bindingDestination[propName] = changes[propName].currentValue; });
propName => this.bindingDestination[propName] = changes[propName].currentValue);
if (this.bindingDestination.$onChanges) {
this.bindingDestination.$onChanges(changes);
}
this.callLifecycleHook('$onChanges', this.bindingDestination, changes);
}
ngDoCheck() {
@ -165,6 +159,17 @@ export class UpgradeComponent implements OnInit, OnChanges, DoCheck {
});
}
ngOnDestroy() {
this.callLifecycleHook('$onDestroy', this.controllerInstance);
this.$componentScope.$destroy();
}
private callLifecycleHook(method: LifecycleHook, context: IBindingDestination, arg?: any) {
if (context && typeof context[method] === 'function') {
context[method](arg);
}
}
private getDirective(name: string): angular.IDirective {
const directives: angular.IDirective[] = this.$injector.get(name + 'Directive');
if (directives.length > 1) {
@ -254,6 +259,7 @@ export class UpgradeComponent implements OnInit, OnChanges, DoCheck {
private buildController(
controllerType: angular.IController, $scope: angular.IScope,
$element: angular.IAugmentedJQuery, controllerAs: string) {
// TODO: Document that we do not pre-assign bindings on the controller instance
var locals = {$scope, $element};
var controller = this.$controller(controllerType, locals, null, controllerAs);
$element.data(controllerKey(this.directive.name), controller);

View File

@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/
import {Component, Directive, ElementRef, EventEmitter, Injector, Input, NO_ERRORS_SCHEMA, NgModule, Output, destroyPlatform} from '@angular/core';
import {Component, Directive, ElementRef, EventEmitter, Injector, Input, NO_ERRORS_SCHEMA, NgModule, Output, SimpleChanges, 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';
@ -1381,8 +1381,307 @@ export function main() {
});
describe('lifecycle hooks', () => {
xit('should call `$onChanges()` on controller', () => {});
xit('should call `$onChanges()` on scope', () => {});
it('should call `$onChanges()` on binding destination (prototype)', fakeAsync(() => {
const scopeOnChanges = jasmine.createSpy('scopeOnChanges');
const controllerOnChangesA = jasmine.createSpy('controllerOnChangesA');
const controllerOnChangesB = jasmine.createSpy('controllerOnChangesB');
let ng2ComponentInstance: Ng2Component;
// Define `ng1Directive`
const ng1DirectiveA: angular.IDirective = {
template: '',
scope: {inputA: '<'},
bindToController: false,
controllerAs: '$ctrl',
controller: class {
$onChanges(changes: SimpleChanges) {
controllerOnChangesA(changes);
}
}
};
const ng1DirectiveB: angular.IDirective = {
template: '',
scope: {inputB: '<'},
bindToController: true,
controllerAs: '$ctrl',
controller: class {
constructor($scope: angular.IScope) {
Object.getPrototypeOf($scope)['$onChanges'] = scopeOnChanges;
}
$onChanges(changes: SimpleChanges) {
controllerOnChangesB(changes);
}
}
};
// Define `Ng1ComponentFacade`
@Directive({selector: 'ng1A'})
class Ng1ComponentAFacade extends UpgradeComponent {
@Input() inputA: any;
constructor(elementRef: ElementRef, injector: Injector) {
super('ng1A', elementRef, injector);
}
}
@Directive({selector: 'ng1B'})
class Ng1ComponentBFacade extends UpgradeComponent {
@Input() inputB: any;
constructor(elementRef: ElementRef, injector: Injector) {
super('ng1B', elementRef, injector);
}
}
// Define `Ng2Component`
@Component({
selector: 'ng2',
template: '<ng1A [inputA]="data"></ng1A> | <ng1B [inputB]="data"></ng1B>'
})
class Ng2Component {
data = {foo: 'bar'};
constructor() { ng2ComponentInstance = this; }
}
// Define `ng1Module`
const ng1Module = angular.module('ng1Module', [])
.directive('ng1A', () => ng1DirectiveA)
.directive('ng1B', () => ng1DirectiveB)
.directive('ng2', downgradeComponent({component: Ng2Component}));
// Define `Ng2Module`
@NgModule({
declarations: [Ng1ComponentAFacade, Ng1ComponentBFacade, Ng2Component],
entryComponents: [Ng2Component],
imports: [BrowserModule, UpgradeModule]
})
class Ng2Module {
ngDoBootstrap() {}
}
// Bootstrap
const element = html(`<ng2></ng2>`);
bootstrap(platformBrowserDynamic(), Ng2Module, element, ng1Module).then(adapter => {
// Initial change
expect(scopeOnChanges.calls.count()).toBe(1);
expect(controllerOnChangesA).not.toHaveBeenCalled();
expect(controllerOnChangesB.calls.count()).toBe(1);
expect(scopeOnChanges.calls.argsFor(0)[0]).toEqual({inputA: jasmine.any(Object)});
expect(scopeOnChanges.calls.argsFor(0)[0].inputA.currentValue).toEqual({foo: 'bar'});
expect(scopeOnChanges.calls.argsFor(0)[0].inputA.isFirstChange()).toBe(true);
expect(controllerOnChangesB.calls.argsFor(0)[0].inputB.currentValue).toEqual({
foo: 'bar'
});
expect(controllerOnChangesB.calls.argsFor(0)[0].inputB.isFirstChange()).toBe(true);
// Change: Re-assign `data`
ng2ComponentInstance.data = {foo: 'baz'};
digest(adapter);
tick();
expect(scopeOnChanges.calls.count()).toBe(2);
expect(controllerOnChangesA).not.toHaveBeenCalled();
expect(controllerOnChangesB.calls.count()).toBe(2);
expect(scopeOnChanges.calls.argsFor(1)[0]).toEqual({inputA: jasmine.any(Object)});
expect(scopeOnChanges.calls.argsFor(1)[0].inputA.previousValue).toEqual({foo: 'bar'});
expect(scopeOnChanges.calls.argsFor(1)[0].inputA.currentValue).toEqual({foo: 'baz'});
expect(scopeOnChanges.calls.argsFor(1)[0].inputA.isFirstChange()).toBe(false);
expect(controllerOnChangesB.calls.argsFor(1)[0].inputB.previousValue).toEqual({
foo: 'bar'
});
expect(controllerOnChangesB.calls.argsFor(1)[0].inputB.currentValue).toEqual({
foo: 'baz'
});
expect(controllerOnChangesB.calls.argsFor(1)[0].inputB.isFirstChange()).toBe(false);
// No change: Update internal property
ng2ComponentInstance.data.foo = 'qux';
digest(adapter);
tick();
expect(scopeOnChanges.calls.count()).toBe(2);
expect(controllerOnChangesA).not.toHaveBeenCalled();
expect(controllerOnChangesB.calls.count()).toBe(2);
// Change: Re-assign `data` (even if it looks the same)
ng2ComponentInstance.data = {foo: 'qux'};
digest(adapter);
tick();
expect(scopeOnChanges.calls.count()).toBe(3);
expect(controllerOnChangesA).not.toHaveBeenCalled();
expect(controllerOnChangesB.calls.count()).toBe(3);
expect(scopeOnChanges.calls.argsFor(2)[0]).toEqual({inputA: jasmine.any(Object)});
expect(scopeOnChanges.calls.argsFor(2)[0].inputA.previousValue).toEqual({foo: 'qux'});
expect(scopeOnChanges.calls.argsFor(2)[0].inputA.currentValue).toEqual({foo: 'qux'});
expect(scopeOnChanges.calls.argsFor(2)[0].inputA.isFirstChange()).toBe(false);
expect(controllerOnChangesB.calls.argsFor(2)[0].inputB.previousValue).toEqual({
foo: 'qux'
});
expect(controllerOnChangesB.calls.argsFor(2)[0].inputB.currentValue).toEqual({
foo: 'qux'
});
expect(controllerOnChangesB.calls.argsFor(2)[0].inputB.isFirstChange()).toBe(false);
});
}));
it('should call `$onChanges()` on binding destination (instance)', fakeAsync(() => {
const scopeOnChangesA = jasmine.createSpy('scopeOnChangesA');
const scopeOnChangesB = jasmine.createSpy('scopeOnChangesB');
const controllerOnChangesA = jasmine.createSpy('controllerOnChangesA');
const controllerOnChangesB = jasmine.createSpy('controllerOnChangesB');
let ng2ComponentInstance: Ng2Component;
// Define `ng1Directive`
const ng1DirectiveA: angular.IDirective = {
template: '',
scope: {inputA: '<'},
bindToController: false,
controllerAs: '$ctrl',
controller: class {
constructor($scope: angular.IScope) {
$scope['$onChanges'] = scopeOnChangesA;
(this as any).$onChanges = controllerOnChangesA;
}
}
};
const ng1DirectiveB: angular.IDirective = {
template: '',
scope: {inputB: '<'},
bindToController: true,
controllerAs: '$ctrl',
controller: class {
constructor($scope: angular.IScope) {
$scope['$onChanges'] = scopeOnChangesB;
(this as any).$onChanges = controllerOnChangesB;
}
}
};
// Define `Ng1ComponentFacade`
@Directive({selector: 'ng1A'})
class Ng1ComponentAFacade extends UpgradeComponent {
@Input() inputA: any;
constructor(elementRef: ElementRef, injector: Injector) {
super('ng1A', elementRef, injector);
}
}
@Directive({selector: 'ng1B'})
class Ng1ComponentBFacade extends UpgradeComponent {
@Input() inputB: any;
constructor(elementRef: ElementRef, injector: Injector) {
super('ng1B', elementRef, injector);
}
}
// Define `Ng2Component`
@Component({
selector: 'ng2',
template: '<ng1A [inputA]="data"></ng1A> | <ng1B [inputB]="data"></ng1B>'
})
class Ng2Component {
data = {foo: 'bar'};
constructor() { ng2ComponentInstance = this; }
}
// Define `ng1Module`
const ng1Module = angular.module('ng1Module', [])
.directive('ng1A', () => ng1DirectiveA)
.directive('ng1B', () => ng1DirectiveB)
.directive('ng2', downgradeComponent({component: Ng2Component}));
// Define `Ng2Module`
@NgModule({
declarations: [Ng1ComponentAFacade, Ng1ComponentBFacade, Ng2Component],
entryComponents: [Ng2Component],
imports: [BrowserModule, UpgradeModule]
})
class Ng2Module {
ngDoBootstrap() {}
}
// Bootstrap
const element = html(`<ng2></ng2>`);
bootstrap(platformBrowserDynamic(), Ng2Module, element, ng1Module).then(adapter => {
// Initial change
expect(scopeOnChangesA.calls.count()).toBe(1);
expect(scopeOnChangesB).not.toHaveBeenCalled();
expect(controllerOnChangesA).not.toHaveBeenCalled();
expect(controllerOnChangesB.calls.count()).toBe(1);
expect(scopeOnChangesA.calls.argsFor(0)[0].inputA.currentValue).toEqual({foo: 'bar'});
expect(scopeOnChangesA.calls.argsFor(0)[0].inputA.isFirstChange()).toBe(true);
expect(controllerOnChangesB.calls.argsFor(0)[0].inputB.currentValue).toEqual({
foo: 'bar'
});
expect(controllerOnChangesB.calls.argsFor(0)[0].inputB.isFirstChange()).toBe(true);
// Change: Re-assign `data`
ng2ComponentInstance.data = {foo: 'baz'};
digest(adapter);
tick();
expect(scopeOnChangesA.calls.count()).toBe(2);
expect(scopeOnChangesB).not.toHaveBeenCalled();
expect(controllerOnChangesA).not.toHaveBeenCalled();
expect(controllerOnChangesB.calls.count()).toBe(2);
expect(scopeOnChangesA.calls.argsFor(1)[0].inputA.previousValue).toEqual({foo: 'bar'});
expect(scopeOnChangesA.calls.argsFor(1)[0].inputA.currentValue).toEqual({foo: 'baz'});
expect(scopeOnChangesA.calls.argsFor(1)[0].inputA.isFirstChange()).toBe(false);
expect(controllerOnChangesB.calls.argsFor(1)[0].inputB.previousValue).toEqual({
foo: 'bar'
});
expect(controllerOnChangesB.calls.argsFor(1)[0].inputB.currentValue).toEqual({
foo: 'baz'
});
expect(controllerOnChangesB.calls.argsFor(1)[0].inputB.isFirstChange()).toBe(false);
// No change: Update internal property
ng2ComponentInstance.data.foo = 'qux';
digest(adapter);
tick();
expect(scopeOnChangesA.calls.count()).toBe(2);
expect(scopeOnChangesB).not.toHaveBeenCalled();
expect(controllerOnChangesA).not.toHaveBeenCalled();
expect(controllerOnChangesB.calls.count()).toBe(2);
// Change: Re-assign `data` (even if it looks the same)
ng2ComponentInstance.data = {foo: 'qux'};
digest(adapter);
tick();
expect(scopeOnChangesA.calls.count()).toBe(3);
expect(scopeOnChangesB).not.toHaveBeenCalled();
expect(controllerOnChangesA).not.toHaveBeenCalled();
expect(controllerOnChangesB.calls.count()).toBe(3);
expect(scopeOnChangesA.calls.argsFor(2)[0].inputA.previousValue).toEqual({foo: 'qux'});
expect(scopeOnChangesA.calls.argsFor(2)[0].inputA.currentValue).toEqual({foo: 'qux'});
expect(scopeOnChangesA.calls.argsFor(2)[0].inputA.isFirstChange()).toBe(false);
expect(controllerOnChangesB.calls.argsFor(2)[0].inputB.previousValue).toEqual({
foo: 'qux'
});
expect(controllerOnChangesB.calls.argsFor(2)[0].inputB.currentValue).toEqual({
foo: 'qux'
});
expect(controllerOnChangesB.calls.argsFor(2)[0].inputB.isFirstChange()).toBe(false);
});
}));
it('should call `$onInit()` on controller', async(() => {
// Define `ng1Directive`
@ -1400,9 +1699,10 @@ export function main() {
template: 'Called: {{ called }}',
bindToController: true,
controller: class {
constructor(private $scope: angular.IScope) { $scope['called'] = 'no'; }
$onInit() { this.$scope['called'] = 'yes'; }
constructor($scope: angular.IScope) {
$scope['called'] = 'no';
(this as any)['$onInit'] = () => $scope['called'] = 'yes';
}
}
};
@ -1445,11 +1745,8 @@ export function main() {
// Bootstrap
const element = html(`<ng2></ng2>`);
platformBrowserDynamic().bootstrapModule(Ng2Module).then(ref => {
var adapter = ref.injector.get(UpgradeModule) as UpgradeModule;
adapter.bootstrap(element, [ng1Module.name]);
expect(multiTrim(document.body.textContent)).toBe('Called: yes | Called: yes');
bootstrap(platformBrowserDynamic(), Ng2Module, element, ng1Module).then(() => {
expect(multiTrim(element.textContent)).toBe('Called: yes | Called: yes');
});
}));
@ -1462,6 +1759,7 @@ export function main() {
constructor($scope: angular.IScope) {
$scope['called'] = 'no';
$scope['$onInit'] = () => $scope['called'] = 'yes';
Object.getPrototypeOf($scope)['$onInit'] = () => $scope['called'] = 'yes';
}
}
};
@ -1473,6 +1771,7 @@ export function main() {
constructor($scope: angular.IScope) {
$scope['called'] = 'no';
$scope['$onInit'] = () => $scope['called'] = 'yes';
Object.getPrototypeOf($scope)['$onInit'] = () => $scope['called'] = 'yes';
}
}
};
@ -1516,21 +1815,461 @@ export function main() {
// Bootstrap
const element = html(`<ng2></ng2>`);
platformBrowserDynamic().bootstrapModule(Ng2Module).then(ref => {
var adapter = ref.injector.get(UpgradeModule) as UpgradeModule;
adapter.bootstrap(element, [ng1Module.name]);
expect(multiTrim(document.body.textContent)).toBe('Called: no | Called: no');
bootstrap(platformBrowserDynamic(), Ng2Module, element, ng1Module).then(() => {
expect(multiTrim(element.textContent)).toBe('Called: no | Called: no');
});
}));
xit('should call `$onPostDigest()` on controller', () => {});
xit('should not call `$onPostDigest()` on scope', () => {});
it('should call `$postLink()` on controller', async(() => {
// Define `ng1Directive`
const ng1DirectiveA: angular.IDirective = {
template: 'Called: {{ called }}',
bindToController: false,
controller: class {
constructor(private $scope: angular.IScope) { $scope['called'] = 'no'; }
xit('should call `$onDestroy()` on controller', () => {});
xit('should not call `$onDestroy()` on scope', () => {});
$postLink() { this.$scope['called'] = 'yes'; }
}
};
const ng1DirectiveB: angular.IDirective = {
template: 'Called: {{ called }}',
bindToController: true,
controller: class {
constructor($scope: angular.IScope) {
$scope['called'] = 'no';
(this as any)['$postLink'] = () => $scope['called'] = 'yes';
}
}
};
// Define `Ng1ComponentFacade`
@Directive({selector: 'ng1A'})
class Ng1ComponentAFacade extends UpgradeComponent {
constructor(elementRef: ElementRef, injector: Injector) {
super('ng1A', elementRef, injector);
}
}
@Directive({selector: 'ng1B'})
class Ng1ComponentBFacade extends UpgradeComponent {
constructor(elementRef: ElementRef, injector: Injector) {
super('ng1B', elementRef, injector);
}
}
// Define `Ng2Component`
@Component({selector: 'ng2', template: '<ng1A></ng1A> | <ng1B></ng1B>'})
class Ng2Component {
}
// Define `ng1Module`
const ng1Module = angular.module('ng1Module', [])
.directive('ng1A', () => ng1DirectiveA)
.directive('ng1B', () => ng1DirectiveB)
.directive('ng2', downgradeComponent({component: Ng2Component}));
// Define `Ng2Module`
@NgModule({
declarations: [Ng1ComponentAFacade, Ng1ComponentBFacade, Ng2Component],
entryComponents: [Ng2Component],
imports: [BrowserModule, UpgradeModule]
})
class Ng2Module {
ngDoBootstrap() {}
}
// Bootstrap
const element = html(`<ng2></ng2>`);
bootstrap(platformBrowserDynamic(), Ng2Module, element, ng1Module).then(() => {
expect(multiTrim(element.textContent)).toBe('Called: yes | Called: yes');
});
}));
it('should not call `$postLink()` on scope', async(() => {
// Define `ng1Directive`
const ng1DirectiveA: angular.IDirective = {
template: 'Called: {{ called }}',
bindToController: false,
controller: class {
constructor($scope: angular.IScope) {
$scope['called'] = 'no';
$scope['$postLink'] = () => $scope['called'] = 'yes';
Object.getPrototypeOf($scope)['$postLink'] = () => $scope['called'] = 'yes';
}
}
};
const ng1DirectiveB: angular.IDirective = {
template: 'Called: {{ called }}',
bindToController: true,
controller: class {
constructor($scope: angular.IScope) {
$scope['called'] = 'no';
$scope['$postLink'] = () => $scope['called'] = 'yes';
Object.getPrototypeOf($scope)['$postLink'] = () => $scope['called'] = 'yes';
}
}
};
// Define `Ng1ComponentFacade`
@Directive({selector: 'ng1A'})
class Ng1ComponentAFacade extends UpgradeComponent {
constructor(elementRef: ElementRef, injector: Injector) {
super('ng1A', elementRef, injector);
}
}
@Directive({selector: 'ng1B'})
class Ng1ComponentBFacade extends UpgradeComponent {
constructor(elementRef: ElementRef, injector: Injector) {
super('ng1B', elementRef, injector);
}
}
// Define `Ng2Component`
@Component({selector: 'ng2', template: '<ng1A></ng1A> | <ng1B></ng1B>'})
class Ng2Component {
}
// Define `ng1Module`
const ng1Module = angular.module('ng1Module', [])
.directive('ng1A', () => ng1DirectiveA)
.directive('ng1B', () => ng1DirectiveB)
.directive('ng2', downgradeComponent({component: Ng2Component}));
// Define `Ng2Module`
@NgModule({
declarations: [Ng1ComponentAFacade, Ng1ComponentBFacade, Ng2Component],
entryComponents: [Ng2Component],
imports: [BrowserModule, UpgradeModule]
})
class Ng2Module {
ngDoBootstrap() {}
}
// Bootstrap
const element = html(`<ng2></ng2>`);
bootstrap(platformBrowserDynamic(), Ng2Module, element, ng1Module).then(() => {
expect(multiTrim(element.textContent)).toBe('Called: no | Called: no');
});
}));
it('should call `$onDestroy()` on controller', async(() => {
const controllerOnDestroyA = jasmine.createSpy('controllerOnDestroyA');
const controllerOnDestroyB = jasmine.createSpy('controllerOnDestroyB');
let ng2ComponentInstance: Ng2Component;
// Define `ng1Directive`
const ng1DirectiveA: angular.IDirective = {
template: 'ng1A',
scope: {},
bindToController: false,
controllerAs: '$ctrl',
controller: class {$onDestroy() { controllerOnDestroyA(); }}
};
const ng1DirectiveB: angular.IDirective = {
template: 'ng1B',
scope: {},
bindToController: true,
controllerAs: '$ctrl',
controller: class {
constructor() {
(this as any)['$onDestroy'] = controllerOnDestroyB;
}
}
};
// Define `Ng1ComponentFacade`
@Directive({selector: 'ng1A'})
class Ng1ComponentAFacade extends UpgradeComponent {
constructor(elementRef: ElementRef, injector: Injector) {
super('ng1A', elementRef, injector);
}
}
@Directive({selector: 'ng1B'})
class Ng1ComponentBFacade extends UpgradeComponent {
constructor(elementRef: ElementRef, injector: Injector) {
super('ng1B', elementRef, injector);
}
}
// Define `Ng2Component`
@Component(
{selector: 'ng2', template: '<div *ngIf="show"><ng1A></ng1A> | <ng1B></ng1B></div>'})
class Ng2Component {
@Input() show: boolean;
}
// Define `ng1Module`
const ng1Module =
angular.module('ng1Module', [])
.directive('ng1A', () => ng1DirectiveA)
.directive('ng1B', () => ng1DirectiveB)
.directive(
'ng2', downgradeComponent({component: Ng2Component, inputs: ['show']}));
// Define `Ng2Module`
@NgModule({
declarations: [Ng1ComponentAFacade, Ng1ComponentBFacade, Ng2Component],
entryComponents: [Ng2Component],
imports: [BrowserModule, UpgradeModule]
})
class Ng2Module {
ngDoBootstrap() {}
}
// Bootstrap
const element = html('<ng2 [show]="!destroyFromNg2" ng-if="!destroyFromNg1"></ng2>');
bootstrap(platformBrowserDynamic(), Ng2Module, element, ng1Module).then(adapter => {
const $rootScope = adapter.$injector.get('$rootScope') as angular.IRootScopeService;
expect(multiTrim(document.body.textContent)).toBe('ng1A | ng1B');
expect(controllerOnDestroyA).not.toHaveBeenCalled();
expect(controllerOnDestroyB).not.toHaveBeenCalled();
$rootScope.$apply('destroyFromNg1 = true');
expect(multiTrim(document.body.textContent)).toBe('');
expect(controllerOnDestroyA).toHaveBeenCalled();
expect(controllerOnDestroyB).toHaveBeenCalled();
controllerOnDestroyA.calls.reset();
controllerOnDestroyB.calls.reset();
$rootScope.$apply('destroyFromNg1 = false');
expect(multiTrim(document.body.textContent)).toBe('ng1A | ng1B');
expect(controllerOnDestroyA).not.toHaveBeenCalled();
expect(controllerOnDestroyB).not.toHaveBeenCalled();
$rootScope.$apply('destroyFromNg2 = true');
expect(multiTrim(document.body.textContent)).toBe('');
expect(controllerOnDestroyA).toHaveBeenCalled();
expect(controllerOnDestroyB).toHaveBeenCalled();
});
}));
it('should not call `$onDestroy()` on scope', async(() => {
const scopeOnDestroy = jasmine.createSpy('scopeOnDestroy');
let ng2ComponentInstance: Ng2Component;
// Define `ng1Directive`
const ng1DirectiveA: angular.IDirective = {
template: 'ng1A',
scope: {},
bindToController: false,
controllerAs: '$ctrl',
controller: class {
constructor($scope: angular.IScope) {
$scope['$onDestroy'] = scopeOnDestroy;
Object.getPrototypeOf($scope)['$onDestroy'] = scopeOnDestroy;
}
}
};
const ng1DirectiveB: angular.IDirective = {
template: 'ng1B',
scope: {},
bindToController: true,
controllerAs: '$ctrl',
controller: class {
constructor($scope: angular.IScope) {
$scope['$onDestroy'] = scopeOnDestroy;
Object.getPrototypeOf($scope)['$onDestroy'] = scopeOnDestroy;
}
}
};
// Define `Ng1ComponentFacade`
@Directive({selector: 'ng1A'})
class Ng1ComponentAFacade extends UpgradeComponent {
constructor(elementRef: ElementRef, injector: Injector) {
super('ng1A', elementRef, injector);
}
}
@Directive({selector: 'ng1B'})
class Ng1ComponentBFacade extends UpgradeComponent {
constructor(elementRef: ElementRef, injector: Injector) {
super('ng1B', elementRef, injector);
}
}
// Define `Ng2Component`
@Component(
{selector: 'ng2', template: '<div *ngIf="show"><ng1A></ng1A> | <ng1B></ng1B></div>'})
class Ng2Component {
@Input() show: boolean;
}
// Define `ng1Module`
const ng1Module =
angular.module('ng1Module', [])
.directive('ng1A', () => ng1DirectiveA)
.directive('ng1B', () => ng1DirectiveB)
.directive(
'ng2', downgradeComponent({component: Ng2Component, inputs: ['show']}));
// Define `Ng2Module`
@NgModule({
declarations: [Ng1ComponentAFacade, Ng1ComponentBFacade, Ng2Component],
entryComponents: [Ng2Component],
imports: [BrowserModule, UpgradeModule]
})
class Ng2Module {
ngDoBootstrap() {}
}
// Bootstrap
const element = html('<ng2 [show]="!destroyFromNg2" ng-if="!destroyFromNg1"></ng2>');
bootstrap(platformBrowserDynamic(), Ng2Module, element, ng1Module).then(adapter => {
const $rootScope = adapter.$injector.get('$rootScope') as angular.IRootScopeService;
expect(multiTrim(document.body.textContent)).toBe('ng1A | ng1B');
expect(scopeOnDestroy).not.toHaveBeenCalled();
$rootScope.$apply('destroyFromNg1 = true');
expect(multiTrim(document.body.textContent)).toBe('');
expect(scopeOnDestroy).not.toHaveBeenCalled();
$rootScope.$apply('destroyFromNg1 = false');
expect(multiTrim(document.body.textContent)).toBe('ng1A | ng1B');
expect(scopeOnDestroy).not.toHaveBeenCalled();
$rootScope.$apply('destroyFromNg2 = true');
expect(multiTrim(document.body.textContent)).toBe('');
expect(scopeOnDestroy).not.toHaveBeenCalled();
});
}));
it('should be called in order `$onChanges()` > `$onInit()` > `$postLink()`', async(() => {
// Define `ng1Component`
const ng1Component: angular.IComponent = {
template: '{{ $ctrl.calls.join(" > ") }}',
bindings: {value: '<'},
controller: class {
calls: string[] = [];
$onChanges() { this.calls.push('$onChanges'); } $onInit() {
this.calls.push('$onInit');
} $postLink() { this.calls.push('$postLink'); }
}
};
// Define `Ng1ComponentFacade`
@Directive({selector: 'ng1'})
class Ng1ComponentFacade extends UpgradeComponent {
@Input() value: any;
constructor(elementRef: ElementRef, injector: Injector) {
super('ng1', elementRef, injector);
}
}
// Define `Ng2Component`
@Component({selector: 'ng2', template: '<ng1 value="foo"></ng1>'})
class Ng2Component {
}
// Define `ng1Module`
const ng1Module = angular.module('ng1Module', [])
.component('ng1', ng1Component)
.directive('ng2', downgradeComponent({component: Ng2Component}));
// Define `Ng2Module`
@NgModule({
declarations: [Ng1ComponentFacade, Ng2Component],
entryComponents: [Ng2Component],
imports: [BrowserModule, UpgradeModule]
})
class Ng2Module {
ngDoBootstrap() {}
}
// Bootstrap
const element = html(`<ng2></ng2>`);
bootstrap(platformBrowserDynamic(), Ng2Module, element, ng1Module).then(() => {
expect(multiTrim(element.textContent)).toBe('$onChanges > $onInit > $postLink');
});
}));
});
it('should destroy `$componentScope` when destroying the upgraded component', async(() => {
const scopeDestroyListener = jasmine.createSpy('scopeDestroyListener');
let ng2ComponentAInstance: Ng2ComponentA;
// Define `ng1Component`
const ng1Component: angular.IComponent = {
controller: class {
constructor($scope: angular.IScope) {
$scope.$on('$destroy', scopeDestroyListener);
}
}
};
// Define `Ng1ComponentFacade`
@Directive({selector: 'ng1'})
class Ng1ComponentFacade extends UpgradeComponent {
constructor(elementRef: ElementRef, injector: Injector) {
super('ng1', elementRef, injector);
}
}
// Define `Ng2Component`
@Component({selector: 'ng2A', template: '<ng2B *ngIf="!destroyIt"></ng2B>'})
class Ng2ComponentA {
destroyIt = false;
constructor() {
ng2ComponentAInstance = this;
}
}
@Component({selector: 'ng2B', template: '<ng1></ng1>'})
class Ng2ComponentB {
}
// Define `ng1Module`
const ng1Module =
angular.module('ng1Module', [])
.component('ng1', ng1Component)
.directive('ng2A', downgradeComponent({component: Ng2ComponentA}));
// Define `Ng2Module`
@NgModule({
declarations: [Ng1ComponentFacade, Ng2ComponentA, Ng2ComponentB],
entryComponents: [Ng2ComponentA],
imports: [BrowserModule, UpgradeModule]
})
class Ng2Module {
ngDoBootstrap() {}
}
// Bootstrap
const element = html(`<ng2-a></ng2-a>`);
bootstrap(platformBrowserDynamic(), Ng2Module, element, ng1Module).then(adapter => {
expect(scopeDestroyListener).not.toHaveBeenCalled();
ng2ComponentAInstance.destroyIt = true;
digest(adapter);
expect(scopeDestroyListener).toHaveBeenCalled();
});
}));
// it('should support ng2 > ng1 > ng2', async(() => {
// const adapter: UpgradeAdapter = new UpgradeAdapter(forwardRef(() => Ng2Module));
// const ng1Module = angular.module('ng1', []);

View File

@ -5,10 +5,11 @@ export declare function downgradeComponent(info: ComponentInfo): angular.IInject
export declare function downgradeInjectable(token: any): (string | ((i: Injector) => any))[];
/** @experimental */
export declare class UpgradeComponent implements OnInit, OnChanges, DoCheck {
export declare class UpgradeComponent implements OnInit, OnChanges, DoCheck, OnDestroy {
constructor(name: string, elementRef: ElementRef, injector: Injector);
ngDoCheck(): void;
ngOnChanges(changes: SimpleChanges): void;
ngOnDestroy(): void;
ngOnInit(): void;
}