Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions packages/compiler-cli/ngcc/src/host/esm5_host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,41 @@ export class Esm5ReflectionHost extends Esm2015ReflectionHost {
const classDeclarationParent = classSymbol.valueDeclaration.parent;
return ts.isBlock(classDeclarationParent) ? Array.from(classDeclarationParent.statements) : [];
}

/**
* Try to retrieve the symbol of a static property on a class.
*
* In ES5, a static property can either be set on the inner function declaration inside the class'
* IIFE, or it can be set on the outer variable declaration. Therefore, the ES5 host checks both
* places, first looking up the property on the inner symbol, and if the property is not found it
* will fall back to looking up the property on the outer symbol.
*
* @param symbol the class whose property we are interested in.
* @param propertyName the name of static property.
* @returns the symbol if it is found or `undefined` if not.
*/
protected getStaticProperty(symbol: ClassSymbol, propertyName: ts.__String): ts.Symbol|undefined {
// The symbol corresponds with the inner function declaration. First lets see if the static
// property is set there.
const prop = super.getStaticProperty(symbol, propertyName);
if (prop !== undefined) {
return prop;
}

// Otherwise, obtain the outer variable declaration and resolve its symbol, in order to lookup
// static properties there.
const outerClass = getClassDeclarationFromInnerFunctionDeclaration(symbol.valueDeclaration);
if (outerClass === undefined) {
return undefined;
}

const outerSymbol = this.checker.getSymbolAtLocation(outerClass.name);
if (outerSymbol === undefined || outerSymbol.valueDeclaration === undefined) {
return undefined;
}

return super.getStaticProperty(outerSymbol as ClassSymbol, propertyName);
}
}

///////////// Internal Helpers /////////////
Expand Down
5 changes: 5 additions & 0 deletions packages/compiler-cli/ngcc/src/packages/bundle_program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import * as ts from 'typescript';

import {AbsoluteFsPath} from '../../../src/ngtsc/path';
import {FileSystem} from '../file_system/file_system';
import {patchTsGetExpandoInitializer, restoreGetExpandoInitializer} from './patch_ts_expando_initializer';

