diff --git a/packages/core/schematics/migrations/output-migration/output-migration.spec.ts b/packages/core/schematics/migrations/output-migration/output-migration.spec.ts index 9849cb67c2be..df0ab5469315 100644 --- a/packages/core/schematics/migrations/output-migration/output-migration.spec.ts +++ b/packages/core/schematics/migrations/output-migration/output-migration.spec.ts @@ -149,8 +149,58 @@ describe('outputs', () => { this.someChange.next('clicked'); } + someMethod() { + this.someChange.pipe(); + } + } + `, + ); + }); + }); + + describe('.complete migration', () => { + it('should remove .complete usage for migrated outputs', () => { + verify({ + before: ` + import {Directive, Output, EventEmitter} from '@angular/core'; + + @Directive() + export class TestDir { + @Output() someChange = new EventEmitter(); + + ngOnDestroy() { + this.someChange.complete(); + } + } + `, + after: ` + import { Directive, output } from '@angular/core'; + + @Directive() + export class TestDir { + readonly someChange = output(); + + ngOnDestroy() { + + } + } + `, + }); + }); + + it('should _not_ migrate .complete usage outside of expression statements', () => { + verifyNoChange( + ` + import {Directive, Output, EventEmitter} from '@angular/core'; + + @Directive() + export class TestDir { + @Output() someChange = new EventEmitter(); + ngOnDestroy() { - this.someChange.complete(); + // play it safe and skip replacement for any .complete usage that are not + // trivial expression statements + (this.someChange.complete()); } } `, @@ -174,18 +224,18 @@ describe('outputs', () => { `); }); - it('should _not_ migrate outputs that are used with .complete', () => { + it('should _not_ migrate outputs that are used with .pipe outside of a component class', () => { verifyNoChange(` - import {Directive, Output, EventEmitter, OnDestroy} from '@angular/core'; + import {Directive, Output, EventEmitter} from '@angular/core'; @Directive() - export class TestDir implements OnDestroy { - @Output() someChange = new EventEmitter(); - - ngOnDestroy() { - this.someChange.complete(); - } + export class TestDir { + @Output() someChange = new EventEmitter(); } + + let instance: TestDir; + + instance.someChange.pipe(); `); }); }); diff --git a/packages/core/schematics/migrations/output-migration/output-migration.ts b/packages/core/schematics/migrations/output-migration/output-migration.ts index 0f7c6d5d784b..697442796704 100644 --- a/packages/core/schematics/migrations/output-migration/output-migration.ts +++ b/packages/core/schematics/migrations/output-migration/output-migration.ts @@ -24,13 +24,15 @@ import { getUniqueIdForProperty, isTargetOutputDeclaration, extractSourceOutputDefinition, - isPotentialProblematicEventEmitterUsage, + isPotentialCompleteCallUsage, isPotentialNextCallUsage, + isPotentialPipeCallUsage, } from './output_helpers'; import { calculateImportReplacements, - calculateDeclarationReplacements, + calculateDeclarationReplacement, calculateNextFnReplacement, + calculateCompleteCallReplacement, } from './output-replacements'; interface OutputMigrationData { @@ -73,11 +75,11 @@ export class OutputMigration extends TsurgeFunnelMigration< const relativePath = projectRelativePath(node.getSourceFile(), projectDirAbsPath); filesWithOutputDeclarations.add(relativePath); - addOutputReplacements( + addOutputReplacement( outputFieldReplacements, outputDef.id, relativePath, - calculateDeclarationReplacements(projectDirAbsPath, node, outputDef.aliasParam), + calculateDeclarationReplacement(projectDirAbsPath, node, outputDef.aliasParam), ); } } @@ -93,16 +95,43 @@ export class OutputMigration extends TsurgeFunnelMigration< if (propertyDeclaration !== null) { const id = getUniqueIdForProperty(projectDirAbsPath, propertyDeclaration); const relativePath = projectRelativePath(node.getSourceFile(), projectDirAbsPath); - addOutputReplacements(outputFieldReplacements, id, relativePath, [ + addOutputReplacement( + outputFieldReplacements, + id, + relativePath, calculateNextFnReplacement(projectDirAbsPath, node.expression.name), - ]); + ); + } + } + + // detect .complete usages that should be removed + if (isPotentialCompleteCallUsage(node) && ts.isPropertyAccessExpression(node.expression)) { + const propertyDeclaration = isTargetOutputDeclaration( + node.expression.expression, + checker, + reflector, + dtsReader, + ); + if (propertyDeclaration !== null) { + const id = getUniqueIdForProperty(projectDirAbsPath, propertyDeclaration); + const relativePath = projectRelativePath(node.getSourceFile(), projectDirAbsPath); + if (ts.isExpressionStatement(node.parent)) { + addOutputReplacement( + outputFieldReplacements, + id, + relativePath, + calculateCompleteCallReplacement(projectDirAbsPath, node.parent), + ); + } else { + problematicUsages[id] = true; + } } } // detect unsafe access of the output property - if (isPotentialProblematicEventEmitterUsage(node)) { + if (isPotentialPipeCallUsage(node) && ts.isPropertyAccessExpression(node.expression)) { const propertyDeclaration = isTargetOutputDeclaration( - node.expression, + node.expression.expression, checker, reflector, dtsReader, @@ -202,19 +231,19 @@ export class OutputMigration extends TsurgeFunnelMigration< } } -function addOutputReplacements( +function addOutputReplacement( outputFieldReplacements: Record, outputId: OutputID, relativePath: ProjectRelativePath, - replacements: Replacement[], + replacement: Replacement, ): void { const existingReplacements = outputFieldReplacements[outputId]; if (existingReplacements !== undefined) { - existingReplacements.replacements.push(...replacements); + existingReplacements.replacements.push(replacement); } else { outputFieldReplacements[outputId] = { path: relativePath, - replacements: replacements, + replacements: [replacement], }; } } diff --git a/packages/core/schematics/migrations/output-migration/output-replacements.ts b/packages/core/schematics/migrations/output-migration/output-replacements.ts index fc41e6dbcaf1..a869285d9a85 100644 --- a/packages/core/schematics/migrations/output-migration/output-replacements.ts +++ b/packages/core/schematics/migrations/output-migration/output-replacements.ts @@ -19,11 +19,11 @@ import {applyImportManagerChanges} from '../../utils/tsurge/helpers/apply_import const printer = ts.createPrinter(); -export function calculateDeclarationReplacements( +export function calculateDeclarationReplacement( projectDirAbsPath: AbsoluteFsPath, node: ts.PropertyDeclaration, aliasParam?: ts.Expression, -): Replacement[] { +): Replacement { const sf = node.getSourceFile(); const payloadTypes = node.initializer !== undefined && ts.isNewExpression(node.initializer) @@ -53,16 +53,11 @@ export function calculateDeclarationReplacements( outputCall, ); - return [ - new Replacement( - projectRelativePath(sf, projectDirAbsPath), - new TextUpdate({ - position: node.getStart(), - end: node.getEnd(), - toInsert: printer.printNode(ts.EmitHint.Unspecified, updatedOutputDeclaration, sf), - }), - ), - ]; + return prepareTextReplacement( + projectDirAbsPath, + node, + printer.printNode(ts.EmitHint.Unspecified, updatedOutputDeclaration, sf), + ); } export function calculateImportReplacements( @@ -103,6 +98,21 @@ export function calculateImportReplacements( export function calculateNextFnReplacement( projectDirAbsPath: AbsoluteFsPath, node: ts.MemberName, +): Replacement { + return prepareTextReplacement(projectDirAbsPath, node, 'emit'); +} + +export function calculateCompleteCallReplacement( + projectDirAbsPath: AbsoluteFsPath, + node: ts.ExpressionStatement, +): Replacement { + return prepareTextReplacement(projectDirAbsPath, node, ''); +} + +function prepareTextReplacement( + projectDirAbsPath: AbsoluteFsPath, + node: ts.Node, + replacement: string, ): Replacement { const sf = node.getSourceFile(); return new Replacement( @@ -110,7 +120,7 @@ export function calculateNextFnReplacement( new TextUpdate({ position: node.getStart(), end: node.getEnd(), - toInsert: 'emit', + toInsert: replacement, }), ); } diff --git a/packages/core/schematics/migrations/output-migration/output_helpers.ts b/packages/core/schematics/migrations/output-migration/output_helpers.ts index c570c5c403bf..c3cbe43cea6f 100644 --- a/packages/core/schematics/migrations/output-migration/output_helpers.ts +++ b/packages/core/schematics/migrations/output-migration/output_helpers.ts @@ -28,8 +28,6 @@ export interface ExtractedOutput { aliasParam?: ts.Expression; } -const PROBLEMATIC_OUTPUT_USAGES = new Set(['complete', 'pipe']); - /** * Determines if the given node refers to a decorator-based output, and * returns its resolved metadata if possible. @@ -60,29 +58,28 @@ function isOutputDeclarationEligibleForMigration(node: ts.PropertyDeclaration) { ); } -export function isPotentialProblematicEventEmitterUsage( - node: ts.Node, -): node is ts.PropertyAccessExpression { - return ( - ts.isPropertyAccessExpression(node) && - ts.isIdentifier(node.name) && - PROBLEMATIC_OUTPUT_USAGES.has(node.name.text) - ); -} - -export function isPotentialNextCallUsage(node: ts.Node): node is ts.CallExpression { +function isPotentialOutputCallUsage(node: ts.Node, name: string): node is ts.CallExpression { if ( ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && ts.isIdentifier(node.expression.name) ) { - const methodName = node.expression.name.text; - if (methodName === 'next') { - return true; - } + return node.expression?.name.text === name; + } else { + return false; } +} + +export function isPotentialPipeCallUsage(node: ts.Node): node is ts.CallExpression { + return isPotentialOutputCallUsage(node, 'pipe'); +} + +export function isPotentialNextCallUsage(node: ts.Node): node is ts.CallExpression { + return isPotentialOutputCallUsage(node, 'next'); +} - return false; +export function isPotentialCompleteCallUsage(node: ts.Node): node is ts.CallExpression { + return isPotentialOutputCallUsage(node, 'complete'); } export function isTargetOutputDeclaration(