fix(forms): parse numeric model values for signal forms <select>

When a `<select>` element is bound via `[formField]` to a field with a
`number | null` model, the native DOM always reads `element.value` as a
string. This caused the model to silently receive a string (e.g. `"42"`)
instead of the expected number, and writing `null` to `element.value`
coerced it to the string `"null"` rather than clearing the selection.

Extend `getNativeControlValue` with a `select-one` case that mirrors the
existing `<input type="text">` numeric-model logic: when the current model
type is `number | null`, parse the selected option's string value as a
number, return `null` for an empty selection, and produce a parse error if
the option value is not a valid number.

Extend `setNativeControlValue` with a matching `select-one` case that
converts `null` and `NaN` to `''` (clearing the selection) and writes
numeric values as `String(value)`.

Replace the bare `input.value = ... as string` assignment in the
`observeSelectMutations` callback in `nativeControlCreate` with a call to
`setNativeControlValue` so that option-change re-sync also benefits from
the same null/number handling.

Fixes #68217
This commit is contained in:
Sonu Kapoor 2026-04-29 09:11:52 -04:00
parent 08930e60bb
commit c075ef1173
3 changed files with 229 additions and 1 deletions

View file

@ -81,7 +81,7 @@ export function nativeControlCreate(
if (!updateMode) {
return;
}
input.value = parent.state().controlValue() as string;
setNativeControlValue(input, parent.state().controlValue());
},
parent.destroyRef,
);

View file

@ -74,6 +74,21 @@ export function getNativeControlValue(
return {value: element.valueAsNumber};
}
break;
case 'select-one':
// We can read a `number` or a `string` from a <select>. Prefer whichever is consistent
// with the current type.
modelValue = untracked(currentValue);
if (typeof modelValue === 'number' || modelValue === null) {
if (element.value === '') {
return {value: null};
}
const parsed = Number(element.value);
if (Number.isNaN(parsed)) {
return {error: new NativeInputParseError() as WithoutFieldTree<NativeInputParseError>};
}
return {value: parsed};
}
break;
}
// For text-like <input> elements, parse numeric values if the model is numeric.
@ -136,6 +151,17 @@ export function setNativeControlValue(element: NativeFormControl, value: unknown
setNativeNumberControlValue(element, value);
return;
}
break;
case 'select-one':
// This input type can receive a `number`, `null`, or `string`.
if (typeof value === 'number') {
element.value = isNaN(value) ? '' : String(value);
return;
} else if (value === null) {
element.value = '';
return;
}
break;
}
// For text-like <input> elements, handle numeric and null values.

View file

@ -0,0 +1,202 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {Component, signal} from '@angular/core';
import {TestBed} from '@angular/core/testing';
import {FormField, form} from '../../public_api';
describe('select with numeric model', () => {
it('should render initial number value by selecting the matching option', () => {
@Component({
imports: [FormField],
template: `
<select [formField]="f">
<option value="1">One</option>
<option value="2">Two</option>
<option value="3">Three</option>
</select>
`,
})
class TestCmp {
readonly data = signal<number>(2);
readonly f = form(this.data);
}
const fixture = act(() => TestBed.createComponent(TestCmp));
const select = fixture.nativeElement.querySelector('select') as HTMLSelectElement;
expect(select.value).toBe('2');
expect(fixture.componentInstance.f().value()).toBe(2);
});
it('should update model as a number when user selects an option', () => {
@Component({
imports: [FormField],
template: `
<select [formField]="f">
<option value="1">One</option>
<option value="2">Two</option>
<option value="3">Three</option>
</select>
`,
})
class TestCmp {
readonly data = signal<number>(1);
readonly f = form(this.data);
}
const fixture = act(() => TestBed.createComponent(TestCmp));
const select = fixture.nativeElement.querySelector('select') as HTMLSelectElement;
act(() => {
select.value = '3';
select.dispatchEvent(new Event('input'));
});
expect(fixture.componentInstance.f().value()).toBe(3);
expect(typeof fixture.componentInstance.f().value()).toBe('number');
expect(fixture.componentInstance.f().errors()).toEqual([]);
});
it('should render null model value as empty string', () => {
@Component({
imports: [FormField],
template: `
<select [formField]="f">
<option value="">-- select --</option>
<option value="1">One</option>
<option value="2">Two</option>
</select>
`,
})
class TestCmp {
readonly data = signal<number | null>(null);
readonly f = form(this.data);
}
const fixture = act(() => TestBed.createComponent(TestCmp));
const select = fixture.nativeElement.querySelector('select') as HTMLSelectElement;
expect(select.value).toBe('');
expect(fixture.componentInstance.f().value()).toBeNull();
});
it('should update model to null when user selects the empty option', () => {
@Component({
imports: [FormField],
template: `
<select [formField]="f">
<option value="">-- select --</option>
<option value="1">One</option>
<option value="2">Two</option>
</select>
`,
})
class TestCmp {
readonly data = signal<number | null>(1);
readonly f = form(this.data);
}
const fixture = act(() => TestBed.createComponent(TestCmp));
const select = fixture.nativeElement.querySelector('select') as HTMLSelectElement;
act(() => {
select.value = '';
select.dispatchEvent(new Event('input'));
});
expect(fixture.componentInstance.f().value()).toBeNull();
expect(fixture.componentInstance.f().errors()).toEqual([]);
});
it('should update select when model is set programmatically', () => {
@Component({
imports: [FormField],
template: `
<select [formField]="f">
<option value="10">Ten</option>
<option value="20">Twenty</option>
<option value="30">Thirty</option>
</select>
`,
})
class TestCmp {
readonly data = signal<number>(10);
readonly f = form(this.data);
}
const fixture = act(() => TestBed.createComponent(TestCmp));
const select = fixture.nativeElement.querySelector('select') as HTMLSelectElement;
expect(select.value).toBe('10');
act(() => {
fixture.componentInstance.data.set(30);
});
expect(select.value).toBe('30');
expect(fixture.componentInstance.f().value()).toBe(30);
});
it('should render NaN model value as empty string', () => {
@Component({
imports: [FormField],
template: `
<select [formField]="f">
<option value="">-- select --</option>
<option value="1">One</option>
</select>
`,
})
class TestCmp {
readonly data = signal<number | null>(NaN);
readonly f = form(this.data);
}
const fixture = act(() => TestBed.createComponent(TestCmp));
const select = fixture.nativeElement.querySelector('select') as HTMLSelectElement;
expect(select.value).toBe('');
expect(fixture.componentInstance.f().value()).toEqual(NaN);
});
it('should preserve string model type when model is a string', () => {
@Component({
imports: [FormField],
template: `
<select [formField]="f">
<option value="a">A</option>
<option value="b">B</option>
</select>
`,
})
class TestCmp {
readonly data = signal<string>('a');
readonly f = form(this.data);
}
const fixture = act(() => TestBed.createComponent(TestCmp));
const select = fixture.nativeElement.querySelector('select') as HTMLSelectElement;
act(() => {
select.value = 'b';
select.dispatchEvent(new Event('input'));
});
expect(fixture.componentInstance.f().value()).toBe('b');
expect(typeof fixture.componentInstance.f().value()).toBe('string');
});
});
function act<T>(fn: () => T): T {
try {
return fn();
} finally {
TestBed.tick();
}
}