@@ -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+
3646export 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 (
0 commit comments