/**
* An entry point bundle contains one or two programs, e.g. `src` and `dts`,
Expand Down Expand Up @@ -37,7 +38,11 @@ export function makeBundleProgram(
const r3SymbolsPath =
isCore ? findR3SymbolsPath(fs, AbsoluteFsPath.dirname(path), r3FileName) : null;
const rootPaths = r3SymbolsPath ? [path, r3SymbolsPath] : [path];

const originalGetExpandoInitializer = patchTsGetExpandoInitializer();
const program = ts.createProgram(rootPaths, options, host);
restoreGetExpandoInitializer(originalGetExpandoInitializer);

const file = program.getSourceFile(path) !;
const r3SymbolsFile = r3SymbolsPath && program.getSourceFile(r3SymbolsPath) || null;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/**
* @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 * as ts from 'typescript';
import {hasNameIdentifier} from '../utils';

/**
* Consider the following ES5 code that may have been generated for a class:
*
* ```
* var A = (function(){
* function A() {}
* return A;
* }());
* A.staticProp = true;
* ```
*
* Here, TypeScript marks the symbol for "A" as a so-called "expando symbol", which causes
* "staticProp" to be added as an export of the "A" symbol.
*
* In the example above, symbol "A" has been assigned some flags to indicate that it represents a
* class. Due to this flag, the symbol is considered an expando symbol and as such, "staticProp" is
* stored in `ts.Symbol.exports`.
*
* A problem arises when "A" is not at the top-level, i.e. in UMD bundles. In that case, the symbol
* does not have the flag that marks the symbol as a class. Therefore, TypeScript inspects "A"'s
* initializer expression, which is an IIFE in the above example. Unfortunately however, only IIFEs
* of the form `(function(){})()` qualify as initializer for an "expando symbol"; the slightly
* different form seen in the example above, `(function(){}())`, does not. This prevents the "A"
* symbol from being considered an expando symbol, in turn preventing "staticProp" from being stored
* in `ts.Symbol.exports`.
*
* The logic for identifying symbols as "expando symbols" can be found here:
* https://github.com/microsoft/TypeScript/blob/v3.4.5/src/compiler/binder.ts#L2656-L2685
*
* Notice how the `getExpandoInitializer` function is available on the "ts" namespace in the
* compiled bundle, so we are able to override this function to accommodate for the alternative
* IIFE notation. The original implementation can be found at:
* https://github.com/Microsoft/TypeScript/blob/v3.4.5/src/compiler/utilities.ts#L1864-L1887
*
* Issue tracked in https://github.com/microsoft/TypeScript/issues/31778
*
* @returns the function to pass to `restoreGetExpandoInitializer` to undo the patch, or null if
* the issue is known to have been fixed.
*/
export function patchTsGetExpandoInitializer(): unknown {
if (isTs31778GetExpandoInitializerFixed()) {
return null;
}

const originalGetExpandoInitializer = (ts as any).getExpandoInitializer;
if (originalGetExpandoInitializer === undefined) {
throw makeUnsupportedTypeScriptError();
}

// Override the function to add support for recognizing the IIFE structure used in ES5 bundles.
(ts as any).getExpandoInitializer =

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a comment explaining what this function is.

Also, let's have a graceful failure mode if the function doesn't exist. I think throwing an error explaining that the TS version is incompatible with ngcc is a good approach. It's not likely but TS is free to remove this API whenever they please, and ngcc shouldn't silently stop working or crash without a clear message if they do.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you feel the docblock is not sufficient here?

Regarding the verification, that's a good idea. I will take it one step further and actually use the verification function above to assert that applying the patch has the desired effect.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docblock is fine 👍 that sounds good to me!

(initializer: ts.Node, isPrototypeAssignment: boolean): ts.Expression | undefined => {
// If the initializer is a call expression within parenthesis, unwrap the parenthesis
// upfront such that unsupported IIFE syntax `(function(){}())` becomes `function(){}()`,
// which is supported.
if (ts.isParenthesizedExpression(initializer) &&
ts.isCallExpression(initializer.expression)) {
initializer = initializer.expression;
}
return originalGetExpandoInitializer(initializer, isPrototypeAssignment);
};
return originalGetExpandoInitializer;
}

export function restoreGetExpandoInitializer(originalGetExpandoInitializer: unknown): void {
if (originalGetExpandoInitializer !== null) {
(ts as any).getExpandoInitializer = originalGetExpandoInitializer;
}
}

let ts31778FixedResult: boolean|null = null;

function isTs31778GetExpandoInitializerFixed(): boolean {
// If the result has already been computed, return early.
if (ts31778FixedResult !== null) {
return ts31778FixedResult;
}

// Determine if the issue has been fixed by checking if an expando property is present in a
// minimum reproduction using unpatched TypeScript.
ts31778FixedResult = checkIfExpandoPropertyIsPresent();

// If the issue does not appear to have been fixed, verify that applying the patch has the desired
// effect.
if (!ts31778FixedResult) {
const originalGetExpandoInitializer = patchTsGetExpandoInitializer();
try {
const patchIsSuccessful = checkIfExpandoPropertyIsPresent();
if (!patchIsSuccessful) {
throw makeUnsupportedTypeScriptError();
}
} finally {
restoreGetExpandoInitializer(originalGetExpandoInitializer);
}
}

return ts31778FixedResult;
}

