feat(ngUpgrade): add support for AoT compiled upgrade applications

This commit introduces a new API to the ngUpgrade module, which is compatible
with AoT compilation. Primarily, it removes the dependency on reflection
over the Angular 2 metadata by introducing an API where this information
is explicitly defined, in the source code, in a way that is not lost through
AoT compilation.

This commit is a collaboration between @mhevery (who provided the original
design of the API); @gkalpak & @petebacondarwin (who implemented the
API and migrated the specs from the original ngUpgrade tests) and @alexeagle
(who provided input and review).

This commit is an starting point, there is still work to be done:

* add more documentation
* validate the API via internal projects
* align the ngUpgrade compilation of A1 directives closer to the real A1
  compiler
* add more unit tests
* consider support for async `templateUrl` A1 upgraded components

Closes #12239
This commit is contained in:
Peter Bacon Darwin
2016-10-19 21:41:04 +01:00
committed by Alex Rickabaugh
parent a2d35641e3
commit d6791ff0e0
30 changed files with 3263 additions and 39 deletions

View File

@ -0,0 +1,80 @@
/**
* @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 {Component, Directive, ElementRef, Injector, NgModule, destroyPlatform} from '@angular/core';
import {async} from '@angular/core/testing';
import {BrowserModule} from '@angular/platform-browser';
import {platformBrowserDynamic} from '@angular/platform-browser-dynamic';
import {UpgradeComponent, UpgradeModule, downgradeComponent} from '@angular/upgrade';
import * as angular from '@angular/upgrade/src/angular_js';
import {bootstrap, html} from '../test_helpers';
export function main() {
describe('scope/component change-detection', () => {
beforeEach(() => destroyPlatform());
afterEach(() => destroyPlatform());
it('should interleave scope and component expressions', async(() => {
const log: any[] /** TODO #9100 */ = [];
const l = (value: any /** TODO #9100 */) => {
log.push(value);
return value + ';';
};
@Directive({selector: 'ng1a'})
class Ng1aComponent extends UpgradeComponent {
constructor(elementRef: ElementRef, injector: Injector) {
super('ng1a', elementRef, injector);
}
}
@Directive({selector: 'ng1b'})
class Ng1bComponent extends UpgradeComponent {
constructor(elementRef: ElementRef, injector: Injector) {
super('ng1b', elementRef, injector);
}
}
@Component({
selector: 'ng2',
template: `{{l('2A')}}<ng1a></ng1a>{{l('2B')}}<ng1b></ng1b>{{l('2C')}}`
})
class Ng2Component {
l: (value: any) => string;
constructor() { this.l = l; }
}
@NgModule({
declarations: [Ng1aComponent, Ng1bComponent, Ng2Component],
entryComponents: [Ng2Component],
imports: [BrowserModule, UpgradeModule]
})
class Ng2Module {
ngDoBootstrap() {}
}
const ng1Module = angular.module('ng1', [])
.directive('ng1a', () => ({template: '{{ l(\'ng1a\') }}'}))
.directive('ng1b', () => ({template: '{{ l(\'ng1b\') }}'}))
.directive('ng2', downgradeComponent({component: Ng2Component}))
.run(($rootScope: any /** TODO #9100 */) => {
$rootScope.l = l;
$rootScope.reset = () => log.length = 0;
});
const element =
html('<div>{{reset(); l(\'1A\');}}<ng2>{{l(\'1B\')}}</ng2>{{l(\'1C\')}}</div>');
bootstrap(platformBrowserDynamic(), Ng2Module, element, ng1Module).then((upgrade) => {
expect(document.body.textContent).toEqual('1A;2A;ng1a;2B;ng1b;2C;1C;');
// https://github.com/angular/angular.js/issues/12983
expect(log).toEqual(['1A', '1B', '1C', '2A', '2B', '2C', 'ng1a', 'ng1b']);
});
}));
});
}

View File

