1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-09 21:20:27 +00:00
Files
browser/scripts/migrations/takeuntil/takeuntil-migrator.ts
2025-07-25 10:51:46 +02:00

644 lines
20 KiB
TypeScript
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env ts-node
/* eslint-disable no-console */
import { readFileSync } from "fs";
import { resolve } from "path";
import { Project, SyntaxKind, Scope, SourceFile, CallExpression, ClassDeclaration } from "ts-morph";
/**
* CLI utility to migrate RxJS takeUntil patterns to Angular's takeUntilDestroyed
*
* This tool identifies and transforms the following patterns:
* 1. takeUntil(this._destroy) -> takeUntilDestroyed(this.destroyRef)
* 2. takeUntil(this.destroy$) -> takeUntilDestroyed(this.destroyRef)
* 3. Removes destroy Subject properties when they're only used for takeUntil
* 4. Adds DestroyRef injection when needed
* 5. Updates imports
*/
interface MigrationStats {
filesProcessed: number;
filesMigrated: number;
takeUntilCallsReplaced: number;
destroyPropertiesRemoved: number;
destroyRefPropertiesAdded: number;
}
interface TakeUntilPattern {
callExpression: CallExpression;
destroyProperty: string;
withinConstructor: boolean;
withinMethod: boolean;
}
class TakeUntilMigrator {
private project: Project;
private stats: MigrationStats = {
filesProcessed: 0,
filesMigrated: 0,
takeUntilCallsReplaced: 0,
destroyPropertiesRemoved: 0,
destroyRefPropertiesAdded: 0,
};
constructor(tsConfigPath: string) {
this.project = new Project({
tsConfigFilePath: tsConfigPath,
});
}
/**
* Main migration method
*/
migrate(pattern: string = "/**/*.+(component|directive|pipe|service).ts"): MigrationStats {
console.log("🚀 Starting takeUntil to takeUntilDestroyed migration...");
console.log(`📁 Using pattern: ${pattern}`);
const files = this.project.getSourceFiles(pattern);
console.log(`📄 Found ${files.length} files to process`);
for (const file of files) {
this.processFile(file);
}
this.printSummary();
return this.stats;
}
/**
* Process a single file
*/
private processFile(file: SourceFile): void {
this.stats.filesProcessed++;
const filePath = file.getFilePath();
console.log(`🔍 Processing: ${filePath.split("/").pop()}`);
const classes = file.getDescendantsOfKind(SyntaxKind.ClassDeclaration);
let fileMigrated = false;
let fileNeedsDestroyRef = false;
for (const clazz of classes) {
const result = this.processClass(clazz, file);
if (result.migrated) {
fileMigrated = true;
}
if (result.needsDestroyRef) {
fileNeedsDestroyRef = true;
}
}
if (fileMigrated) {
this.stats.filesMigrated++;
this.updateImports(file, fileNeedsDestroyRef);
file.saveSync();
console.log(`✅ Migrated: ${filePath.split("/").pop()}`);
}
}
/**
* Process a single class
*/
private processClass(
clazz: ClassDeclaration,
file: SourceFile,
): { migrated: boolean; needsDestroyRef: boolean } {
// Only process Angular classes (Component, Directive, Pipe, Injectable)
if (!this.isAngularClass(clazz)) {
return { migrated: false, needsDestroyRef: false };
}
const takeUntilPatterns = this.findTakeUntilPatterns(clazz);
if (takeUntilPatterns.length === 0) {
return { migrated: false, needsDestroyRef: false };
}
console.log(
` 🎯 Found ${takeUntilPatterns.length} takeUntil pattern(s) in class ${clazz.getName()}`,
);
let needsDestroyRef = false;
const destroyPropertiesUsed = new Set<string>();
// Process each takeUntil pattern
for (const pattern of takeUntilPatterns) {
destroyPropertiesUsed.add(pattern.destroyProperty);
// Only use auto-inference when directly within constructor
// Methods called from constructor might also be called elsewhere, so they need explicit destroyRef
if (pattern.withinConstructor) {
// Directly in constructor: takeUntilDestroyed() can auto-infer destroyRef
pattern.callExpression.replaceWithText("takeUntilDestroyed()");
} else {
// In methods or property initializers: need explicit destroyRef
pattern.callExpression.replaceWithText("takeUntilDestroyed(this.destroyRef)");
needsDestroyRef = true;
}
this.stats.takeUntilCallsReplaced++;
}
// Add destroyRef property if needed
if (needsDestroyRef && !this.hasDestroyRefProperty(clazz)) {
this.addDestroyRefProperty(clazz);
this.stats.destroyRefPropertiesAdded++;
}
// Remove destroy properties that are only used for takeUntil
for (const destroyPropertyName of destroyPropertiesUsed) {
if (this.canRemoveDestroyProperty(clazz, destroyPropertyName)) {
this.removeDestroyProperty(clazz, destroyPropertyName);
this.stats.destroyPropertiesRemoved++;
}
}
// Remove ngOnDestroy if it only handled destroy subject
this.cleanupNgOnDestroy(clazz, destroyPropertiesUsed);
return { migrated: true, needsDestroyRef };
}
/**
* Check if class has Angular decorators
*/
private isAngularClass(clazz: ClassDeclaration): boolean {
const angularDecorators = ["Component", "Directive", "Pipe", "Injectable"];
return clazz
.getDecorators()
.some((decorator) => angularDecorators.includes(decorator.getName()));
}
/**
* Find all takeUntil patterns in a class
*/
private findTakeUntilPatterns(clazz: ClassDeclaration): TakeUntilPattern[] {
const patterns: TakeUntilPattern[] = [];
const takeUntilCalls = clazz.getDescendantsOfKind(SyntaxKind.CallExpression).filter((call) => {
const identifier = call.getExpression();
return identifier.getText() === "takeUntil";
});
for (const call of takeUntilCalls) {
const args = call.getArguments();
if (args.length !== 1) {
continue;
}
const arg = args[0].getText();
// Match patterns like this._destroy, this.destroy$, this._destroy$, etc.
const destroyPropertyMatch = arg.match(/^this\.(_?destroy\$?|_?destroy_?\$?)$/);
if (!destroyPropertyMatch) {
continue;
}
const destroyProperty = destroyPropertyMatch[1];
const withinConstructor = !!call.getFirstAncestorByKind(SyntaxKind.Constructor);
const withinMethod = !!call.getFirstAncestorByKind(SyntaxKind.MethodDeclaration);
patterns.push({
callExpression: call,
destroyProperty,
withinConstructor,
withinMethod: withinMethod && !withinConstructor,
});
}
return patterns;
}
/**
* Check if class already has a destroyRef property
*/
private hasDestroyRefProperty(clazz: ClassDeclaration): boolean {
return clazz.getInstanceProperties().some((prop) => prop.getName() === "destroyRef");
}
/**
* Add destroyRef property to class
*/
private addDestroyRefProperty(clazz: ClassDeclaration): void {
const lastProperty = clazz.getInstanceProperties().slice(-1)[0];
const insertIndex = lastProperty ? lastProperty.getChildIndex() + 1 : 0;
clazz.insertProperty(insertIndex, {
name: "destroyRef",
scope: Scope.Private,
isReadonly: true,
initializer: "inject(DestroyRef)",
});
console.log(` Added destroyRef property`);
}
/**
* Check if a destroy property can be safely removed
*/
private canRemoveDestroyProperty(clazz: ClassDeclaration, propertyName: string): boolean {
const property = clazz.getInstanceProperty(propertyName);
if (!property) {
return false;
}
// Find all references to this property in the class
const propertyReferences = clazz
.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)
.filter(
(access) =>
access.getName() === propertyName && access.getExpression().getText() === "this",
);
// Check if all references are only in takeUntil calls or ngOnDestroy
for (const ref of propertyReferences) {
// Skip if it's the property declaration itself
const refText = ref.getFullText();
if (refText.includes("=") && refText.includes("new Subject")) {
continue;
}
// Allow if it's in a takeUntil call argument
const takeUntilCall = ref.getFirstAncestorByKind(SyntaxKind.CallExpression);
if (takeUntilCall && takeUntilCall.getExpression().getText() === "takeUntil") {
continue;
}
// Allow if it's in ngOnDestroy for calling next() or complete()
const method = ref.getFirstAncestorByKind(SyntaxKind.MethodDeclaration);
if (method && method.getName() === "ngOnDestroy") {
// Check if this is a method call on the property
const parent = ref.getParent();
if (parent && parent.getKind() === SyntaxKind.PropertyAccessExpression) {
const grandParent = parent.getParent();
if (grandParent && grandParent.getKind() === SyntaxKind.CallExpression) {
const methodCall = parent.asKindOrThrow(SyntaxKind.PropertyAccessExpression);
const methodName = methodCall.getName();
if (methodName === "next" || methodName === "complete") {
continue;
}
}
}
}
// If we reach here, the property is used elsewhere and can't be removed
console.log(` 💡 Property ${propertyName} is used elsewhere, keeping it`);
return false;
}
return true;
}
/**
* Remove a destroy property from the class
*/
private removeDestroyProperty(clazz: ClassDeclaration, propertyName: string): void {
const property = clazz.getInstanceProperty(propertyName);
if (property) {
property.remove();
console.log(` Removed destroy property: ${propertyName}`);
}
}
/**
* Clean up ngOnDestroy method if it only handled destroy subjects
*/
private cleanupNgOnDestroy(clazz: ClassDeclaration, destroyProperties: Set<string>): void {
const ngOnDestroy = clazz.getMethod("ngOnDestroy");
if (!ngOnDestroy) {
return;
}
const body = ngOnDestroy.getBody();
if (!body) {
return;
}
// Type assertion to access getStatements method
const blockBody = body as any;
if (!blockBody.getStatements) {
return;
}
const statements = blockBody.getStatements();
let hasOnlyDestroySubjectCalls = true;
let hasDestroySubjectCalls = false;
// Check if any destroy properties are still in use (not removed)
const hasRemainingDestroyProperties = Array.from(destroyProperties).some((prop) => {
return clazz.getInstanceProperty(prop) !== undefined;
});
// If any destroy properties remain, preserve ngOnDestroy
if (hasRemainingDestroyProperties) {
console.log(` 💡 Preserving ngOnDestroy because destroy properties are still in use`);
return;
}
// Check if all statements are just destroy subject calls
for (const statement of statements) {
const text = statement.getText().trim();
// Allow empty statements or comments
if (!text || text.startsWith("//") || text.startsWith("/*")) {
continue;
}
// Check if it's a destroy subject call
let isDestroySubjectCall = false;
for (const destroyProp of destroyProperties) {
if (
text.includes(`this.${destroyProp}.next(`) ||
text.includes(`this.${destroyProp}.complete(`)
) {
isDestroySubjectCall = true;
hasDestroySubjectCalls = true;
break;
}
}
if (!isDestroySubjectCall) {
hasOnlyDestroySubjectCalls = false;
}
}
// Only remove the method if it ONLY has destroy subject calls and no other logic
if (hasOnlyDestroySubjectCalls && hasDestroySubjectCalls) {
ngOnDestroy.remove();
// Remove OnDestroy from implements clause if it exists
const implementsClause = clazz.getImplements();
const onDestroyIndex = implementsClause.findIndex((impl) =>
impl.getText().includes("OnDestroy"),
);
if (onDestroyIndex !== -1) {
clazz.removeImplements(onDestroyIndex);
}
console.log(` Removed ngOnDestroy method`);
} else if (hasDestroySubjectCalls) {
// If there are other statements, just remove the destroy subject calls
this.removeDestroySubjectCallsFromNgOnDestroy(ngOnDestroy, destroyProperties);
}
}
/**
* Remove only the destroy subject calls from ngOnDestroy, preserving other logic
*/
private removeDestroySubjectCallsFromNgOnDestroy(
ngOnDestroy: any,
destroyProperties: Set<string>,
): void {
const body = ngOnDestroy.getBody();
if (!body) {
return;
}
const blockBody = body as any;
if (!blockBody.getStatements) {
return;
}
const statements = blockBody.getStatements();
const statementsToRemove: any[] = [];
for (const statement of statements) {
const text = statement.getText().trim();
// Check if it's a destroy subject call
for (const destroyProp of destroyProperties) {
if (
text.includes(`this.${destroyProp}.next(`) ||
text.includes(`this.${destroyProp}.complete(`)
) {
statementsToRemove.push(statement);
break;
}
}
}
// Remove the destroy subject call statements
for (const statement of statementsToRemove) {
statement.remove();
}
if (statementsToRemove.length > 0) {
console.log(` Removed destroy subject calls from ngOnDestroy`);
}
}
/**
* Update file imports
*/
private updateImports(file: SourceFile, needsDestroyRef: boolean): void {
// Remove unused imports
this.removeUnusedRxjsImports(file);
this.removeUnusedAngularImports(file);
// Add Angular imports
if (needsDestroyRef) {
this.addImport(file, "@angular/core", ["inject", "DestroyRef"]);
}
this.addImport(file, "@angular/core/rxjs-interop", ["takeUntilDestroyed"]);
}
/**
* Remove unused Angular imports
*/
private removeUnusedAngularImports(file: SourceFile): void {
const angularImports = file
.getImportDeclarations()
.filter((imp) => imp.getModuleSpecifierValue() === "@angular/core");
for (const importDecl of angularImports) {
const namedImports = importDecl.getNamedImports();
const unusedImports: string[] = [];
for (const namedImport of namedImports) {
const importName = namedImport.getName();
if (importName === "OnDestroy") {
// Check if OnDestroy is still used in the file (in implements clauses or method signatures)
const onDestroyUsages = file
.getDescendantsOfKind(SyntaxKind.Identifier)
.filter((id) => id.getText() === "OnDestroy" && id !== namedImport.getNameNode());
if (onDestroyUsages.length === 0) {
unusedImports.push(importName);
}
}
}
// Remove unused imports
for (const unusedImport of unusedImports) {
const namedImport = namedImports.find((ni) => ni.getName() === unusedImport);
if (namedImport) {
namedImport.remove();
console.log(` Removed unused import: ${unusedImport}`);
}
}
}
}
/**
* Remove unused RxJS imports
*/
private removeUnusedRxjsImports(file: SourceFile): void {
const rxjsImports = file
.getImportDeclarations()
.filter((imp) => imp.getModuleSpecifierValue() === "rxjs");
for (const importDecl of rxjsImports) {
const namedImports = importDecl.getNamedImports();
const importsToRemove: { name: string; import: any }[] = [];
for (const namedImport of namedImports) {
const importName = namedImport.getName();
if (importName === "Subject") {
// Check if Subject is still used in the file
const subjectUsages = file
.getDescendantsOfKind(SyntaxKind.Identifier)
.filter((id) => id.getText() === "Subject" && id !== namedImport.getNameNode());
if (subjectUsages.length === 0) {
importsToRemove.push({ name: importName, import: namedImport });
}
} else if (importName === "takeUntil") {
// Check if takeUntil is still used in the file
const takeUntilUsages = file
.getDescendantsOfKind(SyntaxKind.Identifier)
.filter((id) => id.getText() === "takeUntil" && id !== namedImport.getNameNode());
if (takeUntilUsages.length === 0) {
importsToRemove.push({ name: importName, import: namedImport });
}
}
}
// Remove unused imports
for (const { import: namedImport } of importsToRemove) {
namedImport.remove();
}
// Remove the entire import if no named imports left
if (importDecl.getNamedImports().length === 0 && !importDecl.getDefaultImport()) {
importDecl.remove();
}
}
}
/**
* Add import to file
*/
private addImport(file: SourceFile, moduleSpecifier: string, namedImports: string[]): void {
let importDecl = file.getImportDeclaration(
(imp) => imp.getModuleSpecifierValue() === moduleSpecifier,
);
if (!importDecl) {
importDecl = file.addImportDeclaration({
moduleSpecifier,
});
}
const existingImports = importDecl.getNamedImports().map((ni) => ni.getName());
const missingImports = namedImports.filter((ni) => !existingImports.includes(ni));
if (missingImports.length > 0) {
importDecl.addNamedImports(missingImports);
}
}
/**
* Print migration summary
*/
private printSummary(): void {
console.log("\n📊 Migration Summary:");
console.log(` 📄 Files processed: ${this.stats.filesProcessed}`);
console.log(` ✅ Files migrated: ${this.stats.filesMigrated}`);
console.log(` 🔄 takeUntil calls replaced: ${this.stats.takeUntilCallsReplaced}`);
console.log(` DestroyRef properties added: ${this.stats.destroyRefPropertiesAdded}`);
console.log(` Destroy properties removed: ${this.stats.destroyPropertiesRemoved}`);
if (this.stats.filesMigrated > 0) {
console.log("\n🎉 Migration completed successfully!");
console.log("💡 Don't forget to:");
console.log(" 1. Run your linter/formatter (eslint, prettier)");
console.log(" 2. Run your tests to ensure everything works");
console.log(" 3. Remove OnDestroy imports from @angular/core if no longer needed");
} else {
console.log("\n🤷 No files needed migration.");
}
}
}
// CLI Interface
function main() {
const args = process.argv.slice(2);
const helpFlag = args.includes("--help") || args.includes("-h");
if (helpFlag) {
console.log(`
🚀 takeUntil to takeUntilDestroyed Migration Tool
Usage:
npx ts-node takeuntil-migrator.ts [options]
Options:
--tsconfig <path> Path to tsconfig.json (default: ./tsconfig.json)
--pattern <pattern> File pattern to match (default: /**/*.+(component|directive|pipe|service).ts)
--help, -h Show this help message
Examples:
npx ts-node takeuntil-migrator.ts
npx ts-node takeuntil-migrator.ts --tsconfig ./apps/web/tsconfig.json
npx ts-node takeuntil-migrator.ts --pattern "src/**/*.component.ts"
What this tool does:
✅ Converts takeUntil(this._destroy) to takeUntilDestroyed()
✅ Converts takeUntil(this.destroy$) to takeUntilDestroyed()
✅ Adds DestroyRef injection when needed
✅ Removes unused destroy Subject properties
✅ Cleans up ngOnDestroy methods when no longer needed
✅ Updates imports automatically
Note: Always run this on a clean git repository and test thoroughly!
`);
process.exit(0);
}
const tsconfigIndex = args.indexOf("--tsconfig");
const patternIndex = args.indexOf("--pattern");
const tsConfigPath =
tsconfigIndex !== -1 && args[tsconfigIndex + 1] ? args[tsconfigIndex + 1] : "./tsconfig.json";
const pattern =
patternIndex !== -1 && args[patternIndex + 1]
? args[patternIndex + 1]
: "/**/*.+(component|directive|pipe|service).ts";
try {
// Verify tsconfig exists
readFileSync(resolve(tsConfigPath));
const migrator = new TakeUntilMigrator(tsConfigPath);
migrator.migrate(pattern);
} catch (error: unknown) {
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
console.error(`❌ Error: tsconfig.json not found at ${tsConfigPath}`);
console.error(` Please provide a valid path with --tsconfig option`);
} else {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`❌ Error during migration:`, errorMessage);
}
process.exit(1);
}
}
// Only run if this file is executed directly
if (require.main === module) {
main();
}
export { TakeUntilMigrator, MigrationStats };