fix(compiler-cli): allow passing uninvoked signals as foreign component props

Avoid triggering the `interpolated_signal_not_invoked` diagnostic when
a signal is passed directly as a property binding to a foreign
component. Foreign components may accept signals directly, so they
should not be flagged as uninvoked in this context.

To support testing this, the typecheck testing infrastructure was
updated to allow defining mock foreign components in the test setup.
This commit is contained in:
leonsenft
2026-05-26 09:32:07 -07:00
committed by Andrew Scott
parent e678955eac
commit aeb55c8bc1
3 changed files with 80 additions and 6 deletions
@@ -61,6 +61,10 @@ class InterpolatedSignalCheck extends TemplateCheckWithVisitor<ErrorCode.INTERPO
}
// check bound inputs like `[prop]="mySignal"` on an element or inline template
else if (node instanceof TmplAstElement && node.inputs.length > 0) {
// Allow signals to be passed directly to foreign components, without invocation.
if (ctx.templateTypeChecker.getForeignComponent(component, node) !== null) {
return [];
}
const directivesOfElement = ctx.templateTypeChecker.getDirectivesOfNode(component, node);
return node.inputs.flatMap((input) =>
checkBoundAttribute(ctx, component, directivesOfElement, input),
@@ -353,6 +353,38 @@ runInEachFileSystem(() => {
expect(diags.length).toBe(0);
});
it('should not produce a warning when a signal is not invoked in a property binding on a foreign component', () => {
const fileName = absoluteFrom('/main.ts');
const {program, templateTypeChecker} = setup([
{
fileName,
templates: {
'TestCmp': `<FancyButton [disabled]="mySignal"></FancyButton>`,
},
source: `
import {signal} from '@angular/core';
export function FancyButton() {}
export class TestCmp {
mySignal = signal(false);
}`,
foreignComponents: ['FancyButton'],
},
]);
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 signal in a nested property read is not invoked', () => {
const fileName = absoluteFrom('/main.ts');
const {program, templateTypeChecker} = setup([
@@ -12,6 +12,7 @@ import {
ClassPropertyMapping,
CssSelector,
DomSchemaChecker,
ForeignComponentMeta,
MatchSource,
OutOfBandDiagnosticRecorder,
ParseSourceFile,
@@ -78,6 +79,7 @@ import {
AmbientImport,
ClassDeclaration,
isNamedClassDeclaration,
isNamedFunctionDeclaration,
TypeScriptReflectionHost,
} from '../../reflection';
import {
@@ -362,6 +364,7 @@ export function tcb(
config?: Partial<TypeCheckingConfig>,
options?: {emitSpans?: boolean},
templateParserOptions?: ParseTemplateOptions,
foreignComponents: string[] = [],
): string {
const codeLines = [
'declare const ɵNgFieldDirective: unique symbol;',
@@ -398,13 +401,15 @@ export function tcb(
throw new Error('Template parse errors: \n' + errors.join('\n'));
}
const {matcher, pipes} = prepareDeclarations(
const {matcher, pipes, foreignMatcher} = prepareDeclarations(
declarations,
(decl) => getClass(sf, decl.name),
new Map(),
selectorlessEnabled,
foreignComponents,
(name) => getFunction(sf, name),
);
const binder = new R3TargetBinder<DirectiveMeta>(matcher);
const binder = new R3TargetBinder<DirectiveMeta>(matcher, foreignMatcher);
const boundTarget = binder.bind({template: nodes});
const id = 'tcb' as TypeCheckId;
@@ -503,6 +508,11 @@ export interface TypeCheckingTarget {
* components in this file.
*/
declarations?: TestDeclaration[];
/**
* Names of foreign components that are available in the template scope.
*/
foreignComponents?: string[];
}
/**
@@ -622,6 +632,7 @@ export function setup(
}
const declarations = target.declarations ?? [];
const foreignComponents = target.foreignComponents ?? [];
for (const className of Object.keys(target.templates)) {
const classDecl = getClass(sf, className);
@@ -633,7 +644,7 @@ export function setup(
throw new Error('Template parse errors: \n' + errors.join('\n'));
}
const {matcher, pipes} = prepareDeclarations(
const {matcher, pipes, foreignMatcher} = prepareDeclarations(
declarations,
(decl) => {
let declFile = sf;
@@ -647,8 +658,10 @@ export function setup(
},
fakeMetadataRegistry,
overrides.parseOptions?.enableSelectorless ?? false,
foreignComponents,
(name) => getFunction(sf, name),
);
const binder = new R3TargetBinder<DirectiveMeta>(matcher);
const binder = new R3TargetBinder<DirectiveMeta>(matcher, foreignMatcher);
const classRef = new Reference(classDecl);
const templateContext: TemplateContext = {
nodes,
@@ -820,6 +833,8 @@ function prepareDeclarations(
resolveDeclaration: DeclarationResolver,
metadataRegistry: Map<string, TypeCheckableDirectiveMeta>,
selectorlessEnabled: boolean,
foreignComponentNames: string[] = [],
resolveForeignComponent: (name: string) => ClassDeclaration,
) {
const pipes = new Map<string, PipeMeta>();
const hostDirectiveResolder = new HostDirectivesResolver(
@@ -850,6 +865,17 @@ function prepareDeclarations(
}
}
const foreignRegistry = new Map<string, ForeignComponentMeta[]>();
for (const name of foreignComponentNames) {
foreignRegistry.set(name, [
{
name,
ref: new Reference(resolveForeignComponent(name)),
},
]);
}
const foreignMatcher = new SelectorlessMatcher<ForeignComponentMeta>(foreignRegistry);
// We need to make two passes over the directives so that all declarations
// have been registered by the time we resolve the host directives.
@@ -858,7 +884,7 @@ function prepareDeclarations(
for (const meta of directives) {
registry.set(meta.name, [meta, ...hostDirectiveResolder.resolve(meta)]);
}
return {matcher: new SelectorlessMatcher<DirectiveMeta>(registry), pipes};
return {matcher: new SelectorlessMatcher<DirectiveMeta>(registry), pipes, foreignMatcher};
} else {
const matcher = new SelectorMatcher<DirectiveMeta[]>();
for (const meta of directives) {
@@ -867,7 +893,7 @@ function prepareDeclarations(
matcher.addSelectables(selector, matches);
}
return {matcher, pipes};
return {matcher, pipes, foreignMatcher};
}
}
@@ -880,6 +906,18 @@ export function getClass(sf: ts.SourceFile, name: string): ClassDeclaration<ts.C
throw new Error(`Class ${name} not found in file: ${sf.fileName}: ${sf.text}`);
}
export function getFunction(
sf: ts.SourceFile,
name: string,
): ClassDeclaration<ts.FunctionDeclaration> {
for (const stmt of sf.statements) {
if (isNamedFunctionDeclaration(stmt) && stmt.name.text === name) {
return stmt;
}
}
throw new Error(`Function ${name} not found in file: ${sf.fileName}`);
}
function getDirectiveMetaFromDeclaration(
decl: TestDirective,
resolveDeclaration: DeclarationResolver,