refactor(compiler-cli): add support for if/switch to the non-invoked signal diagnostic (#63502)

This commit adds the support to the existing "interpolated_signal_not_invoked" diagnostic (even though it's not really a interpolation)

PR Close #63502
This commit is contained in:
Matthieu Riegler 2025-08-30 18:32:32 +02:00 committed by Andrew Scott
parent b3d102b475
commit 35a1d5d7bf
2 changed files with 150 additions and 0 deletions

View file

@ -14,7 +14,9 @@ import {
PrefixNot,
PropertyRead,
TmplAstBoundAttribute,
TmplAstIfBlock,
TmplAstNode,
TmplAstSwitchBlock,
} from '@angular/compiler';
import ts from 'typescript';
@ -80,6 +82,29 @@ class InterpolatedSignalCheck extends TemplateCheckWithVisitor<ErrorCode.INTERPO
return buildDiagnosticForSignal(ctx, nodeAst, component);
}
}
// if blocks like `@if(mySignal) { ... }`
else if (node instanceof TmplAstIfBlock) {
return node.branches
.map((branch) => branch.expression)
.filter((expr): expr is ASTWithSource => expr instanceof ASTWithSource)
.map((expr) => {
const ast = expr.ast;
return ast instanceof PrefixNot ? ast.expression : ast;
})
.filter((ast): ast is PropertyRead => ast instanceof PropertyRead)
.flatMap((item) => buildDiagnosticForSignal(ctx, item, component));
}
// switch blocks like `@switch(mySignal) { ... }`
else if (node instanceof TmplAstSwitchBlock && node.expression instanceof ASTWithSource) {
const expression =
node.expression.ast instanceof PrefixNot
? node.expression.ast.expression
: node.expression.ast;
if (expression instanceof PropertyRead) {
return buildDiagnosticForSignal(ctx, expression, component);
}
}
return [];
}
}

View file

@ -852,6 +852,131 @@ runInEachFileSystem(() => {
const diags = extendedTemplateChecker.getDiagnosticsForComponent(component);
expect(diags.length).toBe(0);
});
[false, true].forEach((negate) => {
// Control flow
it(`should produce a warning when a property named '${functionInstanceProperty}' of a not invoked signal is used in an @if control flow expression`, () => {
const fileName = absoluteFrom('/main.ts');
const {program, templateTypeChecker} = setup([
{
fileName,
templates: {
'TestCmp': `@if(${negate ? '!' : ''}myObject.mySignal.${functionInstanceProperty}) { <div>Show</div> }`,
},
source: `
import {signal} from '@angular/core';
export class TestCmp {
myObject = { mySignal: signal<{ ${functionInstanceProperty}: string }>({ ${functionInstanceProperty}: 'foo' }) };
}`,
},
]);
const sf = getSourceFileOrError(program, fileName);
const component = getClass(sf, 'TestCmp');
const extendedTemplateChecker = new ExtendedTemplateCheckerImpl(
templateTypeChecker,
program.getTypeChecker(),
[interpolatedSignalFactory],
{},
/* options */
);
const diags = extendedTemplateChecker.getDiagnosticsForComponent(component);
expect(diags.length).toBe(1);
expect(diags[0].category).toBe(ts.DiagnosticCategory.Warning);
expect(diags[0].code).toBe(ngErrorCode(ErrorCode.INTERPOLATED_SIGNAL_NOT_INVOKED));
expect(getSourceCodeForDiagnostic(diags[0])).toBe(`mySignal`);
});
it(`should not produce a warning when a property named '${functionInstanceProperty}' of an invoked signal is used in an @if control flow expression`, () => {
const fileName = absoluteFrom('/main.ts');
const {program, templateTypeChecker} = setup([
{
fileName,
templates: {
'TestCmp': `@if(${negate ? '!' : ''}myObject.mySignal().${functionInstanceProperty}) { <div>Show</div> }`,
},
source: `
import {signal} from '@angular/core';
export class TestCmp {
myObject = { mySignal: signal<{ ${functionInstanceProperty}: string }>({ ${functionInstanceProperty}: 'foo' }) };
}`,
},
]);
const sf = getSourceFileOrError(program, fileName);
const component = getClass(sf, 'TestCmp');
const extendedTemplateChecker = new ExtendedTemplateCheckerImpl(
templateTypeChecker,
program.getTypeChecker(),
[interpolatedSignalFactory],
{},
/* options */
);
const diags = extendedTemplateChecker.getDiagnosticsForComponent(component);
expect(diags.length).toBe(0);
});
it(`should produce a warning when a property named '${functionInstanceProperty}' of a not invoked signal is used in an @switch control flow expression`, () => {
const fileName = absoluteFrom('/main.ts');
const {program, templateTypeChecker} = setup([
{
fileName,
templates: {
'TestCmp': `@switch(${negate ? '!' : ''}myObject.mySignal.${functionInstanceProperty}) { }`,
},
source: `
import {signal} from '@angular/core';
export class TestCmp {
myObject = { mySignal: signal<{ ${functionInstanceProperty}: string }>({ ${functionInstanceProperty}: 'foo' }) };
}`,
},
]);
const sf = getSourceFileOrError(program, fileName);
const component = getClass(sf, 'TestCmp');
const extendedTemplateChecker = new ExtendedTemplateCheckerImpl(
templateTypeChecker,
program.getTypeChecker(),
[interpolatedSignalFactory],
{},
/* options */
);
const diags = extendedTemplateChecker.getDiagnosticsForComponent(component);
expect(diags.length).toBe(1);
expect(diags[0].category).toBe(ts.DiagnosticCategory.Warning);
expect(diags[0].code).toBe(ngErrorCode(ErrorCode.INTERPOLATED_SIGNAL_NOT_INVOKED));
expect(getSourceCodeForDiagnostic(diags[0])).toBe(`mySignal`);
});
it(`should not produce a warning when a property named '${functionInstanceProperty}' of an invoked signal is used in an @switch control flow expression`, () => {
const fileName = absoluteFrom('/main.ts');
const {program, templateTypeChecker} = setup([
{
fileName,
templates: {
'TestCmp': `@switch(${negate ? '!' : ''}myObject.mySignal().${functionInstanceProperty}) { }`,
},
source: `
import {signal} from '@angular/core';
export class TestCmp {
myObject = { mySignal: signal<{ ${functionInstanceProperty}: string }>({ ${functionInstanceProperty}: 'foo' }) };
}`,
},
]);
const sf = getSourceFileOrError(program, fileName);
const component = getClass(sf, 'TestCmp');
const extendedTemplateChecker = new ExtendedTemplateCheckerImpl(
templateTypeChecker,
program.getTypeChecker(),
[interpolatedSignalFactory],
{},
/* options */
);
const diags = extendedTemplateChecker.getDiagnosticsForComponent(component);
expect(diags.length).toBe(0);
});
});
},
);
});