Skip to content

Commit e170d24

Browse files
crisbetoAndrewKushnir
authored andcommitted
fix(core): add migration away from InjectFlags (angular#60318)
Adds an automated migration that will switch users from the deprecated `InjectFlags` API to its non-deprecated equivalent. PR Close angular#60318
1 parent 9a124c8 commit e170d24

File tree

8 files changed

+558
-2
lines changed

8 files changed

+558
-2
lines changed

packages/core/schematics/BUILD.bazel

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ rollup_bundle(
4545
"//packages/core/schematics/ng-generate/signal-queries-migration:index.ts": "signal-queries-migration",
4646
"//packages/core/schematics/ng-generate/output-migration:index.ts": "output-migration",
4747
"//packages/core/schematics/ng-generate/self-closing-tags-migration:index.ts": "self-closing-tags-migration",
48+
"//packages/core/schematics/migrations/inject-flags:index.ts": "inject-flags",
4849
},
4950
format = "cjs",
5051
link_workspace_root = True,
@@ -54,6 +55,7 @@ rollup_bundle(
5455
"//packages/core/schematics/test:__pkg__",
5556
],
5657
deps = [
58+
"//packages/core/schematics/migrations/inject-flags",
5759
"//packages/core/schematics/ng-generate/cleanup-unused-imports",
5860
"//packages/core/schematics/ng-generate/control-flow-migration",
5961
"//packages/core/schematics/ng-generate/inject-migration",
Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
11
{
2-
"schematics": {}
2+
"schematics": {
3+
"inject-flags": {
4+
"version": "20.0.0",
5+
"description": "Replaces usages of the deprecated InjectFlags enum",
6+
"factory": "./bundles/inject-flags#migrate"
7+
}
8+
}
39
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
load("//tools:defaults.bzl", "ts_library")
2+
3+
package(
4+
default_visibility = [
5+
"//packages/core/schematics:__pkg__",
6+
"//packages/core/schematics/migrations/google3:__pkg__",
7+
"//packages/core/schematics/test:__pkg__",
8+
],
9+
)
10+
11+
ts_library(
12+
name = "inject-flags",
13+
srcs = glob(["**/*.ts"]),
14+
tsconfig = "//packages/core/schematics:tsconfig.json",
15+
deps = [
16+
"//packages/compiler-cli/private",
17+
"//packages/compiler-cli/src/ngtsc/file_system",
18+
"//packages/core/schematics/utils",
19+
"//packages/core/schematics/utils/tsurge",
20+
"//packages/core/schematics/utils/tsurge/helpers/angular_devkit",
21+
"@npm//@angular-devkit/schematics",
22+
"@npm//@types/node",
23+
"@npm//typescript",
24+
],
25+
)
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
## Remove `InjectFlags` migration
2+
Replaces the usages of the deprecated `InjectFlags` symbol with its non-deprecated equivalent,
3+
for example:
4+
5+
### Before
6+
```typescript
7+
import { inject, InjectFlags, Directive, ElementRef } from '@angular/core';
8+
9+
@Directive()
10+
export class Dir {
11+
element = inject(ElementRef, InjectFlags.Optional | InjectFlags.Host | InjectFlags.SkipSelf);
12+
}
13+
```
14+
15+
### After
16+
```typescript
17+
import { inject, Directive, ElementRef } from '@angular/core';
18+
19+
@Directive()
20+
export class Dir {
21+
element = inject(ElementRef, { optional: true, host: true, skipSelf: true });
22+
}
23+
```
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import {Rule, SchematicsException} from '@angular-devkit/schematics';
10+
11+
import {getProjectTsConfigPaths} from '../../utils/project_tsconfig_paths';
12+
import {DevkitMigrationFilesystem} from '../../utils/tsurge/helpers/angular_devkit/devkit_filesystem';
13+
import {groupReplacementsByFile} from '../../utils/tsurge/helpers/group_replacements';
14+
import {setFileSystem} from '@angular/compiler-cli/src/ngtsc/file_system';
15+
import {ProjectRootRelativePath, TextUpdate} from '../../utils/tsurge';
16+
import {synchronouslyCombineUnitData} from '../../utils/tsurge/helpers/combine_units';
17+
import {CompilationUnitData, InjectFlagsMigration} from './inject_flags_migration';
18+
19+
export function migrate(): Rule {
20+
return async (tree) => {
21+
const {buildPaths, testPaths} = await getProjectTsConfigPaths(tree);
22+
23+
if (!buildPaths.length && !testPaths.length) {
24+
throw new SchematicsException(
25+
'Could not find any tsconfig file. Cannot replace `InjectFlags` usages.',
26+
);
27+
}
28+
29+
const fs = new DevkitMigrationFilesystem(tree);
30+
setFileSystem(fs);
31+
32+
const migration = new InjectFlagsMigration();
33+
const unitResults: CompilationUnitData[] = [];
34+
const programInfos = [...buildPaths, ...testPaths].map((tsconfigPath) => {
35+
const baseInfo = migration.createProgram(tsconfigPath, fs);
36+
const info = migration.prepareProgram(baseInfo);
37+
return {info, tsconfigPath};
38+
});
39+
40+
for (const {info} of programInfos) {
41+
unitResults.push(await migration.analyze(info));
42+
}
43+
44+
const combined = await synchronouslyCombineUnitData(migration, unitResults);
45+
if (combined === null) {
46+
return;
47+
}
48+
49+
const globalMeta = await migration.globalMeta(combined);
50+
const replacementsPerFile: Map<ProjectRootRelativePath, TextUpdate[]> = new Map();
51+
const {replacements} = await migration.migrate(globalMeta);
52+
const changesPerFile = groupReplacementsByFile(replacements);
53+
54+
for (const [file, changes] of changesPerFile) {
55+
if (!replacementsPerFile.has(file)) {
56+
replacementsPerFile.set(file, changes);
57+
}
58+
}
59+
60+
for (const [file, changes] of replacementsPerFile) {
61+
const recorder = tree.beginUpdate(file);
62+
for (const c of changes) {
63+
recorder
64+
.remove(c.data.position, c.data.end - c.data.position)
65+
.insertRight(c.data.position, c.data.toInsert);
66+
}
67+
tree.commitUpdate(recorder);
68+
}
69+
};
70+
}
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import ts from 'typescript';
10+
import {
11+
confirmAsSerializable,
12+
ProgramInfo,
13+
ProjectFile,
14+
projectFile,
15+
ProjectFileID,
16+
Replacement,
17+
Serializable,
18+
TextUpdate,
19+
TsurgeFunnelMigration,
20+
} from '../../utils/tsurge';
21+
import {ImportManager} from '@angular/compiler-cli/private/migrations';
22+
import {applyImportManagerChanges} from '../../utils/tsurge/helpers/apply_import_manager';
23+
import {getImportSpecifier} from '../../utils/typescript/imports';
24+
25+
export interface CompilationUnitData {
26+
/** Tracks information about `InjectFlags` binary expressions and how they should be replaced. */
27+
locations: Record<NodeID, ReplacementLocation>;
28+
29+
/** Tracks files and their import removal replacements, */
30+
importRemovals: Record<ProjectFileID, Replacement[]>;
31+
}
32+
33+
/** Information about a single `InjectFlags` expression. */
34+
interface ReplacementLocation {
35+
/** File in which the expression is defined. */
36+
file: ProjectFile;
37+
38+
/** `InjectFlags` used in the expression. */
39+
flags: string[];
40+
41+
/** Start of the expression. */
42+
position: number;
43+
44+
/** End of the expression. */
45+
end: number;
46+
}
47+
48+
/** Mapping between `InjectFlag` enum members to their object literal equvalients. */
49+
const FLAGS_TO_FIELDS: Record<string, string> = {
50+
'Default': 'default',
51+
'Host': 'host',
52+
'Optional': 'optional',
53+
'Self': 'self',
54+
'SkipSelf': 'skipSelf',
55+
};
56+
57+
/** ID of a node based on its location. */
58+
type NodeID = string & {__nodeID: true};
59+
60+
/** Migration that replaces `InjectFlags` usages with object literals. */
61+
export class InjectFlagsMigration extends TsurgeFunnelMigration<
62+
CompilationUnitData,
63+
CompilationUnitData
64+
> {
65+
override async analyze(info: ProgramInfo): Promise<Serializable<CompilationUnitData>> {
66+
const locations: Record<NodeID, ReplacementLocation> = {};
67+
const importRemovals: Record<ProjectFileID, Replacement[]> = {};
68+
69+
for (const sourceFile of info.sourceFiles) {
70+
const specifier = getImportSpecifier(sourceFile, '@angular/core', 'InjectFlags');
71+
72+
if (specifier === null) {
73+
continue;
74+
}
75+
76+
const file = projectFile(sourceFile, info);
77+
const importManager = new ImportManager();
78+
const importReplacements: Replacement[] = [];
79+
80+
// Always remove the `InjectFlags` since it has been removed from Angular.
81+
// Note that it be better to do this inside of `migrate`, but we don't have AST access there.
82+
importManager.removeImport(sourceFile, 'InjectFlags', '@angular/core');
83+
applyImportManagerChanges(importManager, importReplacements, [sourceFile], info);
84+
importRemovals[file.id] = importReplacements;
85+
86+
sourceFile.forEachChild(function walk(node) {
87+
if (
88+
// Note: we don't use the type checker for matching here, because
89+
// the `InjectFlags` will be removed which can break the lookup.
90+
ts.isPropertyAccessExpression(node) &&
91+
ts.isIdentifier(node.expression) &&
92+
node.expression.text === specifier.name.text &&
93+
FLAGS_TO_FIELDS.hasOwnProperty(node.name.text)
94+
) {
95+
const root = getInjectFlagsRootExpression(node);
96+
97+
if (root !== null) {
98+
const flagName = FLAGS_TO_FIELDS[node.name.text];
99+
const id = getNodeID(file, root);
100+
locations[id] ??= {file, flags: [], position: root.getStart(), end: root.getEnd()};
101+
102+
// The flags can't be a set here, because they need to be serializable.
103+
if (!locations[id].flags.includes(flagName)) {
104+
locations[id].flags.push(flagName);
105+
}
106+
}
107+
} else {
108+
node.forEachChild(walk);
109+
}
110+
});
111+
}
112+
113+
return confirmAsSerializable({locations, importRemovals});
114+
}
115+
116+
override async migrate(globalData: CompilationUnitData) {
117+
const replacements: Replacement[] = [];
118+
119+
for (const removals of Object.values(globalData.importRemovals)) {
120+
replacements.push(...removals);
121+
}
122+
123+
for (const {file, position, end, flags} of Object.values(globalData.locations)) {
124+
// Declare a property for each flag, except for `default` which does not have a flag.
125+
const properties = flags.filter((flag) => flag !== 'default').map((flag) => `${flag}: true`);
126+
const toInsert = properties.length ? `{ ${properties.join(', ')} }` : '{}';
127+
replacements.push(new Replacement(file, new TextUpdate({position, end, toInsert})));
128+
}
129+
130+
return confirmAsSerializable({replacements});
131+
}
132+
133+
override async combine(
134+
unitA: CompilationUnitData,
135+
unitB: CompilationUnitData,
136+
): Promise<Serializable<CompilationUnitData>> {
137+
return confirmAsSerializable({
138+
locations: {
139+
...unitA.locations,
140+
...unitB.locations,
141+
},
142+
importRemovals: {
143+
...unitA.importRemovals,
144+
...unitB.importRemovals,
145+
},
146+
});
147+
}
148+
149+
override async globalMeta(
150+
combinedData: CompilationUnitData,
151+
): Promise<Serializable<CompilationUnitData>> {
152+
return confirmAsSerializable(combinedData);
153+
}
154+
155+
override async stats() {
156+
return {counters: {}};
157+
}
158+
}
159+
160+
/** Gets an ID that can be used to look up a node based on its location. */
161+
function getNodeID(file: ProjectFile, node: ts.Node): NodeID {
162+
return `${file.id}/${node.getStart()}/${node.getWidth()}` as NodeID;
163+
}
164+
165+
/**
166+
* Gets the root expression of an `InjectFlags` usage. For example given `InjectFlags.Optional`.
167+
* in `InjectFlags.Host | InjectFlags.Optional | InjectFlags.SkipSelf`, the function will return
168+
* the top-level binary expression.
169+
* @param start Node from which to start searching.
170+
*/
171+
function getInjectFlagsRootExpression(start: ts.Expression): ts.Expression | null {
172+
let current = start as ts.Node | undefined;
173+
let parent = current?.parent;
174+
175+
while (parent && (ts.isBinaryExpression(parent) || ts.isParenthesizedExpression(parent))) {
176+
current = parent;
177+
parent = current.parent;
178+
}
179+
180+
// Only allow allow expressions that are call parameters, variable initializer or parameter
181+
// initializers which are the only officially supported usages of `InjectFlags`.
182+
if (
183+
current &&
184+
parent &&
185+
((ts.isCallExpression(parent) && parent.arguments.includes(current as ts.Expression)) ||
186+
(ts.isVariableDeclaration(parent) && parent.initializer === current) ||
187+
(ts.isParameter(parent) && parent.initializer === current))
188+
) {
189+
return current as ts.Expression;
190+
}
191+
192+
return null;
193+
}

0 commit comments

Comments
 (0)