From c075ef11738b6a948d877bcbcc9e4ff0782f5eee Mon Sep 17 00:00:00 2001 From: Sonu Kapoor Date: Wed, 29 Apr 2026 09:11:52 -0400 Subject: [PATCH 1/2] fix(forms): parse numeric model values for signal forms ` 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 `` 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 --- .../signals/src/directive/control_native.ts | 2 +- .../forms/signals/src/directive/native.ts | 26 +++ .../signals/test/web/select_numeric.spec.ts | 202 ++++++++++++++++++ 3 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 packages/forms/signals/test/web/select_numeric.spec.ts diff --git a/packages/forms/signals/src/directive/control_native.ts b/packages/forms/signals/src/directive/control_native.ts index d90537886b3..3cba243ca06 100644 --- a/packages/forms/signals/src/directive/control_native.ts +++ b/packages/forms/signals/src/directive/control_native.ts @@ -81,7 +81,7 @@ export function nativeControlCreate( if (!updateMode) { return; } - input.value = parent.state().controlValue() as string; + setNativeControlValue(input, parent.state().controlValue()); }, parent.destroyRef, ); diff --git a/packages/forms/signals/src/directive/native.ts b/packages/forms/signals/src/directive/native.ts index 6b299e9a58f..2a4570afc57 100644 --- a/packages/forms/signals/src/directive/native.ts +++ b/packages/forms/signals/src/directive/native.ts @@ -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 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 elements, handle numeric and null values. diff --git a/packages/forms/signals/test/web/select_numeric.spec.ts b/packages/forms/signals/test/web/select_numeric.spec.ts new file mode 100644 index 00000000000..2cf7fa28f95 --- /dev/null +++ b/packages/forms/signals/test/web/select_numeric.spec.ts @@ -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: ` + + `, + }) + class TestCmp { + readonly data = signal(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: ` + + `, + }) + class TestCmp { + readonly data = signal(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: ` + + `, + }) + class TestCmp { + readonly data = signal(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: ` + + `, + }) + class TestCmp { + readonly data = signal(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: ` + + `, + }) + class TestCmp { + readonly data = signal(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: ` + + `, + }) + class TestCmp { + readonly data = signal(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: ` + + `, + }) + class TestCmp { + readonly data = signal('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(fn: () => T): T { + try { + return fn(); + } finally { + TestBed.tick(); + } +} From b1b7ae429490c22aac160265fd2ba83a0093e24f Mon Sep 17 00:00:00 2001 From: Sonu Kapoor Date: Wed, 29 Apr 2026 09:25:43 -0400 Subject: [PATCH 2/2] fix(forms): widen signal forms bound via [formField] as accepting only string models. Now that the runtime correctly parses numeric option values when the model is number | null, the type-checker must accept the same set of value types. Change getExpectedTypeFromDomNode to return 'string | number | null' for . Update the corresponding TCB spec expectation. --- .../src/ngtsc/typecheck/test/type_check_block_spec.ts | 4 ++-- packages/compiler/src/typecheck/ops/signal_forms.ts | 8 ++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts b/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts index a62824d52b8..3db9c59e2f0 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts @@ -2915,9 +2915,9 @@ describe('type check blocks', () => { expect(block).toContain('_t2.field = (((this).f));'); }); - it('should generate a string field for a select', () => { + it('should generate a string | number | null field for a select', () => { const block = tcb('', [FieldMock]); - expect(block).toContain('var _t1 = null! as string;'); + expect(block).toContain('var _t1 = null! as string | number | null;'); expect(block).toContain('_t1 = ((this).f)().value();'); expect(block).toContain('var _t2 = null! as i0.FormField;'); expect(block).toContain('_t2.field = (((this).f));'); diff --git a/packages/compiler/src/typecheck/ops/signal_forms.ts b/packages/compiler/src/typecheck/ops/signal_forms.ts index 3e89888d840..99b8451cca7 100644 --- a/packages/compiler/src/typecheck/ops/signal_forms.ts +++ b/packages/compiler/src/typecheck/ops/signal_forms.ts @@ -142,11 +142,15 @@ export class TcbNativeFieldOp extends TcbOp { } private getExpectedTypeFromDomNode(node: Element): string | null { - if (node.name === 'textarea' || node.name === 'select') { - // `