Skip to content

Commit a2a9ac7

Browse files
crisbetoalxhub
authored andcommitted
refactor(migrations): hoist uninitialized members to top of class if they are not combined in the internal migration (#58393)
When the internal mode for the `inject` migration is enabled, we find properties without initializers, we add their initializers and we prepend the `inject` calls before them. The remaining properties that couldn't be combined are left in place. This appears to break some internal cases. These changes work around the issue by hoisting all the non-combined members above the `inject()` calls. This should be safe, because they don't have initializers and as such can't have dependencies. PR Close #58393
1 parent 57db5dd commit a2a9ac7

File tree

4 files changed

+179
-50
lines changed

4 files changed

+179
-50
lines changed

packages/core/schematics/ng-generate/inject-migration/analysis.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ export function analyzeFile(sourceFile: ts.SourceFile, localTypeChecker: ts.Type
128128
export function getConstructorUnusedParameters(
129129
declaration: ts.ConstructorDeclaration,
130130
localTypeChecker: ts.TypeChecker,
131-
removedStatements: Set<ts.Statement> | null,
131+
removedStatements: Set<ts.Statement>,
132132
): Set<ts.Declaration> {
133133
const accessedTopLevelParameters = new Set<ts.Declaration>();
134134
const topLevelParameters = new Set<ts.Declaration>();
@@ -149,7 +149,7 @@ export function getConstructorUnusedParameters(
149149

150150
declaration.body.forEachChild(function walk(node) {
151151
// Don't descend into statements that were removed already.
152-
if (removedStatements && ts.isStatement(node) && removedStatements.has(node)) {
152+
if (ts.isStatement(node) && removedStatements.has(node)) {
153153
return;
154154
}
155155

packages/core/schematics/ng-generate/inject-migration/internal.ts

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,12 @@ export function findUninitializedPropertiesToCombine(
3232
node: ts.ClassDeclaration,
3333
constructor: ts.ConstructorDeclaration,
3434
localTypeChecker: ts.TypeChecker,
35-
): Map<ts.PropertyDeclaration, ts.Expression> | null {
36-
let result: Map<ts.PropertyDeclaration, ts.Expression> | null = null;
35+
): {
36+
toCombine: Map<ts.PropertyDeclaration, ts.Expression>;
37+
toHoist: ts.PropertyDeclaration[];
38+
} | null {
39+
let toCombine: Map<ts.PropertyDeclaration, ts.Expression> | null = null;
40+
let toHoist: ts.PropertyDeclaration[] = [];
3741

3842
const membersToDeclarations = new Map<string, ts.PropertyDeclaration>();
3943
for (const member of node.members) {
@@ -47,25 +51,43 @@ export function findUninitializedPropertiesToCombine(
4751
}
4852

4953
if (membersToDeclarations.size === 0) {
50-
return result;
54+
return null;
5155
}
5256

5357
const memberInitializers = getMemberInitializers(constructor);
5458
if (memberInitializers === null) {
55-
return result;
59+
return null;
5660
}
5761

58-
for (const [name, initializer] of memberInitializers.entries()) {
59-
if (
60-
membersToDeclarations.has(name) &&
61-
!hasLocalReferences(initializer, constructor, localTypeChecker)
62-
) {
63-
result = result || new Map();
64-
result.set(membersToDeclarations.get(name)!, initializer);
62+
for (const [name, decl] of membersToDeclarations.entries()) {
63+
if (memberInitializers.has(name)) {
64+
const initializer = memberInitializers.get(name)!;
65+
66+
if (!hasLocalReferences(initializer, constructor, localTypeChecker)) {
67+
toCombine = toCombine || new Map();
68+
toCombine.set(membersToDeclarations.get(name)!, initializer);
69+
}
70+
} else {
71+
// Mark members that have no initializers and can't be combined to be hoisted above the
72+
// injected members. This is either a no-op or it allows us to avoid some patterns internally
73+
// like the following:
74+
// ```
75+
// class Foo {
76+
// publicFoo: Foo;
77+
// private privateFoo: Foo;
78+
//
79+
// constructor() {
80+
// this.initializePrivateFooSomehow();
81+
// this.publicFoo = this.privateFoo;
82+
// }
83+
// }
84+
// ```
85+
toHoist.push(decl);
6586
}
6687
}
6788

68-
return result;
89+
// If no members need to be combined, none need to be hoisted either.
90+
return toCombine === null ? null : {toCombine, toHoist};
6991
}
7092

7193
/**

packages/core/schematics/ng-generate/inject-migration/migration.ts

Lines changed: 86 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -82,34 +82,20 @@ export function migrateFile(sourceFile: ts.SourceFile, options: MigrationOptions
8282
const tracker = new ChangeTracker(printer);
8383

8484
analysis.classes.forEach(({node, constructor, superCall}) => {
85-
let removedStatements: Set<ts.Statement> | null = null;
85+
const memberIndentation = getLeadingLineWhitespaceOfNode(node.members[0]);
86+
const prependToClass: string[] = [];
87+
const removedStatements = new Set<ts.Statement>();
8688

8789
if (options._internalCombineMemberInitializers) {
88-
findUninitializedPropertiesToCombine(node, constructor, localTypeChecker)?.forEach(
89-
(initializer, property) => {
90-
const statement = closestNode(initializer, ts.isStatement);
91-
92-
if (!statement) {
93-
return;
94-
}
95-
96-
const newProperty = ts.factory.createPropertyDeclaration(
97-
cloneModifiers(property.modifiers),
98-
cloneName(property.name),
99-
property.questionToken,
100-
property.type,
101-
initializer,
102-
);
103-
tracker.replaceText(
104-
statement.getSourceFile(),
105-
statement.getFullStart(),
106-
statement.getFullWidth(),
107-
'',
108-
);
109-
tracker.replaceNode(property, newProperty);
110-
removedStatements = removedStatements || new Set();
111-
removedStatements.add(statement);
112-
},
90+
applyInternalOnlyChanges(
91+
node,
92+
constructor,
93+
localTypeChecker,
94+
tracker,
95+
printer,
96+
removedStatements,
97+
prependToClass,
98+
memberIndentation,
11399
);
114100
}
115101

@@ -118,6 +104,8 @@ export function migrateFile(sourceFile: ts.SourceFile, options: MigrationOptions
118104
constructor,
119105
superCall,
120106
options,
107+
memberIndentation,
108+
prependToClass,
121109
removedStatements,
122110
localTypeChecker,
123111
printer,
@@ -141,6 +129,8 @@ export function migrateFile(sourceFile: ts.SourceFile, options: MigrationOptions
141129
* @param constructor Reference to the class' constructor node.
142130
* @param superCall Reference to the constructor's `super()` call, if any.
143131
* @param options Options used to configure the migration.
132+
* @param memberIndentation Indentation string of the members of the class.
133+
* @param prependToClass Text that should be prepended to the class.
144134
* @param removedStatements Statements that have been removed from the constructor already.
145135
* @param localTypeChecker Type checker set up for the specific file.
146136
* @param printer Printer used to output AST nodes as strings.
@@ -151,7 +141,9 @@ function migrateClass(
151141
constructor: ts.ConstructorDeclaration,
152142
superCall: ts.CallExpression | null,
153143
options: MigrationOptions,
154-
removedStatements: Set<ts.Statement> | null,
144+
memberIndentation: string,
145+
prependToClass: string[],
146+
removedStatements: Set<ts.Statement>,
155147
localTypeChecker: ts.TypeChecker,
156148
printer: ts.Printer,
157149
tracker: ChangeTracker,
@@ -173,14 +165,12 @@ function migrateClass(
173165
const superParameters = superCall
174166
? getSuperParameters(constructor, superCall, localTypeChecker)
175167
: null;
176-
const memberIndentation = getLeadingLineWhitespaceOfNode(node.members[0]);
177-
const removedStatementCount = removedStatements?.size || 0;
168+
const removedStatementCount = removedStatements.size;
178169
const innerReference =
179170
superCall ||
180-
constructor.body?.statements.find((statement) => !removedStatements?.has(statement)) ||
171+
constructor.body?.statements.find((statement) => !removedStatements.has(statement)) ||
181172
constructor;
182173
const innerIndentation = getLeadingLineWhitespaceOfNode(innerReference);
183-
const propsToAdd: string[] = [];
184174
const prependToConstructor: string[] = [];
185175
const afterSuper: string[] = [];
186176
const removedMembers = new Set<ts.ClassElement>();
@@ -201,7 +191,7 @@ function migrateClass(
201191
memberIndentation,
202192
innerIndentation,
203193
prependToConstructor,
204-
propsToAdd,
194+
prependToClass,
205195
afterSuper,
206196
);
207197
}
@@ -249,21 +239,21 @@ function migrateClass(
249239

250240
// The new signature always has to be right before the constructor implementation.
251241
if (memberReference === constructor) {
252-
propsToAdd.push(extraSignature);
242+
prependToClass.push(extraSignature);
253243
} else {
254244
tracker.insertText(sourceFile, constructor.getFullStart(), '\n' + extraSignature);
255245
}
256246
}
257247

258-
if (propsToAdd.length > 0) {
248+
if (prependToClass.length > 0) {
259249
if (removedMembers.size === node.members.length) {
260-
tracker.insertText(sourceFile, constructor.getEnd() + 1, `${propsToAdd.join('\n')}\n`);
250+
tracker.insertText(sourceFile, constructor.getEnd() + 1, `${prependToClass.join('\n')}\n`);
261251
} else {
262252
// Insert the new properties after the first member that hasn't been deleted.
263253
tracker.insertText(
264254
sourceFile,
265255
memberReference.getFullStart(),
266-
`\n${propsToAdd.join('\n')}\n`,
256+
`\n${prependToClass.join('\n')}\n`,
267257
);
268258
}
269259
}
@@ -685,3 +675,63 @@ function canRemoveConstructor(
685675
(statementCount === 1 && superCall !== null && superCall.arguments.length === 0)
686676
);
687677
}
678+
679+
/**
680+
* Applies the internal-specific migrations to a class.
681+
* @param node Class being migrated.
682+
* @param constructor The migrated class' constructor.
683+
* @param localTypeChecker File-specific type checker.
684+
* @param tracker Object keeping track of the changes.
685+
* @param printer Printer used to output AST nodes as text.
686+
* @param removedStatements Statements that have been removed by the migration.
687+
* @param prependToClass Text that will be prepended to a class.
688+
* @param memberIndentation Indentation string of the class' members.
689+
*/
690+
function applyInternalOnlyChanges(
691+
node: ts.ClassDeclaration,
692+
constructor: ts.ConstructorDeclaration,
693+
localTypeChecker: ts.TypeChecker,
694+
tracker: ChangeTracker,
695+
printer: ts.Printer,
696+
removedStatements: Set<ts.Statement>,
697+
prependToClass: string[],
698+
memberIndentation: string,
699+
) {
700+
const result = findUninitializedPropertiesToCombine(node, constructor, localTypeChecker);
701+
702+
result?.toCombine.forEach((initializer, property) => {
703+
const statement = closestNode(initializer, ts.isStatement);
704+
705+
if (!statement) {
706+
return;
707+
}
708+
709+
const newProperty = ts.factory.createPropertyDeclaration(
710+
cloneModifiers(property.modifiers),
711+
cloneName(property.name),
712+
property.questionToken,
713+
property.type,
714+
initializer,
715+
);
716+
tracker.replaceText(
717+
statement.getSourceFile(),
718+
statement.getFullStart(),
719+
statement.getFullWidth(),
720+
'',
721+
);
722+
tracker.replaceNode(property, newProperty);
723+
removedStatements.add(statement);
724+
});
725+
726+
result?.toHoist.forEach((decl) => {
727+
prependToClass.push(
728+
memberIndentation + printer.printNode(ts.EmitHint.Unspecified, decl, decl.getSourceFile()),
729+
);
730+
tracker.replaceText(decl.getSourceFile(), decl.getFullStart(), decl.getFullWidth(), '');
731+
});
732+
733+
// If we added any hoisted properties, separate them visually with a new line.
734+
if (prependToClass.length > 0) {
735+
prependToClass.push('');
736+
}
737+
}

packages/core/schematics/test/inject_migration_spec.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1884,5 +1884,62 @@ describe('inject migration', () => {
18841884
`}`,
18851885
]);
18861886
});
1887+
1888+
it('should hoist property declarations that were not combined above the inject() calls', async () => {
1889+
writeFile(
1890+
'/dir.ts',
1891+
[
1892+
`import { Injectable } from '@angular/core';`,
1893+
`import { Observable } from 'rxjs';`,
1894+
`import { StateService, State } from './state';`,
1895+
``,
1896+
`@Injectable()`,
1897+
`export class SomeService {`,
1898+
` /** Public state */`,
1899+
` readonly state: Observable<State>;`,
1900+
``,
1901+
` /** Private state */`,
1902+
` private internalState?: State;`,
1903+
``,
1904+
` constructor(readonly stateService: StateService) {`,
1905+
` this.initializeInternalState();`,
1906+
` this.state = this.internalState;`,
1907+
` }`,
1908+
``,
1909+
` private initializeInternalState() {`,
1910+
` this.internalState = new State();`,
1911+
` }`,
1912+
`}`,
1913+
].join('\n'),
1914+
);
1915+
1916+
await runInternalMigration();
1917+
1918+
expect(tree.readContent('/dir.ts').split('\n')).toEqual([
1919+
`import { Injectable, inject } from '@angular/core';`,
1920+
`import { Observable } from 'rxjs';`,
1921+
`import { StateService, State } from './state';`,
1922+
``,
1923+
`@Injectable()`,
1924+
`export class SomeService {`,
1925+
` /** Private state */`,
1926+
// The indentation here is slightly off, but it's not a problem because this code is internal-only.
1927+
`private internalState?: State;`,
1928+
``,
1929+
` readonly stateService = inject(StateService);`,
1930+
``,
1931+
` /** Public state */`,
1932+
` readonly state: Observable<State> = this.internalState;`,
1933+
``,
1934+
` constructor() {`,
1935+
` this.initializeInternalState();`,
1936+
` }`,
1937+
``,
1938+
` private initializeInternalState() {`,
1939+
` this.internalState = new State();`,
1940+
` }`,
1941+
`}`,
1942+
]);
1943+
});
18871944
});
18881945
});

0 commit comments

Comments
 (0)