Skip to content

Commit b70a286

Browse files
crisbetothePunderWoman
authored andcommitted
refactor(compiler-cli): account for let declarations in two-way binding check (#56199)
Updates the check that prevent writes to template variables in two-way bindings to account for let declarations. Also fixes some old tests that weren't properly setting up two-way bindings. PR Close #56199
1 parent 35e9257 commit b70a286

File tree

3 files changed

+87
-9
lines changed

3 files changed

+87
-9
lines changed

packages/compiler-cli/src/ngtsc/typecheck/src/symbol_util.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ const SIGNAL_FNS = new Set([
2222
/** Returns whether a symbol is a reference to a signal. */
2323
export function isSignalReference(symbol: Symbol): boolean {
2424
return (
25-
(symbol.kind === SymbolKind.Expression || symbol.kind === SymbolKind.Variable) &&
25+
(symbol.kind === SymbolKind.Expression ||
26+
symbol.kind === SymbolKind.Variable ||
27+
symbol.kind === SymbolKind.LetDeclaration) &&
2628
// Note that `tsType.symbol` isn't optional in the typings,
2729
// but it appears that it can be undefined at runtime.
2830
((symbol.tsType.symbol !== undefined && isSignalSymbol(symbol.tsType.symbol)) ||

packages/compiler-cli/src/ngtsc/typecheck/template_semantics/src/template_semantics_checker.ts

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
PropertyWrite,
1616
RecursiveAstVisitor,
1717
TmplAstBoundEvent,
18+
TmplAstLetDeclaration,
1819
TmplAstNode,
1920
TmplAstRecursiveVisitor,
2021
TmplAstVariable,
@@ -109,23 +110,35 @@ class ExpressionsSemanticsVisitor extends RecursiveAstVisitor {
109110
}
110111

111112
const target = this.templateTypeChecker.getExpressionTarget(ast, this.component);
112-
if (!(target instanceof TmplAstVariable)) {
113+
const isVariable = target instanceof TmplAstVariable;
114+
const isLet = target instanceof TmplAstLetDeclaration;
115+
116+
if (!isVariable && !isLet) {
113117
return;
114118
}
115119

116120
// Two-way bindings to template variables are only allowed if the variables are signals.
117121
const symbol = this.templateTypeChecker.getSymbolOfNode(target, this.component);
118122
if (symbol !== null && !isSignalReference(symbol)) {
119-
const errorMessage = `Cannot use a non-signal variable '${target.name}' in a two-way binding expression. Template variables are read-only.`;
123+
let errorMessage: string;
124+
125+
if (isVariable) {
126+
errorMessage = `Cannot use a non-signal variable '${target.name}' in a two-way binding expression. Template variables are read-only.`;
127+
} else {
128+
errorMessage = `Cannot use non-signal @let declaration '${target.name}' in a two-way binding expression. @let declarations are read-only.`;
129+
}
130+
120131
this.diagnostics.push(this.makeIllegalTemplateVarDiagnostic(target, context, errorMessage));
121132
}
122133
}
123134

124135
private makeIllegalTemplateVarDiagnostic(
125-
target: TmplAstVariable,
136+
target: TmplAstVariable | TmplAstLetDeclaration,
126137
expressionNode: TmplAstBoundEvent,
127138
errorMessage: string,
128139
): TemplateDiagnostic {
140+
const span =
141+
target instanceof TmplAstVariable ? target.valueSpan || target.sourceSpan : target.sourceSpan;
129142
return this.templateTypeChecker.makeTemplateDiagnostic(
130143
this.component,
131144
expressionNode.handlerSpan,
@@ -134,9 +147,9 @@ class ExpressionsSemanticsVisitor extends RecursiveAstVisitor {
134147
errorMessage,
135148
[
136149
{
137-
text: `The variable ${target.name} is declared here.`,
138-
start: target.valueSpan?.start.offset || target.sourceSpan.start.offset,
139-
end: target.valueSpan?.end.offset || target.sourceSpan.end.offset,
150+
text: `'${target.name}' is declared here.`,
151+
start: span.start.offset,
152+
end: span.end.offset,
140153
sourceFile: this.component.getSourceFile(),
141154
},
142155
],

packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5503,7 +5503,7 @@ suppress
55035503
})
55045504
export class TwoWayDir {
55055505
@Input() value: number = 0;
5506-
@Output() valueChanges: EventEmitter<number> = new EventEmitter();
5506+
@Output() valueChange: EventEmitter<number> = new EventEmitter();
55075507
}
55085508
55095509
@Component({
@@ -5539,7 +5539,7 @@ suppress
55395539
})
55405540
export class TwoWayDir {
55415541
@Input() value: number = 0;
5542-
@Output() valueChanges: EventEmitter<number> = new EventEmitter();
5542+
@Output() valueChange: EventEmitter<number> = new EventEmitter();
55435543
}
55445544
55455545
@Component({
@@ -7072,6 +7072,69 @@ suppress
70727072
expect(diags.length).toBe(1);
70737073
expect(diags[0].messageText).toBe(`Property 'value' does not exist on type 'Main'.`);
70747074
});
7075+
7076+
it('should not be able to write to let declaration in a two-way binding', () => {
7077+
env.write(
7078+
'test.ts',
7079+
`
7080+
import {Component, Directive, Input, Output, EventEmitter} from '@angular/core';
7081+
@Directive({
7082+
selector: '[twoWayDir]',
7083+
standalone: true
7084+
})
7085+
export class TwoWayDir {
7086+
@Input() value: number = 0;
7087+
@Output() valueChange: EventEmitter<number> = new EventEmitter();
7088+
}
7089+
@Component({
7090+
template: \`
7091+
@let nonWritable = 1;
7092+
<button twoWayDir [(value)]="nonWritable"></button>
7093+
\`,
7094+
standalone: true,
7095+
imports: [TwoWayDir]
7096+
})
7097+
export class Main {
7098+
}
7099+
`,
7100+
);
7101+
7102+
const diags = env.driveDiagnostics();
7103+
expect(diags.map((d) => ts.flattenDiagnosticMessageText(d.messageText, ''))).toEqual([
7104+
`Cannot use non-signal @let declaration 'nonWritable' in a two-way binding expression. @let declarations are read-only.`,
7105+
]);
7106+
});
7107+
7108+
it('should allow two-way bindings to signal-based let declarations', () => {
7109+
env.write(
7110+
'test.ts',
7111+
`
7112+
import {Component, Directive, Input, Output, EventEmitter, signal} from '@angular/core';
7113+
@Directive({
7114+
selector: '[twoWayDir]',
7115+
standalone: true
7116+
})
7117+
export class TwoWayDir {
7118+
@Input() value: number = 0;
7119+
@Output() valueChange: EventEmitter<number> = new EventEmitter();
7120+
}
7121+
@Component({
7122+
template: \`
7123+
@let writable = signalValue;
7124+
<button twoWayDir [(value)]="writable"></button>
7125+
\`,
7126+
standalone: true,
7127+
imports: [TwoWayDir]
7128+
})
7129+
export class Main {
7130+
signalValue = signal(1);
7131+
}
7132+
`,
7133+
);
7134+
7135+
const diags = env.driveDiagnostics();
7136+
expect(diags.length).toBe(0);
7137+
});
70757138
});
70767139
});
70777140
});

0 commit comments

Comments
 (0)