diff --git a/packages/core/schematics/BUILD.bazel b/packages/core/schematics/BUILD.bazel index 5887aa17a3..cfd2d69125 100644 --- a/packages/core/schematics/BUILD.bazel +++ b/packages/core/schematics/BUILD.bazel @@ -28,5 +28,6 @@ pkg_npm( "//packages/core/schematics/migrations/undecorated-classes-with-decorated-fields", "//packages/core/schematics/migrations/undecorated-classes-with-di", "//packages/core/schematics/migrations/wait-for-async", + "//packages/core/schematics/migrations/xhr-factory", ], ) diff --git a/packages/core/schematics/migrations.json b/packages/core/schematics/migrations.json index 85f5fe8371..66bcf0ec96 100644 --- a/packages/core/schematics/migrations.json +++ b/packages/core/schematics/migrations.json @@ -89,6 +89,11 @@ "version": "12.0.0-beta", "description": "In Angular version 12, the type of ActivatedRouteSnapshot.fragment is nullable. This migration automatically adds non-null assertions to it.", "factory": "./migrations/activated-route-snapshot-fragment/index" + }, + "migration-v12-xhr-factory": { + "version": "12.0.0-next.6", + "description": "`XhrFactory` has been moved from `@angular/common/http` to `@angular/common`.", + "factory": "./migrations/xhr-factory/index" } } } diff --git a/packages/core/schematics/migrations/xhr-factory/BUILD.bazel b/packages/core/schematics/migrations/xhr-factory/BUILD.bazel new file mode 100644 index 0000000000..570ee39056 --- /dev/null +++ b/packages/core/schematics/migrations/xhr-factory/BUILD.bazel @@ -0,0 +1,17 @@ +load("//tools:defaults.bzl", "ts_library") + +ts_library( + name = "xhr-factory", + srcs = glob(["**/*.ts"]), + tsconfig = "//packages/core/schematics:tsconfig.json", + visibility = [ + "//packages/core/schematics:__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/xhr-factory/README.md b/packages/core/schematics/migrations/xhr-factory/README.md new file mode 100644 index 0000000000..968e76e94d --- /dev/null +++ b/packages/core/schematics/migrations/xhr-factory/README.md @@ -0,0 +1,13 @@ +## XhrFactory migration + +Automatically migrates `XhrFactory` from `@angular/common/http` to `@angular/common`. + +#### Before +```ts +import { XhrFactory } from '@angular/common/http'; +``` + +#### After +```ts +import { XhrFactory } from '@angular/common'; +``` diff --git a/packages/core/schematics/migrations/xhr-factory/index.ts b/packages/core/schematics/migrations/xhr-factory/index.ts new file mode 100644 index 0000000000..db04963e6f --- /dev/null +++ b/packages/core/schematics/migrations/xhr-factory/index.ts @@ -0,0 +1,132 @@ +/** + * @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 {DirEntry, Rule, UpdateRecorder} from '@angular-devkit/schematics'; +import * as ts from 'typescript'; +import {findImportSpecifier} from '../../utils/typescript/imports'; + +function* visit(directory: DirEntry): IterableIterator { + for (const path of directory.subfiles) { + if (path.endsWith('.ts') && !path.endsWith('.d.ts')) { + const entry = directory.file(path); + if (entry) { + const content = entry.content; + if (content.includes('XhrFactory')) { + const source = ts.createSourceFile( + entry.path, + content.toString().replace(/^\uFEFF/, ''), + ts.ScriptTarget.Latest, + true, + ); + + yield source; + } + } + } + } + + for (const path of directory.subdirs) { + if (path === 'node_modules' || path.startsWith('.')) { + continue; + } + + yield* visit(directory.dir(path)); + } +} + +export default function(): Rule { + return tree => { + const printer = ts.createPrinter({newLine: ts.NewLineKind.LineFeed}); + + for (const sourceFile of visit(tree.root)) { + let recorder: UpdateRecorder|undefined; + + const allImportDeclarations = + sourceFile.statements.filter(n => ts.isImportDeclaration(n)) as ts.ImportDeclaration[]; + if (allImportDeclarations.length === 0) { + continue; + } + + const httpCommonImport = findImportDeclaration('@angular/common/http', allImportDeclarations); + if (!httpCommonImport) { + continue; + } + + const commonHttpNamedBinding = getNamedImports(httpCommonImport); + if (commonHttpNamedBinding) { + const commonHttpNamedImports = commonHttpNamedBinding.elements; + const xhrFactorySpecifier = findImportSpecifier(commonHttpNamedImports, 'XhrFactory'); + + if (!xhrFactorySpecifier) { + continue; + } + + recorder = tree.beginUpdate(sourceFile.fileName); + + // Remove 'XhrFactory' from '@angular/common/http' + if (commonHttpNamedImports.length > 1) { + // Remove 'XhrFactory' named import + const index = commonHttpNamedBinding.getStart(); + const length = commonHttpNamedBinding.getWidth(); + + const newImports = printer.printNode( + ts.EmitHint.Unspecified, + ts.factory.updateNamedImports( + commonHttpNamedBinding, + commonHttpNamedBinding.elements.filter(e => e !== xhrFactorySpecifier)), + sourceFile); + recorder.remove(index, length).insertLeft(index, newImports); + } else { + // Remove '@angular/common/http' import + const index = httpCommonImport.getFullStart(); + const length = httpCommonImport.getFullWidth(); + recorder.remove(index, length); + } + + // Import XhrFactory from @angular/common + const commonImport = findImportDeclaration('@angular/common', allImportDeclarations); + const commonNamedBinding = getNamedImports(commonImport); + if (commonNamedBinding) { + // Already has an import for '@angular/common', just add the named import. + const index = commonNamedBinding.getStart(); + const length = commonNamedBinding.getWidth(); + const newImports = printer.printNode( + ts.EmitHint.Unspecified, + ts.factory.updateNamedImports( + commonNamedBinding, [...commonNamedBinding.elements, xhrFactorySpecifier]), + sourceFile); + + recorder.remove(index, length).insertLeft(index, newImports); + } else { + // Add import to '@angular/common' + const index = httpCommonImport.getFullStart(); + recorder.insertLeft(index, `\nimport { XhrFactory } from '@angular/common';`); + } + } + + if (recorder) { + tree.commitUpdate(recorder); + } + } + }; +} + +function findImportDeclaration(moduleSpecifier: string, importDeclarations: ts.ImportDeclaration[]): + ts.ImportDeclaration|undefined { + return importDeclarations.find( + n => ts.isStringLiteral(n.moduleSpecifier) && n.moduleSpecifier.text === moduleSpecifier); +} + +function getNamedImports(importDeclaration: ts.ImportDeclaration|undefined): ts.NamedImports| + undefined { + const namedBindings = importDeclaration?.importClause?.namedBindings; + if (namedBindings && ts.isNamedImports(namedBindings)) { + return namedBindings; + } + + return undefined; +} \ No newline at end of file diff --git a/packages/core/schematics/test/BUILD.bazel b/packages/core/schematics/test/BUILD.bazel index 93a4fec3f2..135467711d 100644 --- a/packages/core/schematics/test/BUILD.bazel +++ b/packages/core/schematics/test/BUILD.bazel @@ -26,6 +26,7 @@ ts_library( "//packages/core/schematics/migrations/undecorated-classes-with-decorated-fields", "//packages/core/schematics/migrations/undecorated-classes-with-di", "//packages/core/schematics/migrations/wait-for-async", + "//packages/core/schematics/migrations/xhr-factory", "//packages/core/schematics/utils", "@npm//@angular-devkit/core", "@npm//@angular-devkit/schematics", diff --git a/packages/core/schematics/test/xhr_factory_spec.ts b/packages/core/schematics/test/xhr_factory_spec.ts new file mode 100644 index 0000000000..ced959648a --- /dev/null +++ b/packages/core/schematics/test/xhr_factory_spec.ts @@ -0,0 +1,79 @@ +/** + * @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 {tags} from '@angular-devkit/core'; +import {EmptyTree} from '@angular-devkit/schematics'; +import {SchematicTestRunner, UnitTestTree} from '@angular-devkit/schematics/testing'; + +describe('XhrFactory migration', () => { + let tree: UnitTestTree; + const runner = new SchematicTestRunner('test', require.resolve('../migrations.json')); + + beforeEach(() => { + tree = new UnitTestTree(new EmptyTree()); + }); + + it(`should replace 'XhrFactory' from '@angular/common/http' to '@angular/common'`, async () => { + tree.create('/index.ts', tags.stripIndents` + import { HttpClient } from '@angular/common'; + import { HttpErrorResponse, HttpResponse, XhrFactory } from '@angular/common/http'; + `); + + await runMigration(); + expect(tree.readContent('/index.ts')).toBe(tags.stripIndents` + import { HttpClient, XhrFactory } from '@angular/common'; + import { HttpErrorResponse, HttpResponse } from '@angular/common/http'; + `); + }); + + it(`should replace import for 'XhrFactory' to '@angular/common'`, async () => { + tree.create('/index.ts', tags.stripIndents` + import { Injecable } from '@angular/core'; + import { XhrFactory } from '@angular/common/http'; + import { BrowserModule } from '@angular/platform-browser'; + `); + + await runMigration(); + expect(tree.readContent('/index.ts')).toBe(tags.stripIndents` + import { Injecable } from '@angular/core'; + import { XhrFactory } from '@angular/common'; + import { BrowserModule } from '@angular/platform-browser'; + `); + }); + + it(`should remove http import when 'XhrFactory' is the only imported symbol`, async () => { + tree.create('/index.ts', tags.stripIndents` + import { HttpClient } from '@angular/common'; + import { XhrFactory as XhrFactory2 } from '@angular/common/http'; + import { Injecable } from '@angular/core'; + `); + + await runMigration(); + expect(tree.readContent('/index.ts')).toBe(tags.stripIndents` + import { HttpClient, XhrFactory as XhrFactory2 } from '@angular/common'; + import { Injecable } from '@angular/core'; + `); + }); + + it(`should add named import when '@angular/common' is a namespace import`, async () => { + tree.create('/index.ts', tags.stripIndents` + import * as common from '@angular/common'; + import { XhrFactory } from '@angular/common/http'; + `); + + await runMigration(); + expect(tree.readContent('/index.ts')).toBe(tags.stripIndents` + import * as common from '@angular/common'; + import { XhrFactory } from '@angular/common'; + `); + }); + + async function runMigration(): Promise { + await runner.runSchematicAsync('migration-v12-xhr-factory', {}, tree).toPromise(); + } +}); diff --git a/packages/core/schematics/tsconfig.json b/packages/core/schematics/tsconfig.json index ba11c35a3d..53930ff133 100644 --- a/packages/core/schematics/tsconfig.json +++ b/packages/core/schematics/tsconfig.json @@ -3,7 +3,9 @@ "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, "strict": true, - "lib": ["es2015"], + "moduleResolution": "node", + "target": "es2019", + "lib": ["es2019"], "types": ["jasmine"], "baseUrl": ".", "paths": { diff --git a/packages/core/schematics/utils/typescript/imports.ts b/packages/core/schematics/utils/typescript/imports.ts index ccfe75aadd..bf33f0a258 100644 --- a/packages/core/schematics/utils/typescript/imports.ts +++ b/packages/core/schematics/utils/typescript/imports.ts @@ -110,7 +110,7 @@ export function replaceImport( /** Finds an import specifier with a particular name. */ -function findImportSpecifier( +export function findImportSpecifier( nodes: ts.NodeArray, specifierName: string): ts.ImportSpecifier|undefined { return nodes.find(element => { const {name, propertyName} = element;