diff --git a/modules/component/migrations/16_0_0/index.spec.ts b/modules/component/migrations/16_0_0/index.spec.ts new file mode 100644 index 0000000000..1bb372f370 --- /dev/null +++ b/modules/component/migrations/16_0_0/index.spec.ts @@ -0,0 +1,166 @@ +import * as path from 'path'; +import { waitForAsync } from '@angular/core/testing'; +import { Tree } from '@angular-devkit/schematics'; +import { + SchematicTestRunner, + UnitTestTree, +} from '@angular-devkit/schematics/testing'; +import { createPackageJson } from '@ngrx/schematics-core/testing/create-package'; + +describe('Component Migration 16_0_0', () => { + let appTree: UnitTestTree; + const collectionPath = path.join(__dirname, '../migration.json'); + const pkgName = 'component'; + + beforeEach(() => { + appTree = new UnitTestTree(Tree.empty()); + appTree.create( + '/tsconfig.json', + ` + { + "include": [**./*.ts"] + } + ` + ); + createPackageJson('', pkgName, appTree); + }); + + [ + { module: 'LetModule', declarable: 'LetDirective' }, + { + module: 'PushModule', + declarable: 'PushPipe', + }, + ].forEach(({ module, declarable }) => { + describe(`${module} => ${declarable}`, () => { + it(`should replace the ${module} in NgModule with ${declarable}`, waitForAsync(async () => { + const input = ` + import { ${module} } from '@ngrx/component'; + + @NgModule({ + imports: [ + AuthModule, + AppRoutingModule, + ${module}, + CoreModule, + ], + exports: [${module}], + bootstrap: [AppComponent] + }) + export class AppModule {} + `; + const expected = ` + import { ${declarable} } from '@ngrx/component'; + + @NgModule({ + imports: [ + AuthModule, + AppRoutingModule, + ${declarable}, + CoreModule, + ], + exports: [${declarable}], + bootstrap: [AppComponent] + }) + export class AppModule {} + `; + + appTree.create('./app.module.ts', input); + const runner = new SchematicTestRunner('schematics', collectionPath); + + const newTree = await runner.runSchematic( + `ngrx-${pkgName}-migration-16`, + {}, + appTree + ); + const file = newTree.readContent('app.module.ts'); + + expect(file).toBe(expected); + })); + + it(`should replace the ${module} in standalone component with ${declarable}`, waitForAsync(async () => { + const input = ` + import { ${module} } from '@ngrx/component'; + + @Component({ + imports: [ + AuthModule, + ${module} + ] + }) + export class SomeStandaloneComponent {} + `; + const expected = ` + import { ${declarable} } from '@ngrx/component'; + + @Component({ + imports: [ + AuthModule, + ${declarable} + ] + }) + export class SomeStandaloneComponent {} + `; + + appTree.create('./app.module.ts', input); + const runner = new SchematicTestRunner('schematics', collectionPath); + + const newTree = await runner.runSchematic( + `ngrx-${pkgName}-migration-16`, + {}, + appTree + ); + const file = newTree.readContent('app.module.ts'); + + expect(file).toBe(expected); + })); + + it(`should not remove the ${module} JS import when used as a type`, waitForAsync(async () => { + const input = ` + import { ${module} } from '@ngrx/component'; + + const module: ${module}; + + @NgModule({ + imports: [ + AuthModule, + AppRoutingModule, + ${module}, + CoreModule + ], + bootstrap: [AppComponent] + }) + export class AppModule {} + `; + const expected = ` + import { ${module}, ${declarable} } from '@ngrx/component'; + + const module: ${module}; + + @NgModule({ + imports: [ + AuthModule, + AppRoutingModule, + ${declarable}, + CoreModule + ], + bootstrap: [AppComponent] + }) + export class AppModule {} + `; + + appTree.create('./app.module.ts', input); + const runner = new SchematicTestRunner('schematics', collectionPath); + + const newTree = await runner.runSchematic( + `ngrx-${pkgName}-migration-16`, + {}, + appTree + ); + const file = newTree.readContent('app.module.ts'); + + expect(file).toBe(expected); + })); + }); + }); +}); diff --git a/modules/component/migrations/16_0_0/index.ts b/modules/component/migrations/16_0_0/index.ts new file mode 100644 index 0000000000..0914ace341 --- /dev/null +++ b/modules/component/migrations/16_0_0/index.ts @@ -0,0 +1,216 @@ +import * as ts from 'typescript'; +import { Rule, chain, Tree } from '@angular-devkit/schematics'; +import { + visitTSSourceFiles, + commitChanges, + createReplaceChange, + ReplaceChange, +} from '../../schematics-core'; + +const letModuleText = 'LetModule'; +const letDirectiveText = 'LetDirective'; +const pushModuleText = 'PushModule'; +const pushPipeText = 'PushPipe'; +const moduleLocations = { + imports: ['NgModule', 'Component'], + exports: ['NgModule'], +}; + +function migrateToStandaloneAPIs() { + return (tree: Tree) => { + visitTSSourceFiles(tree, (sourceFile) => { + const componentImports = sourceFile.statements + .filter(ts.isImportDeclaration) + .filter(({ moduleSpecifier }) => + moduleSpecifier.getText(sourceFile).includes('@ngrx/component') + ); + + if (componentImports.length === 0) { + return; + } + + const ngModuleReplacements = findNgModuleReplacements(sourceFile); + const possibleModulesUsageCount = + findPossibleModulesUsageCount(sourceFile); + const importAdditionReplacements = findImportDeclarationAdditions( + sourceFile, + componentImports + ); + const jsImportDeclarationReplacements = + possibleModulesUsageCount > + ngModuleReplacements.length + importAdditionReplacements.length + ? importAdditionReplacements + : findImportDeclarationReplacements(sourceFile, componentImports); + + const changes = [ + ...jsImportDeclarationReplacements, + ...ngModuleReplacements, + ]; + + commitChanges(tree, sourceFile.fileName, changes); + }); + }; +} + +function findImportDeclarationReplacements( + sourceFile: ts.SourceFile, + imports: ts.ImportDeclaration[] +) { + return findImportDeclarations(sourceFile, imports) + .map(({ specifier, oldText, newText }) => + !!specifier && !!oldText + ? createReplaceChange(sourceFile, specifier, oldText, newText) + : undefined + ) + .filter((change) => !!change) as Array; +} + +function findImportDeclarationAdditions( + sourceFile: ts.SourceFile, + imports: ts.ImportDeclaration[] +) { + return findImportDeclarations(sourceFile, imports) + .map(({ specifier, oldText, newText }) => + !!specifier && !!oldText + ? createReplaceChange( + sourceFile, + specifier, + oldText, + `${oldText}, ${newText}` + ) + : undefined + ) + .filter((change) => !!change) as Array; +} + +function findImportDeclarations( + sourceFile: ts.SourceFile, + imports: ts.ImportDeclaration[] +) { + return imports + .map((p) => (p?.importClause?.namedBindings as ts.NamedImports)?.elements) + .reduce( + (imports, curr) => imports.concat(curr ?? []), + [] as ts.ImportSpecifier[] + ) + .map((specifier) => { + if (!ts.isImportSpecifier(specifier)) { + return { hit: false }; + } + + if (specifier.name.text === letModuleText) { + return { + hit: true, + specifier, + oldText: specifier.name.text, + newText: letDirectiveText, + }; + } + + if (specifier.name.text === pushModuleText) { + return { + hit: true, + specifier, + oldText: specifier.name.text, + newText: pushPipeText, + }; + } + + // if `LetModule` import is renamed + if (specifier.propertyName?.text === letModuleText) { + return { + hit: true, + specifier, + oldText: specifier.propertyName.text, + newText: letDirectiveText, + }; + } + + // if `PushModule` import is renamed + if (specifier.propertyName?.text === pushModuleText) { + return { + hit: true, + specifier, + oldText: specifier.propertyName.text, + newText: pushPipeText, + }; + } + + return { hit: false }; + }) + .filter(({ hit }) => hit); +} + +function findPossibleModulesUsageCount(sourceFile: ts.SourceFile): number { + let count = 0; + ts.forEachChild(sourceFile, (node) => countUsages(node)); + return count; + + function countUsages(node: ts.Node) { + if ( + ts.isIdentifier(node) && + (node.text === letModuleText || node.text === pushModuleText) + ) { + count = count + 1; + } + + ts.forEachChild(node, (childNode) => countUsages(childNode)); + } +} + +function findNgModuleReplacements(sourceFile: ts.SourceFile) { + const changes: ReplaceChange[] = []; + ts.forEachChild(sourceFile, (node) => find(node, changes)); + return changes; + + function find(node: ts.Node, changes: ReplaceChange[]) { + let change = undefined; + + if ( + ts.isIdentifier(node) && + (node.text === letModuleText || node.text === pushModuleText) && + ts.isArrayLiteralExpression(node.parent) && + ts.isPropertyAssignment(node.parent.parent) + ) { + const property = node.parent.parent; + if (ts.isIdentifier(property.name)) { + const propertyName = String(property.name.escapedText); + if (Object.keys(moduleLocations).includes(propertyName)) { + const decorator = property.parent.parent.parent; + if ( + ts.isDecorator(decorator) && + ts.isCallExpression(decorator.expression) && + ts.isIdentifier(decorator.expression.expression) && + moduleLocations[propertyName as 'imports' | 'exports'].includes( + String(decorator.expression.expression.escapedText) + ) + ) { + change = { + node: node, + oldText: node.text, + newText: + node.text === letModuleText ? letDirectiveText : pushPipeText, + }; + } + } + } + } + + if (change) { + changes.push( + createReplaceChange( + sourceFile, + change.node, + change.oldText, + change.newText + ) + ); + } + + ts.forEachChild(node, (childNode) => find(childNode, changes)); + } +} + +export default function (): Rule { + return chain([migrateToStandaloneAPIs()]); +} diff --git a/modules/component/migrations/migration.json b/modules/component/migrations/migration.json index 3b129f2fa8..dfbeead63b 100644 --- a/modules/component/migrations/migration.json +++ b/modules/component/migrations/migration.json @@ -5,6 +5,11 @@ "description": "As of NgRx v14, `ReactiveComponentModule` is deprecated. It is replaced by `LetModule` and `PushModule`.", "version": "15.0.0-beta", "factory": "./15_0_0-beta/index" + }, + "ngrx-component-migration-16": { + "description": "As of NgRx v16, `LetModule` and `PushModule` are deprecated in favor of standalone `LetDirective` and `PushPipe`.", + "version": "16.0.0", + "factory": "./16_0_0/index" } } }