/**
* Verifies whether TS issue 31778 has been fixed by inspecting a symbol from a minimum
* reproduction. If the symbol does in fact have the "expando" as export, the issue has been fixed.
*
* See https://github.com/microsoft/TypeScript/issues/31778 for details.
*/
function checkIfExpandoPropertyIsPresent(): boolean {
const sourceText = `
(function() {
var A = (function() {
function A() {}
return A;
}());
A.expando = true;
}());`;
const sourceFile =
ts.createSourceFile('test.js', sourceText, ts.ScriptTarget.ES5, true, ts.ScriptKind.JS);
const host: ts.CompilerHost = {
getSourceFile(): ts.SourceFile | undefined{return sourceFile;},
fileExists(): boolean{return true;},
readFile(): string | undefined{return '';},
writeFile() {},
getDefaultLibFileName(): string{return '';},
getCurrentDirectory(): string{return '';},
getDirectories(): string[]{return [];},
getCanonicalFileName(fileName: string): string{return fileName;},
useCaseSensitiveFileNames(): boolean{return true;},
getNewLine(): string{return '\n';},
};
const options = {noResolve: true, noLib: true, noEmit: true, allowJs: true};
const program = ts.createProgram(['test.js'], options, host);

function visitor(node: ts.Node): ts.VariableDeclaration|undefined {
if (ts.isVariableDeclaration(node) && hasNameIdentifier(node) && node.name.text === 'A') {
return node;
}
return ts.forEachChild(node, visitor);
}

const declaration = ts.forEachChild(sourceFile, visitor);
if (declaration === undefined) {
throw new Error('Unable to find declaration of outer A');
}

const symbol = program.getTypeChecker().getSymbolAtLocation(declaration.name);
if (symbol === undefined) {
throw new Error('Unable to resolve symbol of outer A');
}
return symbol.exports !== undefined && symbol.exports.has('expando' as ts.__String);
}

function makeUnsupportedTypeScriptError(): Error {
return new Error('The TypeScript version used is not supported by ngcc.');
}
7 changes: 6 additions & 1 deletion packages/compiler-cli/ngcc/test/helpers/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {makeProgram} from '../../../src/ngtsc/testing/in_memory_typescript';
import {BundleProgram} from '../../src/packages/bundle_program';
import {EntryPointFormat, EntryPointJsonProperty} from '../../src/packages/entry_point';
import {EntryPointBundle} from '../../src/packages/entry_point_bundle';
import {patchTsGetExpandoInitializer, restoreGetExpandoInitializer} from '../../src/packages/patch_ts_expando_initializer';
import {Folder} from './mock_file_system';

export {getDeclaration} from '../../../src/ngtsc/testing/in_memory_typescript';
Expand Down Expand Up @@ -53,7 +54,11 @@ function makeTestProgramInternal(
host: ts.CompilerHost,
options: ts.CompilerOptions,
} {
return makeProgram([getFakeCore(), getFakeTslib(), ...files], {allowJs: true, checkJs: false});
const originalTsGetExpandoInitializer = patchTsGetExpandoInitializer();
const program =
makeProgram([getFakeCore(), getFakeTslib(), ...files], {allowJs: true, checkJs: false});
restoreGetExpandoInitializer(originalTsGetExpandoInitializer);
return program;
}

export function makeTestProgram(
Expand Down
83 changes: 83 additions & 0 deletions packages/compiler-cli/ngcc/test/host/commonjs_host_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,35 @@ exports.SomeDirective = SomeDirective;
`
};

const TOPLEVEL_DECORATORS_FILE = {
name: '/toplevel_decorators.cjs.js',
contents: `
var core = require('@angular/core');

var INJECTED_TOKEN = new InjectionToken('injected');
var ViewContainerRef = {};
var TemplateRef = {};

var SomeDirective = (function() {
function SomeDirective(_viewContainer, _template, injected) {}
return SomeDirective;
}());
SomeDirective.decorators = [
{ type: core.Directive, args: [{ selector: '[someDirective]' },] }
];
SomeDirective.ctorParameters = function() { return [
{ type: ViewContainerRef, },
{ type: TemplateRef, },
{ type: undefined, decorators: [{ type: core.Inject, args: [INJECTED_TOKEN,] },] },
]; };
SomeDirective.propDecorators = {
"input1": [{ type: core.Input },],
"input2": [{ type: core.Input },],
};
exports.SomeDirective = SomeDirective;
`
};

const SIMPLE_ES2015_CLASS_FILE = {
name: '/simple_es2015_class.d.ts',
contents: `
Expand Down Expand Up @@ -757,6 +786,24 @@ describe('CommonJsReflectionHost', () => {
]);
});