@ -0,0 +1,99 @@
/**
* @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 {Component, Directive, ElementRef, Injector, NgModule, destroyPlatform} from '@angular/core';
import {async} from '@angular/core/testing';
import {BrowserModule} from '@angular/platform-browser';
import {platformBrowserDynamic} from '@angular/platform-browser-dynamic';
import {UpgradeComponent, UpgradeModule, downgradeComponent} from '@angular/upgrade';
import * as angular from '@angular/upgrade/src/angular_js';
import {bootstrap, html} from '../test_helpers';
export function main() {
describe('content projection', () => {
beforeEach(() => destroyPlatform());
afterEach(() => destroyPlatform());
it('should instantiate ng2 in ng1 template and project content', async(() => {
// the ng2 component that will be used in ng1 (downgraded)
@Component({selector: 'ng2', template: `{{ 'NG2' }}(<ng-content></ng-content>)`})
class Ng2Component {
}
// our upgrade module to host the component to downgrade
@NgModule({
imports: [BrowserModule, UpgradeModule],
declarations: [Ng2Component],
entryComponents: [Ng2Component]
})
class Ng2Module {
ngDoBootstrap() {}
}
// the ng1 app module that will consume the downgraded component
const ng1Module = angular
.module('ng1', [])
// create an ng1 facade of the ng2 component
.directive('ng2', downgradeComponent({component: Ng2Component}));
const element =
html('<div>{{ \'ng1[\' }}<ng2>~{{ \'ng-content\' }}~</ng2>{{ \']\' }}</div>');
bootstrap(platformBrowserDynamic(), Ng2Module, element, ng1Module).then((upgrade) => {
expect(document.body.textContent).toEqual('ng1[NG2(~ng-content~)]');
});
}));
it('should instantiate ng1 in ng2 template and project content', async(() => {
@Component({
selector: 'ng2',
template: `{{ 'ng2(' }}<ng1>{{'transclude'}}</ng1>{{ ')' }}`,
})
class Ng2Component {
}
@Directive({selector: 'ng1'})
class Ng1WrapperComponent extends UpgradeComponent {
constructor(elementRef: ElementRef, injector: Injector) {
super('ng1', elementRef, injector);
}
}
@NgModule({
declarations: [Ng1WrapperComponent, Ng2Component],
entryComponents: [Ng2Component],
imports: [BrowserModule, UpgradeModule]
})
class Ng2Module {
ngDoBootstrap() {}
}
const ng1Module = angular.module('ng1', [])
.directive(
'ng1',
() => {
return {
transclude: true,
template: '{{ "ng1" }}(<ng-transclude></ng-transclude>)'
};
})
.directive('ng2', downgradeComponent({component: Ng2Component}));
const element = html('<div>{{\'ng1(\'}}<ng2></ng2>{{\')\'}}</div>');
bootstrap(platformBrowserDynamic(), Ng2Module, element, ng1Module).then((upgrade) => {
expect(document.body.textContent).toEqual('ng1(ng2(ng1(transclude)))');
});
}));
});
}

View File

@ -0,0 +1,259 @@
/**
* @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 {Component, EventEmitter, NgModule, OnChanges, OnDestroy, SimpleChanges, destroyPlatform} from '@angular/core';
import {async} from '@angular/core/testing';
import {BrowserModule} from '@angular/platform-browser';
import {platformBrowserDynamic} from '@angular/platform-browser-dynamic';
import {UpgradeModule, downgradeComponent} from '@angular/upgrade';
import * as angular from '@angular/upgrade/src/angular_js';
import {bootstrap, html, multiTrim} from '../test_helpers';
export function main() {
describe('downgrade ng2 component', () => {
beforeEach(() => destroyPlatform());
afterEach(() => destroyPlatform());
it('should bind properties, events', async(() => {
const ng1Module = angular.module('ng1', []).run(($rootScope: angular.IScope) => {
$rootScope['dataA'] = 'A';
$rootScope['dataB'] = 'B';
$rootScope['modelA'] = 'initModelA';
$rootScope['modelB'] = 'initModelB';
$rootScope['eventA'] = '?';
$rootScope['eventB'] = '?';
});
@Component({
selector: 'ng2',
inputs: ['literal', 'interpolate', 'oneWayA', 'oneWayB', 'twoWayA', 'twoWayB'],
outputs: [
'eventA', 'eventB', 'twoWayAEmitter: twoWayAChange', 'twoWayBEmitter: twoWayBChange'
],
template: 'ignore: {{ignore}}; ' +
'literal: {{literal}}; interpolate: {{interpolate}}; ' +
'oneWayA: {{oneWayA}}; oneWayB: {{oneWayB}}; ' +
'twoWayA: {{twoWayA}}; twoWayB: {{twoWayB}}; ({{ngOnChangesCount}})'
})
class Ng2Component implements OnChanges {
ngOnChangesCount = 0;
ignore = '-';
literal = '?';
interpolate = '?';
oneWayA = '?';
oneWayB = '?';
twoWayA = '?';
twoWayB = '?';
eventA = new EventEmitter();
eventB = new EventEmitter();
twoWayAEmitter = new EventEmitter();
twoWayBEmitter = new EventEmitter();
ngOnChanges(changes: SimpleChanges) {
const assert = (prop: string, value: any) => {
const propVal = (this as any)[prop];
if (propVal != value) {
throw new Error(`Expected: '${prop}' to be '${value}' but was '${propVal}'`);
}
};
const assertChange = (prop: string, value: any) => {
assert(prop, value);
if (!changes[prop]) {
throw new Error(`Changes record for '${prop}' not found.`);
}
const actualValue = changes[prop].currentValue;
if (actualValue != value) {
throw new Error(
`Expected changes record for'${prop}' to be '${value}' but was '${actualValue}'`);
}
};
switch (this.ngOnChangesCount++) {
case 0:
assert('ignore', '-');
assertChange('literal', 'Text');
assertChange('interpolate', 'Hello world');
assertChange('oneWayA', 'A');
assertChange('oneWayB', 'B');
assertChange('twoWayA', 'initModelA');
assertChange('twoWayB', 'initModelB');
this.twoWayAEmitter.emit('newA');
this.twoWayBEmitter.emit('newB');
this.eventA.emit('aFired');
this.eventB.emit('bFired');
break;
case 1:
assertChange('twoWayA', 'newA');
break;
case 2:
assertChange('twoWayB', 'newB');
break;
default:
throw new Error('Called too many times! ' + JSON.stringify(changes));
}
};
}
ng1Module.directive(
'ng2', downgradeComponent({
component: Ng2Component,
inputs: ['literal', 'interpolate', 'oneWayA', 'oneWayB', 'twoWayA', 'twoWayB'],
outputs: [
'eventA', 'eventB', 'twoWayAEmitter: twoWayAChange',
'twoWayBEmitter: twoWayBChange'
]
}));
@NgModule({
declarations: [Ng2Component],
entryComponents: [Ng2Component],
imports: [BrowserModule, UpgradeModule]
})
class Ng2Module {
ngDoBootstrap() {}
}
const element = html(`
<div>
<ng2 literal="Text" interpolate="Hello {{'world'}}"
bind-one-way-a="dataA" [one-way-b]="dataB"
bindon-two-way-a="modelA" [(two-way-b)]="modelB"
on-event-a='eventA=$event' (event-b)="eventB=$event"></ng2>
| modelA: {{modelA}}; modelB: {{modelB}}; eventA: {{eventA}}; eventB: {{eventB}};
</div>`);
bootstrap(platformBrowserDynamic(), Ng2Module, element, ng1Module).then((upgrade) => {
expect(multiTrim(document.body.textContent))
.toEqual(
'ignore: -; ' +
'literal: Text; interpolate: Hello world; ' +
'oneWayA: A; oneWayB: B; twoWayA: newA; twoWayB: newB; (2) | ' +
'modelA: newA; modelB: newB; eventA: aFired; eventB: bFired;');
});
}));
it('should properly run cleanup when ng1 directive is destroyed', async(() => {
let destroyed = false;
@Component({selector: 'ng2', template: 'test'})
class Ng2Component implements OnDestroy {
ngOnDestroy() { destroyed = true; }
}
@NgModule({
declarations: [Ng2Component],
entryComponents: [Ng2Component],
imports: [BrowserModule, UpgradeModule]
})
class Ng2Module {
ngDoBootstrap() {}
}
const ng1Module =
angular.module('ng1', [])
.directive(
'ng1',
() => { return {template: '<div ng-if="!destroyIt"><ng2></ng2></div>'}; })
.directive('ng2', downgradeComponent({component: Ng2Component}));
const element = html('<ng1></ng1>');
platformBrowserDynamic().bootstrapModule(Ng2Module).then((ref) => {
const adapter = ref.injector.get(UpgradeModule) as UpgradeModule;
adapter.bootstrap(element, [ng1Module.name]);
expect(element.textContent).toContain('test');
expect(destroyed).toBe(false);
const $rootScope = adapter.$injector.get('$rootScope');
$rootScope.$apply('destroyIt = true');
expect(element.textContent).not.toContain('test');
expect(destroyed).toBe(true);
});
}));
it('should work when compiled outside the dom (by fallback to the root ng2.injector)',
async(() => {
@Component({selector: 'ng2', template: 'test'})
class Ng2Component {
}
@NgModule({
declarations: [Ng2Component],
entryComponents: [Ng2Component],
imports: [BrowserModule, UpgradeModule]
})
class Ng2Module {
ngDoBootstrap() {}
}
const ng1Module =
angular.module('ng1', [])
.directive(
'ng1',
[
'$compile',
($compile: angular.ICompileService) => {
return {
link: function(
$scope: angular.IScope, $element: angular.IAugmentedJQuery,
$attrs: angular.IAttributes) {
// here we compile some HTML that contains a downgraded component
// since it is not currently in the DOM it is not able to "require"
// an ng2 injector so it should use the `moduleInjector` instead.
const compiled = $compile('<ng2></ng2>');
const template = compiled($scope);
$element.append(template);
}
};
}
])
.directive('ng2', downgradeComponent({component: Ng2Component}));
const element = html('<ng1></ng1>');
bootstrap(platformBrowserDynamic(), Ng2Module, element, ng1Module).then((upgrade) => {
// the fact that the body contains the correct text means that the
// downgraded component was able to access the moduleInjector
// (since there is no other injector in this system)
expect(multiTrim(document.body.textContent)).toEqual('test');
});
}));
it('should allow attribute selectors for components in ng2', async(() => {
@Component({selector: '[itWorks]', template: 'It works'})
class WorksComponent {
}
@Component({selector: 'root-component', template: '<span itWorks></span>!'})
class RootComponent {
}
@NgModule({
declarations: [RootComponent, WorksComponent],
entryComponents: [RootComponent],
imports: [BrowserModule, UpgradeModule]
})
class Ng2Module {
ngDoBootstrap() {}
}
const ng1Module = angular.module('ng1', []).directive(
'rootComponent', downgradeComponent({component: RootComponent}));
const element = html('<root-component></root-component>');
bootstrap(platformBrowserDynamic(), Ng2Module, element, ng1Module).then((upgrade) => {
expect(multiTrim(document.body.textContent)).toBe('It works!');
});
}));
});
}

View File

@ -0,0 +1,89 @@
/**
* @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 {Component, Directive, ElementRef, Injector, Input, NgModule, destroyPlatform} from '@angular/core';
import {async} from '@angular/core/testing';
import {BrowserModule} from '@angular/platform-browser';
import {platformBrowserDynamic} from '@angular/platform-browser-dynamic';
import {UpgradeComponent, UpgradeModule, downgradeComponent} from '@angular/upgrade';
import * as angular from '@angular/upgrade/src/angular_js';
import {bootstrap, html, multiTrim} from '../test_helpers';
export function main() {
describe('examples', () => {
beforeEach(() => destroyPlatform());
afterEach(() => destroyPlatform());
it('should have angular 1 loaded', () => expect(angular.version.major).toBe(1));
it('should verify UpgradeAdapter example', async(() => {
// This is wrapping (upgrading) an Angular 1 component to be used in an Angular 2
// component
@Directive({selector: 'ng1'})
class Ng1Component extends UpgradeComponent {
@Input() title: string;
constructor(elementRef: ElementRef, injector: Injector) {
super('ng1', elementRef, injector);
}
}
// This is an Angular 2 component that will be downgraded
@Component({
selector: 'ng2',
template: 'ng2[<ng1 [title]="nameProp">transclude</ng1>](<ng-content></ng-content>)'
})
class Ng2Component {
@Input('name') nameProp: string;
}
// This module represents the Angular 2 pieces of the application
@NgModule({
declarations: [Ng1Component, Ng2Component],
entryComponents: [Ng2Component],
imports: [BrowserModule, UpgradeModule]
})
class Ng2Module {
ngDoBootstrap() { /* this is a placeholder to stop the boostrapper from complaining */
}
}
// This module represents the Angular 1 pieces of the application
const ng1Module =
angular
.module('myExample', [])
// This is an Angular 1 component that will be upgraded
.directive(
'ng1',
() => {
return {
scope: {title: '='},
transclude: true,
template: 'ng1[Hello {{title}}!](<span ng-transclude></span>)'
};
})
// This is wrapping (downgrading) an Angular 2 component to be used in Angular 1
.directive(
'ng2',
downgradeComponent({component: Ng2Component, inputs: ['nameProp: name']}));
// This is the (Angular 1) application bootstrap element
// Notice that it is actually a downgraded Angular 2 component
const element = html('<ng2 name="World">project</ng2>');
// Let's use a helper function to make this simpler
bootstrap(platformBrowserDynamic(), Ng2Module, element, ng1Module).then(upgrade => {
expect(multiTrim(element.textContent))
.toBe('ng2[ng1[Hello World!](transclude)](project)');
});
}));
});
}

