Skip to content

Commit 5726517

Browse files
committed
fixup! fix(forms): prevent date inputs from being marked dirty on init
1 parent 59139f8 commit 5726517

2 files changed

Lines changed: 44 additions & 3 deletions

File tree

‎packages/forms/signals/src/directive/control_native.ts‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,17 +32,30 @@ import {
3232
} from './native';
3333
import {observeSelectMutations} from './select';
3434

35+
/**
36+
* Normalizes a control value for comparison, collapsing an invalid `Date` to `null`.
37+
*
38+
* A `Date` with a `NaN` timestamp has no representation a date-like input can hold: assigning it to
39+
* `valueAsDate` empties the input, which then reads back as `null`. Treating the two as the same
40+
* value keeps that round-trip from looking like an edit.
41+
*/
42+
function normalizeControlValue(value: unknown): unknown {
43+
return value instanceof Date && Number.isNaN(value.getTime()) ? null : value;
44+
}
45+
3546
/**
3647
* Compares two control values, treating `Date`s for the same instant as equal.
3748
*
3849
* Date-like inputs are read through `valueAsDate`, which returns a fresh `Date` on every access, so
3950
* identity comparison alone would report a change even when the input was never touched.
4051
*/
4152
function controlValuesEqual(a: unknown, b: unknown): boolean {
42-
if (a instanceof Date && b instanceof Date) {
43-
return a.getTime() === b.getTime();
53+
const normalizedA = normalizeControlValue(a);
54+
const normalizedB = normalizeControlValue(b);
55+
if (normalizedA instanceof Date && normalizedB instanceof Date) {
56+
return normalizedA.getTime() === normalizedB.getTime();
4457
}
45-
return Object.is(a, b);
58+
return Object.is(normalizedA, normalizedB);
4659
}
4760

4861
export function nativeControlCreate(

‎packages/forms/signals/test/web/form_field.spec.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5662,6 +5662,34 @@ describe('field directive', () => {
56625662
expect(field().value()).toEqual(new Date('2024-01-01T00:00:00.000Z'));
56635663
});
56645664

5665+
// An invalid `Date` has a `NaN` timestamp, and `NaN === NaN` is false, so comparing instants
5666+
// alone reports a change on every sync and dirties the field on load. See #69632.
5667+
it('should not be dirty when the initial validity animation fires on an invalid Date model', () => {
5668+
@Component({
5669+
imports: [FormField],
5670+
template: `<input type="date" [formField]="f" />`,
5671+
})
5672+
class TestCmp {
5673+
f = form(signal<Date | null>(new Date('invalid')));
5674+
}
5675+
5676+
const fix = act(() => TestBed.createComponent(TestCmp));
5677+
const input = fix.nativeElement.firstChild as HTMLInputElement;
5678+
const field = fix.componentInstance.f;
5679+
5680+
expect(field().dirty()).toBe(false);
5681+
5682+
act(() => {
5683+
input.dispatchEvent(
5684+
new AnimationEvent('animationstart', {animationName: 'ng-invalid', bubbles: true}),
5685+
);
5686+
});
5687+
5688+
expect(field().dirty()).toBe(false);
5689+
// The model keeps the value the app supplied; the sync must not silently null it out.
5690+
expect(Number.isNaN((field().value() as Date).getTime())).toBe(true);
5691+
});
5692+
56655693
// The initial `:valid` / `:invalid` animation fires for every input type we track validity on,
56665694
// not just `date`, so none of them may dirty their field on load. See #69632.
56675695
//

0 commit comments

Comments
 (0)