it('should find the decorators on a class at the top level', () => {
const {program, host: compilerHost} = makeTestBundleProgram([TOPLEVEL_DECORATORS_FILE]);
const host = new CommonJsReflectionHost(new MockLogger(), false, program, compilerHost);
const classNode = getDeclaration(
program, TOPLEVEL_DECORATORS_FILE.name, 'SomeDirective', isNamedVariableDeclaration);
const decorators = host.getDecoratorsOfDeclaration(classNode) !;

expect(decorators).toBeDefined();
expect(decorators.length).toEqual(1);

const decorator = decorators[0];
expect(decorator.name).toEqual('Directive');
expect(decorator.import).toEqual({name: 'Directive', from: '@angular/core'});
expect(decorator.args !.map(arg => arg.getText())).toEqual([
'{ selector: \'[someDirective]\' }',
]);
});

it('should return null if the symbol is not a class', () => {
const {program, host: compilerHost} = makeTestBundleProgram([FOO_FUNCTION_FILE]);
const host = new CommonJsReflectionHost(new MockLogger(), false, program, compilerHost);
Expand Down Expand Up @@ -895,6 +942,24 @@ describe('CommonJsReflectionHost', () => {
expect(input1.decorators !.map(d => d.name)).toEqual(['Input']);
});

it('should find decorated members on a class at the top level', () => {
const {program, host: compilerHost} = makeTestBundleProgram([TOPLEVEL_DECORATORS_FILE]);
const host = new CommonJsReflectionHost(new MockLogger(), false, program, compilerHost);
const classNode = getDeclaration(
program, TOPLEVEL_DECORATORS_FILE.name, 'SomeDirective', isNamedVariableDeclaration);
const members = host.getMembersOfClass(classNode);

const input1 = members.find(member => member.name === 'input1') !;
expect(input1.kind).toEqual(ClassMemberKind.Property);
expect(input1.isStatic).toEqual(false);
expect(input1.decorators !.map(d => d.name)).toEqual(['Input']);

const input2 = members.find(member => member.name === 'input2') !;
expect(input2.kind).toEqual(ClassMemberKind.Property);
expect(input2.isStatic).toEqual(false);
expect(input1.decorators !.map(d => d.name)).toEqual(['Input']);
});

it('should find non decorated properties on a class', () => {
const {program, host: compilerHost} = makeTestBundleProgram([SOME_DIRECTIVE_FILE]);
const host = new CommonJsReflectionHost(new MockLogger(), false, program, compilerHost);
Expand Down Expand Up @@ -1096,6 +1161,24 @@ describe('CommonJsReflectionHost', () => {
]);
});

it('should find the decorated constructor parameters at the top level', () => {
const {program, host: compilerHost} = makeTestBundleProgram([TOPLEVEL_DECORATORS_FILE]);
const host = new CommonJsReflectionHost(new MockLogger(), false, program, compilerHost);
const classNode = getDeclaration(
program, TOPLEVEL_DECORATORS_FILE.name, 'SomeDirective', isNamedVariableDeclaration);
const parameters = host.getConstructorParameters(classNode);

expect(parameters).toBeDefined();
expect(parameters !.map(parameter => parameter.name)).toEqual([
'_viewContainer', '_template', 'injected'
]);
expectTypeValueReferencesForParameters(parameters !, [
'ViewContainerRef',
'TemplateRef',
null,
]);
});

it('should throw if the symbol is not a class', () => {
const {program, host: compilerHost} = makeTestBundleProgram([FOO_FUNCTION_FILE]);
const host = new CommonJsReflectionHost(new MockLogger(), false, program, compilerHost);
Expand Down
Loading