Skip to content

Commit 49c6cf3

Browse files
feat(component): add migration for replacing ReactiveComponentModule (#3506)
Closes #3491
1 parent 899afe7 commit 49c6cf3

File tree

3 files changed

+390
-1
lines changed

3 files changed

+390
-1
lines changed
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { Tree } from '@angular-devkit/schematics';
2+
import {
3+
SchematicTestRunner,
4+
UnitTestTree,
5+
} from '@angular-devkit/schematics/testing';
6+
import * as path from 'path';
7+
import { createPackageJson } from '@ngrx/schematics-core/testing/create-package';
8+
import { waitForAsync } from '@angular/core/testing';
9+
10+
describe('Component Migration 15_0_0-beta', () => {
11+
let appTree: UnitTestTree;
12+
const collectionPath = path.join(__dirname, '../migration.json');
13+
const pkgName = 'component';
14+
15+
beforeEach(() => {
16+
appTree = new UnitTestTree(Tree.empty());
17+
appTree.create(
18+
'/tsconfig.json',
19+
`
20+
{
21+
"include": [**./*.ts"]
22+
}
23+
`
24+
);
25+
createPackageJson('', pkgName, appTree);
26+
});
27+
28+
describe('Replace ReactiveComponentModule', () => {
29+
it(
30+
`should replace the ReactiveComponentModule in NgModules with LetModule and PushModule`,
31+
waitForAsync(async () => {
32+
const input = `
33+
import { ReactiveComponentModule } from '@ngrx/component';
34+
35+
@NgModule({
36+
imports: [
37+
AuthModule,
38+
AppRoutingModule,
39+
ReactiveComponentModule,
40+
CoreModule,
41+
],
42+
exports: [ReactiveComponentModule],
43+
bootstrap: [AppComponent]
44+
})
45+
export class AppModule {}
46+
`;
47+
const expected = `
48+
import { LetModule, PushModule } from '@ngrx/component';
49+
50+
@NgModule({
51+
imports: [
52+
AuthModule,
53+
AppRoutingModule,
54+
LetModule, PushModule,
55+
CoreModule,
56+
],
57+
exports: [LetModule, PushModule],
58+
bootstrap: [AppComponent]
59+
})
60+
export class AppModule {}
61+
`;
62+
63+
appTree.create('./app.module.ts', input);
64+
const runner = new SchematicTestRunner('schematics', collectionPath);
65+
66+
const newTree = await runner
67+
.runSchematicAsync(`ngrx-${pkgName}-migration-15-beta`, {}, appTree)
68+
.toPromise();
69+
const file = newTree.readContent('app.module.ts');
70+
71+
expect(file).toBe(expected);
72+
})
73+
);
74+
it(
75+
`should replace the ReactiveComponentModule in standalone components with LetModule and PushModule`,
76+
waitForAsync(async () => {
77+
const input = `
78+
import { ReactiveComponentModule } from '@ngrx/component';
79+
80+
@Component({
81+
imports: [
82+
AuthModule,
83+
ReactiveComponentModule
84+
]
85+
})
86+
export class SomeStandaloneComponent {}
87+
`;
88+
const expected = `
89+
import { LetModule, PushModule } from '@ngrx/component';
90+
91+
@Component({
92+
imports: [
93+
AuthModule,
94+
LetModule, PushModule
95+
]
96+
})
97+
export class SomeStandaloneComponent {}
98+
`;
99+
100+
appTree.create('./app.module.ts', input);
101+
const runner = new SchematicTestRunner('schematics', collectionPath);
102+
103+
const newTree = await runner
104+
.runSchematicAsync(`ngrx-${pkgName}-migration-15-beta`, {}, appTree)
105+
.toPromise();
106+
const file = newTree.readContent('app.module.ts');
107+
108+
expect(file).toBe(expected);
109+
})
110+
);
111+
it(
112+
`should not remove the ReactiveComponentModule JS import when used as a type`,
113+
waitForAsync(async () => {
114+
const input = `
115+
import { ReactiveComponentModule } from '@ngrx/component';
116+
117+
const reactiveComponentModule: ReactiveComponentModule;
118+
119+
@NgModule({
120+
imports: [
121+
AuthModule,
122+
AppRoutingModule,
123+
ReactiveComponentModule,
124+
CoreModule
125+
],
126+
bootstrap: [AppComponent]
127+
})
128+
export class AppModule {}
129+
`;
130+
const expected = `
131+
import { ReactiveComponentModule, LetModule, PushModule } from '@ngrx/component';
132+
133+
const reactiveComponentModule: ReactiveComponentModule;
134+
135+
@NgModule({
136+
imports: [
137+
AuthModule,
138+
AppRoutingModule,
139+
LetModule, PushModule,
140+
CoreModule
141+
],
142+
bootstrap: [AppComponent]
143+
})
144+
export class AppModule {}
145+
`;
146+
147+
appTree.create('./app.module.ts', input);
148+
const runner = new SchematicTestRunner('schematics', collectionPath);
149+
150+
const newTree = await runner
151+
.runSchematicAsync(`ngrx-${pkgName}-migration-15-beta`, {}, appTree)
152+
.toPromise();
153+
const file = newTree.readContent('app.module.ts');
154+
155+
expect(file).toBe(expected);
156+
})
157+
);
158+
});
159+
});
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
import * as ts from 'typescript';
2+
import { Rule, chain, Tree } from '@angular-devkit/schematics';
3+
import {
4+
visitTSSourceFiles,
5+
commitChanges,
6+
createReplaceChange,
7+
ReplaceChange,
8+
} from '../../schematics-core';
9+
10+
const reactiveComponentModuleText = 'ReactiveComponentModule';
11+
const reactiveComponentModuleReplacement = 'LetModule, PushModule';
12+
const moduleLocations = {
13+
imports: ['NgModule', 'Component'],
14+
exports: ['NgModule'],
15+
};
16+
17+
function migrateReactiveComponentModule() {
18+
return (tree: Tree) => {
19+
visitTSSourceFiles(tree, (sourceFile) => {
20+
const componentImports = sourceFile.statements
21+
.filter(ts.isImportDeclaration)
22+
.filter(({ moduleSpecifier }) =>
23+
moduleSpecifier.getText(sourceFile).includes('@ngrx/component')
24+
);
25+
26+
if (componentImports.length === 0) {
27+
return;
28+
}
29+
30+
const ngModuleReplacements =
31+
findReactiveComponentModuleNgModuleReplacements(sourceFile);
32+
33+
const possibleUsagesOfReactiveComponentModuleCount =
34+
findPossibleReactiveComponentModuleUsageCount(sourceFile);
35+
36+
const importAdditionReplacements =
37+
findReactiveComponentModuleImportDeclarationAdditions(
38+
sourceFile,
39+
componentImports
40+
);
41+
42+
const importUsagesCount = importAdditionReplacements.length;
43+
44+
const jsImportDeclarationReplacements =
45+
possibleUsagesOfReactiveComponentModuleCount >
46+
ngModuleReplacements.length + importUsagesCount
47+
? importAdditionReplacements
48+
: findReactiveComponentModuleImportDeclarationReplacements(
49+
sourceFile,
50+
componentImports
51+
);
52+
53+
const changes = [
54+
...jsImportDeclarationReplacements,
55+
...ngModuleReplacements,
56+
];
57+
58+
commitChanges(tree, sourceFile.fileName, changes);
59+
});
60+
};
61+
}
62+
63+
function findReactiveComponentModuleImportDeclarationReplacements(
64+
sourceFile: ts.SourceFile,
65+
imports: ts.ImportDeclaration[]
66+
) {
67+
const changes = imports
68+
.map((p) => (p?.importClause?.namedBindings as ts.NamedImports)?.elements)
69+
.reduce(
70+
(imports, curr) => imports.concat(curr ?? []),
71+
[] as ts.ImportSpecifier[]
72+
)
73+
.map((specifier) => {
74+
if (!ts.isImportSpecifier(specifier)) {
75+
return { hit: false };
76+
}
77+
78+
if (specifier.name.text === reactiveComponentModuleText) {
79+
return { hit: true, specifier, text: specifier.name.text };
80+
}
81+
82+
// if import is renamed
83+
if (
84+
specifier.propertyName &&
85+
specifier.propertyName.text === reactiveComponentModuleText
86+
) {
87+
return { hit: true, specifier, text: specifier.propertyName.text };
88+
}
89+
90+
return { hit: false };
91+
})
92+
.filter(({ hit }) => hit)
93+
.map(({ specifier, text }) =>
94+
!!specifier && !!text
95+
? createReplaceChange(
96+
sourceFile,
97+
specifier,
98+
text,
99+
reactiveComponentModuleReplacement
100+
)
101+
: undefined
102+
)
103+
.filter((change) => !!change) as Array<ReplaceChange>;
104+
105+
return changes;
106+
}
107+
108+
function findReactiveComponentModuleImportDeclarationAdditions(
109+
sourceFile: ts.SourceFile,
110+
imports: ts.ImportDeclaration[]
111+
) {
112+
const changes = imports
113+
.map((p) => (p?.importClause?.namedBindings as ts.NamedImports)?.elements)
114+
.reduce(
115+
(imports, curr) => imports.concat(curr ?? []),
116+
[] as ts.ImportSpecifier[]
117+
)
118+
.map((specifier) => {
119+
if (!ts.isImportSpecifier(specifier)) {
120+
return { hit: false };
121+
}
122+
123+
if (specifier.name.text === reactiveComponentModuleText) {
124+
return { hit: true, specifier, text: specifier.name.text };
125+
}
126+
127+
// if import is renamed
128+
if (
129+
specifier.propertyName &&
130+
specifier.propertyName.text === reactiveComponentModuleText
131+
) {
132+
return { hit: true, specifier, text: specifier.propertyName.text };
133+
}
134+
135+
return { hit: false };
136+
})
137+
.filter(({ hit }) => hit)
138+
.map(({ specifier, text }) =>
139+
!!specifier && !!text
140+
? createReplaceChange(
141+
sourceFile,
142+
specifier,
143+
text,
144+
`${text}, ${reactiveComponentModuleReplacement}`
145+
)
146+
: undefined
147+
)
148+
.filter((change) => !!change) as Array<ReplaceChange>;
149+
150+
return changes;
151+
}
152+
153+
function findPossibleReactiveComponentModuleUsageCount(
154+
sourceFile: ts.SourceFile
155+
): number {
156+
let count = 0;
157+
ts.forEachChild(sourceFile, (node) => countUsages(node));
158+
return count;
159+
160+
function countUsages(node: ts.Node) {
161+
if (ts.isIdentifier(node) && node.text === reactiveComponentModuleText) {
162+
count = count + 1;
163+
}
164+
165+
ts.forEachChild(node, (childNode) => countUsages(childNode));
166+
}
167+
}
168+
169+
function findReactiveComponentModuleNgModuleReplacements(
170+
sourceFile: ts.SourceFile
171+
) {
172+
const changes: ReplaceChange[] = [];
173+
ts.forEachChild(sourceFile, (node) => find(node, changes));
174+
return changes;
175+
176+
function find(node: ts.Node, changes: ReplaceChange[]) {
177+
let change = undefined;
178+
179+
if (
180+
ts.isIdentifier(node) &&
181+
node.text === reactiveComponentModuleText &&
182+
ts.isArrayLiteralExpression(node.parent) &&
183+
ts.isPropertyAssignment(node.parent.parent)
184+
) {
185+
const property = node.parent.parent;
186+
if (ts.isIdentifier(property.name)) {
187+
const propertyName = String(property.name.escapedText);
188+
if (Object.keys(moduleLocations).includes(propertyName)) {
189+
const decorator = property.parent.parent.parent;
190+
if (
191+
ts.isDecorator(decorator) &&
192+
ts.isCallExpression(decorator.expression) &&
193+
ts.isIdentifier(decorator.expression.expression) &&
194+
moduleLocations[propertyName as 'imports' | 'exports'].includes(
195+
String(decorator.expression.expression.escapedText)
196+
)
197+
) {
198+
change = {
199+
node: node,
200+
text: node.text,
201+
};
202+
}
203+
}
204+
}
205+
}
206+
207+
if (change) {
208+
changes.push(
209+
createReplaceChange(
210+
sourceFile,
211+
change.node,
212+
change.text,
213+
reactiveComponentModuleReplacement
214+
)
215+
);
216+
}
217+
218+
ts.forEachChild(node, (childNode) => find(childNode, changes));
219+
}
220+
}
221+
222+
export default function (): Rule {
223+
return chain([migrateReactiveComponentModule()]);
224+
}
Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
11
{
22
"$schema": "../../../node_modules/@angular-devkit/schematics/collection-schema.json",
3-
"schematics": {}
3+
"schematics": {
4+
"ngrx-component-migration-15-beta": {
5+
"description": "As of NgRx v14, `ReactiveComponentModule` is deprecated. It is replaced by `LetModule` and `PushModule`.",
6+
"version": "15.0.0-beta",
7+
"factory": "./15_0_0-beta/index"
8+
}
9+
}
410
}

0 commit comments

Comments
 (0)