fix(migrations): skip migration for inputs with 'this' references (#64142)

Prevents migration of @input() properties that contain references to 'this' in their initializer functions. This ensures that functions accessing class members via 'this' remain unchanged, preventing potential build errors.

PR Close #64142
This commit is contained in:
aparziale
2025-09-30 17:26:29 +02:00
committed by kirjs
parent 7345bdc06b
commit 31bc9e4111
2 changed files with 94 additions and 1 deletions
@@ -41,6 +41,15 @@ export function convertToSignalInput(
importManager: ImportManager,
result: MigrationResult,
): Replacement[] {
// Check for 'this' references in initializer before doing anything else
if (
node.initializer &&
(ts.isArrowFunction(node.initializer) || ts.isFunctionExpression(node.initializer)) &&
containsThisReferences(node.initializer)
) {
return []; // Skip migration for this input by returning empty replacements
}
let optionsLiteral: ts.ObjectLiteralExpression | null = null;
// We need an options array for the input because:
@@ -137,12 +146,31 @@ export function convertToSignalInput(
modifiersWithoutInputDecorator.push(ts.factory.createModifier(ts.SyntaxKind.ReadonlyKeyword));
}
// Skip migration if the input is a function that references class members via 'this'
if (
inputInitializer &&
(ts.isArrowFunction(inputInitializer) || ts.isFunctionExpression(inputInitializer))
) {
if (containsThisReferences(inputInitializer)) {
return []; // Skip migration for this input by returning empty replacements
}
}
let finalInitializer: ts.Expression | undefined = inputInitializer;
if (inputInitializer === undefined) {
if (preferShorthandIfPossible === null) {
finalInitializer = ts.factory.createIdentifier('undefined');
} else {
resolvedType = preferShorthandIfPossible.originalType;
}
}
const newNode = ts.factory.createPropertyDeclaration(
modifiersWithoutInputDecorator,
node.name,
undefined,
undefined,
inputInitializer,
finalInitializer,
);
const newPropertyText = result.printer.printNode(
@@ -222,3 +250,44 @@ function extractTransformOfInput(
leadingTodoText,
};
}
/**
* Checks if a function node contains any references to 'this'.
* This is used to skip migration for functions that reference class members.
*/
function containsThisReferences(node: ts.Node): boolean {
let hasThis = false;
const visit = (node: ts.Node) => {
if (hasThis) return;
if (node.kind === ts.SyntaxKind.ThisKeyword) {
hasThis = true;
return;
}
if (ts.isPropertyAccessExpression(node)) {
const expr = node.expression;
if (ts.isIdentifier(expr) && expr.text === 'this') {
hasThis = true;
return;
}
}
ts.forEachChild(node, visit);
};
if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) {
if (node.body) {
if (node.body.kind === ts.SyntaxKind.Block) {
(node.body as ts.Block).statements.forEach(visit);
} else {
visit(node.body);
}
}
} else {
ts.forEachChild(node, visit);
}
return hasThis;
}
@@ -128,6 +128,30 @@ describe('signal input migration', () => {
expect(messages).toContain(` -> Migrated 1/2 inputs.`);
});
it('should not migrate input functions that reference this', async () => {
const source = `
import {Component, Input} from '@angular/core';
@Component({})
export class TestComponent {
@Input() fnDisplay = (item: any) => item;
@Input() fnEquals = (a: any, b: any) => this.fnDisplay(a) === this.fnDisplay(b);
}
`;
writeFile('/index.ts', source);
await runMigration();
const content = tree.readContent('/index.ts');
// Verify the simple function was migrated
expect(content).toContain('readonly fnDisplay = input((item: any) => item);');
expect(content).toContain(
'@Input() fnEquals = (a: any, b: any) => this.fnDisplay()(a) === this.fnDisplay()(b);',
);
});
it('should report correct statistics with best effort mode', async () => {
writeFile(`node_modules/@tsconfig/strictest/tsconfig.json`, `{}`);
writeFile(