Skip to content

Commit 223e402

Browse files
surajy93pkozlowski-opensource
authored andcommitted
fix(migrations): preserve NgClass import on partial migration
When only some NgClass usages are migrated (partial migration), the NgClass import should be preserved in the module/component imports if it is still used elsewhere.
1 parent 79fe1c3 commit 223e402

3 files changed

Lines changed: 322 additions & 51 deletions

File tree

packages/core/schematics/ng-generate/ngclass-to-class-migration/ngclass-to-class-migration.ts

Lines changed: 97 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,16 @@ export interface NgClassMigrationData {
3333
replacements: Replacement[];
3434
}
3535

36+
/** Result of migrating a single class declaration. */
37+
interface ClassMigrationResult {
38+
replacements: Replacement[];
39+
replacementCount: number;
40+
/** Whether every `[ngClass]` binding in this class's template(s) was migrated. */
41+
canRemoveNgClass: boolean;
42+
/** Whether this class's template(s) no longer need any `CommonModule` directive. */
43+
canRemoveCommonModule: boolean;
44+
}
45+
3646
export interface NgClassCompilationUnitData {
3747
ngClassReplacements: Array<NgClassMigrationData>;
3848
importReplacements: Record<ProjectFileID, {add: Replacement[]; addAndRemove: Replacement[]}>;
@@ -55,17 +65,25 @@ export class NgClassMigration extends TsurgeFunnelMigration<
5565
): {
5666
replacements: Replacement[];
5767
replacementCount: number;
68+
canRemoveNgClass: boolean;
5869
canRemoveCommonModule: boolean;
59-
} | null {
60-
const {migrated, changed, replacementCount, canRemoveCommonModule} = migrateNgClassBindings(
61-
template.content,
62-
this.config,
63-
node,
64-
typeChecker,
65-
);
70+
} {
71+
const {migrated, changed, replacementCount, canRemoveNgClass, canRemoveCommonModule} =
72+
migrateNgClassBindings(template.content, this.config, node, typeChecker);
6673

6774
if (!changed) {
68-
return null;
75+
// Even when no replacements were made, we must propagate the real `canRemoveNgClass`
76+
// value. If the template contained [ngClass] bindings that could not be migrated (e.g.
77+
// dynamic variable or array bindings), the visitor's `skippedNgClassCount` will be > 0
78+
// and `canRemoveNgClass` will be false. Returning `true` here unconditionally would
79+
// cause the migration to incorrectly strip `NgClass` from the component's `imports`
80+
// array and from the top-level import statement.
81+
return {
82+
replacements: [],
83+
replacementCount: 0,
84+
canRemoveNgClass,
85+
canRemoveCommonModule,
86+
};
6987
}
7088

7189
const fileToMigrate = template.inline
@@ -76,10 +94,56 @@ export class NgClassMigration extends TsurgeFunnelMigration<
7694
return {
7795
replacements: [prepareTextReplacement(fileToMigrate, migrated, template.start, end)],
7896
replacementCount,
97+
canRemoveNgClass,
7998
canRemoveCommonModule,
8099
};
81100
}
82101

