mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
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:
+4
@@ -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),
|
||||
|
||||
+32
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user