fix(core): add migration away from InjectFlags (#60318)

Adds an automated migration that will switch users from the deprecated `InjectFlags` API to its non-deprecated equivalent.

PR Close #60318
This commit is contained in:
Kristiyan Kostadinov 2025-03-10 17:44:32 +01:00 committed by Andrew Kushnir
parent 9a124c8b5d
commit e170d24240
8 changed files with 558 additions and 2 deletions

View file

@ -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",

View file

@ -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"
}
}
}

View file

@ -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",
],
)

View file

@ -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 });
}
```

View file

@ -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<ProjectRootRelativePath, TextUpdate[]> = 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);
}
};
}

View file

@ -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<NodeID, ReplacementLocation>;
/** Tracks files and their import removal replacements, */
importRemovals: Record<ProjectFileID, Replacement[]>;
}
/** 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<string, string> = {
'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<Serializable<CompilationUnitData>> {
const locations: Record<NodeID, ReplacementLocation> = {};
const importRemovals: Record<ProjectFileID, Replacement[]> = {};
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<Serializable<CompilationUnitData>> {
return confirmAsSerializable({
locations: {
...unitA.locations,
...unitB.locations,
},
importRemovals: {
...unitA.importRemovals,
...unitB.importRemovals,
},
});
}
override async globalMeta(
combinedData: CompilationUnitData,
): Promise<Serializable<CompilationUnitData>> {
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;
}

View file

@ -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 });`,
);
});
});

View file

@ -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",