Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/compiler-cli/src/ngtsc/typecheck/api/checker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,14 @@ export interface TemplateTypeChecker {
*/
getPipeMetadata(pipe: ts.ClassDeclaration): PipeMeta | null;

/**
* Gets the directives that apply to the given template node in a component's template.
*/
getDirectivesOfNode(
component: ts.ClassDeclaration,
node: TmplAstElement | TmplAstTemplate,
): TypeCheckableDirectiveMeta[] | null;

/**
* Gets the directives that have been used in a component's template.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,14 @@ import {
Interpolation,
PropertyRead,
TmplAstBoundAttribute,
TmplAstElement,
TmplAstNode,
TmplAstTemplate,
} from '@angular/compiler';
import ts from 'typescript';

import {ErrorCode, ExtendedTemplateDiagnosticName} from '../../../../diagnostics';
import {NgTemplateDiagnostic, SymbolKind} from '../../../api';
import {NgTemplateDiagnostic, SymbolKind, TypeCheckableDirectiveMeta} from '../../../api';
import {isSignalReference} from '../../../src/symbol_util';
import {TemplateCheckFactory, TemplateCheckWithVisitor, TemplateContext} from '../../api';

Expand Down Expand Up @@ -48,38 +50,62 @@ class InterpolatedSignalCheck extends TemplateCheckWithVisitor<ErrorCode.INTERPO
.filter((item): item is PropertyRead => item instanceof PropertyRead)
.flatMap((item) => buildDiagnosticForSignal(ctx, item, component));
}
// bound properties like `[prop]="mySignal"`
else if (node instanceof TmplAstBoundAttribute) {
// we skip the check if the node is an input binding
const usedDirectives = ctx.templateTypeChecker.getUsedDirectives(component);
if (
usedDirectives !== null &&
usedDirectives.some((dir) => dir.inputs.getByBindingPropertyName(node.name) !== null)
) {
return [];
}
// otherwise, we check if the node is
if (
// a bound property like `[prop]="mySignal"`
(node.type === BindingType.Property ||
// or a class binding like `[class.myClass]="mySignal"`
node.type === BindingType.Class ||
// or a style binding like `[style.width]="mySignal"`
node.type === BindingType.Style ||
// or an attribute binding like `[attr.role]="mySignal"`
node.type === BindingType.Attribute ||
// or an animation binding like `[@myAnimation]="mySignal"`
node.type === BindingType.Animation) &&
node.value instanceof ASTWithSource &&
node.value.ast instanceof PropertyRead
) {
return buildDiagnosticForSignal(ctx, node.value.ast, component);
}
// check bound inputs like `[prop]="mySignal"` on an element or inline template
else if (node instanceof TmplAstElement && node.inputs.length > 0) {
const directivesOfElement = ctx.templateTypeChecker.getDirectivesOfNode(component, node);
return node.inputs.flatMap((input) =>
checkBoundAttribute(ctx, component, directivesOfElement, input),
);
} else if (node instanceof TmplAstTemplate && node.tagName === 'ng-template') {
const directivesOfElement = ctx.templateTypeChecker.getDirectivesOfNode(component, node);
const inputDiagnostics = node.inputs.flatMap((input) => {
return checkBoundAttribute(ctx, component, directivesOfElement, input);
});
const templateAttrDiagnostics = node.templateAttrs.flatMap((attr) => {
if (!(attr instanceof TmplAstBoundAttribute)) {
return [];
}
return checkBoundAttribute(ctx, component, directivesOfElement, attr);
});
return inputDiagnostics.concat(templateAttrDiagnostics);
}
return [];
}
}

function checkBoundAttribute(
ctx: TemplateContext<ErrorCode.INTERPOLATED_SIGNAL_NOT_INVOKED>,
component: ts.ClassDeclaration,
directivesOfElement: Array<TypeCheckableDirectiveMeta> | null,
node: TmplAstBoundAttribute,
): Array<NgTemplateDiagnostic<ErrorCode.INTERPOLATED_SIGNAL_NOT_INVOKED>> {
// we skip the check if the node is an input binding
if (
directivesOfElement !== null &&
directivesOfElement.some((dir) => dir.inputs.getByBindingPropertyName(node.name) !== null)
) {
return [];
}
// otherwise, we check if the node is
if (
(node.type === BindingType.Property ||
// or a class binding like `[class.myClass]="mySignal"`
node.type === BindingType.Class ||
// or a style binding like `[style.width]="mySignal"`
node.type === BindingType.Style ||
// or an attribute binding like `[attr.role]="mySignal"`
node.type === BindingType.Attribute ||
// or an animation binding like `[@myAnimation]="mySignal"`
node.type === BindingType.Animation) &&
node.value instanceof ASTWithSource &&
node.value.ast instanceof PropertyRead
) {
return buildDiagnosticForSignal(ctx, node.value.ast, component);
}

return [];
}

function isFunctionInstanceProperty(name: string): boolean {
return FUNCTION_INSTANCE_PROPERTIES.has(name);
}
Expand Down Expand Up @@ -109,9 +135,11 @@ function buildDiagnosticForSignal(
// error.
// We also check for `{{ mySignal.set }}` or `{{ mySignal.update }}` or
// `{{ mySignal.asReadonly }}` as these are the names of instance properties of Signal
if (!isFunctionInstanceProperty(node.name) && !isSignalInstanceProperty(node.name)) {
return [];
}
const symbolOfReceiver = ctx.templateTypeChecker.getSymbolOfNode(node.receiver, component);
if (
(isFunctionInstanceProperty(node.name) || isSignalInstanceProperty(node.name)) &&
symbolOfReceiver !== null &&
symbolOfReceiver.kind === SymbolKind.Expression &&
isSignalReference(symbolOfReceiver)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,71 @@ runInEachFileSystem(() => {
expect(diags.length).toBe(0);
});

it('should produce a warning when a signal is not invoked for an in-scope directive input that is not applied on the element', () => {
const fileName = absoluteFrom('/main.ts');
const {program, templateTypeChecker} = setup([
{
fileName,
templates: {
'TestCmp': `
<!-- The below "myInput" binding should be ignored, as it corresponds with TestDir -->
<div dir [myInput]="dirSignal"></div>

<!-- The below "myInput" binding should be reported, as it does not correspond with TestDir -->
<div [myInput]="divSignal"></div>

<!-- The below "myInput" binding applies to the "div" element so it should be reported -->
<div *dir [myInput]="structuralSignal"></div>
`,
},
source: `
import {signal, input} from '@angular/core';

export class TestDir {
myInput = input.required();
}
export class TestCmp {
dirSignal = signal(0);
divSignal = signal(0);
structuralSignal = signal(0);
}`,
declarations: [
{
type: 'directive',
name: 'TestDir',
selector: '[dir]',
inputs: {
myInput: {
isSignal: true,
bindingPropertyName: 'myInput',
classPropertyName: 'myInput',
required: true,
transform: null,
},
},
},
],
},
]);
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(2);
expect(diags[0].category).toBe(ts.DiagnosticCategory.Warning);
expect(diags[0].code).toBe(ngErrorCode(ErrorCode.INTERPOLATED_SIGNAL_NOT_INVOKED));
expect(getSourceCodeForDiagnostic(diags[0])).toBe(`divSignal`);
expect(diags[1].category).toBe(ts.DiagnosticCategory.Warning);
expect(diags[1].code).toBe(ngErrorCode(ErrorCode.INTERPOLATED_SIGNAL_NOT_INVOKED));
expect(getSourceCodeForDiagnostic(diags[1])).toBe(`structuralSignal`);
});

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([
Expand Down
9 changes: 9 additions & 0 deletions packages/compiler-cli/src/ngtsc/typecheck/src/checker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,15 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker {
return data.template;
}

getDirectivesOfNode(
component: ts.ClassDeclaration,
node: TmplAstElement | TmplAstTemplate,
): TypeCheckableDirectiveMeta[] | null {
return (
this.getLatestComponentState(component).data?.boundTarget.getDirectivesOfNode(node) ?? null
);
}

getUsedDirectives(component: ts.ClassDeclaration): TypeCheckableDirectiveMeta[] | null {
return this.getLatestComponentState(component).data?.boundTarget.getUsedDirectives() || null;
}
Expand Down