Skip to content

Commit f578889

Browse files
cexbrayatdylhunn
authored andcommitted
fix(compiler-cli): catch function instance properties in interpolated signal diagnostic (#54325)
Currently, the following template compiles without error, even if the signal is not properly called: ``` <div>{{ mySignal.name }}</div> ``` This is because `name` is one of the instance properties of Function (see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function#instance_properties). The interpolated signal diagnostic is now extended to catch such issues. PR Close #54325
1 parent 6842909 commit f578889

File tree

2 files changed

+134
-6
lines changed

2 files changed

+134
-6
lines changed

packages/compiler-cli/src/ngtsc/typecheck/extended/checks/interpolated_signal_not_invoked/index.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,15 @@ const SIGNAL_FNS = new Set([
2222
'ModelSignal',
2323
]);
2424

25+
/** Names of known signal instance properties. */
26+
const SIGNAL_INSTANCE_PROPERTIES = new Set(['set', 'update', 'asReadonly']);
27+
28+
/**
29+
* Names of known function instance properties.
30+
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function#instance_properties
31+
*/
32+
const FUNCTION_INSTANCE_PROPERTIES = new Set(['name', 'length', 'prototype']);
33+
2534
/**
2635
* Ensures Signals are invoked when used in template interpolations.
2736
*/
@@ -53,12 +62,20 @@ function isSignal(symbol: ts.Symbol|undefined): boolean {
5362
});
5463
}
5564

