From dabadb69018be8f4a91666cf81986344a1589c4d Mon Sep 17 00:00:00 2001 From: Paul Gschwendtner Date: Fri, 5 Jun 2020 16:26:23 +0200 Subject: [PATCH] fix(compiler-cli): downlevel angular decorators to static properties In v7 of Angular we removed `tsickle` from the default `ngc` pipeline. This had the negative potential of breaking ES2015 output and SSR due to a limitation in TypeScript. TypeScript by default preserves type information for decorated constructor parameters when `emitDecoratorMetadata` is enabled. For example, consider this snippet below: ``` @Directive() export class MyDirective { constructor(button: MyButton) {} } export class MyButton {} ``` TypeScript would generate metadata for the `MyDirective` class it has a decorator applied. This metadata would be needed in JIT mode, or for libraries that provide `MyDirective` through NPM. The metadata would look as followed: ``` let MyDirective = class MyDir {} MyDirective = __decorate([ Directive(), __metadata("design:paramtypes", [MyButton]), ], MyDirective); let MyButton = class MyButton {} ``` Notice that TypeScript generated calls to `__decorate` and `__metadata`. These calls are needed so that the Angular compiler is able to determine whether `MyDirective` is actually an directive, and what types are needed for dependency injection. The limitation surfaces in this concrete example because `MyButton` is declared after the `__metadata(..)` call, while `__metadata` actually directly references `MyButton`. This is illegal though because `MyButton` has not been declared at this point. This is due to the so-called temporal dead zone in JavaScript. Errors like followed will be reported at runtime when such file/code evaluates: ``` Uncaught ReferenceError: Cannot access 'MyButton' before initialization ``` As noted, this is a TypeScript limitation because ideally TypeScript shouldn't evaluate `__metadata`/reference `MyButton` immediately. Instead, it should defer the reference until `MyButton` is actually declared. This limitation will not be fixed by the TypeScript team though because it's a limitation as per current design and they will only revisit this once the tc39 decorator proposal is finalized (currently stage-2 at time of writing). Given this wontfix on the TypeScript side, and our heavy reliance on this metadata in libraries (and for JIT mode), we intend to fix this from within the Angular compiler by downleveling decorators to static properties that don't need to evaluate directly. For example: ``` MyDirective.ctorParameters = () => [MyButton]; ``` With this snippet above, `MyButton` is not referenced directly. Only lazily when the Angular runtime needs it. This mitigates the temporal dead zone issue caused by a limitation in TypeScript's decorator metadata output. See: https://github.com/microsoft/TypeScript/issues/27519. In the past (as noted; before version 7), the Angular compiler by default used tsickle that already performed this transformation. We moved the transformation to the CLI for JIT and `ng-packager`, but now we realize that we can move this all to a single place in the compiler so that standalone ngc consumers can benefit too, and that we can disable tsickle in our Bazel `ngc-wrapped` pipeline (that currently still relies on tsickle to perform this decorator processing). This transformation also has another positive side-effect of making Angular application/library code more compatible with server-side rendering. In principle, TypeScript would also preserve type information for decorated class members (similar to how it did that for constructor parameters) at runtime. This becomes an issue when your application relies on native DOM globals for decorated class member types. e.g. ``` @Input() panelElement: HTMLElement; ``` Your application code would then reference `HTMLElement` directly whenever the source file is loaded in NodeJS for SSR. `HTMLElement` does not exist on the server though, so that will become an invalid reference. One could work around this by providing global mocks for these DOM symbols, but that doesn't match up with other places where dependency injection is used for mocking DOM/browser specific symbols. More context in this issue: #30586. The TL;DR here is that the Angular compiler does not care about types for these class members, so it won't ever reference `HTMLElement` at runtime. Fixes #30106. Fixes #30586. Fixes #30141. Resolves FW-2196. Resolves FW-2199. --- packages/bazel/src/ngc-wrapped/index.ts | 41 +- .../test/ng_package/example_package.golden | 23 +- packages/compiler-cli/BUILD.bazel | 1 + packages/compiler-cli/src/main.ts | 65 +- .../src/ngtsc/core/src/compiler.ts | 2 +- .../downlevel_decorators_transform.ts | 660 ++++++++++++++++++ .../compiler-cli/src/transformers/program.ts | 138 +--- .../src/transformers/r3_strip_decorators.ts | 167 ----- packages/compiler-cli/test/ngc_spec.ts | 176 +++-- .../test/transformers/BUILD.bazel | 1 + .../downlevel_decorators_transform_spec.ts | 555 +++++++++++++++ .../bundling/todo_i18n/OUTSTANDING_WORK.md | 4 +- 12 files changed, 1413 insertions(+), 420 deletions(-) create mode 100644 packages/compiler-cli/src/transformers/downlevel_decorators_transform.ts delete mode 100644 packages/compiler-cli/src/transformers/r3_strip_decorators.ts create mode 100644 packages/compiler-cli/test/transformers/downlevel_decorators_transform_spec.ts diff --git a/packages/bazel/src/ngc-wrapped/index.ts b/packages/bazel/src/ngc-wrapped/index.ts index eb9e9fa2f0ad49..ca8c9c76d10dee 100644 --- a/packages/bazel/src/ngc-wrapped/index.ts +++ b/packages/bazel/src/ngc-wrapped/index.ts @@ -191,23 +191,8 @@ export function compile({ fileLoader = new UncachedFileLoader(); } - compilerOpts.annotationsAs = 'static fields'; - if (!bazelOpts.es5Mode) { - if (bazelOpts.workspaceName === 'google3') { - compilerOpts.annotateForClosureCompiler = true; - } else { - compilerOpts.annotateForClosureCompiler = false; - } - } - // Detect from compilerOpts whether the entrypoint is being invoked in Ivy mode. const isInIvyMode = !!compilerOpts.enableIvy; - - // Disable downleveling and Closure annotation if in Ivy mode. - if (isInIvyMode) { - compilerOpts.annotationsAs = 'decorators'; - } - if (!compilerOpts.rootDirs) { throw new Error('rootDirs is not set!'); } @@ -264,9 +249,6 @@ export function compile({ } if (isInIvyMode) { - // Also need to disable decorator downleveling in the BazelHost in Ivy mode. - bazelHost.transformDecorators = false; - const delegate = bazelHost.shouldSkipTsickleProcessing.bind(bazelHost); bazelHost.shouldSkipTsickleProcessing = (fileName: string) => { // The base implementation of shouldSkipTsickleProcessing checks whether `fileName` is part of @@ -277,12 +259,29 @@ export function compile({ }; } + // Always disable tsickle decorator transforming in the tsickle compiler host. The + // Angular compilers have their own logic for decorator processing and we wouldn't + // want tsickle to interfere with that. + bazelHost.transformDecorators = false; + + // By default in the `prodmode` output, we do not add annotations for closure compiler. + // Though, if we are building inside `google3`, closure annotations are desired for + // prodmode output, so we enable it by default. The defaults can be overridden by + // setting the `annotateForClosureCompiler` compiler option in the user tsconfig. + if (!bazelOpts.es5Mode && compilerOpts.annotateForClosureCompiler === undefined) { + if (bazelOpts.workspaceName === 'google3') { + compilerOpts.annotateForClosureCompiler = true; + } else { + compilerOpts.annotateForClosureCompiler = false; + } + } + + // The `annotateForClosureCompiler` Angular compiler option is not respected by default + // as ngc-wrapped handles tsickle emit on its own. This means that we need to update + // the tsickle compiler host based on the `annotateForClosureCompiler` flag. if (compilerOpts.annotateForClosureCompiler) { bazelHost.transformTypesToClosure = true; } - if (compilerOpts.annotateForClosureCompiler || compilerOpts.annotationsAs === 'static fields') { - bazelHost.transformDecorators = true; - } const origBazelHostFileExist = bazelHost.fileExists; bazelHost.fileExists = (fileName: string) => { diff --git a/packages/bazel/test/ng_package/example_package.golden b/packages/bazel/test/ng_package/example_package.golden index ed9853d15619bc..f31be526345299 100644 --- a/packages/bazel/test/ng_package/example_package.golden +++ b/packages/bazel/test/ng_package/example_package.golden @@ -253,10 +253,10 @@ Hello var MySecondService = /** @class */ (function () { function MySecondService() { } + MySecondService.ɵprov = i0.ɵɵdefineInjectable({ factory: function MySecondService_Factory() { return new MySecondService(); }, token: MySecondService, providedIn: "root" }); MySecondService.decorators = [ { type: i0.Injectable, args: [{ providedIn: 'root' },] } ]; - MySecondService.ɵprov = i0.ɵɵdefineInjectable({ factory: function MySecondService_Factory() { return new MySecondService(); }, token: MySecondService, providedIn: "root" }); return MySecondService; }()); @@ -271,14 +271,13 @@ Hello function MyService(secondService) { this.secondService = secondService; } + MyService.ɵprov = i0.ɵɵdefineInjectable({ factory: function MyService_Factory() { return new MyService(i0.ɵɵinject(MySecondService)); }, token: MyService, providedIn: "root" }); MyService.decorators = [ { type: i0.Injectable, args: [{ providedIn: 'root' },] } ]; - /** @nocollapse */ MyService.ctorParameters = function () { return [ { type: MySecondService } ]; }; - MyService.ɵprov = i0.ɵɵdefineInjectable({ factory: function MyService_Factory() { return new MyService(i0.ɵɵinject(MySecondService)); }, token: MyService, providedIn: "root" }); return MyService; }()); @@ -317,7 +316,7 @@ Hello * * 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 - */var n=function(){function e(){}return e.decorators=[{type:t.Injectable,args:[{providedIn:"root"}]}],e.ɵprov=t.ɵɵdefineInjectable({factory:function t(){return new e},token:e,providedIn:"root"}),e}(),r=function(){function e(e){this.secondService=e}return e.decorators=[{type:t.Injectable,args:[{providedIn:"root"}]}],e.ctorParameters=function(){return[{type:n}]},e.ɵprov=t.ɵɵdefineInjectable({factory:function r(){return new e(t.ɵɵinject(n))},token:e,providedIn:"root"}),e}(); + */var n=function(){function e(){}return e.ɵprov=t.ɵɵdefineInjectable({factory:function t(){return new e},token:e,providedIn:"root"}),e.decorators=[{type:t.Injectable,args:[{providedIn:"root"}]}],e}(),r=function(){function e(e){this.secondService=e}return e.ɵprov=t.ɵɵdefineInjectable({factory:function r(){return new e(t.ɵɵinject(n))},token:e,providedIn:"root"}),e.decorators=[{type:t.Injectable,args:[{providedIn:"root"}]}],e.ctorParameters=function(){return[{type:n}]},e}(); /** * @license * Copyright Google LLC All Rights Reserved. @@ -602,18 +601,17 @@ let MyService = /** @class */ (() => { this.secondService = secondService; } } + MyService.ɵprov = i0.ɵɵdefineInjectable({ factory: function MyService_Factory() { return new MyService(i0.ɵɵinject(i1.MySecondService)); }, token: MyService, providedIn: "root" }); MyService.decorators = [ { type: Injectable, args: [{ providedIn: 'root' },] } ]; - /** @nocollapse */ MyService.ctorParameters = () => [ { type: MySecondService } ]; - MyService.ɵprov = i0.ɵɵdefineInjectable({ factory: function MyService_Factory() { return new MyService(i0.ɵɵinject(i1.MySecondService)); }, token: MyService, providedIn: "root" }); return MyService; })(); export { MyService }; -//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicHVibGljLWFwaS5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uLy4uLy4uLy4uLy4uLy4uLy4uLy4uL3BhY2thZ2VzL2JhemVsL3Rlc3QvbmdfcGFja2FnZS9leGFtcGxlL2ltcG9ydHMvcHVibGljLWFwaS50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQTs7Ozs7O0dBTUc7QUFFSCxPQUFPLEVBQUMsVUFBVSxFQUFDLE1BQU0sZUFBZSxDQUFDO0FBQ3pDLE9BQU8sRUFBQyxlQUFlLEVBQUMsTUFBTSxVQUFVLENBQUM7OztBQUV6QztJQUFBLE1BQ2EsU0FBUztRQUNwQixZQUFtQixhQUE4QjtZQUE5QixrQkFBYSxHQUFiLGFBQWEsQ0FBaUI7UUFBRyxDQUFDOzs7Z0JBRnRELFVBQVUsU0FBQyxFQUFDLFVBQVUsRUFBRSxNQUFNLEVBQUM7Ozs7Z0JBRnhCLGVBQWU7OztvQkFUdkI7S0FjQztTQUZZLFNBQVMiLCJzb3VyY2VzQ29udGVudCI6WyIvKipcbiAqIEBsaWNlbnNlXG4gKiBDb3B5cmlnaHQgR29vZ2xlIExMQyBBbGwgUmlnaHRzIFJlc2VydmVkLlxuICpcbiAqIFVzZSBvZiB0aGlzIHNvdXJjZSBjb2RlIGlzIGdvdmVybmVkIGJ5IGFuIE1JVC1zdHlsZSBsaWNlbnNlIHRoYXQgY2FuIGJlXG4gKiBmb3VuZCBpbiB0aGUgTElDRU5TRSBmaWxlIGF0IGh0dHBzOi8vYW5ndWxhci5pby9saWNlbnNlXG4gKi9cblxuaW1wb3J0IHtJbmplY3RhYmxlfSBmcm9tICdAYW5ndWxhci9jb3JlJztcbmltcG9ydCB7TXlTZWNvbmRTZXJ2aWNlfSBmcm9tICcuL3NlY29uZCc7XG5cbkBJbmplY3RhYmxlKHtwcm92aWRlZEluOiAncm9vdCd9KVxuZXhwb3J0IGNsYXNzIE15U2VydmljZSB7XG4gIGNvbnN0cnVjdG9yKHB1YmxpYyBzZWNvbmRTZXJ2aWNlOiBNeVNlY29uZFNlcnZpY2UpIHt9XG59XG4iXX0= +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicHVibGljLWFwaS5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uLy4uLy4uLy4uLy4uLy4uLy4uLy4uL3BhY2thZ2VzL2JhemVsL3Rlc3QvbmdfcGFja2FnZS9leGFtcGxlL2ltcG9ydHMvcHVibGljLWFwaS50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQTs7Ozs7O0dBTUc7QUFFSCxPQUFPLEVBQUMsVUFBVSxFQUFDLE1BQU0sZUFBZSxDQUFDO0FBQ3pDLE9BQU8sRUFBQyxlQUFlLEVBQUMsTUFBTSxVQUFVLENBQUM7OztBQUV6QztJQUFBLE1BQ2EsU0FBUztRQUNwQixZQUFtQixhQUE4QjtZQUE5QixrQkFBYSxHQUFiLGFBQWEsQ0FBaUI7UUFBRyxDQUFDOzs7O2dCQUZ0RCxVQUFVLFNBQUMsRUFBQyxVQUFVLEVBQUUsTUFBTSxFQUFDOzs7Z0JBRUksZUFBZTs7b0JBYm5EO0tBY0M7U0FGWSxTQUFTIiwic291cmNlc0NvbnRlbnQiOlsiLyoqXG4gKiBAbGljZW5zZVxuICogQ29weXJpZ2h0IEdvb2dsZSBMTEMgQWxsIFJpZ2h0cyBSZXNlcnZlZC5cbiAqXG4gKiBVc2Ugb2YgdGhpcyBzb3VyY2UgY29kZSBpcyBnb3Zlcm5lZCBieSBhbiBNSVQtc3R5bGUgbGljZW5zZSB0aGF0IGNhbiBiZVxuICogZm91bmQgaW4gdGhlIExJQ0VOU0UgZmlsZSBhdCBodHRwczovL2FuZ3VsYXIuaW8vbGljZW5zZVxuICovXG5cbmltcG9ydCB7SW5qZWN0YWJsZX0gZnJvbSAnQGFuZ3VsYXIvY29yZSc7XG5pbXBvcnQge015U2Vjb25kU2VydmljZX0gZnJvbSAnLi9zZWNvbmQnO1xuXG5ASW5qZWN0YWJsZSh7cHJvdmlkZWRJbjogJ3Jvb3QnfSlcbmV4cG9ydCBjbGFzcyBNeVNlcnZpY2Uge1xuICBjb25zdHJ1Y3RvcihwdWJsaWMgc2Vjb25kU2VydmljZTogTXlTZWNvbmRTZXJ2aWNlKSB7fVxufVxuIl19 --- esm2015/imports/second.js --- @@ -629,14 +627,14 @@ import * as i0 from "@angular/core"; let MySecondService = /** @class */ (() => { class MySecondService { } + MySecondService.ɵprov = i0.ɵɵdefineInjectable({ factory: function MySecondService_Factory() { return new MySecondService(); }, token: MySecondService, providedIn: "root" }); MySecondService.decorators = [ { type: Injectable, args: [{ providedIn: 'root' },] } ]; - MySecondService.ɵprov = i0.ɵɵdefineInjectable({ factory: function MySecondService_Factory() { return new MySecondService(); }, token: MySecondService, providedIn: "root" }); return MySecondService; })(); export { MySecondService }; -//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoic2Vjb25kLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vLi4vLi4vLi4vLi4vLi4vLi4vLi4vLi4vcGFja2FnZXMvYmF6ZWwvdGVzdC9uZ19wYWNrYWdlL2V4YW1wbGUvaW1wb3J0cy9zZWNvbmQudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUE7Ozs7OztHQU1HO0FBRUgsT0FBTyxFQUFDLFVBQVUsRUFBQyxNQUFNLGVBQWUsQ0FBQzs7QUFFekM7SUFBQSxNQUNhLGVBQWU7OztnQkFEM0IsVUFBVSxTQUFDLEVBQUMsVUFBVSxFQUFFLE1BQU0sRUFBQzs7OzBCQVZoQztLQVlDO1NBRFksZUFBZSIsInNvdXJjZXNDb250ZW50IjpbIi8qKlxuICogQGxpY2Vuc2VcbiAqIENvcHlyaWdodCBHb29nbGUgTExDIEFsbCBSaWdodHMgUmVzZXJ2ZWQuXG4gKlxuICogVXNlIG9mIHRoaXMgc291cmNlIGNvZGUgaXMgZ292ZXJuZWQgYnkgYW4gTUlULXN0eWxlIGxpY2Vuc2UgdGhhdCBjYW4gYmVcbiAqIGZvdW5kIGluIHRoZSBMSUNFTlNFIGZpbGUgYXQgaHR0cHM6Ly9hbmd1bGFyLmlvL2xpY2Vuc2VcbiAqL1xuXG5pbXBvcnQge0luamVjdGFibGV9IGZyb20gJ0Bhbmd1bGFyL2NvcmUnO1xuXG5ASW5qZWN0YWJsZSh7cHJvdmlkZWRJbjogJ3Jvb3QnfSlcbmV4cG9ydCBjbGFzcyBNeVNlY29uZFNlcnZpY2Uge1xufVxuIl19 +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoic2Vjb25kLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vLi4vLi4vLi4vLi4vLi4vLi4vLi4vLi4vcGFja2FnZXMvYmF6ZWwvdGVzdC9uZ19wYWNrYWdlL2V4YW1wbGUvaW1wb3J0cy9zZWNvbmQudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUE7Ozs7OztHQU1HO0FBRUgsT0FBTyxFQUFDLFVBQVUsRUFBQyxNQUFNLGVBQWUsQ0FBQzs7QUFFekM7SUFBQSxNQUNhLGVBQWU7Ozs7Z0JBRDNCLFVBQVUsU0FBQyxFQUFDLFVBQVUsRUFBRSxNQUFNLEVBQUM7OzBCQVZoQztLQVlDO1NBRFksZUFBZSIsInNvdXJjZXNDb250ZW50IjpbIi8qKlxuICogQGxpY2Vuc2VcbiAqIENvcHlyaWdodCBHb29nbGUgTExDIEFsbCBSaWdodHMgUmVzZXJ2ZWQuXG4gKlxuICogVXNlIG9mIHRoaXMgc291cmNlIGNvZGUgaXMgZ292ZXJuZWQgYnkgYW4gTUlULXN0eWxlIGxpY2Vuc2UgdGhhdCBjYW4gYmVcbiAqIGZvdW5kIGluIHRoZSBMSUNFTlNFIGZpbGUgYXQgaHR0cHM6Ly9hbmd1bGFyLmlvL2xpY2Vuc2VcbiAqL1xuXG5pbXBvcnQge0luamVjdGFibGV9IGZyb20gJ0Bhbmd1bGFyL2NvcmUnO1xuXG5ASW5qZWN0YWJsZSh7cHJvdmlkZWRJbjogJ3Jvb3QnfSlcbmV4cG9ydCBjbGFzcyBNeVNlY29uZFNlcnZpY2Uge1xufVxuIl19 --- esm2015/index.js --- @@ -800,7 +798,7 @@ export { A11yModule }; * License: MIT */ -import { Injectable, ɵɵdefineInjectable, ɵɵinject } from '@angular/core'; +import { ɵɵdefineInjectable, Injectable, ɵɵinject } from '@angular/core'; /** * @license @@ -812,10 +810,10 @@ import { Injectable, ɵɵdefineInjectable, ɵɵinject } from '@angular/core'; let MySecondService = /** @class */ (() => { class MySecondService { } + MySecondService.ɵprov = ɵɵdefineInjectable({ factory: function MySecondService_Factory() { return new MySecondService(); }, token: MySecondService, providedIn: "root" }); MySecondService.decorators = [ { type: Injectable, args: [{ providedIn: 'root' },] } ]; - MySecondService.ɵprov = ɵɵdefineInjectable({ factory: function MySecondService_Factory() { return new MySecondService(); }, token: MySecondService, providedIn: "root" }); return MySecondService; })(); @@ -832,14 +830,13 @@ let MyService = /** @class */ (() => { this.secondService = secondService; } } + MyService.ɵprov = ɵɵdefineInjectable({ factory: function MyService_Factory() { return new MyService(ɵɵinject(MySecondService)); }, token: MyService, providedIn: "root" }); MyService.decorators = [ { type: Injectable, args: [{ providedIn: 'root' },] } ]; - /** @nocollapse */ MyService.ctorParameters = () => [ { type: MySecondService } ]; - MyService.ɵprov = ɵɵdefineInjectable({ factory: function MyService_Factory() { return new MyService(ɵɵinject(MySecondService)); }, token: MyService, providedIn: "root" }); return MyService; })(); diff --git a/packages/compiler-cli/BUILD.bazel b/packages/compiler-cli/BUILD.bazel index fa0e14e6e0411a..2ad66dfa6aff32 100644 --- a/packages/compiler-cli/BUILD.bazel +++ b/packages/compiler-cli/BUILD.bazel @@ -29,6 +29,7 @@ ts_library( "//packages/compiler-cli/src/ngtsc/file_system", "//packages/compiler-cli/src/ngtsc/indexer", "//packages/compiler-cli/src/ngtsc/perf", + "//packages/compiler-cli/src/ngtsc/reflection", "//packages/compiler-cli/src/ngtsc/typecheck", "@npm//@bazel/typescript", "@npm//@types/node", diff --git a/packages/compiler-cli/src/main.ts b/packages/compiler-cli/src/main.ts index a629759a4b8248..0c8eb8ddb899f9 100644 --- a/packages/compiler-cli/src/main.ts +++ b/packages/compiler-cli/src/main.ts @@ -89,18 +89,9 @@ export function mainDiagnosticsForTest( } function createEmitCallback(options: api.CompilerOptions): api.TsEmitCallback|undefined { - const transformDecorators = - (options.enableIvy === false && options.annotationsAs !== 'decorators'); - const transformTypesToClosure = options.annotateForClosureCompiler; - if (!transformDecorators && !transformTypesToClosure) { + if (!options.annotateForClosureCompiler) { return undefined; } - if (transformDecorators) { - // This is needed as a workaround for https://github.com/angular/tsickle/issues/635 - // Otherwise tsickle might emit references to non imported values - // as TypeScript elided the import. - options.emitDecoratorMetadata = true; - } const tsickleHost: Pick< tsickle.TsickleHost, 'shouldSkipTsickleProcessing'|'pathToModuleName'|'shouldIgnoreWarningsForPath'| @@ -115,41 +106,29 @@ function createEmitCallback(options: api.CompilerOptions): api.TsEmitCallback|un googmodule: false, untyped: true, convertIndexImportShorthand: false, - transformDecorators, - transformTypesToClosure, + // Decorators are transformed as part of the Angular compiler programs. To avoid + // conflicts, we disable decorator transformations for tsickle. + transformDecorators: false, + transformTypesToClosure: true, }; - if (options.annotateForClosureCompiler || options.annotationsAs === 'static fields') { - return ({ - program, - targetSourceFile, - writeFile, - cancellationToken, - emitOnlyDtsFiles, - customTransformers = {}, - host, - options - }) => - // tslint:disable-next-line:no-require-imports only depend on tsickle if requested - require('tsickle').emitWithTsickle( - program, {...tsickleHost, options, host, moduleResolutionHost: host}, host, options, - targetSourceFile, writeFile, cancellationToken, emitOnlyDtsFiles, { - beforeTs: customTransformers.before, - afterTs: customTransformers.after, - }); - } else { - return ({ - program, - targetSourceFile, - writeFile, - cancellationToken, - emitOnlyDtsFiles, - customTransformers = {}, - }) => - program.emit( - targetSourceFile, writeFile, cancellationToken, emitOnlyDtsFiles, - {after: customTransformers.after, before: customTransformers.before}); - } + return ({ + program, + targetSourceFile, + writeFile, + cancellationToken, + emitOnlyDtsFiles, + customTransformers = {}, + host, + options + }) => + // tslint:disable-next-line:no-require-imports only depend on tsickle if requested + require('tsickle').emitWithTsickle( + program, {...tsickleHost, options, host, moduleResolutionHost: host}, host, options, + targetSourceFile, writeFile, cancellationToken, emitOnlyDtsFiles, { + beforeTs: customTransformers.before, + afterTs: customTransformers.after, + }); } export interface NgcParsedConfiguration extends ParsedConfiguration { diff --git a/packages/compiler-cli/src/ngtsc/core/src/compiler.ts b/packages/compiler-cli/src/ngtsc/core/src/compiler.ts index 0a54b3d9d59098..a52b17c34d0a87 100644 --- a/packages/compiler-cli/src/ngtsc/core/src/compiler.ts +++ b/packages/compiler-cli/src/ngtsc/core/src/compiler.ts @@ -752,7 +752,7 @@ export class NgCompiler { /** * Determine if the given `Program` is @angular/core. */ -function isAngularCorePackage(program: ts.Program): boolean { +export function isAngularCorePackage(program: ts.Program): boolean { // Look for its_just_angular.ts somewhere in the program. const r3Symbols = getR3SymbolsFile(program); if (r3Symbols === null) { diff --git a/packages/compiler-cli/src/transformers/downlevel_decorators_transform.ts b/packages/compiler-cli/src/transformers/downlevel_decorators_transform.ts new file mode 100644 index 00000000000000..5f5bf2b791adc4 --- /dev/null +++ b/packages/compiler-cli/src/transformers/downlevel_decorators_transform.ts @@ -0,0 +1,660 @@ +/** + * @license + * Copyright Google LLC 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 {Decorator, ReflectionHost} from '../ngtsc/reflection/src/host'; + +/** + * Whether a given decorator should be treated as an Angular decorator. + * Either it's used in @angular/core, or it's imported from there. + */ +function isAngularDecorator(decorator: Decorator, isCore: boolean): boolean { + return isCore || (decorator.import !== null && decorator.import.from === '@angular/core'); +} + +/** + * Describes a TypeScript transformation context with the internal emit + * resolver exposed. There are requests upstream in TypeScript to expose + * that as public API: https://github.com/microsoft/TypeScript/issues/17516.. + */ +type TransformationContextWithResolver = ts.TransformationContext&{ + getEmitResolver?: () => EmitResolver; +}; + +/** Describes a subset of the TypeScript internal emit resolver. */ +type EmitResolver = { + isReferencedAliasDeclaration(node: ts.Node, checkChildren?: boolean): boolean; +}; + +/** + * Patches the alias declaration reference resolution for a given transformation context + * so that TypeScript knows about the specified alias declarations being referenced. + * + * This exists because TypeScript performs analysis of import usage before transformers + * run and doesn't refresh its state after transformations. This means that imports + * for symbols used as constructor types are elided due to their original type-only usage. + * + * In reality though, since we downlevel decorators and constructor parameters, we want + * these symbols to be retained in the JavaScript output as they will be used as values + * at runtime. We can instruct TypeScript to preserve imports for such identifiers by + * creating a mutable clone of a given import specifier/clause or namespace, but that + * has the downside of preserving the full import in the JS output. See: + * https://github.com/microsoft/TypeScript/blob/3eaa7c65f6f076a08a5f7f1946fd0df7c7430259/src/compiler/transformers/ts.ts#L242-L250. + * + * This is a trick the CLI used in the past for constructor parameter downleveling in JIT: + * https://github.com/angular/angular-cli/blob/b3f84cc5184337666ce61c07b7b9df418030106f/packages/ngtools/webpack/src/transformers/ctor-parameters.ts#L323-L325 + * The trick is not ideal though as it preserves the full import (as outlined before), and it + * results in a slow-down due to the type checker being involved multiple times. The CLI + * worked around this import preserving issue by having another complex post-process step that + * detects and elides unused imports. Note that these unused imports could cause unused chunks + * being generated by Webpack if the application or library is not marked as side-effect free. + * + * This is not ideal though, as we basically re-implement the complex import usage resolution + * from TypeScript. We can do better by letting TypeScript do the import eliding, but providing + * information about the alias declarations (e.g. import specifiers) that should not be elided + * because they are actually referenced (as they will now appear in static properties). + * + * More information about these limitations with transformers can be found in: + * 1. https://github.com/Microsoft/TypeScript/issues/17552. + * 2. https://github.com/microsoft/TypeScript/issues/17516. + * 3. https://github.com/angular/tsickle/issues/635. + * + * The patch we apply to tell TypeScript about actual referenced aliases (i.e. imported symbols), + * matches conceptually with the logic that runs internally in TypeScript when the + * `emitDecoratorMetadata` flag is enabled. TypeScript basically surfaces the same problem and + * solves it conceptually the same way, but obviously doesn't need to access an `@internal` API. + * + * See: + * https://sourcegraph.com/github.com/microsoft/TypeScript@3eaa7c65f6f076a08a5f7f1946fd0df7c7430259/-/blob/src/compiler/checker.ts#L31219-31257 + * + * @returns A boolean indicating whether the alias reference solution could be patched properly. + */ +function patchAliasReferenceResolution( + context: TransformationContextWithResolver, referencedAliases: Set): boolean { + // If the `getEmitResolver` method is not available, TS most likely changed the + // internal structure of the transformation context. We will abort gracefully. + if (context.getEmitResolver === undefined) { + return false; + } + const emitResolver = context.getEmitResolver(); + const originalReferenceResolution = emitResolver.isReferencedAliasDeclaration; + // If the emit resolver does not have a function called `isReferencedAliasDeclaration`, then + // we abort gracefully as most likely TS changed the internal structure of the emit resolver. + if (originalReferenceResolution === undefined) { + return false; + } + emitResolver.isReferencedAliasDeclaration = function(node, checkChildren) { + if (isAliasImportDeclaration(node) && referencedAliases.has(node)) { + return true; + } + return originalReferenceResolution.call(emitResolver, node, checkChildren); + }; + return true; +} + +/** + * Gets whether a given node corresponds to an import alias declaration. Alias + * declarations can be import specifiers, namespace imports or import clauses + * as these do not declare an actual symbol but just point to a target declaration. + */ +function isAliasImportDeclaration(node: ts.Node): node is ts.ImportSpecifier|ts.NamespaceImport| + ts.ImportClause { + return ts.isImportSpecifier(node) || ts.isNamespaceImport(node) || ts.isImportClause(node); +} + +/* + ##################################################################### + Code below has been extracted from the tsickle decorator downlevel transformer + and a few local modifications have been applied: + + 1. Tsickle by default processed all decorators that had the `@Annotation` JSDoc. + We modified the transform to only be concerned with known Angular decorators. + 2. Tsickle by default added `@nocollapse` to all generated `ctorParameters` properties. + We only do this when `annotateForClosureCompiler` is enabled. + 3. Tsickle does not handle union types for dependency injection. i.e. if a injected type + is denoted with `@Optional`, the actual type could be set to `T | null`. + See: https://github.com/angular/angular-cli/commit/826803d0736b807867caff9f8903e508970ad5e4. + 4. Tsickle relied on `emitDecoratorMetadata` to be set to `true`. This is due to a limitation + in TypeScript transformers that never has been fixed. We were able to work around this + limitation so that `emitDecoratorMetadata` doesn't need to be specified. + See: `patchAliasReferenceResolution` for more details. + + Here is a link to the tsickle revision on which this transformer is based: + https://github.com/angular/tsickle/blob/fae06becb1570f491806060d83f29f2d50c43cdd/src/decorator_downlevel_transformer.ts + ##################################################################### +*/ + +/** + * Creates the AST for the decorator field type annotation, which has the form + * { type: Function, args?: any[] }[] + */ +function createDecoratorInvocationType(): ts.TypeNode { + const typeElements: ts.TypeElement[] = []; + typeElements.push(ts.createPropertySignature( + undefined, 'type', undefined, + ts.createTypeReferenceNode(ts.createIdentifier('Function'), undefined), undefined)); + typeElements.push(ts.createPropertySignature( + undefined, 'args', ts.createToken(ts.SyntaxKind.QuestionToken), + ts.createArrayTypeNode(ts.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword)), undefined)); + return ts.createArrayTypeNode(ts.createTypeLiteralNode(typeElements)); +} + +/** + * Extracts the type of the decorator (the function or expression invoked), as well as all the + * arguments passed to the decorator. Returns an AST with the form: + * + * // For @decorator(arg1, arg2) + * { type: decorator, args: [arg1, arg2] } + */ +function extractMetadataFromSingleDecorator( + decorator: ts.Decorator, diagnostics: ts.Diagnostic[]): ts.ObjectLiteralExpression { + const metadataProperties: ts.ObjectLiteralElementLike[] = []; + const expr = decorator.expression; + switch (expr.kind) { + case ts.SyntaxKind.Identifier: + // The decorator was a plain @Foo. + metadataProperties.push(ts.createPropertyAssignment('type', expr)); + break; + case ts.SyntaxKind.CallExpression: + // The decorator was a call, like @Foo(bar). + const call = expr as ts.CallExpression; + metadataProperties.push(ts.createPropertyAssignment('type', call.expression)); + if (call.arguments.length) { + const args: ts.Expression[] = []; + for (const arg of call.arguments) { + args.push(arg); + } + const argsArrayLiteral = ts.createArrayLiteral(args); + argsArrayLiteral.elements.hasTrailingComma = true; + metadataProperties.push(ts.createPropertyAssignment('args', argsArrayLiteral)); + } + break; + default: + diagnostics.push({ + file: decorator.getSourceFile(), + start: decorator.getStart(), + length: decorator.getEnd() - decorator.getStart(), + messageText: + `${ts.SyntaxKind[decorator.kind]} not implemented in gathering decorator metadata.`, + category: ts.DiagnosticCategory.Error, + code: 0, + }); + break; + } + return ts.createObjectLiteral(metadataProperties); +} + +/** + * Takes a list of decorator metadata object ASTs and produces an AST for a + * static class property of an array of those metadata objects. + */ +function createDecoratorClassProperty(decoratorList: ts.ObjectLiteralExpression[]) { + const modifier = ts.createToken(ts.SyntaxKind.StaticKeyword); + const type = createDecoratorInvocationType(); + const initializer = ts.createArrayLiteral(decoratorList, true); + // NB: the .decorators property does not get a @nocollapse property. There is + // no good reason why - it means .decorators is not runtime accessible if you + // compile with collapse properties, whereas propDecorators is, which doesn't + // follow any stringent logic. However this has been the case previously, and + // adding it back in leads to substantial code size increases as Closure fails + // to tree shake these props without @nocollapse. + return ts.createProperty(undefined, [modifier], 'decorators', undefined, type, initializer); +} + +/** + * Creates the AST for the 'ctorParameters' field type annotation: + * () => ({ type: any, decorators?: {type: Function, args?: any[]}[] }|null)[] + */ +function createCtorParametersClassPropertyType(): ts.TypeNode { + // Sorry about this. Try reading just the string literals below. + const typeElements: ts.TypeElement[] = []; + typeElements.push(ts.createPropertySignature( + undefined, 'type', undefined, + ts.createTypeReferenceNode(ts.createIdentifier('any'), undefined), undefined)); + typeElements.push(ts.createPropertySignature( + undefined, 'decorators', ts.createToken(ts.SyntaxKind.QuestionToken), + ts.createArrayTypeNode(ts.createTypeLiteralNode([ + ts.createPropertySignature( + undefined, 'type', undefined, + ts.createTypeReferenceNode(ts.createIdentifier('Function'), undefined), undefined), + ts.createPropertySignature( + undefined, 'args', ts.createToken(ts.SyntaxKind.QuestionToken), + ts.createArrayTypeNode( + ts.createTypeReferenceNode(ts.createIdentifier('any'), undefined)), + undefined), + ])), + undefined)); + return ts.createFunctionTypeNode( + undefined, [], + ts.createArrayTypeNode( + ts.createUnionTypeNode([ts.createTypeLiteralNode(typeElements), ts.createNull()]))); +} + +/** + * Sets a Closure \@nocollapse synthetic comment on the given node. This prevents Closure Compiler + * from collapsing the apparently static property, which would make it impossible to find for code + * trying to detect it at runtime. + */ +function addNoCollapseComment(n: ts.Node) { + ts.setSyntheticLeadingComments(n, [{ + kind: ts.SyntaxKind.MultiLineCommentTrivia, + text: '* @nocollapse ', + pos: -1, + end: -1, + hasTrailingNewLine: true + }]); +} + +/** + * createCtorParametersClassProperty creates a static 'ctorParameters' property containing + * downleveled decorator information. + * + * The property contains an arrow function that returns an array of object literals of the shape: + * static ctorParameters = () => [{ + * type: SomeClass|undefined, // the type of the param that's decorated, if it's a value. + * decorators: [{ + * type: DecoratorFn, // the type of the decorator that's invoked. + * args: [ARGS], // the arguments passed to the decorator. + * }] + * }]; + */ +function createCtorParametersClassProperty( + diagnostics: ts.Diagnostic[], + entityNameToExpression: (n: ts.EntityName) => ts.Expression | undefined, + ctorParameters: ParameterDecorationInfo[], + isClosureCompilerEnabled: boolean): ts.PropertyDeclaration { + const params: ts.Expression[] = []; + + for (const ctorParam of ctorParameters) { + if (!ctorParam.type && ctorParam.decorators.length === 0) { + params.push(ts.createNull()); + continue; + } + + const paramType = ctorParam.type ? + typeReferenceToExpression(entityNameToExpression, ctorParam.type) : + undefined; + const members = + [ts.createPropertyAssignment('type', paramType || ts.createIdentifier('undefined'))]; + + const decorators: ts.ObjectLiteralExpression[] = []; + for (const deco of ctorParam.decorators) { + decorators.push(extractMetadataFromSingleDecorator(deco, diagnostics)); + } + if (decorators.length) { + members.push(ts.createPropertyAssignment('decorators', ts.createArrayLiteral(decorators))); + } + params.push(ts.createObjectLiteral(members)); + } + + const initializer = ts.createArrowFunction( + undefined, undefined, [], undefined, ts.createToken(ts.SyntaxKind.EqualsGreaterThanToken), + ts.createArrayLiteral(params, true)); + const type = createCtorParametersClassPropertyType(); + const ctorProp = ts.createProperty( + undefined, [ts.createToken(ts.SyntaxKind.StaticKeyword)], 'ctorParameters', undefined, type, + initializer); + if (isClosureCompilerEnabled) { + addNoCollapseComment(ctorProp); + } + return ctorProp; +} + +/** + * createPropDecoratorsClassProperty creates a static 'propDecorators' property containing type + * information for every property that has a decorator applied. + * + * static propDecorators: {[key: string]: {type: Function, args?: any[]}[]} = { + * propA: [{type: MyDecorator, args: [1, 2]}, ...], + * ... + * }; + */ +function createPropDecoratorsClassProperty( + diagnostics: ts.Diagnostic[], properties: Map): ts.PropertyDeclaration { + // `static propDecorators: {[key: string]: ` + {type: Function, args?: any[]}[] + `} = {\n`); + const entries: ts.ObjectLiteralElementLike[] = []; + for (const [name, decorators] of properties.entries()) { + entries.push(ts.createPropertyAssignment( + name, + ts.createArrayLiteral( + decorators.map(deco => extractMetadataFromSingleDecorator(deco, diagnostics))))); + } + const initializer = ts.createObjectLiteral(entries, true); + const type = ts.createTypeLiteralNode([ts.createIndexSignature( + undefined, undefined, [ts.createParameter( + undefined, undefined, undefined, 'key', undefined, + ts.createTypeReferenceNode('string', undefined), undefined)], + createDecoratorInvocationType())]); + return ts.createProperty( + undefined, [ts.createToken(ts.SyntaxKind.StaticKeyword)], 'propDecorators', undefined, type, + initializer); +} + +/** + * Returns an expression representing the (potentially) value part for the given node. + * + * This is a partial re-implementation of TypeScript's serializeTypeReferenceNode. This is a + * workaround for https://github.com/Microsoft/TypeScript/issues/17516 (serializeTypeReferenceNode + * not being exposed). In practice this implementation is sufficient for Angular's use of type + * metadata. + */ +function typeReferenceToExpression( + entityNameToExpression: (n: ts.EntityName) => ts.Expression | undefined, + node: ts.TypeNode): ts.Expression|undefined { + let kind = node.kind; + if (ts.isLiteralTypeNode(node)) { + // Treat literal types like their base type (boolean, string, number). + kind = node.literal.kind; + } + switch (kind) { + case ts.SyntaxKind.FunctionType: + case ts.SyntaxKind.ConstructorType: + return ts.createIdentifier('Function'); + case ts.SyntaxKind.ArrayType: + case ts.SyntaxKind.TupleType: + return ts.createIdentifier('Array'); + case ts.SyntaxKind.TypePredicate: + case ts.SyntaxKind.TrueKeyword: + case ts.SyntaxKind.FalseKeyword: + case ts.SyntaxKind.BooleanKeyword: + return ts.createIdentifier('Boolean'); + case ts.SyntaxKind.StringLiteral: + case ts.SyntaxKind.StringKeyword: + return ts.createIdentifier('String'); + case ts.SyntaxKind.ObjectKeyword: + return ts.createIdentifier('Object'); + case ts.SyntaxKind.NumberKeyword: + case ts.SyntaxKind.NumericLiteral: + return ts.createIdentifier('Number'); + case ts.SyntaxKind.TypeReference: + const typeRef = node as ts.TypeReferenceNode; + // Ignore any generic types, just return the base type. + return entityNameToExpression(typeRef.typeName); + case ts.SyntaxKind.UnionType: + const childTypeNodes = + (node as ts.UnionTypeNode).types.filter(t => t.kind !== ts.SyntaxKind.NullKeyword); + return childTypeNodes.length === 1 ? + typeReferenceToExpression(entityNameToExpression, childTypeNodes[0]) : + undefined; + default: + return undefined; + } +} + +/** + * Returns true if the given symbol refers to a value (as distinct from a type). + * + * Expands aliases, which is important for the case where + * import * as x from 'some-module'; + * and x is now a value (the module object). + */ +function symbolIsValue(tc: ts.TypeChecker, sym: ts.Symbol): boolean { + if (sym.flags & ts.SymbolFlags.Alias) sym = tc.getAliasedSymbol(sym); + return (sym.flags & ts.SymbolFlags.Value) !== 0; +} + +/** ParameterDecorationInfo describes the information for a single constructor parameter. */ +interface ParameterDecorationInfo { + /** + * The type declaration for the parameter. Only set if the type is a value (e.g. a class, not an + * interface). + */ + type: ts.TypeNode|null; + /** The list of decorators found on the parameter, null if none. */ + decorators: ts.Decorator[]; +} + +/** + * Gets a transformer for downleveling Angular decorators. + * @param typeChecker Reference to the program's type checker. + * @param diagnostics List which will be populated with diagnostics if any. + * @param host Reflection host that is used for determining decorators. + * @param isCore Whether the current TypeScript program is for the `@angular/core` package. + * @param isClosureCompilerEnabled Whether closure annotations need to be added where needed. + */ +export function getDownlevelDecoratorsTransform( + typeChecker: ts.TypeChecker, diagnostics: ts.Diagnostic[], host: ReflectionHost, + isCore: boolean, isClosureCompilerEnabled: boolean): ts.TransformerFactory { + return (context: ts.TransformationContext) => { + let referencedParameterTypes = new Set(); + + /** + * Converts an EntityName (from a type annotation) to an expression (accessing a value). + * + * For a given qualified name, this walks depth first to find the leftmost identifier, + * and then converts the path into a property access that can be used as expression. + */ + function entityNameToExpression(name: ts.EntityName): ts.Expression|undefined { + if (ts.isQualifiedName(name)) { + const containerExpr = entityNameToExpression(name.left); + if (containerExpr === undefined) { + return undefined; + } + return ts.createPropertyAccess(containerExpr, name.right); + } + const symbol = typeChecker.getSymbolAtLocation(name); + // Check if the entity name references a symbol that is an actual value. If it is not, it + // cannot be referenced by an expression, so return undefined. + if (!symbol || !symbolIsValue(typeChecker, symbol)) { + return undefined; + } + if (symbol.declarations.length === 0) { + return undefined; + } + const decl = symbol.declarations[0]; + // If the given entity name has been resolved to an alias import declaration, + // ensure that the alias declaration is not elided by TypeScript. + if (isAliasImportDeclaration(decl)) { + referencedParameterTypes.add(decl); + } + return ts.getMutableClone(name); + } + + /** + * Transforms a class element. Returns a three tuple of name, transformed element, and + * decorators found. Returns an undefined name if there are no decorators to lower on the + * element, or the element has an exotic name. + */ + function transformClassElement(element: ts.ClassElement): + [string|undefined, ts.ClassElement, ts.Decorator[]] { + element = ts.visitEachChild(element, decoratorDownlevelVisitor, context); + const decoratorsToKeep: ts.Decorator[] = []; + const toLower: ts.Decorator[] = []; + const decorators = host.getDecoratorsOfDeclaration(element) || []; + for (const decorator of decorators) { + // We only deal with concrete nodes in TypeScript sources, so we don't + // need to handle synthetically created decorators. + const decoratorNode = decorator.node! as ts.Decorator; + if (!isAngularDecorator(decorator, isCore)) { + decoratorsToKeep.push(decoratorNode); + continue; + } + toLower.push(decoratorNode); + } + if (!toLower.length) return [undefined, element, []]; + + if (!element.name || element.name.kind !== ts.SyntaxKind.Identifier) { + // Method has a weird name, e.g. + // [Symbol.foo]() {...} + diagnostics.push({ + file: element.getSourceFile(), + start: element.getStart(), + length: element.getEnd() - element.getStart(), + messageText: `Cannot process decorators for class element with non-analyzable name.`, + category: ts.DiagnosticCategory.Error, + code: 0, + }); + return [undefined, element, []]; + } + + const name = (element.name as ts.Identifier).text; + const mutable = ts.getMutableClone(element); + mutable.decorators = decoratorsToKeep.length ? + ts.setTextRange(ts.createNodeArray(decoratorsToKeep), mutable.decorators) : + undefined; + return [name, mutable, toLower]; + } + + /** + * Transforms a constructor. Returns the transformed constructor and the list of parameter + * information collected, consisting of decorators and optional type. + */ + function transformConstructor(ctor: ts.ConstructorDeclaration): + [ts.ConstructorDeclaration, ParameterDecorationInfo[]] { + ctor = ts.visitEachChild(ctor, decoratorDownlevelVisitor, context); + + const newParameters: ts.ParameterDeclaration[] = []; + const oldParameters = + ts.visitParameterList(ctor.parameters, decoratorDownlevelVisitor, context); + const parametersInfo: ParameterDecorationInfo[] = []; + for (const param of oldParameters) { + const decoratorsToKeep: ts.Decorator[] = []; + const paramInfo: ParameterDecorationInfo = {decorators: [], type: null}; + const decorators = host.getDecoratorsOfDeclaration(param) || []; + + for (const decorator of decorators) { + // We only deal with concrete nodes in TypeScript sources, so we don't + // need to handle synthetically created decorators. + const decoratorNode = decorator.node! as ts.Decorator; + if (!isAngularDecorator(decorator, isCore)) { + decoratorsToKeep.push(decoratorNode); + continue; + } + paramInfo!.decorators.push(decoratorNode); + } + if (param.type) { + // param has a type provided, e.g. "foo: Bar". + // The type will be emitted as a value expression in entityNameToExpression, which takes + // care not to emit anything for types that cannot be expressed as a value (e.g. + // interfaces). + paramInfo!.type = param.type; + } + parametersInfo.push(paramInfo); + const newParam = ts.updateParameter( + param, + // Must pass 'undefined' to avoid emitting decorator metadata. + decoratorsToKeep.length ? decoratorsToKeep : undefined, param.modifiers, + param.dotDotDotToken, param.name, param.questionToken, param.type, param.initializer); + newParameters.push(newParam); + } + const updated = ts.updateConstructor( + ctor, ctor.decorators, ctor.modifiers, newParameters, + ts.visitFunctionBody(ctor.body, decoratorDownlevelVisitor, context)); + return [updated, parametersInfo]; + } + + /** + * Transforms a single class declaration: + * - dispatches to strip decorators on members + * - converts decorators on the class to annotations + * - creates a ctorParameters property + * - creates a propDecorators property + */ + function transformClassDeclaration(classDecl: ts.ClassDeclaration): ts.ClassDeclaration { + classDecl = ts.getMutableClone(classDecl); + + const newMembers: ts.ClassElement[] = []; + const decoratedProperties = new Map(); + let classParameters: ParameterDecorationInfo[]|null = null; + + for (const member of classDecl.members) { + switch (member.kind) { + case ts.SyntaxKind.PropertyDeclaration: + case ts.SyntaxKind.GetAccessor: + case ts.SyntaxKind.SetAccessor: + case ts.SyntaxKind.MethodDeclaration: { + const [name, newMember, decorators] = transformClassElement(member); + newMembers.push(newMember); + if (name) decoratedProperties.set(name, decorators); + continue; + } + case ts.SyntaxKind.Constructor: { + const ctor = member as ts.ConstructorDeclaration; + if (!ctor.body) break; + const [newMember, parametersInfo] = + transformConstructor(member as ts.ConstructorDeclaration); + classParameters = parametersInfo; + newMembers.push(newMember); + continue; + } + default: + break; + } + newMembers.push(ts.visitEachChild(member, decoratorDownlevelVisitor, context)); + } + const decorators = host.getDecoratorsOfDeclaration(classDecl) || []; + + const decoratorsToLower = []; + const decoratorsToKeep: ts.Decorator[] = []; + for (const decorator of decorators) { + // We only deal with concrete nodes in TypeScript sources, so we don't + // need to handle synthetically created decorators. + const decoratorNode = decorator.node! as ts.Decorator; + if (isAngularDecorator(decorator, isCore)) { + decoratorsToLower.push(extractMetadataFromSingleDecorator(decoratorNode, diagnostics)); + } else { + decoratorsToKeep.push(decoratorNode); + } + } + + const newClassDeclaration = ts.getMutableClone(classDecl); + + if (decoratorsToLower.length) { + newMembers.push(createDecoratorClassProperty(decoratorsToLower)); + } + if (classParameters) { + if ((decoratorsToLower.length) || classParameters.some(p => !!p.decorators.length)) { + // emit ctorParameters if the class was decoratored at all, or if any of its ctors + // were classParameters + newMembers.push(createCtorParametersClassProperty( + diagnostics, entityNameToExpression, classParameters, isClosureCompilerEnabled)); + } + } + if (decoratedProperties.size) { + newMembers.push(createPropDecoratorsClassProperty(diagnostics, decoratedProperties)); + } + newClassDeclaration.members = ts.setTextRange( + ts.createNodeArray(newMembers, newClassDeclaration.members.hasTrailingComma), + classDecl.members); + newClassDeclaration.decorators = + decoratorsToKeep.length ? ts.createNodeArray(decoratorsToKeep) : undefined; + return newClassDeclaration; + } + + /** + * Transformer visitor that looks for Angular decorators and replaces them with + * downleveled static properties. Also collects constructor type metadata for + * class declaration that are decorated with an Angular decorator. + */ + function decoratorDownlevelVisitor(node: ts.Node): ts.Node { + switch (node.kind) { + case ts.SyntaxKind.ClassDeclaration: { + return transformClassDeclaration(node as ts.ClassDeclaration); + } + default: + return ts.visitEachChild(node, decoratorDownlevelVisitor, context); + } + } + + return (sf: ts.SourceFile) => { + // Ensure that referenced type symbols are not elided by TypeScript. Imports for + // such parameter type symbols previously could be type-only, but now might be also + // used in the `ctorParameters` static property as a value. We want to make sure + // that TypeScript does not elide imports for such type references. Read more + // about this in the description for `patchAliasReferenceResolution`. + if (patchAliasReferenceResolution( + context as TransformationContextWithResolver, referencedParameterTypes) === false) { + throw Error('Unable to downlevel Angular decorators. Emit resolver could not be patched.'); + } + // Downlevel decorators and constructor parameter types. We will keep track of all + // referenced constructor parameter types so that we can instruct TypeScript to + // not elide their imports if they previously were only type-only. + return decoratorDownlevelVisitor(sf) as ts.SourceFile; + }; + }; +} diff --git a/packages/compiler-cli/src/transformers/program.ts b/packages/compiler-cli/src/transformers/program.ts index dbc3dcdb709f75..90c07c2f3c3ea6 100644 --- a/packages/compiler-cli/src/transformers/program.ts +++ b/packages/compiler-cli/src/transformers/program.ts @@ -7,26 +7,28 @@ * found in the LICENSE file at https://angular.io/license */ -import {AotCompiler, AotCompilerHost, AotCompilerOptions, core, createAotCompiler, EmitterVisitorContext, FormattedMessageChain, GeneratedFile, getParseErrors, isFormattedError, isSyntaxError, MessageBundle, NgAnalyzedFile, NgAnalyzedFileWithInjectables, NgAnalyzedModules, ParseSourceSpan, PartialModule, Position, Serializer, StaticSymbol, TypeScriptEmitter, Xliff, Xliff2, Xmb} from '@angular/compiler'; +import {AotCompiler, AotCompilerOptions, core, createAotCompiler, FormattedMessageChain, GeneratedFile, getParseErrors, isFormattedError, isSyntaxError, MessageBundle, NgAnalyzedFileWithInjectables, NgAnalyzedModules, ParseSourceSpan, PartialModule, Serializer, Xliff, Xliff2, Xmb} from '@angular/compiler'; import * as fs from 'fs'; import * as path from 'path'; import * as ts from 'typescript'; -import {translateDiagnostics, TypeCheckHost} from '../diagnostics/translate_diagnostics'; -import {createBundleIndexHost, MetadataCollector, ModuleMetadata} from '../metadata'; +import {translateDiagnostics} from '../diagnostics/translate_diagnostics'; +import {createBundleIndexHost, MetadataCollector} from '../metadata'; +import {isAngularCorePackage} from '../ngtsc/core/src/compiler'; import {NgtscProgram} from '../ngtsc/program'; +import {TypeScriptReflectionHost} from '../ngtsc/reflection/src/typescript'; import {verifySupportedTypeScriptVersion} from '../typescript_support'; -import {CompilerHost, CompilerOptions, CustomTransformers, DEFAULT_ERROR_CODE, Diagnostic, DiagnosticMessageChain, EmitFlags, LazyRoute, LibrarySummary, Program, SOURCE, TsEmitArguments, TsEmitCallback, TsMergeEmitResultsCallback} from './api'; +import {CompilerHost, CompilerOptions, CustomTransformers, DEFAULT_ERROR_CODE, Diagnostic, DiagnosticMessageChain, EmitFlags, LazyRoute, LibrarySummary, Program, SOURCE, TsEmitCallback, TsMergeEmitResultsCallback} from './api'; import {CodeGenerator, getOriginalReferences, TsCompilerAotCompilerTypeCheckHostAdapter} from './compiler_host'; +import {getDownlevelDecoratorsTransform} from './downlevel_decorators_transform'; import {getInlineResourcesTransformFactory, InlineResourcesMetadataTransformer} from './inline_resources'; import {getExpressionLoweringTransformFactory, LowerMetadataTransform} from './lower_expressions'; import {MetadataCache, MetadataTransformer} from './metadata_cache'; import {getAngularEmitterTransformFactory} from './node_emitter_transform'; import {PartialModuleMetadataTransformer} from './r3_metadata_transform'; -import {getDecoratorStripTransformerFactory, StripDecoratorsMetadataTransformer} from './r3_strip_decorators'; import {getAngularClassTransformerFactory} from './r3_transform'; -import {createMessageDiagnostic, DTS, GENERATED_FILES, isInRootDir, ngToTsDiagnostic, StructureIsReused, TS, tsStructureIsReused, userError} from './util'; +import {createMessageDiagnostic, DTS, GENERATED_FILES, isInRootDir, ngToTsDiagnostic, StructureIsReused, TS, tsStructureIsReused} from './util'; /** @@ -46,14 +48,6 @@ const LOWER_FIELDS = ['useValue', 'useFactory', 'data', 'id', 'loadChildren']; */ const R3_LOWER_FIELDS = [...LOWER_FIELDS, 'providers', 'imports', 'exports']; -const R3_REIFIED_DECORATORS = [ - 'Component', - 'Directive', - 'Injectable', - 'NgModule', - 'Pipe', -]; - const emptyModules: NgAnalyzedModules = { ngModules: [], ngModuleByPipeOrDirective: new Map(), @@ -99,8 +93,7 @@ class AngularCompilerProgram implements Program { private _structuralDiagnostics: Diagnostic[]|undefined; private _programWithStubs: ts.Program|undefined; private _optionsDiagnostics: Diagnostic[] = []; - // TODO(issue/24571): remove '!'. - private _reifiedDecorators!: Set; + private _transformTsDiagnostics: ts.Diagnostic[] = []; constructor( rootNames: ReadonlyArray, private options: CompilerOptions, @@ -263,72 +256,6 @@ class AngularCompilerProgram implements Program { return this._emitRender2(parameters); } - private _emitRender3({ - emitFlags = EmitFlags.Default, - cancellationToken, - customTransformers, - emitCallback = defaultEmitCallback, - mergeEmitResultsCallback = mergeEmitResults, - }: { - emitFlags?: EmitFlags, - cancellationToken?: ts.CancellationToken, - customTransformers?: CustomTransformers, - emitCallback?: TsEmitCallback, - mergeEmitResultsCallback?: TsMergeEmitResultsCallback, - } = {}): ts.EmitResult { - const emitStart = Date.now(); - if ((emitFlags & (EmitFlags.JS | EmitFlags.DTS | EmitFlags.Metadata | EmitFlags.Codegen)) === - 0) { - return {emitSkipped: true, diagnostics: [], emittedFiles: []}; - } - - // analyzedModules and analyzedInjectables are created together. If one exists, so does the - // other. - const modules = - this.compiler.emitAllPartialModules(this.analyzedModules, this._analyzedInjectables!); - - const writeTsFile: ts.WriteFileCallback = - (outFileName, outData, writeByteOrderMark, onError?, sourceFiles?) => { - this.writeFile(outFileName, outData, writeByteOrderMark, onError, undefined, sourceFiles); - }; - - const emitOnlyDtsFiles = (emitFlags & (EmitFlags.DTS | EmitFlags.JS)) == EmitFlags.DTS; - - const tsCustomTransformers = this.calculateTransforms( - /* genFiles */ undefined, /* partialModules */ modules, - /* stripDecorators */ this.reifiedDecorators, customTransformers); - - - // Restore the original references before we emit so TypeScript doesn't emit - // a reference to the .d.ts file. - const augmentedReferences = new Map>(); - for (const sourceFile of this.tsProgram.getSourceFiles()) { - const originalReferences = getOriginalReferences(sourceFile); - if (originalReferences) { - augmentedReferences.set(sourceFile, sourceFile.referencedFiles); - sourceFile.referencedFiles = originalReferences; - } - } - - try { - return emitCallback({ - program: this.tsProgram, - host: this.host, - options: this.options, - writeFile: writeTsFile, - emitOnlyDtsFiles, - customTransformers: tsCustomTransformers - }); - } finally { - // Restore the references back to the augmented value to ensure that the - // checks that TypeScript makes for project structure reuse will succeed. - for (const [sourceFile, references] of Array.from(augmentedReferences)) { - // TODO(chuckj): Remove any cast after updating build to 2.6 - (sourceFile as any).referencedFiles = references; - } - } - } - private _emitRender2({ emitFlags = EmitFlags.Default, cancellationToken, @@ -367,6 +294,7 @@ class AngularCompilerProgram implements Program { const genFileByFileName = new Map(); genFiles.forEach(genFile => genFileByFileName.set(genFile.genFileUrl, genFile)); this.emittedLibrarySummaries = []; + this._transformTsDiagnostics = []; const emittedSourceFiles = [] as ts.SourceFile[]; const writeTsFile: ts.WriteFileCallback = (outFileName, outData, writeByteOrderMark, onError?, sourceFiles?) => { @@ -389,8 +317,8 @@ class AngularCompilerProgram implements Program { const modules = this._analyzedInjectables && this.compiler.emitAllPartialModules2(this._analyzedInjectables); - const tsCustomTransformers = this.calculateTransforms( - genFileByFileName, modules, /* stripDecorators */ undefined, customTransformers); + const tsCustomTransformers = + this.calculateTransforms(genFileByFileName, modules, customTransformers); const emitOnlyDtsFiles = (emitFlags & (EmitFlags.DTS | EmitFlags.JS)) == EmitFlags.DTS; // Restore the original references before we emit so TypeScript doesn't emit // a reference to the .d.ts file. @@ -548,22 +476,23 @@ class AngularCompilerProgram implements Program { return this._tsProgram!; } - private get reifiedDecorators(): Set { - if (!this._reifiedDecorators) { - const reflector = this.compiler.reflector; - this._reifiedDecorators = new Set( - R3_REIFIED_DECORATORS.map(name => reflector.findDeclaration('@angular/core', name))); + /** Whether the program is compiling the Angular core package. */ + private get isCompilingAngularCore(): boolean { + if (this._isCompilingAngularCore !== null) { + return this._isCompilingAngularCore; } - return this._reifiedDecorators; + return this._isCompilingAngularCore = isAngularCorePackage(this.tsProgram); } + private _isCompilingAngularCore: boolean|null = null; private calculateTransforms( genFiles: Map|undefined, partialModules: PartialModule[]|undefined, - stripDecorators: Set|undefined, customTransformers?: CustomTransformers): ts.CustomTransformers { const beforeTs: Array> = []; const metadataTransforms: MetadataTransformer[] = []; const flatModuleMetadataTransforms: MetadataTransformer[] = []; + const annotateForClosureCompiler = this.options.annotateForClosureCompiler || false; + if (this.options.enableResourceInlining) { beforeTs.push(getInlineResourcesTransformFactory(this.tsProgram, this.hostAdapter)); const transformer = new InlineResourcesMetadataTransformer(this.hostAdapter); @@ -576,7 +505,6 @@ class AngularCompilerProgram implements Program { getExpressionLoweringTransformFactory(this.loweringMetadataTransform, this.tsProgram)); metadataTransforms.push(this.loweringMetadataTransform); } - const annotateForClosureCompiler = this.options.annotateForClosureCompiler || false; if (genFiles) { beforeTs.push(getAngularEmitterTransformFactory( genFiles, this.getTsProgram(), annotateForClosureCompiler)); @@ -591,18 +519,26 @@ class AngularCompilerProgram implements Program { flatModuleMetadataTransforms.push(transformer); } - if (stripDecorators) { - beforeTs.push(getDecoratorStripTransformerFactory( - stripDecorators, this.compiler.reflector, this.getTsProgram().getTypeChecker())); - const transformer = - new StripDecoratorsMetadataTransformer(stripDecorators, this.compiler.reflector); - metadataTransforms.push(transformer); - flatModuleMetadataTransforms.push(transformer); - } - if (customTransformers && customTransformers.beforeTs) { beforeTs.push(...customTransformers.beforeTs); } + + // If decorators should be converted to static fields (enabled by default), we set up + // the decorator downlevel transform. Note that we set it up as last transform as that + // allows custom transformers to strip Angular decorators without having to deal with + // identifying static properties. e.g. it's more difficult handling `<..>.decorators` + // or `<..>.ctorParameters` compared to the `ts.Decorator` AST nodes. + if (this.options.annotationsAs !== 'decorators') { + const typeChecker = this.getTsProgram().getTypeChecker(); + const reflectionHost = new TypeScriptReflectionHost(typeChecker); + // Similarly to how we handled tsickle decorator downleveling in the past, we just + // ignore diagnostics that have been collected by the transformer. These are + // non-significant failures that shouldn't prevent apps from compiling. + beforeTs.push(getDownlevelDecoratorsTransform( + typeChecker, [], reflectionHost, this.isCompilingAngularCore, + annotateForClosureCompiler)); + } + if (metadataTransforms.length > 0) { this.metadataCache = this.createMetadataCache(metadataTransforms); } diff --git a/packages/compiler-cli/src/transformers/r3_strip_decorators.ts b/packages/compiler-cli/src/transformers/r3_strip_decorators.ts deleted file mode 100644 index 03f480e7075409..00000000000000 --- a/packages/compiler-cli/src/transformers/r3_strip_decorators.ts +++ /dev/null @@ -1,167 +0,0 @@ -/** - * @license - * Copyright Google LLC 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 {StaticReflector, StaticSymbol} from '@angular/compiler'; -import * as ts from 'typescript'; - -import {isClassMetadata, isMetadataImportedSymbolReferenceExpression, isMetadataSymbolicCallExpression, MetadataValue} from '../metadata'; - -import {MetadataTransformer, ValueTransform} from './metadata_cache'; - -export type Transformer = (sourceFile: ts.SourceFile) => ts.SourceFile; -export type TransformerFactory = (context: ts.TransformationContext) => Transformer; - -export function getDecoratorStripTransformerFactory( - coreDecorators: Set, reflector: StaticReflector, - checker: ts.TypeChecker): TransformerFactory { - return function(context: ts.TransformationContext) { - return function(sourceFile: ts.SourceFile): ts.SourceFile { - const stripDecoratorsFromClassDeclaration = - (node: ts.ClassDeclaration): ts.ClassDeclaration => { - if (node.decorators === undefined) { - return node; - } - const decorators = node.decorators.filter(decorator => { - const callExpr = decorator.expression; - if (ts.isCallExpression(callExpr)) { - const id = callExpr.expression; - if (ts.isIdentifier(id)) { - const symbol = resolveToStaticSymbol(id, sourceFile.fileName, reflector, checker); - return symbol && coreDecorators.has(symbol); - } - } - return true; - }); - if (decorators.length !== node.decorators.length) { - return ts.updateClassDeclaration( - node, - decorators, - node.modifiers, - node.name, - node.typeParameters, - node.heritageClauses || [], - node.members, - ); - } - return node; - }; - - const stripDecoratorPropertyAssignment = (node: ts.ClassDeclaration): ts.ClassDeclaration => { - return ts.visitEachChild(node, member => { - if (!ts.isPropertyDeclaration(member) || !isDecoratorAssignment(member) || - !member.initializer || !ts.isArrayLiteralExpression(member.initializer)) { - return member; - } - - const newInitializer = ts.visitEachChild(member.initializer, decorator => { - if (!ts.isObjectLiteralExpression(decorator)) { - return decorator; - } - const type = lookupProperty(decorator, 'type'); - if (!type || !ts.isIdentifier(type)) { - return decorator; - } - const symbol = resolveToStaticSymbol(type, sourceFile.fileName, reflector, checker); - if (!symbol || !coreDecorators.has(symbol)) { - return decorator; - } - return undefined; - }, context); - - if (newInitializer === member.initializer) { - return member; - } else if (newInitializer.elements.length === 0) { - return undefined; - } else { - return ts.updateProperty( - member, member.decorators, member.modifiers, member.name, member.questionToken, - member.type, newInitializer); - } - }, context); - }; - - return ts.visitEachChild(sourceFile, stmt => { - if (ts.isClassDeclaration(stmt)) { - let decl = stmt; - if (stmt.decorators) { - decl = stripDecoratorsFromClassDeclaration(stmt); - } - return stripDecoratorPropertyAssignment(decl); - } - return stmt; - }, context); - }; - }; -} - -function isDecoratorAssignment(member: ts.ClassElement): boolean { - if (!ts.isPropertyDeclaration(member)) { - return false; - } - if (!member.modifiers || - !member.modifiers.some(mod => mod.kind === ts.SyntaxKind.StaticKeyword)) { - return false; - } - if (!ts.isIdentifier(member.name) || member.name.text !== 'decorators') { - return false; - } - if (!member.initializer || !ts.isArrayLiteralExpression(member.initializer)) { - return false; - } - return true; -} - -function lookupProperty(expr: ts.ObjectLiteralExpression, prop: string): ts.Expression|undefined { - const decl = expr.properties.find( - elem => !!elem.name && ts.isIdentifier(elem.name) && elem.name.text === prop); - if (decl === undefined || !ts.isPropertyAssignment(decl)) { - return undefined; - } - return decl.initializer; -} - -function resolveToStaticSymbol( - id: ts.Identifier, containingFile: string, reflector: StaticReflector, - checker: ts.TypeChecker): StaticSymbol|null { - const res = checker.getSymbolAtLocation(id); - if (!res || !res.declarations || res.declarations.length === 0) { - return null; - } - const decl = res.declarations[0]; - if (!ts.isImportSpecifier(decl)) { - return null; - } - const moduleSpecifier = decl.parent!.parent!.parent!.moduleSpecifier; - if (!ts.isStringLiteral(moduleSpecifier)) { - return null; - } - return reflector.tryFindDeclaration(moduleSpecifier.text, id.text, containingFile); -} - -export class StripDecoratorsMetadataTransformer implements MetadataTransformer { - constructor(private coreDecorators: Set, private reflector: StaticReflector) {} - - start(sourceFile: ts.SourceFile): ValueTransform|undefined { - return (value: MetadataValue, node: ts.Node): MetadataValue => { - if (isClassMetadata(value) && ts.isClassDeclaration(node) && value.decorators) { - value.decorators = value.decorators.filter(d => { - if (isMetadataSymbolicCallExpression(d) && - isMetadataImportedSymbolReferenceExpression(d.expression)) { - const declaration = this.reflector.tryFindDeclaration( - d.expression.module, d.expression.name, sourceFile.fileName); - if (declaration && this.coreDecorators.has(declaration)) { - return false; - } - } - return true; - }); - } - return value; - }; - } -} diff --git a/packages/compiler-cli/test/ngc_spec.ts b/packages/compiler-cli/test/ngc_spec.ts index 8eee29092b6d37..b310c5adf705a6 100644 --- a/packages/compiler-cli/test/ngc_spec.ts +++ b/packages/compiler-cli/test/ngc_spec.ts @@ -10,7 +10,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as ts from 'typescript'; -import {main, readCommandLineAndConfiguration, watchMode} from '../src/main'; +import {main, mainDiagnosticsForTest, readCommandLineAndConfiguration, watchMode} from '../src/main'; import {setup, stripAnsi} from './test_support'; describe('ngc transformer command-line', () => { @@ -97,6 +97,103 @@ describe('ngc transformer command-line', () => { expect(exitCode).toBe(1); }); + describe('decorator metadata', () => { + it('should add metadata as decorators if "annotationsAs" is set to "decorators"', () => { + writeConfig(`{ + "extends": "./tsconfig-base.json", + "compilerOptions": { + "emitDecoratorMetadata": true + }, + "angularCompilerOptions": { + "annotationsAs": "decorators" + }, + "files": ["mymodule.ts"] + }`); + write('aclass.ts', `export class AClass {}`); + write('mymodule.ts', ` + import {NgModule} from '@angular/core'; + import {AClass} from './aclass'; + + @NgModule({declarations: []}) + export class MyModule { + constructor(importedClass: AClass) {} + } + `); + + const exitCode = main(['-p', basePath], errorSpy); + expect(exitCode).toEqual(0); + + const mymodulejs = path.resolve(outDir, 'mymodule.js'); + const mymoduleSource = fs.readFileSync(mymodulejs, 'utf8'); + expect(mymoduleSource).toContain('MyModule = __decorate(['); + expect(mymoduleSource).toContain(`import { AClass } from './aclass';`); + expect(mymoduleSource).toContain(`__metadata("design:paramtypes", [AClass])`); + expect(mymoduleSource).not.toContain('MyModule.ctorParameters'); + expect(mymoduleSource).not.toContain('MyModule.decorators'); + }); + + it('should add metadata for Angular-decorated classes as static fields', () => { + writeConfig(`{ + "extends": "./tsconfig-base.json", + "files": ["mymodule.ts"] + }`); + write('aclass.ts', `export class AClass {}`); + write('mymodule.ts', ` + import {NgModule} from '@angular/core'; + import {AClass} from './aclass'; + + @NgModule({declarations: []}) + export class MyModule { + constructor(importedClass: AClass) {} + } + `); + + const exitCode = main(['-p', basePath], errorSpy); + expect(exitCode).toEqual(0); + + const mymodulejs = path.resolve(outDir, 'mymodule.js'); + const mymoduleSource = fs.readFileSync(mymodulejs, 'utf8'); + expect(mymoduleSource).not.toContain('__decorate'); + expect(mymoduleSource).toContain('args: [{ declarations: [] },] }'); + expect(mymoduleSource).not.toContain(`__metadata`); + expect(mymoduleSource).toContain(`import { AClass } from './aclass';`); + expect(mymoduleSource).toContain(`{ type: AClass }`); + }); + + it('should not downlevel decorators for classes with custom decorators', () => { + writeConfig(`{ + "extends": "./tsconfig-base.json", + "files": ["mymodule.ts"] + }`); + write('aclass.ts', `export class AClass {}`); + write('decorator.ts', ` + export function CustomDecorator(metadata: any) { + return (...args: any[]) => {} + } + `); + write('mymodule.ts', ` + import {AClass} from './aclass'; + import {CustomDecorator} from './decorator'; + + @CustomDecorator({declarations: []}) + export class MyModule { + constructor(importedClass: AClass) {} + } + `); + + const exitCode = main(['-p', basePath], errorSpy); + expect(exitCode).toEqual(0); + + const mymodulejs = path.resolve(outDir, 'mymodule.js'); + const mymoduleSource = fs.readFileSync(mymodulejs, 'utf8'); + expect(mymoduleSource).toContain('__decorate'); + expect(mymoduleSource).toContain('({ declarations: [] })'); + expect(mymoduleSource).not.toContain('AClass'); + expect(mymoduleSource).not.toContain('.ctorParameters ='); + expect(mymoduleSource).not.toContain('.decorators = '); + }); + }); + describe('errors', () => { beforeEach(() => { errorSpy.and.stub(); @@ -557,8 +654,6 @@ describe('ngc transformer command-line', () => { const mymodulejs = path.resolve(outDir, 'mymodule.js'); const mymoduleSource = fs.readFileSync(mymodulejs, 'utf8'); expect(mymoduleSource).not.toContain('@fileoverview added by tsickle'); - expect(mymoduleSource).toContain('MyComp = __decorate'); - expect(mymoduleSource).not.toContain('MyComp.decorators = ['); }); it('should add closure annotations', () => { @@ -570,10 +665,14 @@ describe('ngc transformer command-line', () => { "files": ["mymodule.ts"] }`); write('mymodule.ts', ` - import {NgModule, Component} from '@angular/core'; + import {NgModule, Component, Injectable} from '@angular/core'; + + @Injectable() + export class InjectedClass {} @Component({template: ''}) export class MyComp { + constructor(injected: InjectedClass) {} fn(p: any) {} } @@ -588,74 +687,7 @@ describe('ngc transformer command-line', () => { const mymoduleSource = fs.readFileSync(mymodulejs, 'utf8'); expect(mymoduleSource).toContain('@fileoverview added by tsickle'); expect(mymoduleSource).toContain('@param {?} p'); - }); - - it('should add metadata as decorators', () => { - writeConfig(`{ - "extends": "./tsconfig-base.json", - "compilerOptions": { - "emitDecoratorMetadata": true - }, - "angularCompilerOptions": { - "annotationsAs": "decorators" - }, - "files": ["mymodule.ts"] - }`); - write('aclass.ts', `export class AClass {}`); - write('mymodule.ts', ` - import {NgModule} from '@angular/core'; - import {AClass} from './aclass'; - - @NgModule({declarations: []}) - export class MyModule { - constructor(importedClass: AClass) {} - } - `); - - const exitCode = main(['-p', basePath], errorSpy); - expect(exitCode).toEqual(0); - - const mymodulejs = path.resolve(outDir, 'mymodule.js'); - const mymoduleSource = fs.readFileSync(mymodulejs, 'utf8'); - expect(mymoduleSource).toContain('MyModule = __decorate(['); - expect(mymoduleSource).toContain(`import { AClass } from './aclass';`); - expect(mymoduleSource).toContain(`__metadata("design:paramtypes", [AClass])`); - }); - - it('should add metadata as static fields', () => { - // Note: Don't specify emitDecoratorMetadata here on purpose, - // as regression test for https://github.com/angular/angular/issues/19916. - writeConfig(`{ - "extends": "./tsconfig-base.json", - "compilerOptions": { - "emitDecoratorMetadata": false - }, - "angularCompilerOptions": { - "annotationsAs": "static fields" - }, - "files": ["mymodule.ts"] - }`); - write('aclass.ts', `export class AClass {}`); - write('mymodule.ts', ` - import {NgModule} from '@angular/core'; - import {AClass} from './aclass'; - - @NgModule({declarations: []}) - export class MyModule { - constructor(importedClass: AClass) {} - } - `); - - const exitCode = main(['-p', basePath], errorSpy); - expect(exitCode).toEqual(0); - - const mymodulejs = path.resolve(outDir, 'mymodule.js'); - const mymoduleSource = fs.readFileSync(mymodulejs, 'utf8'); - expect(mymoduleSource).not.toContain('__decorate'); - expect(mymoduleSource).toContain('args: [{ declarations: [] },] }'); - expect(mymoduleSource).not.toContain(`__metadata`); - expect(mymoduleSource).toContain(`import { AClass } from './aclass';`); - expect(mymoduleSource).toContain(`{ type: AClass }`); + expect(mymoduleSource).toMatch(/\/\*\* @nocollapse \*\/\s+MyComp\.ctorParameters = /); }); }); diff --git a/packages/compiler-cli/test/transformers/BUILD.bazel b/packages/compiler-cli/test/transformers/BUILD.bazel index dd82f46edc8113..60965e95b4fdbc 100644 --- a/packages/compiler-cli/test/transformers/BUILD.bazel +++ b/packages/compiler-cli/test/transformers/BUILD.bazel @@ -8,6 +8,7 @@ ts_library( "//packages:types", "//packages/compiler", "//packages/compiler-cli", + "//packages/compiler-cli/src/ngtsc/reflection", "//packages/compiler-cli/test:test_utils", "//packages/compiler/test:test_utils", "//packages/core", diff --git a/packages/compiler-cli/test/transformers/downlevel_decorators_transform_spec.ts b/packages/compiler-cli/test/transformers/downlevel_decorators_transform_spec.ts new file mode 100644 index 00000000000000..1707bb80e7ef12 --- /dev/null +++ b/packages/compiler-cli/test/transformers/downlevel_decorators_transform_spec.ts @@ -0,0 +1,555 @@ +/** + * @license + * Copyright Google LLC 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 {TypeScriptReflectionHost} from '../../src/ngtsc/reflection/src/typescript'; +import {getDownlevelDecoratorsTransform} from '../../src/transformers/downlevel_decorators_transform'; +import {MockAotContext, MockCompilerHost} from '../mocks'; + +const TEST_FILE_INPUT = '/test.ts'; +const TEST_FILE_OUTPUT = `/test.js`; +const TEST_FILE_DTS_OUTPUT = `/test.d.ts`; + +describe('downlevel decorator transform', () => { + let host: MockCompilerHost; + let context: MockAotContext; + let diagnostics: ts.Diagnostic[]; + let isClosureEnabled: boolean; + + beforeEach(() => { + diagnostics = []; + context = new MockAotContext('/', { + 'dom_globals.d.ts': ` + declare class HTMLElement {}; + declare class Document {}; + ` + }); + host = new MockCompilerHost(context); + isClosureEnabled = false; + }); + + function transform( + contents: string, compilerOptions: ts.CompilerOptions = {}, + preTransformers: ts.TransformerFactory[] = []) { + context.writeFile(TEST_FILE_INPUT, contents); + const program = ts.createProgram( + [TEST_FILE_INPUT, '/dom_globals.d.ts'], { + module: ts.ModuleKind.CommonJS, + importHelpers: true, + lib: ['dom', 'es2015'], + target: ts.ScriptTarget.ES2017, + declaration: true, + experimentalDecorators: true, + emitDecoratorMetadata: false, + ...compilerOptions + }, + host); + const testFile = program.getSourceFile(TEST_FILE_INPUT); + const typeChecker = program.getTypeChecker(); + const reflectionHost = new TypeScriptReflectionHost(typeChecker); + const transformers: ts.CustomTransformers = { + before: [ + ...preTransformers, + getDownlevelDecoratorsTransform( + program.getTypeChecker(), diagnostics, reflectionHost, + /* isCore */ false, isClosureEnabled) + ] + }; + let output: string|null = null; + let dtsOutput: string|null = null; + const emitResult = program.emit( + testFile, ((fileName, outputText) => { + if (fileName === TEST_FILE_OUTPUT) { + output = outputText; + } else if (fileName === TEST_FILE_DTS_OUTPUT) { + dtsOutput = outputText; + } + }), + undefined, undefined, transformers); + diagnostics.push(...emitResult.diagnostics); + expect(output).not.toBeNull(); + return { + output: omitLeadingWhitespace(output!), + dtsOutput: dtsOutput ? omitLeadingWhitespace(dtsOutput) : null + }; + } + + it('should downlevel decorators for @Injectable decorated class', () => { + const {output} = transform(` + import {Injectable} from '@angular/core'; + + export class ClassInject {}; + + @Injectable() + export class MyService { + constructor(v: ClassInject) {} + } + `); + + expect(diagnostics.length).toBe(0); + expect(output).toContain(dedent` + MyService.decorators = [ + { type: core_1.Injectable } + ]; + MyService.ctorParameters = () => [ + { type: ClassInject } + ];`); + expect(output).not.toContain('tslib'); + }); + + it('should downlevel decorators for @Directive decorated class', () => { + const {output} = transform(` + import {Directive} from '@angular/core'; + + export class ClassInject {}; + + @Directive() + export class MyDir { + constructor(v: ClassInject) {} + } + `); + + expect(diagnostics.length).toBe(0); + expect(output).toContain(dedent` + MyDir.decorators = [ + { type: core_1.Directive } + ]; + MyDir.ctorParameters = () => [ + { type: ClassInject } + ];`); + expect(output).not.toContain('tslib'); + }); + + it('should downlevel decorators for @Component decorated class', () => { + const {output} = transform(` + import {Component} from '@angular/core'; + + export class ClassInject {}; + + @Component({template: 'hello'}) + export class MyComp { + constructor(v: ClassInject) {} + } + `); + + expect(diagnostics.length).toBe(0); + expect(output).toContain(dedent` + MyComp.decorators = [ + { type: core_1.Component, args: [{ template: 'hello' },] } + ]; + MyComp.ctorParameters = () => [ + { type: ClassInject } + ];`); + expect(output).not.toContain('tslib'); + }); + + it('should downlevel decorators for @Pipe decorated class', () => { + const {output} = transform(` + import {Pipe} from '@angular/core'; + + export class ClassInject {}; + + @Pipe({selector: 'hello'}) + export class MyPipe { + constructor(v: ClassInject) {} + } + `); + + expect(diagnostics.length).toBe(0); + expect(output).toContain(dedent` + MyPipe.decorators = [ + { type: core_1.Pipe, args: [{ selector: 'hello' },] } + ]; + MyPipe.ctorParameters = () => [ + { type: ClassInject } + ];`); + expect(output).not.toContain('tslib'); + }); + + it('should not downlevel non-Angular class decorators', () => { + const {output} = transform(` + @SomeUnknownDecorator() + export class MyClass {} + `); + + expect(diagnostics.length).toBe(0); + expect(output).toContain(dedent` + MyClass = tslib_1.__decorate([ + SomeUnknownDecorator() + ], MyClass); + `); + expect(output).not.toContain('MyClass.decorators'); + }); + + it('should downlevel Angular-decorated class member', () => { + const {output} = transform(` + import {Input} from '@angular/core'; + + export class MyDir { + @Input() disabled: boolean = false; + } + `); + + expect(diagnostics.length).toBe(0); + expect(output).toContain(dedent` + MyDir.propDecorators = { + disabled: [{ type: core_1.Input }] + }; + `); + expect(output).not.toContain('tslib'); + }); + + it('should not downlevel class member with unknown decorator', () => { + const {output} = transform(` + export class MyDir { + @SomeDecorator() disabled: boolean = false; + } + `); + + expect(diagnostics.length).toBe(0); + expect(output).toContain(dedent` + tslib_1.__decorate([ + SomeDecorator() + ], MyDir.prototype, "disabled", void 0); + `); + expect(output).not.toContain('MyClass.propDecorators'); + }); + + // Angular is not concerned with type information for decorated class members. Instead, + // the type is omitted. This also helps with server side rendering as DOM globals which + // are used as types, do not load at runtime. https://github.com/angular/angular/issues/30586. + it('should downlevel Angular-decorated class member but not preserve type', () => { + context.writeFile('/other-file.ts', `export class MyOtherClass {}`); + const {output} = transform(` + import {Input} from '@angular/core'; + import {MyOtherClass} from './other-file'; + + export class MyDir { + @Input() trigger: HTMLElement; + @Input() fromOtherFile: MyOtherClass; + } + `); + + expect(diagnostics.length).toBe(0); + expect(output).toContain(dedent` + MyDir.propDecorators = { + trigger: [{ type: core_1.Input }], + fromOtherFile: [{ type: core_1.Input }] + }; + `); + expect(output).not.toContain('HTMLElement'); + expect(output).not.toContain('MyOtherClass'); + }); + + it('should capture constructor type metadata with `emitDecoratorMetadata` enabled', () => { + context.writeFile('/other-file.ts', `export class MyOtherClass {}`); + const {output} = transform( + ` + import {Directive} from '@angular/core'; + import {MyOtherClass} from './other-file'; + + @Directive() + export class MyDir { + constructor(other: MyOtherClass) {} + } + `, + {emitDecoratorMetadata: true}); + + expect(diagnostics.length).toBe(0); + expect(output).toContain('const other_file_1 = require("./other-file");'); + expect(output).toContain(dedent` + MyDir.decorators = [ + { type: core_1.Directive } + ]; + MyDir.ctorParameters = () => [ + { type: other_file_1.MyOtherClass } + ]; + `); + }); + + it('should capture constructor type metadata with `emitDecoratorMetadata` disabled', () => { + context.writeFile('/other-file.ts', `export class MyOtherClass {}`); + const {output, dtsOutput} = transform( + ` + import {Directive} from '@angular/core'; + import {MyOtherClass} from './other-file'; + + @Directive() + export class MyDir { + constructor(other: MyOtherClass) {} + } + `, + {emitDecoratorMetadata: false}); + + expect(diagnostics.length).toBe(0); + expect(output).toContain('const other_file_1 = require("./other-file");'); + expect(output).toContain(dedent` + MyDir.decorators = [ + { type: core_1.Directive } + ]; + MyDir.ctorParameters = () => [ + { type: other_file_1.MyOtherClass } + ]; + `); + expect(dtsOutput).toContain('import'); + }); + + it('should properly serialize constructor parameter with external qualified name type', () => { + context.writeFile('/other-file.ts', `export class MyOtherClass {}`); + const {output} = transform(` + import {Directive} from '@angular/core'; + import * as externalFile from './other-file'; + + @Directive() + export class MyDir { + constructor(other: externalFile.MyOtherClass) {} + } + `); + + expect(diagnostics.length).toBe(0); + expect(output).toContain('const externalFile = require("./other-file");'); + expect(output).toContain(dedent` + MyDir.decorators = [ + { type: core_1.Directive } + ]; + MyDir.ctorParameters = () => [ + { type: externalFile.MyOtherClass } + ]; + `); + }); + + it('should properly serialize constructor parameter with local qualified name type', () => { + const {output} = transform(` + import {Directive} from '@angular/core'; + + namespace other { + export class OtherClass {} + }; + + @Directive() + export class MyDir { + constructor(other: other.OtherClass) {} + } + `); + + expect(diagnostics.length).toBe(0); + expect(output).toContain('var other;'); + expect(output).toContain(dedent` + MyDir.decorators = [ + { type: core_1.Directive } + ]; + MyDir.ctorParameters = () => [ + { type: other.OtherClass } + ]; + `); + }); + + it('should properly downlevel constructor parameter decorators', () => { + const {output} = transform(` + import {Inject, Directive, DOCUMENT} from '@angular/core'; + + @Directive() + export class MyDir { + constructor(@Inject(DOCUMENT) document: Document) {} + } + `); + + expect(diagnostics.length).toBe(0); + expect(output).toContain(dedent` + MyDir.decorators = [ + { type: core_1.Directive } + ]; + MyDir.ctorParameters = () => [ + { type: Document, decorators: [{ type: core_1.Inject, args: [core_1.DOCUMENT,] }] } + ]; + `); + }); + + it('should properly downlevel constructor parameters with union type', () => { + const {output} = transform(` + import {Optional, Directive, NgZone} from '@angular/core'; + + @Directive() + export class MyDir { + constructor(@Optional() ngZone: NgZone|null) {} + } + `); + + expect(diagnostics.length).toBe(0); + expect(output).toContain(dedent` + MyDir.decorators = [ + { type: core_1.Directive } + ]; + MyDir.ctorParameters = () => [ + { type: core_1.NgZone, decorators: [{ type: core_1.Optional }] } + ]; + `); + }); + + it('should add @nocollapse if closure compiler is enabled', () => { + isClosureEnabled = true; + const {output} = transform(` + import {Directive} from '@angular/core'; + + export class ClassInject {}; + + @Directive() + export class MyDir { + constructor(v: ClassInject) {} + } + `); + + expect(diagnostics.length).toBe(0); + expect(output).toContain(dedent` + MyDir.decorators = [ + { type: core_1.Directive } + ]; + /** @nocollapse */ + MyDir.ctorParameters = () => [ + { type: ClassInject } + ]; + `); + expect(output).not.toContain('tslib'); + }); + + it('should not retain unused type imports due to decorator downleveling with ' + + '`emitDecoratorMetadata` enabled.', + () => { + context.writeFile('/external.ts', ` + export class ErrorHandler {} + export class ClassInject {} + `); + const {output} = transform( + ` + import {Directive} from '@angular/core'; + import {ErrorHandler, ClassInject} from './external'; + + @Directive() + export class MyDir { + private _errorHandler: ErrorHandler; + constructor(v: ClassInject) {} + } + `, + {module: ts.ModuleKind.ES2015, emitDecoratorMetadata: true}); + + expect(diagnostics.length).toBe(0); + expect(output).not.toContain('tslib'); + expect(output).not.toContain('ErrorHandler'); + }); + + it('should not retain unused type imports due to decorator downleveling with ' + + '`emitDecoratorMetadata` disabled', + () => { + context.writeFile('/external.ts', ` + export class ErrorHandler {} + export class ClassInject {} + `); + const {output} = transform( + ` + import {Directive} from '@angular/core'; + import {ErrorHandler, ClassInject} from './external'; + + @Directive() + export class MyDir { + private _errorHandler: ErrorHandler; + constructor(v: ClassInject) {} + } + `, + {module: ts.ModuleKind.ES2015, emitDecoratorMetadata: false}); + + expect(diagnostics.length).toBe(0); + expect(output).not.toContain('tslib'); + expect(output).not.toContain('ErrorHandler'); + }); + + it('should be able to serialize circular constructor parameter type', () => { + const {output} = transform(` + import {Directive, Optional, Inject, SkipSelf} from '@angular/core'; + + @Directive() + export class MyDir { + constructor(@Optional() @SkipSelf() @Inject(MyDir) parentDir: MyDir|null) {} + } + `); + + expect(diagnostics.length).toBe(0); + expect(output).toContain(dedent` + MyDir.decorators = [ + { type: core_1.Directive } + ]; + MyDir.ctorParameters = () => [ + { type: MyDir, decorators: [{ type: core_1.Optional }, { type: core_1.SkipSelf }, { type: core_1.Inject, args: [MyDir,] }] } + ]; + `); + }); + + it('should create diagnostic if property name is non-serializable', () => { + transform(` + import {Directive, ViewChild, TemplateRef} from '@angular/core'; + + @Directive() + export class MyDir { + @ViewChild(TemplateRef) ['some' + 'name']: TemplateRef|undefined; + } + `); + + expect(diagnostics.length).toBe(1); + expect(diagnostics[0].messageText as string) + .toBe(`Cannot process decorators for class element with non-analyzable name.`); + }); + + it('should allow preceding custom transformers to strip decorators', () => { + const stripAllDecoratorsTransform: ts.TransformerFactory = context => { + return (sourceFile: ts.SourceFile) => { + const visitNode = (node: ts.Node): ts.Node => { + if (ts.isClassDeclaration(node) || ts.isClassElement(node)) { + const cloned = ts.getMutableClone(node); + cloned.decorators = undefined; + return cloned; + } + return ts.visitEachChild(node, visitNode, context); + }; + return visitNode(sourceFile) as ts.SourceFile; + }; + }; + + const {output} = transform( + ` + import {Directive} from '@angular/core'; + + export class MyInjectedClass {} + + @Directive() + export class MyDir { + constructor(someToken: MyInjectedClass) {} + } + `, + {}, [stripAllDecoratorsTransform]); + + expect(diagnostics.length).toBe(0); + expect(output).not.toContain('MyDir.decorators'); + expect(output).not.toContain('MyDir.ctorParameters'); + expect(output).not.toContain('tslib'); + }); +}); + +/** Template string function that can be used to dedent a given string literal. */ +export function dedent(strings: TemplateStringsArray, ...values: any[]) { + let joinedString = ''; + for (let i = 0; i < values.length; i++) { + joinedString += `${strings[i]}${values[i]}`; + } + joinedString += strings[strings.length - 1]; + return omitLeadingWhitespace(joinedString); +} + +/** Omits the leading whitespace for each line of the given text. */ +function omitLeadingWhitespace(text: string): string { + return text.replace(/^\s+/gm, ''); +} diff --git a/packages/core/test/bundling/todo_i18n/OUTSTANDING_WORK.md b/packages/core/test/bundling/todo_i18n/OUTSTANDING_WORK.md index c2baf476761132..18e924ac47295e 100644 --- a/packages/core/test/bundling/todo_i18n/OUTSTANDING_WORK.md +++ b/packages/core/test/bundling/todo_i18n/OUTSTANDING_WORK.md @@ -7,7 +7,7 @@ - [ ] Make it work with `(keyup.Enter)`. ## Compiler -- [ ] Remove ` tslib_1.__decorate([core_1.Input(), tslib_1.__metadata("design:type", Object)], TodoComponent.prototype, "todo", void 0);` from generated output. +- [X] Remove ` tslib_1.__decorate([core_1.Input(), tslib_1.__metadata("design:type", Object)], TodoComponent.prototype, "todo", void 0);` from generated output. - [ ] Allow compilation of `@angular/common` through ivy. ## Ivy Runtime @@ -33,4 +33,4 @@ ports even after `ctrl-c`. This command kills the outstanding processes. ``` kill -9 $(ps aux | grep ibazel\\\|devserver | cut -c 17-23) -``` \ No newline at end of file +```