View File

@ -0,0 +1,80 @@
/**
* @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 {NgModule, OpaqueToken, destroyPlatform} from '@angular/core';
import {async} from '@angular/core/testing';
import {BrowserModule} from '@angular/platform-browser';
import {platformBrowserDynamic} from '@angular/platform-browser-dynamic';
import {UpgradeModule, downgradeInjectable} from '@angular/upgrade';
import * as angular from '@angular/upgrade/src/angular_js';
import {bootstrap, html} from '../test_helpers';
export function main() {
describe('injection', () => {
beforeEach(() => destroyPlatform());
afterEach(() => destroyPlatform());
it('should downgrade ng2 service to ng1', async(() => {
// Tokens used in ng2 to identify services
const Ng2Service = new OpaqueToken('ng2-service');
// Sample ng1 NgModule for tests
@NgModule({
imports: [BrowserModule, UpgradeModule],
providers: [
{provide: Ng2Service, useValue: 'ng2 service value'},
]
})
class Ng2Module {
ngDoBootstrap() {}
}
// create the ng1 module that will import an ng2 service
const ng1Module =
angular.module('ng1Module', []).factory('ng2Service', downgradeInjectable(Ng2Service));
bootstrap(platformBrowserDynamic(), Ng2Module, html('<div>'), ng1Module)
.then((upgrade) => {
const ng1Injector = upgrade.$injector;
expect(ng1Injector.get('ng2Service')).toBe('ng2 service value');
});
}));
it('should upgrade ng1 service to ng2', async(() => {
// Tokens used in ng2 to identify services
const Ng1Service = new OpaqueToken('ng1-service');
// Sample ng1 NgModule for tests
@NgModule({
imports: [BrowserModule, UpgradeModule],
providers: [
// the following line is the "upgrade" of an Angular 1 service
{
provide: Ng1Service,
useFactory: (i: angular.IInjectorService) => i.get('ng1Service'),
deps: ['$injector']
}
]
})
class Ng2Module {
ngDoBootstrap() {}
}
// create the ng1 module that will import an ng2 service
const ng1Module = angular.module('ng1Module', []).value('ng1Service', 'ng1 service value');
bootstrap(platformBrowserDynamic(), Ng2Module, html('<div>'), ng1Module)
.then((upgrade) => {
var ng2Injector = upgrade.injector;
expect(ng2Injector.get(Ng1Service)).toBe('ng1 service value');
});
}));
});
}

View File

@ -0,0 +1,71 @@
/**
* @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 {NgModule, Testability, destroyPlatform} from '@angular/core';
import {fakeAsync, tick} from '@angular/core/testing';
import {BrowserModule} from '@angular/platform-browser';
import {platformBrowserDynamic} from '@angular/platform-browser-dynamic';
import {UpgradeModule} from '@angular/upgrade';
import * as angular from '@angular/upgrade/src/angular_js';
import {bootstrap, html} from '../test_helpers';
export function main() {
describe('testability', () => {
beforeEach(() => destroyPlatform());
afterEach(() => destroyPlatform());
@NgModule({imports: [BrowserModule, UpgradeModule]})
class Ng2Module {
ngDoBootstrap() {}
}
it('should handle deferred bootstrap', fakeAsync(() => {
let applicationRunning = false;
const ng1Module = angular.module('ng1', []).run(() => { applicationRunning = true; });
const element = html('<div></div>');
window.name = 'NG_DEFER_BOOTSTRAP!' + window.name;
bootstrap(platformBrowserDynamic(), Ng2Module, element, ng1Module);
setTimeout(() => { (<any>window).angular.resumeBootstrap(); }, 100);
expect(applicationRunning).toEqual(false);
tick(100);
expect(applicationRunning).toEqual(true);
}));
it('should wait for ng2 testability', fakeAsync(() => {
const ng1Module = angular.module('ng1', []);
const element = html('<div></div>');
bootstrap(platformBrowserDynamic(), Ng2Module, element, ng1Module).then((upgrade) => {
const ng2Testability: Testability = upgrade.injector.get(Testability);
ng2Testability.increasePendingRequestCount();
let ng2Stable = false;
let ng1Stable = false;
angular.getTestability(element).whenStable(() => { ng1Stable = true; });
setTimeout(() => {
ng2Stable = true;
ng2Testability.decreasePendingRequestCount();
}, 100);
expect(ng1Stable).toEqual(false);
expect(ng2Stable).toEqual(false);
tick(100);
expect(ng1Stable).toEqual(true);
expect(ng2Stable).toEqual(true);
});
}));
});
}

File diff suppressed because it is too large Load Diff