Skip to content

Commit 8389557

Browse files
crisbetodylhunn
authored andcommitted
fix(migrations): don't copy unmigrated declarations into imports array (#48882)
Currently the migration is set up to assume that any elements that exist in a `declarations` array will be converted to standalone and copied into the `imports` array, however that might be incorrect for some special cases like the root component. These changes rework the declaration merging logic so that they take all the declarations being migrated into account. PR Close #48882
1 parent 8273800 commit 8389557

File tree

2 files changed

+179
-74
lines changed

2 files changed

+179
-74
lines changed

packages/core/schematics/ng-generate/standalone-migration/to-standalone.ts

Lines changed: 111 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import ts from 'typescript';
1212

1313
import {getAngularDecorators, NgDecorator} from '../../utils/ng_decorators';
1414
import {getImportSpecifier} from '../../utils/typescript/imports';
15+
import {closestNode} from '../../utils/typescript/nodes';
1516
import {isReferenceToImport} from '../../utils/typescript/symbol';
1617

1718
import {ChangesByFile, ChangeTracker, findClassDeclaration, findLiteralProperty, NamedClassDeclaration} from './util';
@@ -44,10 +45,10 @@ export function toStandalone(
4445
}
4546

4647
for (const node of modulesToMigrate) {
47-
migrateNgModuleClass(node, tracker, templateTypeChecker);
48+
migrateNgModuleClass(node, declarations, tracker, typeChecker, templateTypeChecker);
4849
}
4950

50-
migrateTestDeclarations(testObjectsToMigrate, tracker, typeChecker);
51+
migrateTestDeclarations(testObjectsToMigrate, declarations, tracker, typeChecker);
5152
return tracker.recordChanges();
5253
}
5354

