Skip to content

Commit af4a07f

Browse files
committed
fix(forms): prevent date inputs from being marked dirty on init
Signal Forms inputs that require validity tracking were being marked `dirty()` on component initialization, before any user interaction. This caused validation error messages and invalid/valid styling to appear immediately on load. Fix: angular#69632
1 parent 79fe1c3 commit af4a07f

4 files changed

Lines changed: 40 additions & 10 deletions

File tree

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
setNativeDomProperty,
3131
} from './native';
3232
import {observeSelectMutations} from './select';
33+
import {ControlValueSignal} from '../field/node';
3334

3435
export function nativeControlCreate(
3536
host: ControlDirectiveHost,
@@ -47,7 +48,8 @@ export function nativeControlCreate(
4748
// Read from the model value
4849
() => parent.state().value(),
4950
// Write to the buffered "control value"
50-
(rawValue: unknown) => parent.state().controlValue.set(rawValue),
51+
(rawValue: unknown, markAsDirty: boolean) =>
52+
(parent.state().controlValue as ControlValueSignal<unknown>).set(rawValue, markAsDirty),
5153
// Our parse function doesn't care about the raw value that gets passed in,
5254
// It just reads the newly parsed value directly off the input element.
5355
(_rawValue: unknown) => getNativeControlValue(input, parent.state().value, validityMonitor),
@@ -66,7 +68,9 @@ export function nativeControlCreate(
6668

6769
// TODO: move extraction to first update pass?
6870
if (isInput(input) && inputRequiresValidityTracking(input)) {
69-
validityMonitor.watchValidity(parent.destroyRef, input, () => parser.setRawValue(undefined));
71+
validityMonitor.watchValidity(parent.destroyRef, input, () =>
72+
parser.setRawValue(undefined, false),
73+
);
7074
}
7175

7276
parent.registerAsBinding();

‎packages/forms/signals/src/field/node.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import {ValidationState} from './validation';
5555

5656
export interface ControlValueSignal<T> extends WritableSignal<T> {
5757
rawSet(value: T): void;
58+
set(value: T, markAsDirty?: boolean): void;
5859
}
5960

6061
/**
@@ -117,8 +118,7 @@ export class FieldNode implements FieldState<unknown> {
117118
* first focusable binding in the DOM for any descendant node of this one.
118119
*/
119120
private getBindingForFocus():
120-
| (FormField<unknown> & {focus: (options?: FocusOptions) => void})
121-
| undefined {
121+
(FormField<unknown> & {focus: (options?: FocusOptions) => void}) | undefined {
122122
// First try to focus one of our own bindings.
123123
const own = this.formFieldBindings()
124124
.filter(
@@ -382,11 +382,13 @@ export class FieldNode implements FieldState<unknown> {
382382
const controlValue = linkedSignal(this.value) as ControlValueSignal<unknown>;
383383

384384
controlValue.rawSet = controlValue.set;
385-
controlValue.set = (newValue) => {
385+
controlValue.set = (newValue, markAsDirty = true) => {
386386
// We intentionally allow same-value updates here to ensure that setting the control value
387387
// (even to the same value) still marks the control as dirty.
388388
controlValue.rawSet(newValue);
389-
this.markAsDirty();
389+
if (markAsDirty) {
390+
this.markAsDirty();
391+
}
390392
this.debounceSync();
391393
};
392394
const rawUpdate = controlValue.update;

‎packages/forms/signals/src/util/parser.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export interface Parser<TRaw> {
2323
/**
2424
* Parses the given raw value and updates the underlying model value if successful.
2525
*/
26-
setRawValue: (rawValue: TRaw) => void;
26+
setRawValue: (rawValue: TRaw, markAsDirty?: boolean) => void;
2727
/**
2828
* Resets the parser errors.
2929
*/
@@ -40,7 +40,7 @@ export interface Parser<TRaw> {
4040
*/
4141
export function createParser<TValue, TRaw>(
4242
getValue: () => TValue,
43-
setValue: (value: TValue) => void,
43+
setValue: (value: TValue, markAsDirty: boolean) => void,
4444
parse: (raw: TRaw) => ParseResult<TValue>,
4545
): Parser<TRaw> {
4646
const errors = linkedSignal({
@@ -49,11 +49,11 @@ export function createParser<TValue, TRaw>(
4949
equal: shallowArrayEquals,
5050
});
5151

52-
const setRawValue = (rawValue: TRaw) => {
52+
const setRawValue = (rawValue: TRaw, markAsDirty = true) => {
5353
const result = parse(rawValue);
5454
errors.set(normalizeErrors(result.error));
5555
if (result.value !== undefined) {
56-
setValue(result.value);
56+
setValue(result.value, markAsDirty);
5757
}
5858
// `errors` is a linked signal sourced from the model value; write parse errors after
5959
// model updates so `{value, errors}` results do not get reset by the recomputation.

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5791,6 +5791,30 @@ describe('field directive', () => {
57915791

57925792
expect(field().dirty()).toBe(true);
57935793
});
5794+
5795+
it('native date input is not dirty after initial validity animation', () => {
5796+
@Component({
5797+
imports: [FormField],
5798+
template: `<input type="date" [formField]="f" />`,
5799+
})
5800+
class TestCmp {
5801+
f = form(signal(''), (p) => {
5802+
required(p, {message: 'required field'});
5803+
});
5804+
}
5805+
5806+
const fix = act(() => TestBed.createComponent(TestCmp));
5807+
const input = fix.nativeElement.firstChild as HTMLInputElement;
5808+
const field = fix.componentInstance.f;
5809+
5810+
expect(field().dirty()).toBe(false);
5811+
act(() => {
5812+
input.dispatchEvent(
5813+
new AnimationEvent('animationstart', {animationName: 'ng-invalid', bubbles: true}),
5814+
);
5815+
});
5816+
expect(field().dirty()).toBe(false);
5817+
});
57945818
});
57955819

57965820
it('should throw for invalid field directive host', () => {

0 commit comments

Comments
 (0)