Skip to content

Commit b7d7093

Browse files
committed
feat(migrations): add migration to convert ngStyle to use style
Add migration to convert ngStyle to use style
1 parent e48ba58 commit b7d7093

11 files changed

Lines changed: 1343 additions & 0 deletions

File tree

packages/core/schematics/BUILD.bazel

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ npm_package(
4444
":bundles",
4545
"//packages/core/schematics/migrations/control-flow-migration:static_files",
4646
"//packages/core/schematics/migrations/ngclass-to-class-migration:static_files",
47+
"//packages/core/schematics/migrations/ngstyle-to-style-migration:static_files",
4748
"//packages/core/schematics/ng-generate/cleanup-unused-imports:static_files",
4849
"//packages/core/schematics/ng-generate/inject-migration:static_files",
4950
"//packages/core/schematics/ng-generate/output-migration:static_files",
@@ -102,6 +103,10 @@ bundle_entrypoints = [
102103
"ngclass-to-class-migration",
103104
"packages/core/schematics/migrations/ngclass-to-class-migration/index.js",
104105
],
106+
[
107+
"ngstyle-to-style-migration",
108+
"packages/core/schematics/migrations/ngstyle-to-style-migration/index.js",
109+
],
105110
[
106111
"router-current-navigation",
107112
"packages/core/schematics/migrations/router-current-navigation/index.js",
@@ -123,6 +128,7 @@ rollup.rollup(
123128
"//packages/core/schematics:tsconfig_build",
124129
"//packages/core/schematics/migrations/control-flow-migration",
125130
"//packages/core/schematics/migrations/ngclass-to-class-migration",
131+
"//packages/core/schematics/migrations/ngstyle-to-style-migration",
126132
"//packages/core/schematics/migrations/router-current-navigation",
127133
"//packages/core/schematics/migrations/router-last-successful-navigation",
128134
"//packages/core/schematics/ng-generate/cleanup-unused-imports",

packages/core/schematics/collection.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,12 @@
6363
"factory": "./bundles/ngclass-to-class-migration.cjs#migrate",
6464
"schema": "./migrations/ngclass-to-class-migration/schema.json",
6565
"aliases": ["ngclass-to-class"]
66+
},
67+
"ngstyle-to-style-migration": {
68+
"description": "Updates usages of `ngStyle` directives to the `style` bindings where possible",
69+
"factory": "./bundles/ngstyle-to-style-migration.cjs#migrate",
70+
"schema": "./migrations/ngstyle-to-style-migration/schema.json",
71+
"aliases": ["ngstyle-to-style"]
6672
}
6773
}
6874
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
load("//tools:defaults.bzl", "copy_to_bin", "ts_project")
2+
3+
package(
4+
default_visibility = [
5+
"//packages/core/schematics:__pkg__",
6+
"//packages/core/schematics/test:__pkg__",
7+
],
8+
)
9+
10+
copy_to_bin(
11+
name = "static_files",
12+
srcs = ["schema.json"],
13+
)
14+
15+
ts_project(
16+
name = "ngstyle-to-style-migration",
17+
srcs = glob(["**/*.ts"]),
18+
data = ["schema.json"],
19+
deps = [
20+
"//:node_modules/@angular-devkit/schematics",
21+
"//:node_modules/@types/node",
22+
"//:node_modules/typescript",
23+
"//packages/compiler",
24+
"//packages/compiler-cli",
25+
"//packages/compiler-cli/private",
26+
"//packages/compiler-cli/src/ngtsc/annotations",
27+
"//packages/compiler-cli/src/ngtsc/annotations/directive",
28+
"//packages/compiler-cli/src/ngtsc/file_system",
29+
"//packages/compiler-cli/src/ngtsc/imports",
30+
"//packages/compiler-cli/src/ngtsc/metadata",
31+
"//packages/compiler-cli/src/ngtsc/reflection",
32+
"//packages/core/schematics/utils",
33+
"//packages/core/schematics/utils/tsurge",
34+
"//packages/core/schematics/utils/tsurge/helpers/angular_devkit",
35+
],
36+
)
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
2+
# ngStyle to style migration
3+
This schematic helps developers to convert ngStyle directive usages to style bindings where possible.
4+
5+
## How to run this migration?
6+
The migration can be run using the following command:
7+
8+
```bash
9+
ng generate @angular/core:ngstyle-to-style
10+
```
11+
12+
By default, the migration will go over the entire application. If you want to apply this migration to a subset of the files, you can pass the path argument as shown below:
13+
14+
```bash
15+
ng generate @angular/core:ngstyle-to-style --path src/app/sub-component
16+
```
17+
18+
### How does it work?
19+
The schematic will attempt to find all the places in the templates where the directive is used and check if it can be converted to [style].
20+
21+
Example:
22+
23+
```html
24+
<!-- Before -->
25+
<div [ngStyle]="{'background-color': 'red'}">
26+
27+
<!-- After -->
28+
<div [style]="{'background-color': 'red'}">
29+
```
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
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} from '@angular-devkit/schematics';
10+
import {NgStyleMigration} from './ngstyle-to-style-migration';
11+
import {MigrationStage, runMigrationInDevkit} from '../../utils/tsurge/helpers/angular_devkit';
12+
13+
interface Options {
14+
path: string;
15+
analysisDir: string;
16+
migrateObjectReferences?: boolean;
17+
}
18+
19+
export function migrate(options: Options): Rule {
20+
return async (tree, context) => {
21+
await runMigrationInDevkit({
22+
tree,
23+
getMigration: (fs) =>
24+
new NgStyleMigration({
25+
migrateObjectReferences: options.migrateObjectReferences,
26+
shouldMigrate: (file) => {
27+
return (
28+
file.rootRelativePath.startsWith(fs.normalize(options.path)) &&
29+
!/(^|\/)node_modules\//.test(file.rootRelativePath)
30+
);
31+
},
32+
}),
33+
beforeProgramCreation: (tsconfigPath: string, stage: MigrationStage) => {
34+
if (stage === MigrationStage.Analysis) {
35+
context.logger.info(`Preparing analysis for: ${tsconfigPath}...`);
36+
} else {
37+
context.logger.info(`Running migration for: ${tsconfigPath}...`);
38+
}
39+
},
40+
beforeUnitAnalysis: (tsconfigPath: string) => {
41+
context.logger.info(`Scanning for component tags: ${tsconfigPath}...`);
42+
},
43+
afterAllAnalyzed: () => {
44+
context.logger.info(``);
45+
context.logger.info(`Processing analysis data between targets...`);
46+
context.logger.info(``);
47+
},
48+
afterAnalysisFailure: () => {
49+
context.logger.error('Migration failed unexpectedly with no analysis data');
50+
},
51+
whenDone: ({
52+
touchedFilesCount,
53+
replacementCount,
54+
}: {
55+
touchedFilesCount: number;
56+
replacementCount: number;
57+
}) => {
58+
context.logger.info('');
59+
context.logger.info(`Successfully migrated to style from ngStyle 🎉`);
60+
context.logger.info(
61+
` -> Migrated ${replacementCount} ngStyle to style in ${touchedFilesCount} files.`,
62+
);
63+
},
64+
});
65+
};
66+
}
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
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 {
22+
migrateNgStyleBindings,
23+
calculateImportReplacements,
24+
createNgStyleImportsArrayRemoval,
25+
} from './util';
26+
import {AbsoluteFsPath} from '@angular/compiler-cli';
27+
import {NgComponentTemplateVisitor} from '../../utils/ng_component_template';
28+
import {MigrationConfig} from './types';
29+
30+
export interface NgStyleMigrationData {
31+
file: ProjectFile;
32+
replacementCount: number;
33+
replacements: Replacement[];
34+
}
35+
36+
export interface NgStyleCompilationUnitData {
37+
ngStyleReplacements: Array<NgStyleMigrationData>;
38+
importReplacements: Record<ProjectFileID, {add: Replacement[]; addAndRemove: Replacement[]}>;
39+
}
40+
41+
export class NgStyleMigration extends TsurgeFunnelMigration<
42+
NgStyleCompilationUnitData,
43+
NgStyleCompilationUnitData
44+
> {
45+
constructor(private readonly config: MigrationConfig = {}) {
46+
super();
47+
}
48+
49+
private processTemplate(
50+
template: {content: string; inline: boolean; filePath: string | null; start: number},
51+
node: ts.ClassDeclaration,
52+
file: ProjectFile,
53+
info: ProgramInfo,
54+
typeChecker: ts.TypeChecker,
55+
): {replacements: Replacement[]; replacementCount: number} | null {
56+
const {migrated, changed, replacementCount} = migrateNgStyleBindings(
57+
template.content,
58+
this.config,
59+
node,
60+
typeChecker,
61+
);
62+
63+
if (!changed) {
64+
return null;
65+
}
66+
67+
const fileToMigrate = template.inline
68+
? file
69+
: projectFile(template.filePath as AbsoluteFsPath, info);
70+
const end = template.start + template.content.length;
71+
72+
return {
73+
replacements: [prepareTextReplacement(fileToMigrate, migrated, template.start, end)],
74+
replacementCount,
75+
};
76+
}
77+
78+
override async analyze(info: ProgramInfo): Promise<Serializable<NgStyleCompilationUnitData>> {
79+
const {sourceFiles, program} = info;
80+
const typeChecker = program.getTypeChecker();
81+
const ngStyleReplacements: Array<NgStyleMigrationData> = [];
82+
const filesWithNgStyleDeclarations = new Set<ts.SourceFile>();
83+
84+
for (const sf of sourceFiles) {
85+
ts.forEachChild(sf, (node: ts.Node) => {
86+
if (!ts.isClassDeclaration(node)) {
87+
return;
88+
}
89+
90+
const file = projectFile(sf, info);
91+
92+
if (this.config.shouldMigrate && !this.config.shouldMigrate(file)) {
93+
return;
94+
}
95+
96+
const templateVisitor = new NgComponentTemplateVisitor(typeChecker);
97+
templateVisitor.visitNode(node);
98+
99+
const replacementsForStyle: Replacement[] = [];
100+
let replacementCountForStyle = 0;
101+
102+
for (const template of templateVisitor.resolvedTemplates) {
103+
const result = this.processTemplate(template, node, file, info, typeChecker);
104+
if (result) {
105+
replacementsForStyle.push(...result.replacements);
106+
replacementCountForStyle += result.replacementCount;
107+
}
108+
}
109+
110+
if (replacementCountForStyle === 0) {
111+
return;
112+
}
113+
114+
filesWithNgStyleDeclarations.add(sf);
115+
116+
const importArrayRemoval = createNgStyleImportsArrayRemoval(node, file, typeChecker);
117+
if (importArrayRemoval) {
118+
replacementsForStyle.push(importArrayRemoval);
119+
}
120+
121+
const existing = ngStyleReplacements.find((entry) => entry.file === file);
122+
if (existing) {
123+
existing.replacements.push(...replacementsForStyle);
124+
existing.replacementCount += replacementCountForStyle;
125+
} else {
126+
ngStyleReplacements.push({
127+
file,
128+
replacements: replacementsForStyle,
129+
replacementCount: replacementCountForStyle,
130+
});
131+
}
132+
});
133+
}
134+
135+
const importReplacements = calculateImportReplacements(info, filesWithNgStyleDeclarations);
136+
return confirmAsSerializable({ngStyleReplacements, importReplacements});
137+
}
138+
139+
override async combine(
140+
unitA: NgStyleCompilationUnitData,
141+
unitB: NgStyleCompilationUnitData,
142+
): Promise<Serializable<NgStyleCompilationUnitData>> {
143+
const importReplacements: Record<
144+
ProjectFileID,
145+
{add: Replacement[]; addAndRemove: Replacement[]}
146+
> = {};
147+
148+
for (const unit of [unitA, unitB]) {
149+
for (const fileIDStr of Object.keys(unit.importReplacements)) {
150+
const fileID = fileIDStr as ProjectFileID;
151+
importReplacements[fileID] = unit.importReplacements[fileID];
152+
}
153+
}
154+
155+
return confirmAsSerializable({
156+
ngStyleReplacements: [...unitA.ngStyleReplacements, ...unitB.ngStyleReplacements],
157+
importReplacements,
158+
});
159+
}
160+
161+
override async globalMeta(
162+
combinedData: NgStyleCompilationUnitData,
163+
): Promise<Serializable<NgStyleCompilationUnitData>> {
164+
return confirmAsSerializable({
165+
ngStyleReplacements: combinedData.ngStyleReplacements,
166+
importReplacements: combinedData.importReplacements,
167+
});
168+
}
169+
170+
override async stats(globalMetadata: NgStyleCompilationUnitData) {
171+
const touchedFilesCount = globalMetadata.ngStyleReplacements.length;
172+
const replacementCount = globalMetadata.ngStyleReplacements.reduce(
173+
(acc, cur) => acc + cur.replacementCount,
174+
0,
175+
);
176+
177+
return confirmAsSerializable({
178+
touchedFilesCount,
179+
replacementCount,
180+
});
181+
}
182+
183+
override async migrate(globalData: NgStyleCompilationUnitData) {
184+
const replacements: Replacement[] = [];
185+
186+
replacements.push(...globalData.ngStyleReplacements.flatMap(({replacements}) => replacements));
187+
188+
for (const fileIDStr of Object.keys(globalData.importReplacements)) {
189+
const fileID = fileIDStr as ProjectFileID;
190+
const importReplacements = globalData.importReplacements[fileID];
191+
replacements.push(...importReplacements.addAndRemove);
192+
}
193+
194+
return {replacements};
195+
}
196+
}
197+
198+
function prepareTextReplacement(
199+
file: ProjectFile,
200+
replacement: string,
201+
start: number,
202+
end: number,
203+
): Replacement {
204+
return new Replacement(
205+
file,
206+
new TextUpdate({
207+
position: start,
208+
end: end,
209+
toInsert: replacement,
210+
}),
211+
);
212+
}

0 commit comments

Comments
 (0)