@@ -131,94 +132,116 @@ function getComponentImportExpressions(
131132
/**
132133
* Moves all of the declarations of a class decorated with `@NgModule` to its imports.
133134
* @param node Class being migrated.
135+
* @param allDeclarations All the declarations that are being converted as a part of this migration.
134136
* @param tracker
135137
* @param typeChecker
138+
* @param templateTypeChecker
136139
*/
137140
function migrateNgModuleClass(
138-
node: ts.ClassDeclaration, tracker: ChangeTracker, typeChecker: TemplateTypeChecker) {
139-
const decorator = typeChecker.getNgModuleMetadata(node)?.decorator;
141+
node: ts.ClassDeclaration, allDeclarations: Reference<ts.ClassDeclaration>[],
142+
tracker: ChangeTracker, typeChecker: ts.TypeChecker, templateTypeChecker: TemplateTypeChecker) {
143+
const decorator = templateTypeChecker.getNgModuleMetadata(node)?.decorator;
140144

141145
if (decorator && ts.isCallExpression(decorator.expression) &&
142146
decorator.expression.arguments.length === 1 &&
143147
ts.isObjectLiteralExpression(decorator.expression.arguments[0])) {
144-
moveDeclarationsToImports(decorator.expression.arguments[0], tracker);
148+
moveDeclarationsToImports(
149+
decorator.expression.arguments[0], allDeclarations.map(decl => decl.node), typeChecker,
150+
tracker);
145151
}
146152
}
147153

148154
/**
149155
* Moves all the symbol references from the `declarations` array to the `imports`
150156
* array of an `NgModule` class and removes the `declarations`.
151157
* @param literal Object literal used to configure the module that should be migrated.
158+
* @param allDeclarations All the declarations that are being converted as a part of this migration.
159+
* @param typeChecker
152160
* @param tracker
153161
*/
154162
function moveDeclarationsToImports(
155-
literal: ts.ObjectLiteralExpression, tracker: ChangeTracker): void {
156-
const properties =
157-
literal.properties
158-
.map(prop => {
159-
if (!isNamedPropertyAssignment(prop)) {
160-
return prop;
161-
}
162-
163-
// If there's no `imports`, copy the initializer from the `declarations`.
164-
if (prop.name.text === 'declarations' && !findLiteralProperty(literal, 'imports')) {
165-
return ts.factory.createPropertyAssignment('imports', prop.initializer);
166-
}
167-
168-
// Migrate the `imports`.
169-
if (prop.name.text === 'imports') {
170-
const declarations = findLiteralProperty(literal, 'declarations');
171-
return declarations && ts.isPropertyAssignment(declarations) ?
172-
mergeDeclarationsIntoImports(declarations, prop) :
173-
prop;
174-
}
175-
176-
// Retain any remaining properties.
177-
return prop;
178-
})
179-
// Drop the `declarations` property.
180-
.filter(prop => isNamedPropertyAssignment(prop) && prop.name.text !== 'declarations');
163+
literal: ts.ObjectLiteralExpression, allDeclarations: ts.ClassDeclaration[],
164+
typeChecker: ts.TypeChecker, tracker: ChangeTracker): void {
165+
const declarationsProp = findLiteralProperty(literal, 'declarations');
181166

182-
tracker.replaceNode(
183-
literal, ts.factory.createObjectLiteralExpression(properties, true), ts.EmitHint.Expression);
184-
}
167+
if (!declarationsProp) {
168+
return;
169+
}
185170

186-
/**
187-
* Merges the `declarations` and `imports` arrays of an NgModule.
188-
* @param declarations Node that declares the `declarations` property.
189-
* @param imports Node that declares the `imports` property.
190-
*/
191-
function mergeDeclarationsIntoImports(
192-
declarations: ts.PropertyAssignment, imports: ts.PropertyAssignment) {
193-
const importsIsArray = ts.isArrayLiteralExpression(imports.initializer);
194-
const declarationsIsArray = ts.isArrayLiteralExpression(declarations.initializer);
195-
let arrayElements: ts.Expression[];
196-
197-
if (importsIsArray && declarationsIsArray) {
198-
// Both values are arrays so they can be merged statically.
199-
// E.g. `imports: [Import1, Import2, Declaration1]`.
200-
arrayElements = [...imports.initializer.elements, ...declarations.initializer.elements];
201-
} else if (importsIsArray) {
202-
// Only the imports is an array so we need to use a spread to merge the
203-
// declarations. E.g. `imports: [Import1, Import2, ...DECLARATIONS]`.
204-
arrayElements =
205-
[...imports.initializer.elements, ts.factory.createSpreadElement(declarations.initializer)];
206-
} else if (declarationsIsArray) {
207-
// Declarations are an array, but imports aren't so we have to generate a spread.
208-
// E.g. `imports: [...IMPORTS, Declaration1, Declaration2]`.
209-
arrayElements =
210-
[ts.factory.createSpreadElement(imports.initializer), ...declarations.initializer.elements];
211-
} else {
212-
// Neither the declarations nor the imports are arrays so we have to use spread for
213-
// both. E.g. `imports: [...IMPORTS, ...DECLARATIONS]`.
214-
arrayElements = [
215-
ts.factory.createSpreadElement(imports.initializer),
216-
ts.factory.createSpreadElement(declarations.initializer)
217-
];
171+
const declarationsToPreserve: ts.Expression[] = [];
172+
const declarationsToCopy: ts.Expression[] = [];
173+
const properties: ts.ObjectLiteralElementLike[] = [];
174+
const importsProp = findLiteralProperty(literal, 'imports');
175+
176+
// Separate the declarations that we want to keep and ones we need to copy into the `imports`.
177+
if (ts.isPropertyAssignment(declarationsProp)) {
178+
// If the declarations are an array, we can analyze it to
179+
// find any classes from the current migration.
180+
if (ts.isArrayLiteralExpression(declarationsProp.initializer)) {
181+
for (const el of declarationsProp.initializer.elements) {
182+
if (ts.isIdentifier(el)) {
183+
const correspondingClass = findClassDeclaration(el, typeChecker);
184+
185+
if (!correspondingClass || allDeclarations.includes(correspondingClass)) {
186+
declarationsToCopy.push(el);
187+
} else {
188+
declarationsToPreserve.push(el);
189+
}
190+
} else {
191+
declarationsToCopy.push(el);
192+
}
193+
}
194+
} else {
195+
// Otherwise create a spread that will be copied into the `imports`.
196+
declarationsToCopy.push(ts.factory.createSpreadElement(declarationsProp.initializer));
197+
}
198+
}
199+
200+
// If there are no `imports`, create them with the declarations we want to copy.
201+
if (!importsProp && declarationsToCopy.length > 0) {
202+
properties.push(ts.factory.createPropertyAssignment(
203+
'imports', ts.factory.createArrayLiteralExpression(declarationsToCopy)));
204+
}
205+
206+
for (const prop of literal.properties) {
207+
if (!isNamedPropertyAssignment(prop)) {
208+
properties.push(prop);
209+
continue;
210+
}
211+
212+
// If we have declarations to preserve, update the existing property, otherwise drop it.
213+
if (prop === declarationsProp) {
214+
if (declarationsToPreserve.length > 0) {
215+
properties.push(ts.factory.updatePropertyAssignment(
216+
prop, prop.name, ts.factory.createArrayLiteralExpression(declarationsToPreserve)));
217+
}
218+
continue;
219+
}
220+
221+
// If we have an `imports` array and declarations
222+
// that should be copied, we merge the two arrays.
223+
if (prop === importsProp && declarationsToCopy.length > 0) {
224+
let initializer: ts.Expression;
225+
226+
if (ts.isArrayLiteralExpression(prop.initializer)) {
227+
initializer = ts.factory.updateArrayLiteralExpression(
228+
prop.initializer, [...prop.initializer.elements, ...declarationsToCopy]);
229+
} else {
230+
initializer = ts.factory.createArrayLiteralExpression(
231+
[ts.factory.createSpreadElement(prop.initializer), ...declarationsToCopy]);
232+
}
233+
234+
properties.push(ts.factory.updatePropertyAssignment(prop, prop.name, initializer));
235+
continue;
236+
}
237+
238+
// Retain any remaining properties.
239+
properties.push(prop);
218240
}
219241

220-
return ts.factory.createPropertyAssignment(
221-
imports.name, ts.factory.createArrayLiteralExpression(arrayElements));
242+
tracker.replaceNode(
243+
literal, ts.factory.updateObjectLiteralExpression(literal, properties),
244+
ts.EmitHint.Expression);
222245
}
223246

224247
/** Adds `standalone: true` to a decorator node. */
@@ -397,17 +420,30 @@ function extractDeclarationsFromModule(
397420
* @param typeChecker
398421
*/
399422
function migrateTestDeclarations(
400-
testObjects: ts.ObjectLiteralExpression[], tracker: ChangeTracker,
423+
testObjects: ts.ObjectLiteralExpression[],
424+
declarationsOutsideOfTestFiles: Reference<ts.ClassDeclaration>[], tracker: ChangeTracker,
401425
typeChecker: ts.TypeChecker) {
402-
const {decorators, componentImports} = analyzeTestingModules(testObjects, tracker, typeChecker);
426+
const {decorators, componentImports} = analyzeTestingModules(testObjects, typeChecker);
427+
const allDeclarations: ts.ClassDeclaration[] =
428+
declarationsOutsideOfTestFiles.map(ref => ref.node);
403429

404430
for (const decorator of decorators) {
431+
const closestClass = closestNode(decorator.node, ts.isClassDeclaration);
432+
405433
if (decorator.name === 'Pipe' || decorator.name === 'Directive') {
406434
tracker.replaceNode(decorator.node, addStandaloneToDecorator(decorator.node));
435+
436+
if (closestClass) {
437+
allDeclarations.push(closestClass);
438+
}
407439
} else if (decorator.name === 'Component') {
408440
const newDecorator = addStandaloneToDecorator(decorator.node);
409441
const importsToAdd = componentImports.get(decorator.node);
410442

443+
if (closestClass) {
444+
allDeclarations.push(closestClass);
445+
}
446+
411447
if (importsToAdd && importsToAdd.size > 0) {
412448
tracker.replaceNode(
413449
decorator.node,
@@ -420,6 +456,10 @@ function migrateTestDeclarations(
420456
}
421457
}
422458
}
459+
460+
for (const obj of testObjects) {
461+
moveDeclarationsToImports(obj, allDeclarations, typeChecker, tracker);
462+
}
423463
}
424464

425465
/**
@@ -429,8 +469,7 @@ function migrateTestDeclarations(
429469
* @param testObjects Object literals that should be analyzed.
430470
*/
431471
function analyzeTestingModules(
432-
testObjects: ts.ObjectLiteralExpression[], tracker: ChangeTracker,
433-
typeChecker: ts.TypeChecker) {
472+
testObjects: ts.ObjectLiteralExpression[], typeChecker: ts.TypeChecker) {
434473
const seenDeclarations = new Set<ts.Declaration>();
435474
const decorators: NgDecorator[] = [];
436475
const componentImports = new Map<ts.Decorator, Set<ts.Expression>>();
@@ -447,8 +486,6 @@ function analyzeTestingModules(
447486
importsProp.initializer.elements :
448487
null;
449488

450-
moveDeclarationsToImports(obj, tracker);
451-
452489
for (const decl of declarations) {
453490
if (seenDeclarations.has(decl)) {
454491
continue;

packages/core/schematics/test/standalone_migration_spec.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1144,6 +1144,74 @@ describe('standalone migration', () => {
11441144
`));
11451145
});
11461146

1147+
it('should not move declarations that are not being migrated out of the declarations array',
1148+
async () => {
1149+
const appComponentContent = `
1150+
import {Component} from '@angular/core';
1151+
1152+
@Component({selector: 'app', template: ''})
1153+
export class AppComponent {}
1154+
`;
1155+
1156+
const appModuleContent = `
1157+
import {NgModule} from '@angular/core';
1158+
import {AppComponent} from './app.component';
1159+
1160+
@NgModule({declarations: [AppComponent], bootstrap: [AppComponent]})
1161+
export class AppModule {}
1162+
`;
1163+
1164+
writeFile('app.component.ts', appComponentContent);
1165+
writeFile('app.module.ts', appModuleContent);
1166+
1167+
writeFile('app.spec.ts', `
1168+
import {Component} from '@angular/core';
1169+
import {TestBed} from '@angular/core/testing';
1170+
import {ButtonModule} from './button.module';
1171+
import {MatCardModule} from '@angular/material/card';
1172+
import {AppComponent} from './app.component';
1173+
1174+
describe('bootrstrapping an app', () => {
1175+
it('should work', () => {
1176+
TestBed.configureTestingModule({
1177+
declarations: [AppComponent, TestComp],
1178+
imports: [ButtonModule, MatCardModule]
1179+
});
1180+
const fixture = TestBed.createComponent(App);
1181+
expect(fixture.nativeElement.innerHTML).toBe('');
1182+
});
1183+
});
1184+
1185+
@Component({template: ''})
1186+
class TestComp {}
1187+
`);
1188+
1189+
await runMigration('convert-to-standalone');
1190+
1191+
const testContent = stripWhitespace(tree.readContent('app.spec.ts'));
1192+
1193+
expect(tree.readContent('app.module.ts')).toBe(appModuleContent);
1194+
expect(tree.readContent('app.component.ts')).toBe(appComponentContent);
1195+
expect(testContent).toContain(stripWhitespace(`
1196+
it('should work', () => {
1197+
TestBed.configureTestingModule({
1198+
declarations: [AppComponent],
1199+
imports: [ButtonModule, MatCardModule, TestComp]
1200+
});
1201+
const fixture = TestBed.createComponent(App);
1202+
expect(fixture.nativeElement.innerHTML).toBe('');
1203+
});
1204+
`));
1205+
expect(testContent).toContain(stripWhitespace(`
1206+
@Component({
1207+
template: '',
1208+
standalone: true,
1209+
imports: [ButtonModule, MatCardModule]
1210+
})
1211+
class TestComp {}
1212+
`));
1213+
});
1214+
11471215
it('should not migrate modules with a `bootstrap` array', async () => {
11481216
const initialModule = `
11491217
import {NgModule, Component} from '@angular/core';

0 commit comments

Comments
 (0)