From 4ae1880642c28e3d70dbe5f854b52274c328b11b Mon Sep 17 00:00:00 2001 From: George Kalpakas Date: Tue, 20 Nov 2018 20:34:49 +0200 Subject: [PATCH] fix(upgrade): allow nesting components from different downgraded modules (#27217) PR Close #27217 --- .../upgrade/src/common/downgrade_component.ts | 80 +++++- .../integration/downgrade_module_spec.ts | 257 +++++++++++++++++- 2 files changed, 320 insertions(+), 17 deletions(-) diff --git a/packages/upgrade/src/common/downgrade_component.ts b/packages/upgrade/src/common/downgrade_component.ts index e1539f06f9..fd6d994434 100644 --- a/packages/upgrade/src/common/downgrade_component.ts +++ b/packages/upgrade/src/common/downgrade_component.ts @@ -11,7 +11,7 @@ import {ComponentFactory, ComponentFactoryResolver, Injector, NgZone, Type} from import * as angular from './angular1'; import {$COMPILE, $INJECTOR, $PARSE, INJECTOR_KEY, LAZY_MODULE_REF, REQUIRE_INJECTOR, REQUIRE_NG_MODEL} from './constants'; import {DowngradeComponentAdapter} from './downgrade_component_adapter'; -import {LazyModuleRef, UpgradeAppType, controllerKey, getTypeName, getUpgradeAppType, isFunction, validateInjectionKey} from './util'; +import {LazyModuleRef, UpgradeAppType, controllerKey, getDowngradedModuleCount, getTypeName, getUpgradeAppType, isFunction, validateInjectionKey} from './util'; interface Thenable { @@ -91,6 +91,10 @@ export function downgradeComponent(info: { !isNgUpgradeLite ? cb => cb : cb => () => NgZone.isInAngularZone() ? cb() : ngZone.run(cb); let ngZone: NgZone; + // When downgrading multiple modules, special handling is needed wrt injectors. + const hasMultipleDowngradedModules = + isNgUpgradeLite && (getDowngradedModuleCount($injector) > 1); + return { restrict: 'E', terminal: true, @@ -102,10 +106,11 @@ export function downgradeComponent(info: { // been compiled. const ngModel: angular.INgModelController = required[1]; - let parentInjector: Injector|Thenable|undefined = required[0]; + const parentInjector: Injector|Thenable|undefined = required[0]; + let moduleInjector: Injector|Thenable|undefined = undefined; let ranAsync = false; - if (!parentInjector) { + if (!parentInjector || hasMultipleDowngradedModules) { const downgradedModule = info.downgradedModule || ''; const lazyModuleRefKey = `${LAZY_MODULE_REF}${downgradedModule}`; const attemptedAction = `instantiating component '${getTypeName(info.component)}'`; @@ -113,12 +118,55 @@ export function downgradeComponent(info: { validateInjectionKey($injector, downgradedModule, lazyModuleRefKey, attemptedAction); const lazyModuleRef = $injector.get(lazyModuleRefKey) as LazyModuleRef; - parentInjector = lazyModuleRef.injector || lazyModuleRef.promise as Promise; + moduleInjector = lazyModuleRef.injector || lazyModuleRef.promise as Promise; } - const doDowngrade = (injector: Injector) => { + // Notes: + // + // There are two injectors: `finalModuleInjector` and `finalParentInjector` (they might be + // the same instance, but that is irrelevant): + // - `finalModuleInjector` is used to retrieve `ComponentFactoryResolver`, thus it must be + // on the same tree as the `NgModule` that declares this downgraded component. + // - `finalParentInjector` is used for all other injection purposes. + // (Note that Angular knows to only traverse the component-tree part of that injector, + // when looking for an injectable and then switch to the module injector.) + // + // There are basically three cases: + // - If there is no parent component (thus no `parentInjector`), we bootstrap the downgraded + // `NgModule` and use its injector as both `finalModuleInjector` and + // `finalParentInjector`. + // - If there is a parent component (and thus a `parentInjector`) and we are sure that it + // belongs to the same `NgModule` as this downgraded component (e.g. because there is only + // one downgraded module, we use that `parentInjector` as both `finalModuleInjector` and + // `finalParentInjector`. + // - If there is a parent component, but it may belong to a different `NgModule`, then we + // use the `parentInjector` as `finalParentInjector` and this downgraded component's + // declaring `NgModule`'s injector as `finalModuleInjector`. + // Note 1: If the `NgModule` is already bootstrapped, we just get its injector (we don't + // bootstrap again). + // Note 2: It is possible that (while there are multiple downgraded modules) this + // downgraded component and its parent component both belong to the same NgModule. + // In that case, we could have used the `parentInjector` as both + // `finalModuleInjector` and `finalParentInjector`, but (for simplicity) we are + // treating this case as if they belong to different `NgModule`s. That doesn't + // really affect anything, since `parentInjector` has `moduleInjector` as ancestor + // and trying to resolve `ComponentFactoryResolver` from either one will return + // the same instance. + + // If there is a parent component, use its injector as parent injector. + // If this is a "top-level" Angular component, use the module injector. + const finalParentInjector = parentInjector || moduleInjector !; + + // If this is a "top-level" Angular component or the parent component may belong to a + // different `NgModule`, use the module injector for module-specific dependencies. + // If there is a parent component that belongs to the same `NgModule`, use its injector. + const finalModuleInjector = moduleInjector || parentInjector !; + + const doDowngrade = (injector: Injector, moduleInjector: Injector) => { + // Retrieve `ComponentFactoryResolver` from the injector tied to the `NgModule` this + // component belongs to. const componentFactoryResolver: ComponentFactoryResolver = - injector.get(ComponentFactoryResolver); + moduleInjector.get(ComponentFactoryResolver); const componentFactory: ComponentFactory = componentFactoryResolver.resolveComponentFactory(info.component) !; @@ -146,18 +194,20 @@ export function downgradeComponent(info: { } }; - const downgradeFn = !isNgUpgradeLite ? doDowngrade : (injector: Injector) => { - if (!ngZone) { - ngZone = injector.get(NgZone); - } + const downgradeFn = + !isNgUpgradeLite ? doDowngrade : (pInjector: Injector, mInjector: Injector) => { + if (!ngZone) { + ngZone = pInjector.get(NgZone); + } - wrapCallback(() => doDowngrade(injector))(); - }; + wrapCallback(() => doDowngrade(pInjector, mInjector))(); + }; - if (isThenable(parentInjector)) { - parentInjector.then(downgradeFn); + if (isThenable(finalParentInjector) || isThenable(finalModuleInjector)) { + Promise.all([finalParentInjector, finalModuleInjector]) + .then(([pInjector, mInjector]) => downgradeFn(pInjector, mInjector)); } else { - downgradeFn(parentInjector); + downgradeFn(finalParentInjector, finalModuleInjector); } ranAsync = true; diff --git a/packages/upgrade/test/static/integration/downgrade_module_spec.ts b/packages/upgrade/test/static/integration/downgrade_module_spec.ts index 2c206c4e5e..929ac34afa 100644 --- a/packages/upgrade/test/static/integration/downgrade_module_spec.ts +++ b/packages/upgrade/test/static/integration/downgrade_module_spec.ts @@ -6,13 +6,13 @@ * 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, Type, ViewRef, destroyPlatform, getPlatform} from '@angular/core'; +import {AfterContentChecked, AfterContentInit, AfterViewChecked, AfterViewInit, ApplicationRef, Compiler, Component, Directive, DoCheck, ElementRef, Inject, Injectable, Injector, Input, NgModule, NgZone, OnChanges, OnDestroy, OnInit, StaticProvider, Type, ViewRef, destroyPlatform, getPlatform} 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 {fixmeIvy} from '@angular/private/testing'; -import {downgradeComponent, downgradeModule} from '@angular/upgrade/static'; +import {UpgradeComponent, downgradeComponent, downgradeModule} from '@angular/upgrade/static'; import * as angular from '@angular/upgrade/static/src/common/angular1'; import {$EXCEPTION_HANDLER, $ROOT_SCOPE, INJECTOR_KEY, LAZY_MODULE_REF} from '@angular/upgrade/static/src/common/constants'; import {LazyModuleRef} from '@angular/upgrade/static/src/common/util'; @@ -78,6 +78,259 @@ withEachNg1Version(() => { setTimeout(() => expect(element.textContent).toBe('a | b')); })); + it('should support nesting components from different downgraded modules', async(() => { + @Directive({selector: 'ng1A'}) + class Ng1ComponentA extends UpgradeComponent { + constructor(elementRef: ElementRef, injector: Injector) { + super('ng1A', elementRef, injector); + } + } + + @Component({ + selector: 'ng2A', + template: 'ng2A()', + }) + class Ng2ComponentA { + } + + @Component({ + selector: 'ng2B', + template: 'ng2B', + }) + class Ng2ComponentB { + } + + @NgModule({ + declarations: [Ng1ComponentA, Ng2ComponentA], + entryComponents: [Ng2ComponentA], + imports: [BrowserModule], + }) + class Ng2ModuleA { + ngDoBootstrap() {} + } + + @NgModule({ + declarations: [Ng2ComponentB], + entryComponents: [Ng2ComponentB], + imports: [BrowserModule], + }) + class Ng2ModuleB { + ngDoBootstrap() {} + } + + const doDowngradeModule = (module: Type) => { + const bootstrapFn = (extraProviders: StaticProvider[]) => { + const platformRef = getPlatform() || platformBrowserDynamic(extraProviders); + return platformRef.bootstrapModule(module); + }; + return downgradeModule(bootstrapFn); + }; + + const downModA = doDowngradeModule(Ng2ModuleA); + const downModB = doDowngradeModule(Ng2ModuleB); + const ng1Module = + angular.module('ng1', [downModA, downModB]) + .directive('ng1A', () => ({template: 'ng1A()'})) + .directive('ng2A', downgradeComponent({ + component: Ng2ComponentA, + downgradedModule: downModA, propagateDigest, + })) + .directive('ng2B', downgradeComponent({ + component: Ng2ComponentB, + downgradedModule: downModB, propagateDigest, + })); + + const element = html(''); + const $injector = angular.bootstrap(element, [ng1Module.name]); + const $rootScope = $injector.get($ROOT_SCOPE) as angular.IRootScopeService; + + // Wait for module A to be bootstrapped. + setTimeout(() => { + // Wait for the upgraded component's `ngOnInit()`. + setTimeout(() => { + expect(element.textContent).toBe('ng2A(ng1A())'); + + $rootScope.$apply('showB = true'); + + // Wait for module B to be bootstrapped. + setTimeout(() => expect(element.textContent).toBe('ng2A(ng1A(ng2B))')); + }); + }); + })); + + fixmeIvy('FW-714: ng1 projected content is not being rendered') + .it('should support nesting components from different downgraded modules (via projection)', + async(() => { + @Component({ + selector: 'ng2A', + template: 'ng2A()', + }) + class Ng2ComponentA { + } + + @Component({ + selector: 'ng2B', + template: 'ng2B', + }) + class Ng2ComponentB { + } + + @NgModule({ + declarations: [Ng2ComponentA], + entryComponents: [Ng2ComponentA], + imports: [BrowserModule], + }) + class Ng2ModuleA { + ngDoBootstrap() {} + } + + @NgModule({ + declarations: [Ng2ComponentB], + entryComponents: [Ng2ComponentB], + imports: [BrowserModule], + }) + class Ng2ModuleB { + ngDoBootstrap() {} + } + + const doDowngradeModule = (module: Type) => { + const bootstrapFn = (extraProviders: StaticProvider[]) => { + const platformRef = getPlatform() || platformBrowserDynamic(extraProviders); + return platformRef.bootstrapModule(module); + }; + return downgradeModule(bootstrapFn); + }; + + const downModA = doDowngradeModule(Ng2ModuleA); + const downModB = doDowngradeModule(Ng2ModuleB); + const ng1Module = angular.module('ng1', [downModA, downModB]) + .directive('ng2A', downgradeComponent({ + component: Ng2ComponentA, + downgradedModule: downModA, propagateDigest, + })) + .directive('ng2B', downgradeComponent({ + component: Ng2ComponentB, + downgradedModule: downModB, propagateDigest, + })); + + const element = html(''); + const $injector = angular.bootstrap(element, [ng1Module.name]); + const $rootScope = $injector.get($ROOT_SCOPE) as angular.IRootScopeService; + + // Wait for module A to be bootstrapped. + setTimeout(() => { + expect(element.textContent).toBe('ng2A()'); + + $rootScope.$apply('showB = true'); + + // Wait for module B to be bootstrapped. + setTimeout(() => expect(element.textContent).toBe('ng2A(ng2B)')); + }); + })); + + fixmeIvy('FW-714: ng1 projected content is not being rendered') + .it('should support manually setting up a root module for all downgraded modules', + fakeAsync(() => { + @Injectable({providedIn: 'root'}) + class CounterService { + private static counter = 0; + value = ++CounterService.counter; + } + + @Component({ + selector: 'ng2A', + template: 'ng2A(Counter:{{ counter.value }} | )', + }) + class Ng2ComponentA { + constructor(public counter: CounterService) {} + } + + @Component({ + selector: 'ng2B', + template: 'Counter:{{ counter.value }}', + }) + class Ng2ComponentB { + constructor(public counter: CounterService) {} + } + + @NgModule({ + declarations: [Ng2ComponentA], + entryComponents: [Ng2ComponentA], + }) + class Ng2ModuleA { + } + + @NgModule({ + declarations: [Ng2ComponentB], + entryComponents: [Ng2ComponentB], + }) + class Ng2ModuleB { + } + + // "Empty" module that will serve as root for all downgraded modules, + // ensuring there will only be one instance for all injectables provided in "root". + @NgModule({ + imports: [BrowserModule], + }) + class Ng2ModuleRoot { + ngDoBootstrap() {} + } + + let rootInjectorPromise: Promise|null = null; + const doDowngradeModule = (module: Type) => { + const bootstrapFn = (extraProviders: StaticProvider[]) => { + if (!rootInjectorPromise) { + rootInjectorPromise = platformBrowserDynamic(extraProviders) + .bootstrapModule(Ng2ModuleRoot) + .then(ref => ref.injector); + } + + return rootInjectorPromise.then(rootInjector => { + const compiler = rootInjector.get(Compiler); + const moduleFactory = compiler.compileModuleSync(module); + + return moduleFactory.create(rootInjector); + }); + }; + return downgradeModule(bootstrapFn); + }; + + const downModA = doDowngradeModule(Ng2ModuleA); + const downModB = doDowngradeModule(Ng2ModuleB); + const ng1Module = angular.module('ng1', [downModA, downModB]) + .directive('ng2A', downgradeComponent({ + component: Ng2ComponentA, + downgradedModule: downModA, propagateDigest, + })) + .directive('ng2B', downgradeComponent({ + component: Ng2ComponentB, + downgradedModule: downModB, propagateDigest, + })); + + const element = html(` + + + `); + const $injector = angular.bootstrap(element, [ng1Module.name]); + const $rootScope = $injector.get($ROOT_SCOPE) as angular.IRootScopeService; + + tick(); // Wait for module A to be bootstrapped. + expect(multiTrim(element.textContent)).toBe('ng2A(Counter:1 | )'); + + // Nested component B should use the same `CounterService` instance. + $rootScope.$apply('showB1 = true'); + + tick(); // Wait for module B to be bootstrapped. + expect(multiTrim(element.children[0].textContent)) + .toBe('ng2A(Counter:1 | Counter:1)'); + + // Top-level component B should use the same `CounterService` instance. + $rootScope.$apply('showB2 = true'); + tick(); + + expect(multiTrim(element.children[1].textContent)).toBe('Counter:1'); + })); + it('should support downgrading a component and propagate inputs', async(() => { @Component( {selector: 'ng2A', template: 'a({{ value }}) | '})