fix(core): fix ng generate @angular/core:output-migration. Fixes angular#58650 (#60763)

Fixes #58650 - Insert a TODO comment for empty emit (without parameter).

PR Close #60763
This commit is contained in:
aparziale 2025-04-07 22:18:46 +02:00 committed by Andrew Scott
parent 6a55970373
commit f2bfa3151e
2 changed files with 136 additions and 0 deletions

View file

@ -169,6 +169,65 @@ describe('outputs', () => {
});
});
it('should not insert a TODO comment for emit function with no type', async () => {
await verify({
before: `
import {Directive, Output, EventEmitter} from '@angular/core';
@Directive()
export class TestDir {
@Output() someChange = new EventEmitter();
someMethod(): void {
this.someChange.emit();
}
}
`,
after: `
import {Directive, output} from '@angular/core';
@Directive()
export class TestDir {
readonly someChange = output();
someMethod(): void {
this.someChange.emit();
}
}
`,
});
});
it('should insert a TODO comment for emit function with type', async () => {
await verify({
before: `
import {Directive, Output, EventEmitter} from '@angular/core';
@Directive()
export class TestDir {
@Output() someChange = new EventEmitter<string>();
someMethod(): void {
this.someChange.emit();
}
}
`,
after: `
import {Directive, output} from '@angular/core';
@Directive()
export class TestDir {
readonly someChange = output<string>();
someMethod(): void {
// TODO: The 'emit' function requires a mandatory string argument
this.someChange.emit();
}
}
`,
});
});
it('should migrate multiple outputs', async () => {
await verifyDeclaration({
before:

View file

@ -16,6 +16,7 @@ import {
ProjectFileID,
Replacement,
Serializable,
TextUpdate,
TsurgeFunnelMigration,
} from '../../utils/tsurge';
@ -216,6 +217,8 @@ export class OutputMigration extends TsurgeFunnelMigration<
}
}
addCommentForEmptyEmit(node, info, checker, reflector, dtsReader, outputFieldReplacements);
// detect imports of test runners
if (isTestRunnerImport(node)) {
isTestFile = true;
@ -449,3 +452,77 @@ function addOutputReplacement(
}
existingReplacements.replacements.push(...replacements);
}
function addCommentForEmptyEmit(
node: ts.Node,
info: ProgramInfo,
checker: ts.TypeChecker,
reflector: TypeScriptReflectionHost,
dtsReader: DtsMetadataReader,
outputFieldReplacements: Record<ClassFieldUniqueKey, OutputMigrationData>,
): void {
if (!isEmptyEmitCall(node)) return;
const propertyAccess = getPropertyAccess(node);
if (!propertyAccess) return;
const symbol = checker.getSymbolAtLocation(propertyAccess.name);
if (!symbol || !symbol.declarations?.length) return;
const propertyDeclaration = isTargetOutputDeclaration(
propertyAccess,
checker,
reflector,
dtsReader,
);
if (!propertyDeclaration) return;
const eventEmitterType = getEventEmitterArgumentType(propertyDeclaration);
if (!eventEmitterType) return;
const id = getUniqueIdForProperty(info, propertyDeclaration);
const file = projectFile(node.getSourceFile(), info);
const formatter = getFormatterText(node);
const todoReplacement: TextUpdate = new TextUpdate({
toInsert: `${formatter.indent}// TODO: The 'emit' function requires a mandatory ${eventEmitterType} argument\n`,
end: formatter.lineStartPos,
position: formatter.lineStartPos,
});
addOutputReplacement(outputFieldReplacements, id, file, new Replacement(file, todoReplacement));
}
function isEmptyEmitCall(node: ts.Node): node is ts.CallExpression {
return (
ts.isCallExpression(node) &&
ts.isPropertyAccessExpression(node.expression) &&
node.expression.name.text === 'emit' &&
node.arguments.length === 0
);
}
function getPropertyAccess(node: ts.CallExpression): ts.PropertyAccessExpression | null {
const propertyAccessExpression = (node.expression as ts.PropertyAccessExpression).expression;
return ts.isPropertyAccessExpression(propertyAccessExpression) ? propertyAccessExpression : null;
}
function getEventEmitterArgumentType(propertyDeclaration: ts.PropertyDeclaration): string | null {
const initializer = propertyDeclaration.initializer;
if (!initializer || !ts.isNewExpression(initializer)) return null;
const isEventEmitter =
ts.isIdentifier(initializer.expression) && initializer.expression.getText() === 'EventEmitter';
if (!isEventEmitter) return null;
const [typeArg] = initializer.typeArguments ?? [];
return typeArg ? typeArg.getText() : null;
}
function getFormatterText(node: ts.Node): {indent: string; lineStartPos: number} {
const sourceFile = node.getSourceFile();
const {line} = sourceFile.getLineAndCharacterOfPosition(node.getStart());
const lineStartPos = sourceFile.getPositionOfLineAndCharacter(line, 0);
const indent = sourceFile.text.slice(lineStartPos, node.getStart());
return {indent, lineStartPos};
}