From aeb55c8bc101dc313dc64f83f4428ddfad28d54e Mon Sep 17 00:00:00 2001 From: leonsenft Date: Tue, 26 May 2026 09:32:07 -0700 Subject: [PATCH] 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. --- .../interpolated_signal_not_invoked/index.ts | 4 ++ .../interpolated_signal_not_invoked_spec.ts | 32 ++++++++++++ .../src/ngtsc/typecheck/testing/index.ts | 50 ++++++++++++++++--- 3 files changed, 80 insertions(+), 6 deletions(-) diff --git a/packages/compiler-cli/src/ngtsc/typecheck/extended/checks/interpolated_signal_not_invoked/index.ts b/packages/compiler-cli/src/ngtsc/typecheck/extended/checks/interpolated_signal_not_invoked/index.ts index 5060321e19a..77f329a14a8 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/extended/checks/interpolated_signal_not_invoked/index.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/extended/checks/interpolated_signal_not_invoked/index.ts @@ -61,6 +61,10 @@ class InterpolatedSignalCheck extends TemplateCheckWithVisitor 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), diff --git a/packages/compiler-cli/src/ngtsc/typecheck/extended/test/checks/interpolated_signal_not_invoked/interpolated_signal_not_invoked_spec.ts b/packages/compiler-cli/src/ngtsc/typecheck/extended/test/checks/interpolated_signal_not_invoked/interpolated_signal_not_invoked_spec.ts index 342ea892d91..260dc446780 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/extended/test/checks/interpolated_signal_not_invoked/interpolated_signal_not_invoked_spec.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/extended/test/checks/interpolated_signal_not_invoked/interpolated_signal_not_invoked_spec.ts @@ -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': ``, + }, + 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([ diff --git a/packages/compiler-cli/src/ngtsc/typecheck/testing/index.ts b/packages/compiler-cli/src/ngtsc/typecheck/testing/index.ts index 03de2753259..e2a65f0552b 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/testing/index.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/testing/index.ts @@ -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, 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(matcher); + const binder = new R3TargetBinder(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(matcher); + const binder = new R3TargetBinder(matcher, foreignMatcher); const classRef = new Reference(classDecl); const templateContext: TemplateContext = { nodes, @@ -820,6 +833,8 @@ function prepareDeclarations( resolveDeclaration: DeclarationResolver, metadataRegistry: Map, selectorlessEnabled: boolean, + foreignComponentNames: string[] = [], + resolveForeignComponent: (name: string) => ClassDeclaration, ) { const pipes = new Map(); const hostDirectiveResolder = new HostDirectivesResolver( @@ -850,6 +865,17 @@ function prepareDeclarations( } } + const foreignRegistry = new Map(); + for (const name of foreignComponentNames) { + foreignRegistry.set(name, [ + { + name, + ref: new Reference(resolveForeignComponent(name)), + }, + ]); + } + const foreignMatcher = new SelectorlessMatcher(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(registry), pipes}; + return {matcher: new SelectorlessMatcher(registry), pipes, foreignMatcher}; } else { const matcher = new SelectorMatcher(); 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 { + 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,