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,