Skip to content

Commit 6773d3b

Browse files
crisbetopkozlowski-opensource
authored andcommitted
fix(compiler-cli): check that field radio button values are strings
Adds some type checking code which verifies that the bound `value` on a `Field` radio button is a string. Fixes #65726.
1 parent b74a069 commit 6773d3b

File tree

4 files changed

+87
-23
lines changed

4 files changed

+87
-23
lines changed

packages/compiler-cli/src/ngtsc/typecheck/src/ops/scope.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@ import {
5656
getCustomFieldDirectiveType,
5757
isFieldDirective,
5858
isNativeField,
59-
TcbNativeFieldDirectiveTypeOp,
59+
TcbNativeFieldOp,
60+
TcbNativeRadioButtonFieldOp,
6061
} from './signal_forms';
6162
import {Reference} from '../../../imports';
6263
import {ClassDeclaration} from '../../../reflection';
@@ -774,7 +775,15 @@ export class Scope {
774775
dirMap.set(dir, dirIndex);
775776

776777
if (isNativeField(dir, node, allDirectiveMatches)) {
777-
this.opQueue.push(new TcbNativeFieldDirectiveTypeOp(this.tcb, this, node as TmplAstElement));
778+
const inputType =
779+
(node.name === 'input' && node.attributes.find((attr) => attr.name === 'type')?.value) ||
780+
null;
781+
782+
this.opQueue.push(
783+
inputType === 'radio'
784+
? new TcbNativeRadioButtonFieldOp(this.tcb, this, node)
785+
: new TcbNativeFieldOp(this.tcb, this, node, inputType),
786+
);
778787
}
779788

780789
this.opQueue.push(new TcbDirectiveInputsOp(this.tcb, this, node, dir, customFieldType));

packages/compiler-cli/src/ngtsc/typecheck/src/ops/signal_forms.ts

Lines changed: 39 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ const formControlOptionalFields = new Set([
7474
/**
7575
* A `TcbOp` which constructs an instance of the signal forms `Field` directive on a native element.
7676
*/
77-
export class TcbNativeFieldDirectiveTypeOp extends TcbOp {
77+
export class TcbNativeFieldOp extends TcbOp {
7878
/** Bindings that aren't supported on signal form fields. */
7979
protected readonly unsupportedBindingFields = new Set([
8080
...formControlInputFields,
@@ -84,27 +84,17 @@ export class TcbNativeFieldDirectiveTypeOp extends TcbOp {
8484
'minlength',
8585
]);
8686

87-
private readonly inputType: string | null;
88-
8987
override get optional() {
9088
return false;
9189
}
9290

9391
constructor(
94-
private tcb: Context,
95-
private scope: Scope,
96-
private node: TmplAstElement,
92+
protected tcb: Context,
93+
protected scope: Scope,
94+
protected node: TmplAstElement,
95+
private inputType: string | null,
9796
) {
9897
super();
99-
100-
this.inputType =
101-
(node.name === 'input' && node.attributes.find((attr) => attr.name === 'type')?.value) ||
102-
null;
103-
104-
// Radio controls are allowed to set the `value`.
105-
if (this.inputType === 'radio') {
106-
this.unsupportedBindingFields.delete('value');
107-
}
10898
}
10999

110100
override execute(): null {
@@ -120,11 +110,7 @@ export class TcbNativeFieldDirectiveTypeOp extends TcbOp {
120110

121111
checkUnsupportedFieldBindings(this.node, this.unsupportedBindingFields, this.tcb);
122112

123-
const expectedType =
124-
this.node instanceof TmplAstElement
125-
? this.getExpectedTypeFromDomNode(this.node)
126-
: this.getUnsupportedType();
127-
113+
const expectedType = this.getExpectedTypeFromDomNode(this.node);
128114
const value = extractFieldValue(fieldBinding.value, this.tcb, this.scope);
129115

130116
// Create a variable with the expected type and check that the field value is assignable, e.g.
@@ -180,6 +166,38 @@ export class TcbNativeFieldDirectiveTypeOp extends TcbOp {
180166
}
181167
}
182168

169+
/**
170+
* A variation of the `TcbNativeFieldOp` with specific logic for radio buttons.
171+
*/
172+
export class TcbNativeRadioButtonFieldOp extends TcbNativeFieldOp {
173+
constructor(tcb: Context, scope: Scope, node: TmplAstElement) {
174+
super(tcb, scope, node, 'radio');
175+
this.unsupportedBindingFields.delete('value');
176+
}
177+
178+
override execute(): null {
179+
super.execute();
180+
181+
const valueBinding = this.node.inputs.find((attr) => {
182+
return attr.type === BindingType.Property && attr.name === 'value';
183+
});
184+
185+
if (valueBinding !== undefined) {
186+
// Include an additional expression to check that the `value` is a string.
187+
const id = this.tcb.allocateId();
188+
const value = tcbExpression(valueBinding.value, this.tcb, this.scope);
189+
const assignment = ts.factory.createBinaryExpression(id, ts.SyntaxKind.EqualsToken, value);
190+
addParseSpanInfo(assignment, valueBinding.sourceSpan);
191+
this.scope.addStatement(
192+
tsDeclareVariable(id, ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword)),
193+
);
194+
this.scope.addStatement(ts.factory.createExpressionStatement(assignment));
195+
}
196+
197+
return null;
198+
}
199+
}
200+
183201
/** Expands the set of bound inputs with the ones from custom field directives. */
184202
export function expandBoundAttributesForField(
185203
directive: TypeCheckableDirectiveMeta,
@@ -319,7 +337,7 @@ export function isNativeField(
319337
dir: TypeCheckableDirectiveMeta,
320338
node: TmplAstNode,
321339
allDirectiveMatches: TypeCheckableDirectiveMeta[],
322-
): boolean {
340+
): node is TmplAstElement & {name: 'input' | 'select' | 'textarea'} {
323341
// Only applies to the `Field` directive.
324342
if (!isFieldDirective(dir)) {
325343
return false;

packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2625,6 +2625,16 @@ describe('type check blocks', () => {
26252625
});
26262626
});
26272627

2628+
it('should generate expressions to check the field and value bindings of a radio input', () => {
2629+
const block = tcb('<input type="radio" [field]="f" [value]="expr"/>', [FieldMock]);
2630+
expect(block).toContain('var _t1 = null! as string;');
2631+
expect(block).toContain('_t1 = ((this).f)().value();');
2632+
expect(block).toContain('var _t2 = null! as string;');
2633+
expect(block).toContain('_t2 = ((this).expr);');
2634+
expect(block).toContain('var _t3 = null! as i0.Field;');
2635+
expect(block).toContain('_t3.field = (((this).f));');
2636+
});
2637+
26282638
it('should generate a string field for a textarea', () => {
26292639
const block = tcb('<textarea [field]="f"></textarea>', [FieldMock]);
26302640
expect(block).toContain('var _t1 = null! as string;');

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,33 @@ runInEachFileSystem(() => {
379379
expect(diags.length).toBe(0);
380380
});
381381

382+
it('should check that the radio button value is a string', () => {
383+
env.write(
384+
'test.ts',
385+
`
386+
import {Component, signal} from '@angular/core';
387+
import {Field, form} from '@angular/forms/signals';
388+
389+
@Component({
390+
template: \`
391+
<form>
392+
<input type="radio" [value]="num" [field]="f">
393+
</form>
394+
\`,
395+
imports: [Field]
396+
})
397+
export class Comp {
398+
f = form(signal('a'), {name: 'test'});
399+
num = 1;
400+
}
401+
`,
402+
);
403+
404+
const diags = env.driveDiagnostics();
405+
expect(diags.length).toBe(1);
406+
expect(diags[0].messageText).toBe(`Type 'number' is not assignable to type 'string'.`);
407+
});
408+
382409
it('should report unsupported static attributes of a field', () => {
383410
env.write(
384411
'test.ts',

0 commit comments

Comments
 (0)