102+
/** Migrates a single class declaration, if it has a component template using `[ngClass]`. */
103+
private processClass(
104+
node: ts.ClassDeclaration,
105+
file: ProjectFile,
106+
info: ProgramInfo,
107+
typeChecker: ts.TypeChecker,
108+
): ClassMigrationResult | null {
109+
const templateVisitor = new NgComponentTemplateVisitor(typeChecker);
110+
templateVisitor.visitNode(node);
111+
112+
if (templateVisitor.resolvedTemplates.length === 0) {
113+
return null;
114+
}
115+
116+
const replacements: Replacement[] = [];
117+
let replacementCount = 0;
118+
let canRemoveNgClass = true;
119+
let canRemoveCommonModule = true;
120+
121+
for (const template of templateVisitor.resolvedTemplates) {
122+
const result = this.processTemplate(template, node, file, info, typeChecker);
123+
124+
replacements.push(...result.replacements);
125+
replacementCount += result.replacementCount;
126+
canRemoveNgClass = canRemoveNgClass && result.canRemoveNgClass;
127+
canRemoveCommonModule = canRemoveCommonModule && result.canRemoveCommonModule;
128+
}
129+
130+
// Handle the `@Component({ imports: [...] })` array.
131+
// Only remove NgClass from this class's own imports array if all of its [ngClass] bindings were migrated.
132+
if (canRemoveNgClass) {
133+
const importsRemoval = createNgClassImportsArrayRemoval(
134+
node,
135+
file,
136+
typeChecker,
137+
canRemoveCommonModule,
138+
);
139+
if (importsRemoval) {
140+
replacements.push(importsRemoval);
141+
}
142+
}
143+
144+
return {replacements, replacementCount, canRemoveNgClass, canRemoveCommonModule};
145+
}
146+
83147
override async analyze(info: ProgramInfo): Promise<Serializable<NgClassCompilationUnitData>> {
84148
const {sourceFiles, program} = info;
85149
const typeChecker = program.getTypeChecker();
@@ -88,59 +152,42 @@ export class NgClassMigration extends TsurgeFunnelMigration<
88152
const filesToRemoveCommonModule = new Set<ProjectFileID>();
89153

90154
for (const sf of sourceFiles) {
155+
const file = projectFile(sf, info);
156+
const classResults: ClassMigrationResult[] = [];
157+
91158
ts.forEachChild(sf, (node: ts.Node) => {
92159
if (!ts.isClassDeclaration(node)) {
93160
return;
94161
}
95-
96-
const file = projectFile(sf, info);
97-
98162
if (this.config.shouldMigrate && !this.config.shouldMigrate(file)) {
99163
return;
100164
}
101165

102-
const templateVisitor = new NgComponentTemplateVisitor(typeChecker);
103-
templateVisitor.visitNode(node);
104-
105-
const replacementsForClass: Replacement[] = [];
106-
let replacementCountForClass = 0;
107-
let canRemoveCommonModuleForFile = true;
108-
109-
for (const template of templateVisitor.resolvedTemplates) {
110-
const result = this.processTemplate(template, node, file, info, typeChecker);
111-
if (result) {
112-
replacementsForClass.push(...result.replacements);
113-
replacementCountForClass += result.replacementCount;
114-
if (!result.canRemoveCommonModule) {
115-
canRemoveCommonModuleForFile = false;
116-
}
117-
}
166+
const result = this.processClass(node, file, info, typeChecker);
167+
if (result !== null) {
168+
classResults.push(result);
118169
}
170+
});
171+
172+
if (classResults.length === 0) {
173+
continue;
174+
}
119175

120-
if (replacementsForClass.length > 0) {
121-
if (canRemoveCommonModuleForFile) {
122-
filesToRemoveCommonModule.add(file.id);
123-
}
124-
125-
// Handle the `@Component({ imports: [...] })` array.
126-
const importsRemoval = createNgClassImportsArrayRemoval(
127-
node,
128-
file,
129-
typeChecker,
130-
canRemoveCommonModuleForFile,
131-
);
132-
if (importsRemoval) {
133-
replacementsForClass.push(importsRemoval);
134-
}
135-
136-
ngClassReplacements.push({
137-
file,
138-
replacementCount: replacementCountForClass,
139-
replacements: replacementsForClass,
140-
});
141-
filesWithNgClassDeclarations.add(sf);
176+
for (const {replacements, replacementCount} of classResults) {
177+
if (replacements.length > 0) {
178+
ngClassReplacements.push({file, replacementCount, replacements});
142179
}
143-
});
180+
}
181+
182+
// A single source file may declare multiple classes/components. The top-level
183+
// `NgClass`/`CommonModule` import statements are shared across all of them, so they can
184+
// only be removed once every class in the file no longer needs them.
185+
if (classResults.every((result) => result.canRemoveNgClass)) {
186+
filesWithNgClassDeclarations.add(sf);
187+
}
188+
if (classResults.every((result) => result.canRemoveCommonModule)) {
189+
filesToRemoveCommonModule.add(file.id);
190+
}
144191
}
145192

146193
const importReplacements = calculateImportReplacements(

packages/core/schematics/ng-generate/ngclass-to-class-migration/util.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,18 @@ export function migrateNgClassBindings(
3939
replacementCount: number;
4040
migrated: string;
4141
changed: boolean;
42+
canRemoveNgClass: boolean;
4243
canRemoveCommonModule: boolean;
4344
} {
4445
const parsed = parseTemplate(template);
4546
if (!parsed.tree || !parsed.tree.rootNodes.length) {
46-
return {migrated: template, changed: false, replacementCount: 0, canRemoveCommonModule: false};
47+
return {
48+
migrated: template,
49+
changed: false,
50+
replacementCount: 0,
51+
canRemoveNgClass: true,
52+
canRemoveCommonModule: false,
53+
};
4754
}
4855

4956
const visitor = new NgClassCollector(template, componentNode, typeChecker);
@@ -66,6 +73,7 @@ export function migrateNgClassBindings(
6673
migrated: newTemplate,
6774
changed,
6875
replacementCount,
76+
canRemoveNgClass: visitor.skippedNgClassCount === 0,
6977
canRemoveCommonModule: changed ? canRemoveCommonModule(newTemplate) : false,
7078
};
7179
}
@@ -249,6 +257,7 @@ function replaceTemplate(
249257
*/
250258
export class NgClassCollector extends RecursiveVisitor {
251259
readonly replacements: {start: number; end: number; replacement: string}[] = [];
260+
skippedNgClassCount = 0;
252261
private originalTemplate: string;
253262
private isNgClassImported: boolean = true; // Default to true (permissive)
254263

@@ -290,6 +299,7 @@ export class NgClassCollector extends RecursiveVisitor {
290299
const staticMatch = tryParseStaticObjectLiteral(expr);
291300

292301
if (staticMatch === null) {
302+
this.skippedNgClassCount++;
293303
continue;
294304
}
295305

0 commit comments

Comments
 (0)