65+
function isFunctionInstanceProperty(name: string): boolean {
66+
return FUNCTION_INSTANCE_PROPERTIES.has(name);
67+
}
68+
69+
function isSignalInstanceProperty(name: string): boolean {
70+
return SIGNAL_INSTANCE_PROPERTIES.has(name);
71+
}
72+
5673
function buildDiagnosticForSignal(
5774
ctx: TemplateContext<ErrorCode.INTERPOLATED_SIGNAL_NOT_INVOKED>, node: PropertyRead,
5875
component: ts.ClassDeclaration):
5976
Array<NgTemplateDiagnostic<ErrorCode.INTERPOLATED_SIGNAL_NOT_INVOKED>> {
77+
// check for `{{ mySignal }}`
6078
const symbol = ctx.templateTypeChecker.getSymbolOfNode(node, component);
61-
6279
if (symbol?.kind === SymbolKind.Expression &&
6380
(isSignal(symbol.tsType.symbol) || isSignal(symbol.tsType.aliasSymbol))) {
6481
const templateMapping =
@@ -68,6 +85,25 @@ function buildDiagnosticForSignal(
6885
return [diagnostic];
6986
}
7087

88+
// check for `{{ mySignal.name }}` or `{{ mySignal.length }}` or `{{ mySignal.prototype }}`
89+
// as these are the names of instance properties of Function, the compiler does _not_ throw an
90+
// error.
91+
// We also check for `{{ mySignal.set }}` or `{{ mySignal.update }}` or
92+
// `{{ mySignal.asReadonly }}` as these are the names of instance properties of Signal
93+
const symbolOfReceiver = ctx.templateTypeChecker.getSymbolOfNode(node.receiver, component);
94+
if ((isFunctionInstanceProperty(node.name) || isSignalInstanceProperty(node.name)) &&
95+
symbolOfReceiver?.kind === SymbolKind.Expression &&
96+
(isSignal(symbolOfReceiver.tsType.symbol) || isSignal(symbolOfReceiver.tsType.aliasSymbol))) {
97+
const templateMapping =
98+
ctx.templateTypeChecker.getTemplateMappingAtTcbLocation(symbolOfReceiver.tcbLocation)!;
99+
100+
const errorString =
101+
`${(node.receiver as PropertyRead).name} is a function and should be invoked: ${
102+
(node.receiver as PropertyRead).name}()`;
103+
const diagnostic = ctx.makeTemplateDiagnostic(templateMapping.span, errorString);
104+
return [diagnostic];
105+
}
106+
71107
return [];
72108
}
73109

packages/compiler-cli/src/ngtsc/typecheck/extended/test/checks/interpolated_signal_not_invoked/interpolated_signal_not_invoked_spec.ts

Lines changed: 97 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -371,7 +371,7 @@ runInEachFileSystem(() => {
371371
'TestCmp': `<div id="{{mySignal}}"></div>`,
372372
},
373373
source: `
374-
import {signal, Signal, computed} from '@angular/core';
374+
import {signal} from '@angular/core';
375375
376376
export class TestCmp {
377377
mySignal = signal<number>(0);
@@ -399,7 +399,7 @@ runInEachFileSystem(() => {
399399
'TestCmp': `<div id="{{mySignal()}}"></div>`,
400400
},
401401
source: `
402-
import {signal, Signal, computed} from '@angular/core';
402+
import {signal} from '@angular/core';
403403
404404
export class TestCmp {
405405
mySignal = signal<number>(0);
@@ -424,7 +424,7 @@ runInEachFileSystem(() => {
424424
'TestCmp': `<div attr.id="my-{{mySignal}}-item"></div>`,
425425
},
426426
source: `
427-
import {signal, Signal, computed} from '@angular/core';
427+
import {signal} from '@angular/core';
428428
429429
export class TestCmp {
430430
mySignal = signal<number>(0);
@@ -453,7 +453,7 @@ runInEachFileSystem(() => {
453453
'TestCmp': `<div attr.id="my-{{mySignal()}}-item"></div>`,
454454
},
455455
source: `
456-
import {signal, Signal, computed} from '@angular/core';
456+
import {signal} from '@angular/core';
457457
458458
export class TestCmp {
459459
mySignal = signal<number>(0);
@@ -479,7 +479,7 @@ runInEachFileSystem(() => {
479479
'TestCmp': `<div id="{{myObject.myObject2.myNestedSignal}}"></div>`,
480480
},
481481
source: `
482-
import {signal, Signal, computed} from '@angular/core';
482+
import {signal} from '@angular/core';
483483
484484
export class TestCmp {
485485
myObject = {myObject2: {myNestedSignal: signal<number>(0)}};
@@ -553,4 +553,96 @@ runInEachFileSystem(() => {
553553
const diags = extendedTemplateChecker.getDiagnosticsForComponent(component);
554554
expect(diags.length).toBe(0);
555555
});
556+
557+
['name', 'length', 'prototype', 'set', 'update', 'asReadonly'].forEach(
558+
functionInstanceProperty => {
559+
it(`should produce a warning when a property named '${
560+
functionInstanceProperty}' of a not invoked signal is used in interpolation`,
561+
() => {
562+
const fileName = absoluteFrom('/main.ts');
563+
const {program, templateTypeChecker} = setup([
564+
{
565+
fileName,
566+
templates: {
567+
'TestCmp': `<div>{{myObject.mySignal.${functionInstanceProperty}}}</div>`,
568+
},
569+
source: `
570+
import {signal} from '@angular/core';
571+
572+
export class TestCmp {
573+
myObject = { mySignal: signal<{ ${functionInstanceProperty}: string }>({ ${
574+
functionInstanceProperty}: 'foo' }) };
575+
}`,
576+
},
577+
]);
578+
const sf = getSourceFileOrError(program, fileName);
579+
const component = getClass(sf, 'TestCmp');
580+
const extendedTemplateChecker = new ExtendedTemplateCheckerImpl(
581+
templateTypeChecker, program.getTypeChecker(), [interpolatedSignalFactory], {}
582+
/* options */
583+
);
584+
const diags = extendedTemplateChecker.getDiagnosticsForComponent(component);
585+
expect(diags.length).toBe(1);
586+
expect(diags[0].category).toBe(ts.DiagnosticCategory.Warning);
587+
expect(diags[0].code).toBe(ngErrorCode(ErrorCode.INTERPOLATED_SIGNAL_NOT_INVOKED));
588+
expect(getSourceCodeForDiagnostic(diags[0])).toBe(`mySignal`);
589+
});
590+
591+
it(`should not produce a warning when a property named ${
592+
functionInstanceProperty} of an invoked signal is used in interpolation`,
593+
() => {
594+
const fileName = absoluteFrom('/main.ts');
595+
const {program, templateTypeChecker} = setup([
596+
{
597+
fileName,
598+
templates: {
599+
'TestCmp': `<div>{{mySignal().${functionInstanceProperty}}}</div>`,
600+
},
601+
source: `
602+
import {signal} from '@angular/core';
603+
604+
export class TestCmp {
605+
mySignal = signal<{ ${functionInstanceProperty}: string }>({ ${
606+
functionInstanceProperty}: 'foo' });
607+
}`,
608+
},
609+
]);
610+
const sf = getSourceFileOrError(program, fileName);
611+
const component = getClass(sf, 'TestCmp');
612+
const extendedTemplateChecker = new ExtendedTemplateCheckerImpl(
613+
templateTypeChecker, program.getTypeChecker(), [interpolatedSignalFactory], {}
614+
/* options */
615+
);
616+
const diags = extendedTemplateChecker.getDiagnosticsForComponent(component);
617+
expect(diags.length).toBe(0);
618+
});
619+
620+
it(`should not produce a warning when a property named ${
621+
functionInstanceProperty} of an object is used in interpolation`,
622+
() => {
623+
const fileName = absoluteFrom('/main.ts');
624+
const {program, templateTypeChecker} = setup([
625+
{
626+
fileName,
627+
templates: {
628+
'TestCmp': `<div>{{myObject.${functionInstanceProperty}}}</div>`,
629+
},
630+
source: `
631+
import {signal} from '@angular/core';
632+
633+
export class TestCmp {
634+
myObject = { ${functionInstanceProperty}: 'foo' };
635+
}`,
636+
},
637+
]);
638+
const sf = getSourceFileOrError(program, fileName);
639+
const component = getClass(sf, 'TestCmp');
640+
const extendedTemplateChecker = new ExtendedTemplateCheckerImpl(
641+
templateTypeChecker, program.getTypeChecker(), [interpolatedSignalFactory], {}
642+
/* options */
643+
);
644+
const diags = extendedTemplateChecker.getDiagnosticsForComponent(component);
645+
expect(diags.length).toBe(0);
646+
});
647+
});
556648
});

0 commit comments

Comments
 (0)