From 5dff077d505da60ddead9f2ff2ddaaaea6feeb4a Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Tue, 30 Nov 2021 15:26:54 +0100 Subject: [PATCH] feat(core): add migration to remove entryComponents (#44308) Adds an automated migration that will drop any usages of `entryComponents` from `@NgModule` and `@Component`. PR Close #44308 --- packages/core/schematics/BUILD.bazel | 1 + packages/core/schematics/migrations.json | 10 + .../migrations/entry-components/BUILD.bazel | 18 ++ .../migrations/entry-components/README.md | 33 +++ .../migrations/entry-components/index.ts | 56 +++++ .../migrations/entry-components/util.ts | 51 +++++ .../schematics/migrations/google3/BUILD.bazel | 1 + .../migrations/google3/entryComponentsRule.ts | 28 +++ packages/core/schematics/test/BUILD.bazel | 1 + .../schematics/test/entry_components_spec.ts | 182 +++++++++++++++ .../test/google3/entry_components_spec.ts | 214 ++++++++++++++++++ 11 files changed, 595 insertions(+) create mode 100644 packages/core/schematics/migrations/entry-components/BUILD.bazel create mode 100644 packages/core/schematics/migrations/entry-components/README.md create mode 100644 packages/core/schematics/migrations/entry-components/index.ts create mode 100644 packages/core/schematics/migrations/entry-components/util.ts create mode 100644 packages/core/schematics/migrations/google3/entryComponentsRule.ts create mode 100644 packages/core/schematics/test/entry_components_spec.ts create mode 100644 packages/core/schematics/test/google3/entry_components_spec.ts diff --git a/packages/core/schematics/BUILD.bazel b/packages/core/schematics/BUILD.bazel index a60dbd252de..1a2d8682dce 100644 --- a/packages/core/schematics/BUILD.bazel +++ b/packages/core/schematics/BUILD.bazel @@ -13,6 +13,7 @@ pkg_npm( ], visibility = ["//packages/core:__pkg__"], deps = [ + "//packages/core/schematics/migrations/entry-components", "//packages/core/schematics/migrations/router-link-empty-expression", "//packages/core/schematics/migrations/testbed-teardown", ], diff --git a/packages/core/schematics/migrations.json b/packages/core/schematics/migrations.json index 05f02c3dec0..0ffa43a06c3 100644 --- a/packages/core/schematics/migrations.json +++ b/packages/core/schematics/migrations.json @@ -9,6 +9,16 @@ "version": "13.0.0-beta", "description": "In Angular version 13, the `teardown` flag in `TestBed` will be enabled by default. This migration automatically opts out existing apps from the new teardown behavior.", "factory": "./migrations/testbed-teardown/index" + }, + "migration-v13.1-entry-components": { + "version": "13.1.0-beta", + "description": "As of Angular version 13, `entryComponents` are no longer necessary.", + "factory": "./migrations/entry-components/index" + }, + "migration-v14-entry-components": { + "version": "14.0.0-beta", + "description": "As of Angular version 13, `entryComponents` are no longer necessary.", + "factory": "./migrations/entry-components/index" } } } diff --git a/packages/core/schematics/migrations/entry-components/BUILD.bazel b/packages/core/schematics/migrations/entry-components/BUILD.bazel new file mode 100644 index 00000000000..29508aa640a --- /dev/null +++ b/packages/core/schematics/migrations/entry-components/BUILD.bazel @@ -0,0 +1,18 @@ +load("//tools:defaults.bzl", "ts_library") + +ts_library( + name = "entry-components", + srcs = glob(["**/*.ts"]), + tsconfig = "//packages/core/schematics:tsconfig.json", + visibility = [ + "//packages/core/schematics:__pkg__", + "//packages/core/schematics/migrations/google3:__pkg__", + "//packages/core/schematics/test:__pkg__", + ], + deps = [ + "//packages/core/schematics/utils", + "@npm//@angular-devkit/schematics", + "@npm//@types/node", + "@npm//typescript", + ], +) diff --git a/packages/core/schematics/migrations/entry-components/README.md b/packages/core/schematics/migrations/entry-components/README.md new file mode 100644 index 00000000000..4cbdedb4443 --- /dev/null +++ b/packages/core/schematics/migrations/entry-components/README.md @@ -0,0 +1,33 @@ +## entryComponents migration +As of Angular version 13, the `entryComponents` option in `@NgModule` and `@Component` isn't +necessary anymore. This migration will automatically remove any usages. + +#### Before +```ts +import { NgModule, Component } from '@angular/core'; + +@Component({selector: 'my-comp', template: ''}) +export class MyComp {} + +@NgModule({ + declarations: [MyComp], + entryComponents: [MyComp], + exports: [MyComp] +}) +export class MyModule {} +``` + +#### After +```ts +import { NgModule, Component } from '@angular/core'; + +@Component({selector: 'my-comp', template: ''}) +export class MyComp {} + +@NgModule({ + declarations: [MyComp], + exports: [MyComp] +}) +export class MyModule {} +``` + diff --git a/packages/core/schematics/migrations/entry-components/index.ts b/packages/core/schematics/migrations/entry-components/index.ts new file mode 100644 index 00000000000..8719bc68481 --- /dev/null +++ b/packages/core/schematics/migrations/entry-components/index.ts @@ -0,0 +1,56 @@ +/** + * @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 {Rule, SchematicsException, Tree} from '@angular-devkit/schematics'; +import {relative} from 'path'; +import ts from 'typescript'; + +import {getProjectTsConfigPaths} from '../../utils/project_tsconfig_paths'; +import {canMigrateFile, createMigrationProgram} from '../../utils/typescript/compiler_host'; + +import {migrateEntryComponentsUsages} from './util'; + + +/** Migration that removes `entryComponents` usages. */ +export default function(): Rule { + return async (tree: Tree) => { + const {buildPaths, testPaths} = await getProjectTsConfigPaths(tree); + const basePath = process.cwd(); + const allPaths = [...buildPaths, ...testPaths]; + + if (!allPaths.length) { + throw new SchematicsException( + 'Could not find any tsconfig file. Cannot remove `entryComponents`.'); + } + + for (const tsconfigPath of allPaths) { + runEntryComponentsMigration(tree, tsconfigPath, basePath); + } + }; +} + +function runEntryComponentsMigration(tree: Tree, tsconfigPath: string, basePath: string) { + const {program} = createMigrationProgram(tree, tsconfigPath, basePath); + const typeChecker = program.getTypeChecker(); + const printer = ts.createPrinter(); + + program.getSourceFiles() + .filter(sourceFile => canMigrateFile(basePath, sourceFile, program)) + .forEach(sourceFile => { + const usages = migrateEntryComponentsUsages(typeChecker, printer, sourceFile); + + if (usages.length > 0) { + const update = tree.beginUpdate(relative(basePath, sourceFile.fileName)); + usages.forEach(usage => { + update.remove(usage.start, usage.length); + update.insertRight(usage.start, usage.replacement); + }); + tree.commitUpdate(update); + } + }); +} diff --git a/packages/core/schematics/migrations/entry-components/util.ts b/packages/core/schematics/migrations/entry-components/util.ts new file mode 100644 index 00000000000..451ec00aefd --- /dev/null +++ b/packages/core/schematics/migrations/entry-components/util.ts @@ -0,0 +1,51 @@ +/** + * @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 ts from 'typescript'; + +import {getCallDecoratorImport} from '../../utils/typescript/decorators'; + +/** Finds and migrates all Angular decorators that pass in `entryComponents`. */ +export function migrateEntryComponentsUsages( + typeChecker: ts.TypeChecker, printer: ts.Printer, sourceFile: ts.SourceFile) { + const results: {start: number, length: number, end: number, replacement: string}[] = []; + + sourceFile.forEachChild(function walk(node: ts.Node) { + if (ts.isDecorator(node) && ts.isCallExpression(node.expression) && + node.expression.arguments.length === 1 && + ts.isObjectLiteralExpression(node.expression.arguments[0])) { + const analysis = getCallDecoratorImport(typeChecker, node); + + if (analysis && analysis.importModule === '@angular/core' && + (analysis.name === 'Component' || analysis.name === 'NgModule')) { + const literal = node.expression.arguments[0]; + const entryComponentsProp = literal.properties.find( + property => ts.isPropertyAssignment(property) && ts.isIdentifier(property.name) && + property.name.text === 'entryComponents'); + + if (entryComponentsProp) { + const replacementNode = ts.updateObjectLiteral( + literal, literal.properties.filter(prop => prop !== entryComponentsProp)); + + results.push({ + start: literal.getStart(), + length: literal.getWidth(), + end: literal.getEnd(), + replacement: printer.printNode(ts.EmitHint.Unspecified, replacementNode, sourceFile) + }); + } + } + } + + node.forEachChild(walk); + }); + + // Sort the operations in reverse order in order to avoid + // issues when migrating multiple usages within the same file. + return results.sort((a, b) => b.start - a.start); +} diff --git a/packages/core/schematics/migrations/google3/BUILD.bazel b/packages/core/schematics/migrations/google3/BUILD.bazel index e7a14c4c7fc..2c212e6dca8 100644 --- a/packages/core/schematics/migrations/google3/BUILD.bazel +++ b/packages/core/schematics/migrations/google3/BUILD.bazel @@ -6,6 +6,7 @@ ts_library( tsconfig = "//packages/core/schematics:tsconfig.json", visibility = ["//packages/core/schematics/test/google3:__pkg__"], deps = [ + "//packages/core/schematics/migrations/entry-components", "//packages/core/schematics/migrations/testbed-teardown", "//packages/core/schematics/utils", "//packages/core/schematics/utils/tslint", diff --git a/packages/core/schematics/migrations/google3/entryComponentsRule.ts b/packages/core/schematics/migrations/google3/entryComponentsRule.ts new file mode 100644 index 00000000000..21db701c3f8 --- /dev/null +++ b/packages/core/schematics/migrations/google3/entryComponentsRule.ts @@ -0,0 +1,28 @@ +/** + * @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 {Replacement, RuleFailure, Rules} from 'tslint'; +import ts from 'typescript'; + +import {migrateEntryComponentsUsages} from '../../migrations/entry-components/util'; + + +/** TSLint rule that removes usages of `entryComponents`. */ +export class Rule extends Rules.TypedRule { + override applyWithProgram(sourceFile: ts.SourceFile, program: ts.Program): RuleFailure[] { + const typeChecker = program.getTypeChecker(); + const printer = ts.createPrinter(); + + return migrateEntryComponentsUsages(typeChecker, printer, sourceFile).map(usage => { + return new RuleFailure( + sourceFile, usage.start, usage.end, + 'entryComponents are deprecated and don\'t need to be passed in.', this.ruleName, + new Replacement(usage.start, usage.length, usage.replacement)); + }); + } +} diff --git a/packages/core/schematics/test/BUILD.bazel b/packages/core/schematics/test/BUILD.bazel index 631e77da3e7..e7a60fd3e47 100644 --- a/packages/core/schematics/test/BUILD.bazel +++ b/packages/core/schematics/test/BUILD.bazel @@ -8,6 +8,7 @@ ts_library( "//packages/core/schematics:migrations.json", ], deps = [ + "//packages/core/schematics/migrations/entry-components", "//packages/core/schematics/migrations/router-link-empty-expression", "//packages/core/schematics/migrations/testbed-teardown", "//packages/core/schematics/utils", diff --git a/packages/core/schematics/test/entry_components_spec.ts b/packages/core/schematics/test/entry_components_spec.ts new file mode 100644 index 00000000000..2ef3d8e61c4 --- /dev/null +++ b/packages/core/schematics/test/entry_components_spec.ts @@ -0,0 +1,182 @@ +/** + * @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 {getSystemPath, normalize, virtualFs} from '@angular-devkit/core'; +import {TempScopedNodeJsSyncHost} from '@angular-devkit/core/node/testing'; +import {HostTree} from '@angular-devkit/schematics'; +import {SchematicTestRunner, UnitTestTree} from '@angular-devkit/schematics/testing'; +import * as shx from 'shelljs'; + + +describe('entryComponents migration', () => { + let runner: SchematicTestRunner; + let host: TempScopedNodeJsSyncHost; + let tree: UnitTestTree; + let tmpDirPath: string; + let previousWorkingDir: string; + + beforeEach(() => { + runner = new SchematicTestRunner('test', require.resolve('../migrations.json')); + host = new TempScopedNodeJsSyncHost(); + tree = new UnitTestTree(new HostTree(host)); + + writeFile('/tsconfig.json', JSON.stringify({ + compilerOptions: { + lib: ['es2015'], + strictNullChecks: true, + }, + })); + writeFile('/angular.json', JSON.stringify({ + version: 1, + projects: {t: {architect: {build: {options: {tsConfig: './tsconfig.json'}}}}} + })); + + previousWorkingDir = shx.pwd(); + tmpDirPath = getSystemPath(host.root); + + // Switch into the temporary directory path. This allows us to run + // the schematic against our custom unit test tree. + shx.cd(tmpDirPath); + }); + + afterEach(() => { + shx.cd(previousWorkingDir); + shx.rm('-r', tmpDirPath); + }); + + it('should remove `entryComponents` usages from NgModule', async () => { + writeFile('/index.ts', ` + import { NgModule, Component } from '@angular/core'; + + @Component({selector: 'my-comp', template: ''}) + export class MyComp {} + + @NgModule({ + declarations: [MyComp], + entryComponents: [MyComp], + exports: [MyComp] + }) + export class MyModule {} + `); + + await runMigration(); + + expect(stripWhitespace(tree.readContent('/index.ts'))).toContain(stripWhitespace(` + @NgModule({ + declarations: [MyComp], + exports: [MyComp] + }) + `)); + }); + + it('should remove `entryComponents` usages from Component', async () => { + writeFile('/index.ts', ` + import { Component } from '@angular/core'; + + @Component({selector: 'comp-a', template: ''}) + export class CompA {} + + @Component({ + selector: 'comp-b', + entryComponents: [CompA], + template: '' + }) + export class CompB {} + `); + + await runMigration(); + + expect(stripWhitespace(tree.readContent('/index.ts'))).toContain(stripWhitespace(` + @Component({ + selector: 'comp-b', + template: '' + }) + `)); + }); + + it('should remove multiple `entryComponents` usages from a single file', async () => { + writeFile('/index.ts', ` + import { NgModule, Component } from '@angular/core'; + + @Component({selector: 'comp-a', template: ''}) + export class CompA {} + + @Component({ + selector: 'comp-b', + entryComponents: [CompA], + template: '' + }) + export class CompB {} + + @NgModule({ + declarations: [CompA, CompB], + entryComponents: [CompB], + exports: [CompA, CompB] + }) + export class MyModule {} + `); + + await runMigration(); + + const content = stripWhitespace(tree.readContent('/index.ts')); + + expect(content).toContain(stripWhitespace(` + @Component({ + selector: 'comp-b', + template: '' + }) + `)); + + expect(content).toContain(stripWhitespace(` + @NgModule({ + declarations: [CompA, CompB], + exports: [CompA, CompB] + }) + `)); + }); + + it('should not remove `entryComponents` usages from decorators that do not come from Angular', + async () => { + writeFile('/index.ts', ` + import { Component } from '@angular/core'; + import { NgModule } from '@not-angular/core'; + + @Component({selector: 'my-comp', template: ''}) + export class MyComp {} + + @NgModule({ + declarations: [MyComp], + entryComponents: [MyComp], + exports: [MyComp] + }) + export class MyModule {} + `); + + await runMigration(); + + expect(stripWhitespace(tree.readContent('/index.ts'))).toContain(stripWhitespace(` + @NgModule({ + declarations: [MyComp], + entryComponents: [MyComp], + exports: [MyComp] + }) + `)); + }); + + function writeFile(filePath: string, contents: string) { + host.sync.write(normalize(filePath), virtualFs.stringToFileBuffer(contents)); + } + + function runMigration() { + return runner.runSchematicAsync('migration-v13.1-entry-components', {}, tree).toPromise(); + } + + function stripWhitespace(contents: string) { + return contents.replace(/\s/g, ''); + } +}); diff --git a/packages/core/schematics/test/google3/entry_components_spec.ts b/packages/core/schematics/test/google3/entry_components_spec.ts new file mode 100644 index 00000000000..30462c302d8 --- /dev/null +++ b/packages/core/schematics/test/google3/entry_components_spec.ts @@ -0,0 +1,214 @@ +/** + * @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 {readFileSync, writeFileSync} from 'fs'; +import {dirname, join} from 'path'; +import * as shx from 'shelljs'; +import {Configuration, Linter} from 'tslint'; + +describe('Google3 entryComponents TSLint rule', () => { + const rulesDirectory = dirname(require.resolve('../../migrations/google3/entryComponentsRule')); + let tmpDir: string; + + beforeEach(() => { + tmpDir = join(process.env['TEST_TMPDIR']!, 'google3-test'); + shx.mkdir('-p', tmpDir); + + writeFile('tsconfig.json', JSON.stringify({ + compilerOptions: {module: 'es2015', baseUrl: './'}, + })); + }); + + afterEach(() => shx.rm('-r', tmpDir)); + + function runTSLint(fix: boolean) { + const program = Linter.createProgram(join(tmpDir, 'tsconfig.json')); + const linter = new Linter({fix, rulesDirectory: [rulesDirectory]}, program); + const config = Configuration.parseConfigFile({rules: {'entryComponents': true}}); + + program.getRootFileNames().forEach(fileName => { + linter.lint(fileName, program.getSourceFile(fileName)!.getFullText(), config); + }); + + return linter; + } + + function writeFile(fileName: string, content: string) { + writeFileSync(join(tmpDir, fileName), content); + } + + function getFile(fileName: string) { + return readFileSync(join(tmpDir, fileName), 'utf8'); + } + + + function stripWhitespace(contents: string) { + return contents.replace(/\s/g, ''); + } + + it('should flag entryComponents in NgModule', () => { + writeFile('/index.ts', ` + import { NgModule, Component } from '@angular/core'; + + @Component({selector: 'my-comp', template: ''}) + export class MyComp {} + + @NgModule({ + declarations: [MyComp], + entryComponents: [MyComp], + exports: [MyComp] + }) + export class MyModule {} + `); + + const linter = runTSLint(false); + const failures = linter.getResult().failures.map(failure => failure.getFailure()); + expect(failures.length).toBe(1); + expect(failures[0]).toMatch(/entryComponents are deprecated and don't need to be passed in/); + }); + + it('should flag entryComponents in Component', () => { + writeFile('/index.ts', ` + import { Component } from '@angular/core'; + + @Component({selector: 'comp-a', template: ''}) + export class CompA {} + + @Component({ + selector: 'comp-b', + entryComponents: [CompA], + template: '' + }) + export class CompB {} + `); + + const linter = runTSLint(false); + const failures = linter.getResult().failures.map(failure => failure.getFailure()); + expect(failures.length).toBe(1); + expect(failures[0]).toMatch(/entryComponents are deprecated and don't need to be passed in/); + }); + + it('should remove `entryComponents` usages from NgModule', () => { + writeFile('/index.ts', ` + import { NgModule, Component } from '@angular/core'; + + @Component({selector: 'my-comp', template: ''}) + export class MyComp {} + + @NgModule({ + declarations: [MyComp], + entryComponents: [MyComp], + exports: [MyComp] + }) + export class MyModule {} + `); + + runTSLint(true); + + expect(stripWhitespace(getFile('/index.ts'))).toContain(stripWhitespace(` + @NgModule({ + declarations: [MyComp], + exports: [MyComp] + }) + `)); + }); + + it('should remove `entryComponents` usages from Component', () => { + writeFile('/index.ts', ` + import { Component } from '@angular/core'; + + @Component({selector: 'comp-a', template: ''}) + export class CompA {} + + @Component({ + selector: 'comp-b', + entryComponents: [CompA], + template: '' + }) + export class CompB {} + `); + + runTSLint(true); + + expect(stripWhitespace(getFile('/index.ts'))).toContain(stripWhitespace(` + @Component({ + selector: 'comp-b', + template: '' + }) + `)); + }); + + it('should remove multiple `entryComponents` usages from a single file', () => { + writeFile('/index.ts', ` + import { NgModule, Component } from '@angular/core'; + + @Component({selector: 'comp-a', template: ''}) + export class CompA {} + + @Component({ + selector: 'comp-b', + entryComponents: [CompA], + template: '' + }) + export class CompB {} + + @NgModule({ + declarations: [CompA, CompB], + entryComponents: [CompB], + exports: [CompA, CompB] + }) + export class MyModule {} + `); + + runTSLint(true); + + const content = stripWhitespace(getFile('/index.ts')); + + expect(content).toContain(stripWhitespace(` + @Component({ + selector: 'comp-b', + template: '' + }) + `)); + + expect(content).toContain(stripWhitespace(` + @NgModule({ + declarations: [CompA, CompB], + exports: [CompA, CompB] + }) + `)); + }); + + it('should not remove `entryComponents` usages from decorators that do not come from Angular', + () => { + writeFile('/index.ts', ` + import { Component } from '@angular/core'; + import { NgModule } from '@not-angular/core'; + + @Component({selector: 'my-comp', template: ''}) + export class MyComp {} + + @NgModule({ + declarations: [MyComp], + entryComponents: [MyComp], + exports: [MyComp] + }) + export class MyModule {} + `); + + runTSLint(true); + + expect(stripWhitespace(getFile('/index.ts'))).toContain(stripWhitespace(` + @NgModule({ + declarations: [MyComp], + entryComponents: [MyComp], + exports: [MyComp] + }) + `)); + }); +});