refactor(ngcc): rework undecorated parent migration (#33362)

Previously, the (currently disabled) undecorated parent migration in
ngcc would produce errors when a base class could not be determined
statically or when a class extends from a class in another package. This
is not ideal, as it would cause the library to fail compilation without
a workaround, whereas those problems are not guaranteed to cause issues.

Additionally, inheritance chains were not handled. This commit reworks
the migration to address these limitations.

PR Close #33362
This commit is contained in:
JoostK 2019-10-20 23:28:00 +02:00 committed by Andrew Kushnir
parent 3858b26211
commit 2e5e1dd5f5
4 changed files with 135 additions and 53 deletions

View File

@ -6,12 +6,14 @@
* found in the LICENSE file at https://angular.io/license
*/
import * as ts from 'typescript';
import {ErrorCode, makeDiagnostic} from '../../../src/ngtsc/diagnostics';
import {Reference} from '../../../src/ngtsc/imports';
import {ClassDeclaration} from '../../../src/ngtsc/reflection';
import {isRelativePath} from '../utils';
import {Migration, MigrationHost} from './migration';
import {createDirectiveDecorator, hasConstructor, hasDirectiveDecorator, isClassDeclaration} from './utils';
/**
* Ensure that the parents of directives and components that have no constructor are also decorated
* as a `Directive`.
@ -59,36 +61,49 @@ export class UndecoratedParentMigration implements Migration {
}
// Only interested in `clazz` if it inherits from a base class.
const baseClassExpr = host.reflectionHost.getBaseClassExpression(clazz);
if (baseClassExpr === null) {
return null;
}
let baseClazzRef = determineBaseClass(clazz, host);
while (baseClazzRef !== null) {
const baseClazz = baseClazzRef.node;
if (!ts.isIdentifier(baseClassExpr)) {
return makeDiagnostic(
ErrorCode.NGCC_MIGRATION_EXTERNAL_BASE_CLASS, baseClassExpr,
`${clazz.name.text} class has a dynamic base class ${baseClassExpr.getText()}, so it is not possible to migrate.`);
}
// Do not proceed if the base class already has a decorator, or is not in scope of the
// entry-point that is currently being compiled.
if (hasDirectiveDecorator(host, baseClazz) || !host.isInScope(baseClazz)) {
break;
}
const baseClazz = host.reflectionHost.getDeclarationOfIdentifier(baseClassExpr) !.node;
if (baseClazz === null || !isClassDeclaration(baseClazz)) {
return null;
}
// Inject an `@Directive()` decorator for the base class.
host.injectSyntheticDecorator(baseClazz, createDirectiveDecorator(baseClazz));
// Only interested in this base class if it doesn't have a `Directive` or `Component` decorator.
if (hasDirectiveDecorator(host, baseClazz)) {
return null;
}
// If the base class has a constructor, there's no need to continue walking up the
// inheritance chain. The injected decorator ensures that a factory is generated that does
// not delegate to the base class.
if (hasConstructor(host, baseClazz)) {
break;
}
const importInfo = host.reflectionHost.getImportOfIdentifier(baseClassExpr);
if (importInfo !== null && !isRelativePath(importInfo.from)) {
return makeDiagnostic(
ErrorCode.NGCC_MIGRATION_EXTERNAL_BASE_CLASS, baseClassExpr,
'The base class was imported from an external entry-point so we cannot add a directive to it.');
// Continue with another level of class inheritance.
baseClazzRef = determineBaseClass(baseClazz, host);
}
host.injectSyntheticDecorator(baseClazz, createDirectiveDecorator(baseClazz));
return null;
}
}
/**
* Computes a reference to the base class, or `null` if the class has no base class or if it could
* not be statically determined.
*/
function determineBaseClass(
clazz: ClassDeclaration, host: MigrationHost): Reference<ClassDeclaration>|null {
const baseClassExpr = host.reflectionHost.getBaseClassExpression(clazz);
if (baseClassExpr === null) {
return null;
}
const baseClass = host.evaluator.evaluate(baseClassExpr);
if (!(baseClass instanceof Reference) || !isClassDeclaration(baseClass.node as ts.Declaration)) {
return null;
}
return baseClass as Reference<ClassDeclaration>;
}

View File

@ -19,7 +19,8 @@ export function isClassDeclaration(clazz: ts.Declaration): clazz is ClassDeclara
* Returns true if the `clazz` is decorated as a `Directive` or `Component`.
*/
export function hasDirectiveDecorator(host: MigrationHost, clazz: ClassDeclaration): boolean {
return host.metadata.getDirectiveMetadata(new Reference(clazz)) !== null;
const ref = new Reference(clazz);
return host.metadata.getDirectiveMetadata(ref) !== null;
}
/**
@ -33,18 +34,13 @@ export function hasConstructor(host: MigrationHost, clazz: ClassDeclaration): bo
* Create an empty `Directive` decorator that will be associated with the `clazz`.
*/
export function createDirectiveDecorator(clazz: ClassDeclaration): Decorator {
const selectorArg = ts.createObjectLiteral([
// TODO: At the moment ngtsc does not accept a directive with no selector
ts.createPropertyAssignment('selector', ts.createStringLiteral('NGCC_DUMMY')),
]);
return {
name: 'Directive',
identifier: null,
import: {name: 'Directive', from: '@angular/core'},
node: null,
synthesizedFor: clazz.name,
args: [reifySourceFile(selectorArg)],
args: [],
};
}

View File

@ -26,19 +26,20 @@ runInEachFileSystem(() => {
});
it('should ignore undecorated classes', () => {
const {program, analysis} = setUpAndAnalyzeProgram([{
const {program, analysis, errors} = setUpAndAnalyzeProgram([{
name: INDEX_FILENAME,
contents: `
export class DerivedClass extends BaseClass {}
export class BaseClass {}
`
}]);
expect(errors).toEqual([]);
const file = analysis.get(program.getSourceFile(INDEX_FILENAME) !);
expect(file).toBeUndefined();
});
it('should ignore an undecorated base class if the derived class has a constructor', () => {
const {program, analysis} = setUpAndAnalyzeProgram([{
const {program, analysis, errors} = setUpAndAnalyzeProgram([{
name: INDEX_FILENAME,
contents: `
import {Directive, ViewContainerRef} from '@angular/core';
@ -54,6 +55,7 @@ runInEachFileSystem(() => {
export class BaseClass {}
`
}]);
expect(errors).toEqual([]);
const file = analysis.get(program.getSourceFile(INDEX_FILENAME) !) !;
expect(file.compiledClasses.find(c => c.name === 'DerivedClass')).toBeDefined();
expect(file.compiledClasses.find(c => c.name === 'BaseClass')).toBeUndefined();
@ -61,7 +63,7 @@ runInEachFileSystem(() => {
it('should add a decorator to an undecorated base class if the derived class is a Directive with no constructor',
() => {
const {program, analysis} = setUpAndAnalyzeProgram([{
const {program, analysis, errors} = setUpAndAnalyzeProgram([{
name: INDEX_FILENAME,
contents: `
import {Directive, ViewContainerRef} from '@angular/core';
@ -78,20 +80,96 @@ runInEachFileSystem(() => {
];
`
}]);
expect(errors).toEqual([]);
const file = analysis.get(program.getSourceFile(INDEX_FILENAME) !) !;
expect(file.compiledClasses.find(c => c.name === 'DerivedClass')).toBeDefined();
const baseClass = file.compiledClasses.find(c => c.name === 'BaseClass') !;
expect(baseClass.decorators !.length).toEqual(1);
const decorator = baseClass.decorators ![0];
expect(decorator.name).toEqual('Directive');
expect(decorator.identifier).toBeNull('The decorator must be synthesized');
expect(decorator.import).toEqual({from: '@angular/core', name: 'Directive'});
expect(decorator.args !.length).toEqual(1);
expect(decorator.args !.length).toEqual(0);
});
it('should not add a decorator to a base class that is already decorated', () => {
const {program, analysis, errors} = setUpAndAnalyzeProgram([{
name: INDEX_FILENAME,
contents: `
import {Directive, ViewContainerRef} from '@angular/core';
export class DerivedClass extends BaseClass {
}
DerivedClass.decorators = [
{ type: Directive, args: [{ selector: '[dir]' }] }
];
export class BaseClass {
constructor(private vcr: ViewContainerRef) {}
}
BaseClass.decorators = [
{ type: Directive, args: [] }
];
BaseClass.ctorParameters = () => [
{ type: ViewContainerRef, }
];
`
}]);
expect(errors).toEqual([]);
const file = analysis.get(program.getSourceFile(INDEX_FILENAME) !) !;
expect(file.compiledClasses.find(c => c.name === 'DerivedClass')).toBeDefined();
const baseClass = file.compiledClasses.find(c => c.name === 'BaseClass') !;
expect(baseClass.decorators !.length).toEqual(1);
const decorator = baseClass.decorators ![0];
expect(decorator.name).toEqual('Directive');
expect(decorator.identifier).not.toBeNull('The decorator must not be synthesized');
});
it('should add decorators to all classes in an inheritance chain until a constructor is found',
() => {
const {program, analysis, errors} = setUpAndAnalyzeProgram([{
name: INDEX_FILENAME,
contents: `
import {Directive, ViewContainerRef} from '@angular/core';
export class DerivedClass extends IntermediateClass {
}
DerivedClass.decorators = [
{ type: Directive, args: [{ selector: '[dir]' }] }
];
export class IntermediateClass extends BaseClass {}
export class BaseClass extends RealBaseClass {
constructor(private vcr: ViewContainerRef) {}
}
BaseClass.ctorParameters = () => [
{ type: ViewContainerRef, }
];
export class RealBaseClass {}
`
}]);
expect(errors).toEqual([]);
const file = analysis.get(program.getSourceFile(INDEX_FILENAME) !) !;
expect(file.compiledClasses.find(c => c.name === 'DerivedClass')).toBeDefined();
expect(file.compiledClasses.find(c => c.name === 'RealBaseClass')).toBeUndefined();
const intermediateClass = file.compiledClasses.find(c => c.name === 'IntermediateClass') !;
expect(intermediateClass.decorators !.length).toEqual(1);
const intermediateDecorator = intermediateClass.decorators ![0];
expect(intermediateDecorator.name).toEqual('Directive');
expect(intermediateDecorator.identifier).toBeNull('The decorator must be synthesized');
expect(intermediateDecorator.import).toEqual({from: '@angular/core', name: 'Directive'});
expect(intermediateDecorator.args !.length).toEqual(0);
const baseClass = file.compiledClasses.find(c => c.name === 'BaseClass') !;
expect(baseClass.decorators !.length).toEqual(1);
const baseDecorator = baseClass.decorators ![0];
expect(baseDecorator.name).toEqual('Directive');
expect(baseDecorator.identifier).toBeNull('The decorator must be synthesized');
expect(baseDecorator.import).toEqual({from: '@angular/core', name: 'Directive'});
expect(baseDecorator.args !.length).toEqual(0);
});
it('should handle the base class being in a different file (same package) as the derived class',
() => {
const BASE_FILENAME = _('/node_modules/test-package/base.js');
const {program, analysis} = setUpAndAnalyzeProgram([
const {program, analysis, errors} = setUpAndAnalyzeProgram([
{
name: INDEX_FILENAME,
contents: `
@ -116,18 +194,20 @@ runInEachFileSystem(() => {
`
}
]);
expect(errors).toEqual([]);
const file = analysis.get(program.getSourceFile(BASE_FILENAME) !) !;
const baseClass = file.compiledClasses.find(c => c.name === 'BaseClass') !;
expect(baseClass.decorators !.length).toEqual(1);
const decorator = baseClass.decorators ![0];
expect(decorator.name).toEqual('Directive');
expect(decorator.identifier).toBeNull('The decorator must be synthesized');
expect(decorator.import).toEqual({from: '@angular/core', name: 'Directive'});
expect(decorator.args !.length).toEqual(1);
expect(decorator.args !.length).toEqual(0);
});
it('should error if the base class being is a different package from the derived class', () => {
it('should skip the base class if it is in a different package from the derived class', () => {
const BASE_FILENAME = _('/node_modules/other-package/index.js');
const {errors} = setUpAndAnalyzeProgram([
const {program, analysis, errors} = setUpAndAnalyzeProgram([
{
name: INDEX_FILENAME,
contents: `
@ -152,7 +232,9 @@ runInEachFileSystem(() => {
`
}
]);
expect(errors.length).toEqual(1);
expect(errors).toEqual([]);
const file = analysis.get(program.getSourceFile(BASE_FILENAME) !);
expect(file).toBeUndefined();
});
});

View File

@ -75,17 +75,6 @@ export enum ErrorCode {
*/
NGCC_MIGRATION_DECORATOR_INJECTION_ERROR = 7001,
/**
* Raised when ngcc tries to decorate a base class that was imported from outside the package.
*/
NGCC_MIGRATION_EXTERNAL_BASE_CLASS = 7002,
/**
* Raised when ngcc tries to migrate a class that is extended from a dynamic base class
* expression.
*/
NGCC_MIGRATION_DYNAMIC_BASE_CLASS = 7003,
/**
* An element name failed validation against the DOM schema.
*/