Skip to content

Commit

Permalink
fix(ngcc): detect synthesized delegate constructors for downleveled E…
Browse files Browse the repository at this point in the history
…S2015 classes (angular#38463)

Similarly to the change we landed in the `@angular/core` reflection
capabilities, we need to make sure that ngcc can detect pass-through
delegate constructors for classes using downleveled ES2015 output.

More details can be found in the preceding commit, and in the issue
outlining the problem: angular#38453.

Fixes angular#38453.

PR Close angular#38463
  • Loading branch information
devversion authored and subratpalhar92 committed Aug 29, 2020
1 parent 698b824 commit 4e0ee5c
Show file tree
Hide file tree
Showing 5 changed files with 834 additions and 162 deletions.
341 changes: 217 additions & 124 deletions packages/compiler-cli/ngcc/src/host/esm5_host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import * as ts from 'typescript';

import {ClassDeclaration, ClassMember, ClassMemberKind, Declaration, Decorator, FunctionDefinition, Parameter, reflectObjectLiteral} from '../../../src/ngtsc/reflection';
import {ClassDeclaration, ClassMember, ClassMemberKind, Declaration, Decorator, FunctionDefinition, KnownDeclaration, Parameter, reflectObjectLiteral} from '../../../src/ngtsc/reflection';
import {getTsHelperFnFromDeclaration, getTsHelperFnFromIdentifier, hasNameIdentifier} from '../utils';

import {Esm2015ReflectionHost, getClassDeclarationFromInnerDeclaration, getPropertyValueFromSymbol, isAssignmentStatement, ParamInfo} from './esm2015_host';
Expand Down Expand Up @@ -219,7 +219,7 @@ export class Esm5ReflectionHost extends Esm2015ReflectionHost {
return Array.from(constructor.parameters);
}

if (isSynthesizedConstructor(constructor)) {
if (this.isSynthesizedConstructor(constructor)) {
return null;
}

Expand Down Expand Up @@ -352,6 +352,219 @@ export class Esm5ReflectionHost extends Esm2015ReflectionHost {
const classDeclarationParent = classSymbol.implementation.valueDeclaration.parent;
return ts.isBlock(classDeclarationParent) ? Array.from(classDeclarationParent.statements) : [];
}

///////////// Host Private Helpers /////////////

/**
* A constructor function may have been "synthesized" by TypeScript during JavaScript emit,
* in the case no user-defined constructor exists and e.g. property initializers are used.
* Those initializers need to be emitted into a constructor in JavaScript, so the TypeScript
* compiler generates a synthetic constructor.
*
* We need to identify such constructors as ngcc needs to be able to tell if a class did
* originally have a constructor in the TypeScript source. For ES5, we can not tell an
* empty constructor apart from a synthesized constructor, but fortunately that does not
* matter for the code generated by ngtsc.
*
* When a class has a superclass however, a synthesized constructor must not be considered
* as a user-defined constructor as that prevents a base factory call from being created by
* ngtsc, resulting in a factory function that does not inject the dependencies of the
* superclass. Hence, we identify a default synthesized super call in the constructor body,
* according to the structure that TypeScript's ES2015 to ES5 transformer generates in
* https://github.com/Microsoft/TypeScript/blob/v3.2.2/src/compiler/transformers/es2015.ts#L1082-L1098
*
* Additionally, we handle synthetic delegate constructors that are emitted when TypeScript
* downlevel's ES2015 synthetically generated to ES5. These vary slightly from the default
* structure mentioned above because the ES2015 output uses a spread operator, for delegating
* to the parent constructor, that is preserved through a TypeScript helper in ES5. e.g.
*
* ```
* return _super.apply(this, tslib.__spread(arguments)) || this;
* ```
*
* Such constructs can be still considered as synthetic delegate constructors as they are
* the product of a common TypeScript to ES5 synthetic constructor, just being downleveled
* to ES5 using `tsc`. See: https://github.com/angular/angular/issues/38453.
*
*
* @param constructor a constructor function to test
* @returns true if the constructor appears to have been synthesized
*/
private isSynthesizedConstructor(constructor: ts.FunctionDeclaration): boolean {
if (!constructor.body) return false;

const firstStatement = constructor.body.statements[0];
if (!firstStatement) return false;

return this.isSynthesizedSuperThisAssignment(firstStatement) ||
this.isSynthesizedSuperReturnStatement(firstStatement);
}

/**
* Identifies synthesized super calls which pass-through function arguments directly and are
* being assigned to a common `_this` variable. The following patterns we intend to match:
*
* 1. Delegate call emitted by TypeScript when it emits ES5 directly.
* ```
* var _this = _super !== null && _super.apply(this, arguments) || this;
* ```
*
* 2. Delegate call emitted by TypeScript when it downlevel's ES2015 to ES5.
* ```
* var _this = _super.apply(this, tslib.__spread(arguments)) || this;
* ```
*
*
* @param statement a statement that may be a synthesized super call
* @returns true if the statement looks like a synthesized super call
*/
private isSynthesizedSuperThisAssignment(statement: ts.Statement): boolean {
if (!ts.isVariableStatement(statement)) return false;

const variableDeclarations = statement.declarationList.declarations;
if (variableDeclarations.length !== 1) return false;

const variableDeclaration = variableDeclarations[0];
if (!ts.isIdentifier(variableDeclaration.name) ||
!variableDeclaration.name.text.startsWith('_this'))
return false;

const initializer = variableDeclaration.initializer;
if (!initializer) return false;

return this.isSynthesizedDefaultSuperCall(initializer);
}
/**
* Identifies synthesized super calls which pass-through function arguments directly and
* are being returned. The following patterns correspond to synthetic super return calls:
*
* 1. Delegate call emitted by TypeScript when it emits ES5 directly.
* ```
* return _super !== null && _super.apply(this, arguments) || this;
* ```
*
* 2. Delegate call emitted by TypeScript when it downlevel's ES2015 to ES5.
* ```
* return _super.apply(this, tslib.__spread(arguments)) || this;
* ```
*
* @param statement a statement that may be a synthesized super call
* @returns true if the statement looks like a synthesized super call
*/
private isSynthesizedSuperReturnStatement(statement: ts.Statement): boolean {
if (!ts.isReturnStatement(statement)) return false;

const expression = statement.expression;
if (!expression) return false;

return this.isSynthesizedDefaultSuperCall(expression);
}

/**
* Identifies synthesized super calls which pass-through function arguments directly. The
* synthetic delegate super call match the following patterns we intend to match:
*
* 1. Delegate call emitted by TypeScript when it emits ES5 directly.
* ```
* _super !== null && _super.apply(this, arguments) || this;
* ```
*
* 2. Delegate call emitted by TypeScript when it downlevel's ES2015 to ES5.
* ```
* _super.apply(this, tslib.__spread(arguments)) || this;
* ```
*
* @param expression an expression that may represent a default super call
* @returns true if the expression corresponds with the above form
*/
private isSynthesizedDefaultSuperCall(expression: ts.Expression): boolean {
if (!isBinaryExpr(expression, ts.SyntaxKind.BarBarToken)) return false;
if (expression.right.kind !== ts.SyntaxKind.ThisKeyword) return false;

const left = expression.left;
if (isBinaryExpr(left, ts.SyntaxKind.AmpersandAmpersandToken)) {
return isSuperNotNull(left.left) && this.isSuperApplyCall(left.right);
} else {
return this.isSuperApplyCall(left);
}
}

/**
* Tests whether the expression corresponds to a `super` call passing through
* function arguments without any modification. e.g.
*
* ```
* _super !== null && _super.apply(this, arguments) || this;
* ```
*
* This structure is generated by TypeScript when transforming ES2015 to ES5, see
* https://github.com/Microsoft/TypeScript/blob/v3.2.2/src/compiler/transformers/es2015.ts#L1148-L1163
*
* Additionally, we also handle cases where `arguments` are wrapped by a TypeScript spread helper.
* This can happen if ES2015 class output contain auto-generated constructors due to class
* members. The ES2015 output will be using `super(...arguments)` to delegate to the superclass,
* but once downleveled to ES5, the spread operator will be persisted through a TypeScript spread
* helper. For example:
*
* ```
* _super.apply(this, __spread(arguments)) || this;
* ```
*
* More details can be found in: https://github.com/angular/angular/issues/38453.
*
* @param expression an expression that may represent a default super call
* @returns true if the expression corresponds with the above form
*/
private isSuperApplyCall(expression: ts.Expression): boolean {
if (!ts.isCallExpression(expression) || expression.arguments.length !== 2) return false;

const targetFn = expression.expression;
if (!ts.isPropertyAccessExpression(targetFn)) return false;
if (!isSuperIdentifier(targetFn.expression)) return false;
if (targetFn.name.text !== 'apply') return false;

const thisArgument = expression.arguments[0];
if (thisArgument.kind !== ts.SyntaxKind.ThisKeyword) return false;

const argumentsExpr = expression.arguments[1];

// If the super is directly invoked with `arguments`, return `true`. This represents the
// common TypeScript output where the delegate constructor super call matches the following
// pattern: `super.apply(this, arguments)`.
if (isArgumentsIdentifier(argumentsExpr)) {
return true;
}

// The other scenario we intend to detect: The `arguments` variable might be wrapped with the
// TypeScript spread helper (either through tslib or inlined). This can happen if an explicit
// delegate constructor uses `super(...arguments)` in ES2015 and is downleveled to ES5 using
// `--downlevelIteration`. The output in such cases would not directly pass the function
// `arguments` to the `super` call, but wrap it in a TS spread helper. The output would match
// the following pattern: `super.apply(this, tslib.__spread(arguments))`. We check for such
// constructs below, but perform the detection of the call expression definition as last as
// that is the most expensive operation here.
if (!ts.isCallExpression(argumentsExpr) || argumentsExpr.arguments.length !== 1 ||
!isArgumentsIdentifier(argumentsExpr.arguments[0])) {
return false;
}

const argumentsCallExpr = argumentsExpr.expression;
let argumentsCallDeclaration: Declaration|null = null;

// The `__spread` helper could be globally available, or accessed through a namespaced
// import. Hence we support a property access here as long as it resolves to the actual
// known TypeScript spread helper.
if (ts.isIdentifier(argumentsCallExpr)) {
argumentsCallDeclaration = this.getDeclarationOfIdentifier(argumentsCallExpr);
} else if (
ts.isPropertyAccessExpression(argumentsCallExpr) &&
ts.isIdentifier(argumentsCallExpr.name)) {
argumentsCallDeclaration = this.getDeclarationOfIdentifier(argumentsCallExpr.name);
}

return argumentsCallDeclaration !== null &&
argumentsCallDeclaration.known === KnownDeclaration.TsHelperSpread;
}
}

///////////// Internal Helpers /////////////
Expand Down Expand Up @@ -422,135 +635,15 @@ function reflectArrayElement(element: ts.Expression) {
return ts.isObjectLiteralExpression(element) ? reflectObjectLiteral(element) : null;
}

/**
* A constructor function may have been "synthesized" by TypeScript during JavaScript emit,
* in the case no user-defined constructor exists and e.g. property initializers are used.
* Those initializers need to be emitted into a constructor in JavaScript, so the TypeScript
* compiler generates a synthetic constructor.
*
* We need to identify such constructors as ngcc needs to be able to tell if a class did
* originally have a constructor in the TypeScript source. For ES5, we can not tell an
* empty constructor apart from a synthesized constructor, but fortunately that does not
* matter for the code generated by ngtsc.
*
* When a class has a superclass however, a synthesized constructor must not be considered
* as a user-defined constructor as that prevents a base factory call from being created by
* ngtsc, resulting in a factory function that does not inject the dependencies of the
* superclass. Hence, we identify a default synthesized super call in the constructor body,
* according to the structure that TypeScript's ES2015 to ES5 transformer generates in
* https://github.com/Microsoft/TypeScript/blob/v3.2.2/src/compiler/transformers/es2015.ts#L1082-L1098
*
* @param constructor a constructor function to test
* @returns true if the constructor appears to have been synthesized
*/
function isSynthesizedConstructor(constructor: ts.FunctionDeclaration): boolean {
if (!constructor.body) return false;

const firstStatement = constructor.body.statements[0];
if (!firstStatement) return false;

return isSynthesizedSuperThisAssignment(firstStatement) ||
isSynthesizedSuperReturnStatement(firstStatement);
}

/**
* Identifies a synthesized super call of the form:
*
* ```
* var _this = _super !== null && _super.apply(this, arguments) || this;
* ```
*
* @param statement a statement that may be a synthesized super call
* @returns true if the statement looks like a synthesized super call
*/
function isSynthesizedSuperThisAssignment(statement: ts.Statement): boolean {
if (!ts.isVariableStatement(statement)) return false;

const variableDeclarations = statement.declarationList.declarations;
if (variableDeclarations.length !== 1) return false;

const variableDeclaration = variableDeclarations[0];
if (!ts.isIdentifier(variableDeclaration.name) ||
!variableDeclaration.name.text.startsWith('_this'))
return false;

const initializer = variableDeclaration.initializer;
if (!initializer) return false;

return isSynthesizedDefaultSuperCall(initializer);
}
/**
* Identifies a synthesized super call of the form:
*
* ```
* return _super !== null && _super.apply(this, arguments) || this;
* ```
*
* @param statement a statement that may be a synthesized super call
* @returns true if the statement looks like a synthesized super call
*/
function isSynthesizedSuperReturnStatement(statement: ts.Statement): boolean {
if (!ts.isReturnStatement(statement)) return false;

const expression = statement.expression;
if (!expression) return false;

return isSynthesizedDefaultSuperCall(expression);
}

/**
* Tests whether the expression is of the form:
*
* ```
* _super !== null && _super.apply(this, arguments) || this;
* ```
*
* This structure is generated by TypeScript when transforming ES2015 to ES5, see
* https://github.com/Microsoft/TypeScript/blob/v3.2.2/src/compiler/transformers/es2015.ts#L1148-L1163
*
* @param expression an expression that may represent a default super call
* @returns true if the expression corresponds with the above form
*/
function isSynthesizedDefaultSuperCall(expression: ts.Expression): boolean {
if (!isBinaryExpr(expression, ts.SyntaxKind.BarBarToken)) return false;
if (expression.right.kind !== ts.SyntaxKind.ThisKeyword) return false;

const left = expression.left;
if (!isBinaryExpr(left, ts.SyntaxKind.AmpersandAmpersandToken)) return false;

return isSuperNotNull(left.left) && isSuperApplyCall(left.right);
function isArgumentsIdentifier(expression: ts.Expression): boolean {
return ts.isIdentifier(expression) && expression.text === 'arguments';
}

function isSuperNotNull(expression: ts.Expression): boolean {
return isBinaryExpr(expression, ts.SyntaxKind.ExclamationEqualsEqualsToken) &&
isSuperIdentifier(expression.left);
}

/**
* Tests whether the expression is of the form
*
* ```
* _super.apply(this, arguments)
* ```
*
* @param expression an expression that may represent a default super call
* @returns true if the expression corresponds with the above form
*/
function isSuperApplyCall(expression: ts.Expression): boolean {
if (!ts.isCallExpression(expression) || expression.arguments.length !== 2) return false;

const targetFn = expression.expression;
if (!ts.isPropertyAccessExpression(targetFn)) return false;
if (!isSuperIdentifier(targetFn.expression)) return false;
if (targetFn.name.text !== 'apply') return false;

const thisArgument = expression.arguments[0];
if (thisArgument.kind !== ts.SyntaxKind.ThisKeyword) return false;

const argumentsArgument = expression.arguments[1];
return ts.isIdentifier(argumentsArgument) && argumentsArgument.text === 'arguments';
}

function isBinaryExpr(
expression: ts.Expression, operator: ts.BinaryOperator): expression is ts.BinaryExpression {
return ts.isBinaryExpression(expression) && expression.operatorToken.kind === operator;
Expand Down
Loading

0 comments on commit 4e0ee5c

Please sign in to comment.