diff --git a/packages/core/schematics/BUILD.bazel b/packages/core/schematics/BUILD.bazel index 1111acb0b17..44bd7969685 100644 --- a/packages/core/schematics/BUILD.bazel +++ b/packages/core/schematics/BUILD.bazel @@ -45,6 +45,7 @@ rollup_bundle( "//packages/core/schematics/ng-generate/signal-queries-migration:index.ts": "signal-queries-migration", "//packages/core/schematics/ng-generate/output-migration:index.ts": "output-migration", "//packages/core/schematics/ng-generate/self-closing-tags-migration:index.ts": "self-closing-tags-migration", + "//packages/core/schematics/migrations/inject-flags:index.ts": "inject-flags", }, format = "cjs", link_workspace_root = True, @@ -54,6 +55,7 @@ rollup_bundle( "//packages/core/schematics/test:__pkg__", ], deps = [ + "//packages/core/schematics/migrations/inject-flags", "//packages/core/schematics/ng-generate/cleanup-unused-imports", "//packages/core/schematics/ng-generate/control-flow-migration", "//packages/core/schematics/ng-generate/inject-migration", diff --git a/packages/core/schematics/migrations.json b/packages/core/schematics/migrations.json index 63001b44588..404f08bb8ad 100644 --- a/packages/core/schematics/migrations.json +++ b/packages/core/schematics/migrations.json @@ -1,3 +1,9 @@ { - "schematics": {} + "schematics": { + "inject-flags": { + "version": "20.0.0", + "description": "Replaces usages of the deprecated InjectFlags enum", + "factory": "./bundles/inject-flags#migrate" + } + } } diff --git a/packages/core/schematics/migrations/inject-flags/BUILD.bazel b/packages/core/schematics/migrations/inject-flags/BUILD.bazel new file mode 100644 index 00000000000..25cc85eeaa1 --- /dev/null +++ b/packages/core/schematics/migrations/inject-flags/BUILD.bazel @@ -0,0 +1,25 @@ +load("//tools:defaults.bzl", "ts_library") + +package( + default_visibility = [ + "//packages/core/schematics:__pkg__", + "//packages/core/schematics/migrations/google3:__pkg__", + "//packages/core/schematics/test:__pkg__", + ], +) + +ts_library( + name = "inject-flags", + srcs = glob(["**/*.ts"]), + tsconfig = "//packages/core/schematics:tsconfig.json", + deps = [ + "//packages/compiler-cli/private", + "//packages/compiler-cli/src/ngtsc/file_system", + "//packages/core/schematics/utils", + "//packages/core/schematics/utils/tsurge", + "//packages/core/schematics/utils/tsurge/helpers/angular_devkit", + "@npm//@angular-devkit/schematics", + "@npm//@types/node", + "@npm//typescript", + ], +) diff --git a/packages/core/schematics/migrations/inject-flags/README.md b/packages/core/schematics/migrations/inject-flags/README.md new file mode 100644 index 00000000000..d1a01fe267b --- /dev/null +++ b/packages/core/schematics/migrations/inject-flags/README.md @@ -0,0 +1,23 @@ +## Remove `InjectFlags` migration +Replaces the usages of the deprecated `InjectFlags` symbol with its non-deprecated equivalent, +for example: + +### Before +```typescript +import { inject, InjectFlags, Directive, ElementRef } from '@angular/core'; + +@Directive() +export class Dir { + element = inject(ElementRef, InjectFlags.Optional | InjectFlags.Host | InjectFlags.SkipSelf); +} +``` + +### After +```typescript +import { inject, Directive, ElementRef } from '@angular/core'; + +@Directive() +export class Dir { + element = inject(ElementRef, { optional: true, host: true, skipSelf: true }); +} +``` diff --git a/packages/core/schematics/migrations/inject-flags/index.ts b/packages/core/schematics/migrations/inject-flags/index.ts new file mode 100644 index 00000000000..977914b5fc3 --- /dev/null +++ b/packages/core/schematics/migrations/inject-flags/index.ts @@ -0,0 +1,70 @@ +/** + * @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.dev/license + */ + +import {Rule, SchematicsException} from '@angular-devkit/schematics'; + +import {getProjectTsConfigPaths} from '../../utils/project_tsconfig_paths'; +import {DevkitMigrationFilesystem} from '../../utils/tsurge/helpers/angular_devkit/devkit_filesystem'; +import {groupReplacementsByFile} from '../../utils/tsurge/helpers/group_replacements'; +import {setFileSystem} from '@angular/compiler-cli/src/ngtsc/file_system'; +import {ProjectRootRelativePath, TextUpdate} from '../../utils/tsurge'; +import {synchronouslyCombineUnitData} from '../../utils/tsurge/helpers/combine_units'; +import {CompilationUnitData, InjectFlagsMigration} from './inject_flags_migration'; + +export function migrate(): Rule { + return async (tree) => { + const {buildPaths, testPaths} = await getProjectTsConfigPaths(tree); + + if (!buildPaths.length && !testPaths.length) { + throw new SchematicsException( + 'Could not find any tsconfig file. Cannot replace `InjectFlags` usages.', + ); + } + + const fs = new DevkitMigrationFilesystem(tree); + setFileSystem(fs); + + const migration = new InjectFlagsMigration(); + const unitResults: CompilationUnitData[] = []; + const programInfos = [...buildPaths, ...testPaths].map((tsconfigPath) => { + const baseInfo = migration.createProgram(tsconfigPath, fs); + const info = migration.prepareProgram(baseInfo); + return {info, tsconfigPath}; + }); + + for (const {info} of programInfos) { + unitResults.push(await migration.analyze(info)); + } + + const combined = await synchronouslyCombineUnitData(migration, unitResults); + if (combined === null) { + return; + } + + const globalMeta = await migration.globalMeta(combined); + const replacementsPerFile: Map = new Map(); + const {replacements} = await migration.migrate(globalMeta); + const changesPerFile = groupReplacementsByFile(replacements); + + for (const [file, changes] of changesPerFile) { + if (!replacementsPerFile.has(file)) { + replacementsPerFile.set(file, changes); + } + } + + for (const [file, changes] of replacementsPerFile) { + const recorder = tree.beginUpdate(file); + for (const c of changes) { + recorder + .remove(c.data.position, c.data.end - c.data.position) + .insertRight(c.data.position, c.data.toInsert); + } + tree.commitUpdate(recorder); + } + }; +} diff --git a/packages/core/schematics/migrations/inject-flags/inject_flags_migration.ts b/packages/core/schematics/migrations/inject-flags/inject_flags_migration.ts new file mode 100644 index 00000000000..9de9d3a39cd --- /dev/null +++ b/packages/core/schematics/migrations/inject-flags/inject_flags_migration.ts @@ -0,0 +1,193 @@ +/** + * @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.dev/license + */ + +import ts from 'typescript'; +import { + confirmAsSerializable, + ProgramInfo, + ProjectFile, + projectFile, + ProjectFileID, + Replacement, + Serializable, + TextUpdate, + TsurgeFunnelMigration, +} from '../../utils/tsurge'; +import {ImportManager} from '@angular/compiler-cli/private/migrations'; +import {applyImportManagerChanges} from '../../utils/tsurge/helpers/apply_import_manager'; +import {getImportSpecifier} from '../../utils/typescript/imports'; + +export interface CompilationUnitData { + /** Tracks information about `InjectFlags` binary expressions and how they should be replaced. */ + locations: Record; + + /** Tracks files and their import removal replacements, */ + importRemovals: Record; +} + +/** Information about a single `InjectFlags` expression. */ +interface ReplacementLocation { + /** File in which the expression is defined. */ + file: ProjectFile; + + /** `InjectFlags` used in the expression. */ + flags: string[]; + + /** Start of the expression. */ + position: number; + + /** End of the expression. */ + end: number; +} + +/** Mapping between `InjectFlag` enum members to their object literal equvalients. */ +const FLAGS_TO_FIELDS: Record = { + 'Default': 'default', + 'Host': 'host', + 'Optional': 'optional', + 'Self': 'self', + 'SkipSelf': 'skipSelf', +}; + +/** ID of a node based on its location. */ +type NodeID = string & {__nodeID: true}; + +/** Migration that replaces `InjectFlags` usages with object literals. */ +export class InjectFlagsMigration extends TsurgeFunnelMigration< + CompilationUnitData, + CompilationUnitData +> { + override async analyze(info: ProgramInfo): Promise> { + const locations: Record = {}; + const importRemovals: Record = {}; + + for (const sourceFile of info.sourceFiles) { + const specifier = getImportSpecifier(sourceFile, '@angular/core', 'InjectFlags'); + + if (specifier === null) { + continue; + } + + const file = projectFile(sourceFile, info); + const importManager = new ImportManager(); + const importReplacements: Replacement[] = []; + + // Always remove the `InjectFlags` since it has been removed from Angular. + // Note that it be better to do this inside of `migrate`, but we don't have AST access there. + importManager.removeImport(sourceFile, 'InjectFlags', '@angular/core'); + applyImportManagerChanges(importManager, importReplacements, [sourceFile], info); + importRemovals[file.id] = importReplacements; + + sourceFile.forEachChild(function walk(node) { + if ( + // Note: we don't use the type checker for matching here, because + // the `InjectFlags` will be removed which can break the lookup. + ts.isPropertyAccessExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === specifier.name.text && + FLAGS_TO_FIELDS.hasOwnProperty(node.name.text) + ) { + const root = getInjectFlagsRootExpression(node); + + if (root !== null) { + const flagName = FLAGS_TO_FIELDS[node.name.text]; + const id = getNodeID(file, root); + locations[id] ??= {file, flags: [], position: root.getStart(), end: root.getEnd()}; + + // The flags can't be a set here, because they need to be serializable. + if (!locations[id].flags.includes(flagName)) { + locations[id].flags.push(flagName); + } + } + } else { + node.forEachChild(walk); + } + }); + } + + return confirmAsSerializable({locations, importRemovals}); + } + + override async migrate(globalData: CompilationUnitData) { + const replacements: Replacement[] = []; + + for (const removals of Object.values(globalData.importRemovals)) { + replacements.push(...removals); + } + + for (const {file, position, end, flags} of Object.values(globalData.locations)) { + // Declare a property for each flag, except for `default` which does not have a flag. + const properties = flags.filter((flag) => flag !== 'default').map((flag) => `${flag}: true`); + const toInsert = properties.length ? `{ ${properties.join(', ')} }` : '{}'; + replacements.push(new Replacement(file, new TextUpdate({position, end, toInsert}))); + } + + return confirmAsSerializable({replacements}); + } + + override async combine( + unitA: CompilationUnitData, + unitB: CompilationUnitData, + ): Promise> { + return confirmAsSerializable({ + locations: { + ...unitA.locations, + ...unitB.locations, + }, + importRemovals: { + ...unitA.importRemovals, + ...unitB.importRemovals, + }, + }); + } + + override async globalMeta( + combinedData: CompilationUnitData, + ): Promise> { + return confirmAsSerializable(combinedData); + } + + override async stats() { + return {counters: {}}; + } +} + +/** Gets an ID that can be used to look up a node based on its location. */ +function getNodeID(file: ProjectFile, node: ts.Node): NodeID { + return `${file.id}/${node.getStart()}/${node.getWidth()}` as NodeID; +} + +/** + * Gets the root expression of an `InjectFlags` usage. For example given `InjectFlags.Optional`. + * in `InjectFlags.Host | InjectFlags.Optional | InjectFlags.SkipSelf`, the function will return + * the top-level binary expression. + * @param start Node from which to start searching. + */ +function getInjectFlagsRootExpression(start: ts.Expression): ts.Expression | null { + let current = start as ts.Node | undefined; + let parent = current?.parent; + + while (parent && (ts.isBinaryExpression(parent) || ts.isParenthesizedExpression(parent))) { + current = parent; + parent = current.parent; + } + + // Only allow allow expressions that are call parameters, variable initializer or parameter + // initializers which are the only officially supported usages of `InjectFlags`. + if ( + current && + parent && + ((ts.isCallExpression(parent) && parent.arguments.includes(current as ts.Expression)) || + (ts.isVariableDeclaration(parent) && parent.initializer === current) || + (ts.isParameter(parent) && parent.initializer === current)) + ) { + return current as ts.Expression; + } + + return null; +} diff --git a/packages/core/schematics/test/inject_flags_spec.ts b/packages/core/schematics/test/inject_flags_spec.ts new file mode 100644 index 00000000000..3dac7283e67 --- /dev/null +++ b/packages/core/schematics/test/inject_flags_spec.ts @@ -0,0 +1,237 @@ +/** + * @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.dev/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 {runfiles} from '@bazel/runfiles'; +import shx from 'shelljs'; + +describe('inject-flags migration', () => { + let runner: SchematicTestRunner; + let host: TempScopedNodeJsSyncHost; + let tree: UnitTestTree; + let tmpDirPath: string; + + function writeFile(filePath: string, contents: string) { + host.sync.write(normalize(filePath), virtualFs.stringToFileBuffer(contents)); + } + + function runMigration() { + return runner.runSchematic('inject-flags', {}, tree); + } + + beforeEach(() => { + runner = new SchematicTestRunner('test', runfiles.resolvePackageRelative('../migrations.json')); + host = new TempScopedNodeJsSyncHost(); + tree = new UnitTestTree(new HostTree(host)); + tmpDirPath = getSystemPath(host.root); + + writeFile('/tsconfig.json', '{}'); + writeFile( + '/angular.json', + JSON.stringify({ + version: 1, + projects: {t: {root: '', architect: {build: {options: {tsConfig: './tsconfig.json'}}}}}, + }), + ); + + shx.cd(tmpDirPath); + }); + + it('should migrate a single InjectFlags usage', async () => { + writeFile( + '/test.ts', + ` + import { inject, InjectFlags, Directive, ElementRef } from '@angular/core'; + + @Directive() + export class Dir { + el = inject(ElementRef, InjectFlags.Optional); + } + `, + ); + + await runMigration(); + const content = tree.readContent('/test.ts'); + expect(content).toContain(`import { inject, Directive, ElementRef } from '@angular/core';`); + expect(content).toContain(`el = inject(ElementRef, { optional: true });`); + }); + + it('should migrate multiple InjectFlags', async () => { + writeFile( + '/test.ts', + ` + import { inject, InjectFlags, Directive, ElementRef } from '@angular/core'; + + @Directive() + export class Dir { + el = inject(ElementRef, InjectFlags.Optional | InjectFlags.Host | InjectFlags.SkipSelf); + } + `, + ); + + await runMigration(); + const content = tree.readContent('/test.ts'); + expect(content).toContain(`import { inject, Directive, ElementRef } from '@angular/core';`); + expect(content).toContain( + `el = inject(ElementRef, { optional: true, host: true, skipSelf: true });`, + ); + }); + + it('should not generate a property for InjectFlags.Default', async () => { + writeFile( + '/test.ts', + ` + import { inject, InjectFlags, Directive, ElementRef } from '@angular/core'; + + @Directive() + export class Dir { + el = inject(ElementRef, InjectFlags.Default); + } + `, + ); + + await runMigration(); + const content = tree.readContent('/test.ts'); + expect(content).toContain(`import { inject, Directive, ElementRef } from '@angular/core';`); + expect(content).toContain(`el = inject(ElementRef, {});`); + }); + + it('should migrate InjectFlags used in a variable', async () => { + writeFile( + '/test.ts', + ` + import { inject, InjectFlags, Directive, ElementRef } from '@angular/core'; + + const flags = InjectFlags.SkipSelf | InjectFlags.Optional; + + @Directive() + export class Dir { + el = inject(ElementRef, flags); + } + `, + ); + + await runMigration(); + const content = tree.readContent('/test.ts'); + expect(content).toContain(`import { inject, Directive, ElementRef } from '@angular/core';`); + expect(content).toContain(`const flags = { skipSelf: true, optional: true };`); + }); + + it('should migrate InjectFlags used in a function initializer', async () => { + writeFile( + '/test.ts', + ` + import { inject, InjectFlags, Directive, ElementRef } from '@angular/core'; + + function injectEl(flags = InjectFlags.SkipSelf | InjectFlags.Optional) { + return inject(ElementRef, flags); + } + + @Directive() + export class Dir { + el = injectEl(); + } + `, + ); + + await runMigration(); + const content = tree.readContent('/test.ts'); + expect(content).toContain(`import { inject, Directive, ElementRef } from '@angular/core';`); + expect(content).toContain(`function injectEl(flags = { skipSelf: true, optional: true })`); + }); + + it('should remove InjectFlags import even if InjectFlags is not used', async () => { + writeFile( + '/test.ts', + ` + import { inject, InjectFlags, Directive, ElementRef } from '@angular/core'; + + @Directive() + export class Dir {} + `, + ); + + await runMigration(); + const content = tree.readContent('/test.ts'); + expect(content).not.toContain('InjectFlags'); + }); + + it('should migrate InjectFlags within a parenthesized expression', async () => { + writeFile( + '/test.ts', + ` + import { inject, InjectFlags, Directive, ElementRef } from '@angular/core'; + + @Directive() + export class Dir { + el = inject(ElementRef, ((InjectFlags.Optional) | InjectFlags.SkipSelf)); + } + `, + ); + + await runMigration(); + const content = tree.readContent('/test.ts'); + expect(content).toContain(`import { inject, Directive, ElementRef } from '@angular/core';`); + expect(content).toContain(`el = inject(ElementRef, { optional: true, skipSelf: true });`); + }); + + it('should handle a file that is present in multiple projects', async () => { + writeFile('/tsconfig-2.json', '{}'); + writeFile( + '/angular.json', + JSON.stringify({ + version: 1, + projects: { + a: {root: '', architect: {build: {options: {tsConfig: './tsconfig.json'}}}}, + b: {root: '', architect: {build: {options: {tsConfig: './tsconfig-2.json'}}}}, + }, + }), + ); + + writeFile( + 'test.ts', + ` + import { inject, InjectFlags, Directive, ElementRef } from '@angular/core'; + + @Directive() + export class Dir { + el = inject(ElementRef, InjectFlags.Optional | InjectFlags.SkipSelf); + } + `, + ); + + await runMigration(); + const content = tree.readContent('/test.ts'); + expect(content).toContain(`import { inject, Directive, ElementRef } from '@angular/core';`); + expect(content).toContain(`el = inject(ElementRef, { optional: true, skipSelf: true });`); + }); + + it('should handle aliased InjectFlags', async () => { + writeFile( + '/test.ts', + ` + import { inject, InjectFlags as Foo, Directive, ElementRef } from '@angular/core'; + + @Directive() + export class Dir { + el = inject(ElementRef, Foo.Optional | Foo.Host | Foo.SkipSelf); + } + `, + ); + + await runMigration(); + const content = tree.readContent('/test.ts'); + expect(content).toContain(`import { inject, Directive, ElementRef } from '@angular/core';`); + expect(content).toContain( + `el = inject(ElementRef, { optional: true, host: true, skipSelf: true });`, + ); + }); +}); diff --git a/packages/core/schematics/utils/tsurge/helpers/angular_devkit/BUILD.bazel b/packages/core/schematics/utils/tsurge/helpers/angular_devkit/BUILD.bazel index e6470463b01..40177f9ec6b 100644 --- a/packages/core/schematics/utils/tsurge/helpers/angular_devkit/BUILD.bazel +++ b/packages/core/schematics/utils/tsurge/helpers/angular_devkit/BUILD.bazel @@ -1,6 +1,6 @@ load("//tools:defaults.bzl", "ts_library") -package(default_visibility = ["//packages/core/schematics/ng-generate:__subpackages__"]) +package(default_visibility = ["//packages/core/schematics:__subpackages__"]) ts_library( name = "angular_devkit",