From 053b7a50e1e1f01e942c5971b43ad6c43a6e8b90 Mon Sep 17 00:00:00 2001 From: Misko Hevery Date: Sun, 11 Oct 2015 11:18:11 -0700 Subject: [PATCH] feat(ngUpgrade): faster ng2->ng1 adapter by only compiling ng1 once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The adapter only compiles ng1 template. This means that we need to reimplement / emulate all of the ng1’s API on the HOST element. interface IDirective { compile?: IDirectiveCompileFn; // NOT SUPPORTED controller?: any; // IMPLEMENTED controllerAs?: string; // IMPLEMENTED bindToController?: boolean|Object; // IMPLEMENTED link?: IDirectiveLinkFn | IDirectivePrePost; // IMPLEMENTED (pre-link only) name?: string; // N/A priority?: number; // NOT SUPPORTED replace?: boolean; // NOT SUPPORTED require?: any; // IMPLEMENTED restrict?: string; // WORKING scope?: any; // IMPLEMENTED template?: any; // IMPLEMENTED templateUrl?: any; // IMPLEMENTED terminal?: boolean; // NOT SUPPORTED transclude?: any; // IMPLEMENTED } --- modules/upgrade/src/angular.d.ts | 60 ++++- modules/upgrade/src/constants.ts | 4 +- modules/upgrade/src/downgrade_ng2_adapter.ts | 2 +- modules/upgrade/src/upgrade_adapter.ts | 65 +++-- modules/upgrade/src/upgrade_ng1_adapter.ts | 168 +++++++++++-- modules/upgrade/src/util.ts | 4 + modules/upgrade/test/integration_spec.ts | 238 +++++++++++++++++-- 7 files changed, 474 insertions(+), 67 deletions(-) diff --git a/modules/upgrade/src/angular.d.ts b/modules/upgrade/src/angular.d.ts index c756f38a6a..10f713ca19 100644 --- a/modules/upgrade/src/angular.d.ts +++ b/modules/upgrade/src/angular.d.ts @@ -7,7 +7,7 @@ declare namespace angular { run(a: any); } interface ICompileService { - (element: Element, transclude?: Function): ILinkFn; + (element: Element | NodeList | string, transclude?: Function): ILinkFn; } interface ILinkFn { (scope: IScope, cloneAttachFn?: Function, options?: ILinkFnOptions): void @@ -17,7 +17,8 @@ declare namespace angular { futureParentElement?: Node } interface IRootScopeService { - $new(): IScope; + $new(isolate?: boolean): IScope; + $id: string; $watch(expr: any, fn?: (a1?: any, a2?: any) => void); $apply(): any; $apply(exp: string): any; @@ -29,19 +30,53 @@ declare namespace angular { interface IScope extends IRootScopeService {} interface IAngularBootstrapConfig {} interface IDirective { - require?: string; + compile?: IDirectiveCompileFn; + controller?: any; + controllerAs?: string; + bindToController?: boolean | Object; + link?: IDirectiveLinkFn | IDirectivePrePost; + name?: string; + priority?: number; + replace?: boolean; + require?: any; restrict?: string; - scope?: {[key: string]: string}; - link?: {pre?: Function, post?: Function}; + scope?: any; + template?: any; + templateUrl?: any; + terminal?: boolean; + transclude?: any; + } + interface IDirectiveCompileFn { + (templateElement: IAugmentedJQuery, templateAttributes: IAttributes, + transclude: ITranscludeFunction): IDirectivePrePost; + } + interface IDirectivePrePost { + pre?: IDirectiveLinkFn; + post?: IDirectiveLinkFn; + } + interface IDirectiveLinkFn { + (scope: IScope, instanceElement: IAugmentedJQuery, instanceAttributes: IAttributes, + controller: any, transclude: ITranscludeFunction): void; } interface IAttributes { $observe(attr: string, fn: (v: string) => void); } - interface ITranscludeFunction {} + interface ITranscludeFunction { + // If the scope is provided, then the cloneAttachFn must be as well. + (scope: IScope, cloneAttachFn: ICloneAttachFunction): IAugmentedJQuery; + // If one argument is provided, then it's assumed to be the cloneAttachFn. + (cloneAttachFn?: ICloneAttachFunction): IAugmentedJQuery; + } + interface ICloneAttachFunction { + // Let's hint but not force cloneAttachFn's signature + (clonedElement?: IAugmentedJQuery, scope?: IScope): any; + } interface IAugmentedJQuery { bind(name: string, fn: () => void); data(name: string, value?: any); + inheritedData(name: string, value?: any); contents(): IAugmentedJQuery; + parent(): IAugmentedJQuery; length: number; [index: number]: Node; } @@ -53,6 +88,19 @@ declare namespace angular { } function element(e: Element): IAugmentedJQuery; function bootstrap(e: Element, modules: string[], config: IAngularBootstrapConfig); + interface IHttpBackendService { + (method: string, url: string, post?: any, callback?: Function, headers?: any, timeout?: number, + withCredentials?: boolean): void; + } + interface ICacheObject { + put(key: string, value?: T): T; + get(key: string): any; + } + interface ITemplateCacheService extends ICacheObject {} + interface IControllerService { + (controllerConstructor: Function, locals?: any, later?: any, ident?: any): any; + (controllerName: string, locals?: any): any; + } namespace auto { interface IInjectorService { diff --git a/modules/upgrade/src/constants.ts b/modules/upgrade/src/constants.ts index 27ed30ce06..b3046c3376 100644 --- a/modules/upgrade/src/constants.ts +++ b/modules/upgrade/src/constants.ts @@ -4,10 +4,12 @@ export const NG2_INJECTOR = 'ng2.Injector'; export const NG2_PROTO_VIEW_REF_MAP = 'ng2.ProtoViewRefMap'; export const NG2_ZONE = 'ng2.NgZone'; -export const NG1_REQUIRE_INJECTOR_REF = '$' + NG2_INJECTOR + 'Controller'; +export const NG1_CONTROLLER = '$controller'; export const NG1_SCOPE = '$scope'; export const NG1_ROOT_SCOPE = '$rootScope'; export const NG1_COMPILE = '$compile'; +export const NG1_HTTP_BACKEND = '$httpBackend'; export const NG1_INJECTOR = '$injector'; export const NG1_PARSE = '$parse'; +export const NG1_TEMPLATE_CACHE = '$templateCache'; export const REQUIRE_INJECTOR = '^' + NG2_INJECTOR; diff --git a/modules/upgrade/src/downgrade_ng2_adapter.ts b/modules/upgrade/src/downgrade_ng2_adapter.ts index 8d4612920a..7632b126ad 100644 --- a/modules/upgrade/src/downgrade_ng2_adapter.ts +++ b/modules/upgrade/src/downgrade_ng2_adapter.ts @@ -101,7 +101,7 @@ export class DowngradeNg2ComponentAdapter { this.component.onChanges(inputChanges); }); } - this.componentScope.$watch(() => this.changeDetector.detectChanges()); + this.componentScope.$watch(() => this.changeDetector && this.changeDetector.detectChanges()); } projectContent() { diff --git a/modules/upgrade/src/upgrade_adapter.ts b/modules/upgrade/src/upgrade_adapter.ts index cb1765829b..e3401e6c11 100644 --- a/modules/upgrade/src/upgrade_adapter.ts +++ b/modules/upgrade/src/upgrade_adapter.ts @@ -18,13 +18,12 @@ import {applicationCommonBindings} from 'angular2/src/core/application_ref'; import {compilerProviders} from 'angular2/src/core/compiler/compiler'; import {getComponentInfo, ComponentInfo} from './metadata'; -import {onError} from './util'; +import {onError, controllerKey} from './util'; import { NG1_COMPILE, NG1_INJECTOR, NG1_PARSE, NG1_ROOT_SCOPE, - NG1_REQUIRE_INJECTOR_REF, NG1_SCOPE, NG2_APP_VIEW_MANAGER, NG2_COMPILER, @@ -70,8 +69,10 @@ var upgradeCount: number = 0; * 7. Whenever an adapter component is instantiated the host element is owned by the the framework * doing the instantiation. The other framework then instantiates and owns the view for that * component. This implies that component bindings will always follow the semantics of the - * instantiation framework, but with Angular v2 syntax. + * instantiation framework. The syntax is always that of Angular v2 syntax. * 8. AngularJS v1 is always bootstrapped first and owns the bottom most view. + * 9. The new application is running in Angular v2 zone, and therefore it no longer needs calls to + * `$apply()`. * * ## Example * @@ -81,7 +82,7 @@ var upgradeCount: number = 0; * * module.directive('ng1', function() { * return { - * scope: { title: '@' }, + * scope: { title: '=' }, * template: 'ng1[Hello {{title}}!]()' * }; * }); @@ -127,8 +128,8 @@ export class UpgradeAdapter { } bootstrap(element: Element, modules?: any[], - config?: angular.IAngularBootstrapConfig): UpgradeRef { - var upgrade = new UpgradeRef(); + config?: angular.IAngularBootstrapConfig): UpgradeAdapterRef { + var upgrade = new UpgradeAdapterRef(); var ng1Injector: angular.auto.IInjectorService = null; var platformRef: PlatformRef = platform(); var applicationRef: ApplicationRef = platformRef.application([ @@ -147,6 +148,7 @@ export class UpgradeAdapter { var rootScope: angular.IRootScopeService; var protoViewRefMap: ProtoViewRefMap = {}; var ng1Module = angular.module(this.idPrefix, modules); + var ng1compilePromise: Promise = null; ng1Module.value(NG2_INJECTOR, injector) .value(NG2_ZONE, ngZone) .value(NG2_COMPILER, compiler) @@ -176,22 +178,23 @@ export class UpgradeAdapter { (injector: angular.auto.IInjectorService, rootScope: angular.IRootScopeService) => { ng1Injector = injector; ngZone.overrideOnTurnDone(() => rootScope.$apply()); - UpgradeNg1ComponentAdapterBuilder.resolve(this.downgradedComponents, injector); + ng1compilePromise = + UpgradeNg1ComponentAdapterBuilder.resolve(this.downgradedComponents, injector); } ]); - angular.element(element).data(NG1_REQUIRE_INJECTOR_REF, injector); + angular.element(element).data(controllerKey(NG2_INJECTOR), injector); ngZone.run(() => { angular.bootstrap(element, [this.idPrefix], config); }); - this.compileNg2Components(compiler, protoViewRefMap) - .then((protoViewRefMap: ProtoViewRefMap) => { + Promise.all([this.compileNg2Components(compiler, protoViewRefMap), ng1compilePromise]) + .then(() => { ngZone.run(() => { rootScopePrototype.$apply = original$applyFn; // restore original $apply while (delayApplyExps.length) { rootScope.$apply(delayApplyExps.shift()); } - upgrade.readyFn && upgrade.readyFn(); + (upgrade)._bootstrapDone(applicationRef, ng1Injector); }); - }); + }, onError); return upgrade; } @@ -214,7 +217,7 @@ export class UpgradeAdapter { } interface ProtoViewRefMap { - [selector: string]: ProtoViewRef + [selector: string]: ProtoViewRef; } function ng1ComponentDirective(info: ComponentInfo, idPrefix: string): Function { @@ -246,8 +249,38 @@ function ng1ComponentDirective(info: ComponentInfo, idPrefix: string): Function return directiveFactory; } -export class UpgradeRef { - readyFn: Function; +/** + * Use `UgradeAdapterRef` to control a hybrid AngularJS v1 / Angular v2 application. + */ +export class UpgradeAdapterRef { + /* @internal */ + private _readyFn: (upgradeAdapterRef?: UpgradeAdapterRef) => void = null; - ready(fn: Function) { this.readyFn = fn; } + public applicationRef: ApplicationRef = null; + public ng1Injector: angular.auto.IInjectorService = null; + + /* @internal */ + private _bootstrapDone(applicationRef: ApplicationRef, + ng1Injector: angular.auto.IInjectorService) { + this.applicationRef = applicationRef; + this.ng1Injector = ng1Injector; + this._readyFn && this._readyFn(this); + } + + /** + * Register a callback function which is notified upon successful hybrid AngularJS v1 / Angular v2 + * application has been bootstrapped. + * + * The `ready` callback function is invoked inside the Angular v2 zone, therefore it does not + * require a call to `$apply()`. + */ + public ready(fn: (upgradeAdapterRef?: UpgradeAdapterRef) => void) { this._readyFn = fn; } + + /** + * Dispose of running hybrid AngularJS v1 / Angular v2 application. + */ + public dispose() { + this.ng1Injector.get(NG1_ROOT_SCOPE).$destroy(); + this.applicationRef.dispose(); + } } diff --git a/modules/upgrade/src/upgrade_ng1_adapter.ts b/modules/upgrade/src/upgrade_ng1_adapter.ts index 8c5aba32a1..07fc0b3ae5 100644 --- a/modules/upgrade/src/upgrade_ng1_adapter.ts +++ b/modules/upgrade/src/upgrade_ng1_adapter.ts @@ -8,12 +8,20 @@ import { SimpleChange, Type } from 'angular2/angular2'; -import {NG1_COMPILE, NG1_SCOPE} from './constants'; +import { + NG1_COMPILE, + NG1_SCOPE, + NG1_HTTP_BACKEND, + NG1_TEMPLATE_CACHE, + NG1_CONTROLLER +} from './constants'; +import {controllerKey} from './util'; const CAMEL_CASE = /([A-Z])/g; const INITIAL_VALUE = { __UNINITIALIZED__: true }; +const NOT_SUPPORTED: any = 'NOT_SUPPORTED'; export class UpgradeNg1ComponentAdapterBuilder { @@ -25,6 +33,9 @@ export class UpgradeNg1ComponentAdapterBuilder { propertyOutputs: string[] = []; checkProperties: string[] = []; propertyMap: {[name: string]: string} = {}; + linkFn: angular.ILinkFn = null; + directive: angular.IDirective = null; + $controller: angular.IControllerService = null; constructor(public name: string) { var selector = name.replace(CAMEL_CASE, (all, next: string) => '-' + next.toLowerCase()); @@ -33,14 +44,12 @@ export class UpgradeNg1ComponentAdapterBuilder { Directive({selector: selector, inputs: this.inputsRename, outputs: this.outputsRename}) .Class({ constructor: [ - new Inject(NG1_COMPILE), new Inject(NG1_SCOPE), ElementRef, - function(compile: angular.ICompileService, scope: angular.IScope, - elementRef: ElementRef) { - return new UpgradeNg1ComponentAdapter(compile, scope, elementRef, self.inputs, - self.outputs, self.propertyOutputs, - self.checkProperties, self.propertyMap); + function(scope: angular.IScope, elementRef: ElementRef) { + return new UpgradeNg1ComponentAdapter( + self.linkFn, scope, self.directive, elementRef, self.$controller, self.inputs, + self.outputs, self.propertyOutputs, self.checkProperties, self.propertyMap); } ], onChanges: function() { /* needs to be here for ng2 to properly detect it */ }, @@ -48,13 +57,27 @@ export class UpgradeNg1ComponentAdapterBuilder { }); } - extractBindings(injector: angular.auto.IInjectorService) { + extractDirective(injector: angular.auto.IInjectorService): angular.IDirective { var directives: angular.IDirective[] = injector.get(this.name + 'Directive'); if (directives.length > 1) { throw new Error('Only support single directive definition for: ' + this.name); } var directive = directives[0]; - var scope = directive.scope; + if (directive.replace) this.notSupported('replace'); + if (directive.terminal) this.notSupported('terminal'); + var link = directive.link; + if (typeof link == 'object') { + if ((link).post) this.notSupported('link.post'); + } + return directive; + } + + private notSupported(feature: string) { + throw new Error(`Upgraded directive '${this.name}' does not support '${feature}'.`); + } + + extractBindings() { + var scope = this.directive.scope; if (typeof scope == 'object') { for (var name in scope) { if ((scope).hasOwnProperty(name)) { @@ -94,25 +117,66 @@ export class UpgradeNg1ComponentAdapterBuilder { } } + compileTemplate(compile: angular.ICompileService, templateCache: angular.ITemplateCacheService, + httpBackend: angular.IHttpBackendService): Promise { + if (this.directive.template) { + this.linkFn = compileHtml(this.directive.template); + } else if (this.directive.templateUrl) { + var url = this.directive.templateUrl; + var html = templateCache.get(url); + if (html !== undefined) { + this.linkFn = compileHtml(html); + } else { + return new Promise((resolve, err) => { + httpBackend('GET', url, null, (status, response) => { + if (status == 200) { + resolve(this.linkFn = compileHtml(templateCache.put(url, response))); + } else { + err(`GET ${url} returned ${status}: ${response}`); + } + }); + }); + } + } else { + throw new Error(`Directive '${this.name}' is not a component, it is missing template.`); + } + return null; + function compileHtml(html) { + var div = document.createElement('div'); + div.innerHTML = html; + return compile(div.childNodes); + } + } + static resolve(exportedComponents: {[name: string]: UpgradeNg1ComponentAdapterBuilder}, - injector: angular.auto.IInjectorService) { + injector: angular.auto.IInjectorService): Promise { + var promises = []; + var compile: angular.ICompileService = injector.get(NG1_COMPILE); + var templateCache: angular.ITemplateCacheService = injector.get(NG1_TEMPLATE_CACHE); + var httpBackend: angular.IHttpBackendService = injector.get(NG1_HTTP_BACKEND); + var $controller: angular.IControllerService = injector.get(NG1_CONTROLLER); for (var name in exportedComponents) { if ((exportedComponents).hasOwnProperty(name)) { var exportedComponent = exportedComponents[name]; - exportedComponent.extractBindings(injector); + exportedComponent.directive = exportedComponent.extractDirective(injector); + exportedComponent.$controller = $controller; + exportedComponent.extractBindings(); + var promise = exportedComponent.compileTemplate(compile, templateCache, httpBackend); + if (promise) promises.push(promise) } } + return Promise.all(promises); } } class UpgradeNg1ComponentAdapter implements OnChanges, DoCheck { - componentScope: angular.IScope = null; + destinationObj: any = null; checkLastValues: any[] = []; - constructor(compile: angular.ICompileService, scope: angular.IScope, elementRef: ElementRef, + constructor(linkFn: angular.ILinkFn, scope: angular.IScope, private directive: angular.IDirective, + elementRef: ElementRef, $controller: angular.IControllerService, private inputs: string[], private outputs: string[], private propOuts: string[], private checkProperties: string[], private propertyMap: {[key: string]: string}) { - var chailTail = scope.$$childTail; // remember where the next scope is inserted var element: Element = elementRef.nativeElement; var childNodes: Node[] = []; var childNode; @@ -120,11 +184,31 @@ class UpgradeNg1ComponentAdapter implements OnChanges, DoCheck { element.removeChild(childNode); childNodes.push(childNode); } - element.appendChild(element.ownerDocument.createElement('ng-transclude')); - compile(element)(scope, null, - {parentBoundTranscludeFn: (scope, cloneAttach) => cloneAttach(childNodes)}); - // If we are first scope take it, otherwise take the next one in list. - this.componentScope = chailTail ? chailTail.$$nextSibling : scope.$$childHead; + var componentScope = scope.$new(!!directive.scope); + var $element = angular.element(element); + var controllerType = directive.controller; + var controller: any = null; + if (controllerType) { + var locals = {$scope: componentScope, $element: $element}; + controller = $controller(controllerType, locals, null, directive.controllerAs); + $element.data(controllerKey(directive.name), controller); + } + var link = directive.link; + if (typeof link == 'object') link = (link).pre; + if (link) { + var attrs: angular.IAttributes = NOT_SUPPORTED; + var transcludeFn: angular.ITranscludeFunction = NOT_SUPPORTED; + var linkController = this.resolveRequired($element, directive.require); + (directive.link)(componentScope, $element, attrs, linkController, + transcludeFn); + } + this.destinationObj = directive.bindToController && controller ? controller : componentScope; + + linkFn(componentScope, (clonedElement: Node[], scope: angular.IScope) => { + for (var i = 0, ii = clonedElement.length; i < ii; i++) { + element.appendChild(clonedElement[i]); + } + }, {parentBoundTranscludeFn: (scope, cloneAttach) => { cloneAttach(childNodes) }}); for (var i = 0; i < inputs.length; i++) { this[inputs[i]] = null; @@ -150,11 +234,11 @@ class UpgradeNg1ComponentAdapter implements OnChanges, DoCheck { doCheck() { var count = 0; - var scope = this.componentScope; + var destinationObj = this.destinationObj; var lastValues = this.checkLastValues; var checkProperties = this.checkProperties; for (var i = 0; i < checkProperties.length; i++) { - var value = scope[checkProperties[i]]; + var value = destinationObj[checkProperties[i]]; var last = lastValues[i]; if (value !== last) { if (typeof value == 'number' && isNaN(value) && typeof last == 'number' && isNaN(last)) { @@ -169,6 +253,46 @@ class UpgradeNg1ComponentAdapter implements OnChanges, DoCheck { } setComponentProperty(name: string, value: any) { - this.componentScope[this.propertyMap[name]] = value; + this.destinationObj[this.propertyMap[name]] = value; + } + + private resolveRequired($element: angular.IAugmentedJQuery, require: string | string[]): any { + if (!require) { + return undefined; + } else if (typeof require == 'string') { + var name: string = require; + var isOptional = false; + var startParent = false; + var searchParents = false; + var ch: string; + if (name.charAt(0) == '?') { + isOptional = true; + name = name.substr(1); + } + if (name.charAt(0) == '^') { + searchParents = true; + name = name.substr(1); + } + if (name.charAt(0) == '^') { + startParent = true; + name = name.substr(1); + } + + var key = controllerKey(name); + if (startParent) $element = $element.parent(); + var dep = searchParents ? $element.inheritedData(key) : $element.data(key); + if (!dep && !isOptional) { + throw new Error(`Can not locate '${require}' in '${this.directive.name}'.`); + } + return dep; + } else if (require instanceof Array) { + var deps = []; + for (var i = 0; i < require.length; i++) { + deps.push(this.resolveRequired($element, require[i])); + } + return deps; + } + throw new Error( + `Directive '${this.directive.name}' require syntax unrecognized: ${this.directive.require}`); } } diff --git a/modules/upgrade/src/util.ts b/modules/upgrade/src/util.ts index c0f27eb037..1498c17444 100644 --- a/modules/upgrade/src/util.ts +++ b/modules/upgrade/src/util.ts @@ -10,3 +10,7 @@ export function onError(e: any) { console.log(e, e.stack); throw e; } + +export function controllerKey(name: string): string { + return '$' + name + 'Controller'; +} diff --git a/modules/upgrade/test/integration_spec.ts b/modules/upgrade/test/integration_spec.ts index d8a3146c96..51e08ddd4a 100644 --- a/modules/upgrade/test/integration_spec.ts +++ b/modules/upgrade/test/integration_spec.ts @@ -11,7 +11,7 @@ import { xit, } from 'angular2/testing_internal'; -import {Component, Inject, EventEmitter} from 'angular2/angular2'; +import {Component, Class, Inject, EventEmitter} from 'angular2/angular2'; import {UpgradeAdapter} from 'upgrade/upgrade'; export function main() { @@ -29,8 +29,9 @@ export function main() { var adapter: UpgradeAdapter = new UpgradeAdapter(); ng1Module.directive('ng2', adapter.downgradeNg2Component(Ng2)); adapter.bootstrap(element, ['ng1']) - .ready(() => { + .ready((ref) => { expect(document.body.textContent).toEqual("ng1[NG2(~ng-content~)]"); + ref.dispose(); async.done(); }); })); @@ -54,8 +55,9 @@ export function main() { var element = html("
{{'ng1('}}{{')'}}
"); adapter.bootstrap(element, ['ng1']) - .ready(() => { + .ready((ref) => { expect(document.body.textContent).toEqual("ng1(ng2(ng1(transclude)))"); + ref.dispose(); async.done(); }); })); @@ -90,16 +92,17 @@ export function main() { var element = html("
{{reset(); l('1A');}}{{l('1B')}}{{l('1C')}}
"); adapter.bootstrap(element, ['ng1']) - .ready(() => { + .ready((ref) => { 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']); + ref.dispose(); async.done(); }); })); }); - describe('binding from ng1 to ng2', () => { + describe('downgrade ng2 component', () => { it('should bind properties, events', inject([AsyncTestCompleter], (async) => { var adapter: UpgradeAdapter = new UpgradeAdapter(); var ng1Module = angular.module('ng1', []); @@ -197,7 +200,7 @@ export function main() { | modelA: {{modelA}}; modelB: {{modelB}}; eventA: {{eventA}}; eventB: {{eventB}}; `); adapter.bootstrap(element, ['ng1']) - .ready(() => { + .ready((ref) => { expect(multiTrim(document.body.textContent)) .toEqual( "ignore: -; " + "literal: Text; interpolate: Hello world; " + @@ -210,6 +213,7 @@ export function main() { .toEqual("ignore: -; " + "literal: Text; interpolate: Hello world; " + "oneWayA: A; oneWayB: B; twoWayA: newA; twoWayB: newB; (3) | " + "modelA: newA; modelB: newB; eventA: aFired; eventB: bFired;"); + ref.dispose(); async.done(); }); }); @@ -217,7 +221,7 @@ export function main() { })); }); - describe('binding from ng2 to ng1', () => { + describe('upgrade ng1 component', () => { it('should bind properties, events', inject([AsyncTestCompleter], (async) => { var adapter = new UpgradeAdapter(); var ng1Module = angular.module('ng1', []); @@ -225,19 +229,19 @@ export function main() { var ng1 = function() { return { template: 'Hello {{fullName}}; A: {{dataA}}; B: {{dataB}}; | ', - scope: {fullName: '@', modelA: '=dataA', modelB: '=dataB', event: '&'}, - link: function(scope) { - scope.$watch('dataB', (v) => { - if (v == 'Savkin') { - scope.dataB = 'SAVKIN'; - scope.event('WORKS'); + scope: {fullName: '@', modelA: '=dataA', modelB: '=dataB', event: '&'}, + link: function(scope) { + scope.$watch('dataB', (v) => { + if (v == 'Savkin') { + scope.dataB = 'SAVKIN'; + scope.event('WORKS'); - // Should not update becaus [model-a] is uni directional - scope.dataA = 'VICTOR'; - } - }) + // Should not update becaus [model-a] is uni directional + scope.dataA = 'VICTOR'; } - } + }) + } + }; }; ng1Module.directive('ng1', ng1); var Ng2 = @@ -260,17 +264,208 @@ export function main() { ng1Module.directive('ng2', adapter.downgradeNg2Component(Ng2)); var element = html(`
`); adapter.bootstrap(element, ['ng1']) - .ready(() => { + .ready((ref) => { // we need to do setTimeout, because the EventEmitter uses setTimeout to schedule // events, and so without this we would not see the events processed. setTimeout(() => { expect(multiTrim(document.body.textContent)) .toEqual( "Hello SAVKIN, Victor; A: VICTOR; B: SAVKIN; | Hello TEST; A: First; B: Last; | WORKS-SAVKIN, Victor"); + ref.dispose(); async.done(); }, 0); }); })); + + it('should support templateUrl fetched from $httpBackend', + inject([AsyncTestCompleter], (async) => { + var adapter = new UpgradeAdapter(); + var ng1Module = angular.module('ng1', []); + ng1Module.value('$httpBackend', + (method, url, post, cbFn) => { cbFn(200, `${method}:${url}`); }); + + var ng1 = function() { return {templateUrl: 'url.html'}; }; + ng1Module.directive('ng1', ng1); + var Ng2 = Component({ + selector: 'ng2', + template: '', + directives: [adapter.upgradeNg1Component('ng1')] + }).Class({constructor: function() {}}); + ng1Module.directive('ng2', adapter.downgradeNg2Component(Ng2)); + var element = html(`
`); + adapter.bootstrap(element, ['ng1']) + .ready((ref) => { + expect(multiTrim(document.body.textContent)).toEqual('GET:url.html'); + ref.dispose(); + async.done(); + }); + })); + + it('should support templateUrl fetched from $templateCache', + inject([AsyncTestCompleter], (async) => { + var adapter = new UpgradeAdapter(); + var ng1Module = angular.module('ng1', []); + ng1Module.run(($templateCache) => $templateCache.put('url.html', 'WORKS')); + + var ng1 = function() { return {templateUrl: 'url.html'}; }; + ng1Module.directive('ng1', ng1); + var Ng2 = Component({ + selector: 'ng2', + template: '', + directives: [adapter.upgradeNg1Component('ng1')] + }).Class({constructor: function() {}}); + ng1Module.directive('ng2', adapter.downgradeNg2Component(Ng2)); + var element = html(`
`); + adapter.bootstrap(element, ['ng1']) + .ready((ref) => { + expect(multiTrim(document.body.textContent)).toEqual('WORKS'); + ref.dispose(); + async.done(); + }); + })); + + it('should support controller with controllerAs', inject([AsyncTestCompleter], (async) => { + var adapter = new UpgradeAdapter(); + var ng1Module = angular.module('ng1', []); + + var ng1 = function() { + return { + scope: true, + template: + '{{ctl.scope}}; {{ctl.isClass}}; {{ctl.hasElement}}; {{ctl.isPublished()}}', + controllerAs: 'ctl', + controller: Class({ + constructor: function($scope, $element) { + (this).verifyIAmAClass(); + this.scope = $scope.$parent.$parent == $scope.$root ? 'scope' : 'wrong-scope'; + this.hasElement = $element[0].nodeName; + this.$element = $element; + }, + verifyIAmAClass: function() { this.isClass = 'isClass'; }, + isPublished: function() { + return this.$element.controller('ng1') == this ? 'published' : 'not-published'; + } + }) + }; + }; + ng1Module.directive('ng1', ng1); + var Ng2 = Component({ + selector: 'ng2', + template: '', + directives: [adapter.upgradeNg1Component('ng1')] + }).Class({constructor: function() {}}); + ng1Module.directive('ng2', adapter.downgradeNg2Component(Ng2)); + var element = html(`
`); + adapter.bootstrap(element, ['ng1']) + .ready((ref) => { + expect(multiTrim(document.body.textContent)) + .toEqual('scope; isClass; NG1; published'); + ref.dispose(); + async.done(); + }); + })); + + it('should support bindToController', inject([AsyncTestCompleter], (async) => { + var adapter = new UpgradeAdapter(); + var ng1Module = angular.module('ng1', []); + + var ng1 = function() { + return { + scope: {title: '@'}, + bindToController: true, template: '{{ctl.title}}', + controllerAs: 'ctl', + controller: Class({constructor: function() {}}) + }; + }; + ng1Module.directive('ng1', ng1); + var Ng2 = Component({ + selector: 'ng2', + template: '', + directives: [adapter.upgradeNg1Component('ng1')] + }).Class({constructor: function() {}}); + ng1Module.directive('ng2', adapter.downgradeNg2Component(Ng2)); + var element = html(`
`); + adapter.bootstrap(element, ['ng1']) + .ready((ref) => { + expect(multiTrim(document.body.textContent)).toEqual('WORKS'); + ref.dispose(); + async.done(); + }); + })); + + it('should support single require in linking fn', inject([AsyncTestCompleter], (async) => { + var adapter = new UpgradeAdapter(); + var ng1Module = angular.module('ng1', []); + + var ng1 = function($rootScope) { + return { + scope: {title: '@'}, + bindToController: true, template: '{{ctl.status}}', + require: 'ng1', + controller: Class({constructor: function() { this.status = 'WORKS'; }}), + link: function(scope, element, attrs, linkController) { + expect(scope.$root).toEqual($rootScope); + expect(element[0].nodeName).toEqual('NG1'); + expect(linkController.status).toEqual('WORKS'); + scope.ctl = linkController; + } + }; + }; + ng1Module.directive('ng1', ng1); + var Ng2 = Component({ + selector: 'ng2', + template: '', + directives: [adapter.upgradeNg1Component('ng1')] + }).Class({constructor: function() {}}); + ng1Module.directive('ng2', adapter.downgradeNg2Component(Ng2)); + var element = html(`
`); + adapter.bootstrap(element, ['ng1']) + .ready((ref) => { + expect(multiTrim(document.body.textContent)).toEqual('WORKS'); + ref.dispose(); + async.done(); + }); + })); + + it('should support array require in linking fn', inject([AsyncTestCompleter], (async) => { + var adapter = new UpgradeAdapter(); + var ng1Module = angular.module('ng1', []); + + var parent = function() { + return {controller: Class({constructor: function() { this.parent = 'PARENT'; }})}; + }; + var ng1 = function() { + return { + scope: {title: '@'}, + bindToController: true, template: '{{parent.parent}}:{{ng1.status}}', + require: ['ng1', '^parent', '?^^notFound'], + controller: Class({constructor: function() { this.status = 'WORKS'; }}), + link: function(scope, element, attrs, linkControllers) { + expect(linkControllers[0].status).toEqual('WORKS'); + expect(linkControllers[1].parent).toEqual('PARENT'); + expect(linkControllers[2]).toBe(undefined); + scope.ng1 = linkControllers[0]; + scope.parent = linkControllers[1]; + } + }; + }; + ng1Module.directive('parent', parent); + ng1Module.directive('ng1', ng1); + var Ng2 = Component({ + selector: 'ng2', + template: '', + directives: [adapter.upgradeNg1Component('ng1')] + }).Class({constructor: function() {}}); + ng1Module.directive('ng2', adapter.downgradeNg2Component(Ng2)); + var element = html(`
`); + adapter.bootstrap(element, ['ng1']) + .ready((ref) => { + expect(multiTrim(document.body.textContent)).toEqual('PARENT:WORKS'); + ref.dispose(); + async.done(); + }); + })); + }); describe('examples', () => { @@ -280,7 +475,7 @@ export function main() { module.directive('ng1', function() { return { - scope: {title: '@'}, + scope: {title: '='}, transclude: true, template: 'ng1[Hello {{title}}!]()' }; }); @@ -299,9 +494,10 @@ export function main() { document.body.innerHTML = 'project'; adapter.bootstrap(document.body, ['myExample']) - .ready(function() { + .ready((ref) => { expect(multiTrim(document.body.textContent)) .toEqual("ng2[ng1[Hello World!](transclude)](project)"); + ref.dispose(); async.done(); }); }));