Revert "fix(forms): preserve intermediate number values in signal forms"

This reverts commit 2e32872720.
This commit is contained in:
Pawel Kozlowski
2026-07-20 10:15:52 +02:00
parent f8c9e9d736
commit 7a626175f7
4 changed files with 7 additions and 180 deletions
@@ -76,7 +76,7 @@ export function bindingUpdated<TKey extends string>(
key: TKey,
value: unknown,
) {
if (!Object.is(bindings[key], value)) {
if (bindings[key] !== value) {
bindings[key] = value;
return true;
}
@@ -41,19 +41,13 @@ export function nativeControlCreate(
): () => void {
let updateMode = false;
const input = parent.nativeFormElement;
const bindings = createBindings<ControlBindingKey | 'controlValue'>();
// TODO: (perf) ok to always create this?
const parser = createParser(
// Read from the model value
() => parent.state().value(),
// Write to the buffered "control value"
(rawValue: unknown) => {
// Mark the parsed value as already seen from this native control so the next update pass
// does not reformat and write it back over the user's in-progress input text.
bindings['controlValue'] = rawValue;
parent.state().controlValue.set(rawValue);
},
(rawValue: unknown) => parent.state().controlValue.set(rawValue),
// Our parse function doesn't care about the raw value that gets passed in,
// It just reads the newly parsed value directly off the input element.
(_rawValue: unknown) => getNativeControlValue(input, parent.state().value, validityMonitor),
@@ -100,6 +94,8 @@ export function nativeControlCreate(
);
}
const bindings = createBindings<ControlBindingKey | 'controlValue'>();
return () => {
const state = parent.state();
+2 -13
View File
@@ -84,8 +84,8 @@ export function getNativeControlValue(
if (element.value === '') {
return {value: null};
}
const parsed = parseDecimalNumber(element.value);
if (parsed === undefined) {
const parsed = Number(element.value);
if (Number.isNaN(parsed)) {
return {error: new NativeInputParseError() as WithoutFieldTree<NativeInputParseError>};
}
return {value: parsed};
@@ -164,17 +164,6 @@ export function setNativeControlValue(element: NativeFormControl, value: unknown
element.value = value as string;
}
function parseDecimalNumber(value: string): number | undefined {
const parsed = Number(value);
// `parseFloat` does not consume non-decimal prefixes like `0b`/`0x`, while `Number`
// rejects trailing garbage. Requiring both to agree keeps decimal input permissive
// without accepting other JavaScript numeric literal forms.
if (Number.isNaN(parsed) || !Object.is(parsed, parseFloat(value))) {
return undefined;
}
return parsed;
}
/** Writes a value to a native <input type="number">. */
export function setNativeNumberControlValue(element: HTMLInputElement, value: number) {
// Writing `NaN` causes a warning in the console, so we instead write `''`.
@@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.dev/license
*/
import {Component, signal, viewChildren} from '@angular/core';
import {Component, signal, viewChildren, Injectable} from '@angular/core';
import {TestBed} from '@angular/core/testing';
import {FormField, form} from '../../public_api';
import {InputValidityMonitor} from '../../src/directive/input_validity_monitor';
@@ -58,90 +58,6 @@ describe('numeric inputs', () => {
expect(fixture.componentInstance.f().errors()).toEqual([]);
});
it('should preserve a negative decimal typed into a number input', () => {
@Component({
imports: [FormField],
template: `<input type="number" step="0.01" [formField]="f" />`,
})
class TestCmp {
readonly data = signal<number | null>(null);
readonly f = form(this.data);
}
const fixture = act(() => TestBed.createComponent(TestCmp));
const input = fixture.nativeElement.querySelector('input') as HTMLInputElement;
act(() => {
validityMonitor.setInputState(input, '-', true);
});
expect(fixture.componentInstance.f().value()).toBeNull();
expect(fixture.componentInstance.f().errors()).toEqual([
jasmine.objectContaining({kind: 'parse'}),
]);
act(() => {
validityMonitor.setInputState(input, '-0', false);
});
expect(fixture.componentInstance.f().value()).toBe(0);
expect(fixture.componentInstance.f().errors()).toEqual([]);
expect(input.value).toBe('-0');
act(() => {
validityMonitor.setInputState(input, '-0.5', false);
});
expect(fixture.componentInstance.f().value()).toBe(-0.5);
expect(input.value).toBe('-0.5');
});
it('should preserve fractional zeroes while editing a number input', () => {
@Component({
imports: [FormField],
template: `<input type="number" step="0.01" [formField]="f" />`,
})
class TestCmp {
readonly data = signal<number | null>(null);
readonly f = form(this.data);
}
const fixture = act(() => TestBed.createComponent(TestCmp));
const input = fixture.nativeElement.querySelector('input') as HTMLInputElement;
act(() => {
validityMonitor.setInputState(input, '1', false);
});
act(() => {
validityMonitor.setInputState(input, '1.0', false);
});
act(() => {
validityMonitor.setInputState(input, '1.00', false);
});
act(() => {
validityMonitor.setInputState(input, '1.000', false);
});
act(() => {
validityMonitor.setInputState(input, '1.0000', false);
});
expect(fixture.componentInstance.f().value()).toBe(1);
expect(input.value).toBe('1.0000');
act(() => {
validityMonitor.setInputState(input, '1.00005', false);
});
act(() => {
validityMonitor.setInputState(input, '1.0000', false);
});
act(() => {
validityMonitor.setInputState(input, '1.00004', false);
});
expect(fixture.componentInstance.f().value()).toBe(1.00004);
expect(input.value).toBe('1.00004');
});
it('should clear parse errors on one control when another control for the same field updates the model', () => {
@Component({
imports: [FormField],
@@ -289,73 +205,6 @@ describe('text input with numeric model', () => {
expect(fixture.componentInstance.f().errors()).toEqual([]);
});
it('should preserve a negative decimal typed into a text input with a numeric model', () => {
@Component({
imports: [FormField],
template: `<input type="text" inputmode="decimal" [formField]="f" />`,
})
class TestCmp {
readonly data = signal<number | null>(null);
readonly f = form(this.data);
}
const fixture = act(() => TestBed.createComponent(TestCmp));
const input = fixture.nativeElement.querySelector('input') as HTMLInputElement;
setInputValue(input, '-');
expect(fixture.componentInstance.f().value()).toBeNull();
expect(fixture.componentInstance.f().errors()).toEqual([
jasmine.objectContaining({kind: 'parse'}),
]);
expect(input.value).toBe('-');
setInputValue(input, '-0');
expect(fixture.componentInstance.f().errors()).toEqual([]);
expect(input.value).toBe('-0');
setInputValue(input, '-0.');
expect(fixture.componentInstance.f().errors()).toEqual([]);
expect(input.value).toBe('-0.');
setInputValue(input, '-0.5');
expect(fixture.componentInstance.f().value()).toBe(-0.5);
expect(input.value).toBe('-0.5');
});
it('should not parse non-decimal numeric text', () => {
@Component({
imports: [FormField],
template: `<input type="text" [formField]="f" />`,
})
class TestCmp {
readonly data = signal<number | null>(42);
readonly f = form(this.data);
}
const fixture = act(() => TestBed.createComponent(TestCmp));
const input = fixture.nativeElement.querySelector('input') as HTMLInputElement;
setInputValue(input, '0b0101');
expect(fixture.componentInstance.f().value()).toBe(42);
expect(fixture.componentInstance.f().errors()).toEqual([
jasmine.objectContaining({kind: 'parse'}),
]);
expect(input.value).toBe('0b0101');
setInputValue(input, '0x22');
expect(fixture.componentInstance.f().value()).toBe(42);
expect(fixture.componentInstance.f().errors()).toEqual([
jasmine.objectContaining({kind: 'parse'}),
]);
expect(input.value).toBe('0x22');
});
it('should produce a parse error when user types non-numeric text', () => {
@Component({
imports: [FormField],
@@ -459,13 +308,6 @@ describe('text input with numeric model', () => {
});
});
function setInputValue(input: HTMLInputElement, value: string) {
act(() => {
input.value = value;
input.dispatchEvent(new Event('input'));
});
}
function act<T>(fn: () => T): T {
try {
return fn();