@@ -32,32 +32,6 @@ import {
3232} from './native' ;
3333import { 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-
46- /**
47- * Compares two control values, treating `Date`s for the same instant as equal.
48- *
49- * Date-like inputs are read through `valueAsDate`, which returns a fresh `Date` on every access, so
50- * identity comparison alone would report a change even when the input was never touched.
51- */
52- function controlValuesEqual ( a : unknown , b : unknown ) : boolean {
53- const normalizedA = normalizeControlValue ( a ) ;
54- const normalizedB = normalizeControlValue ( b ) ;
55- if ( normalizedA instanceof Date && normalizedB instanceof Date ) {
56- return normalizedA . getTime ( ) === normalizedB . getTime ( ) ;
57- }
58- return Object . is ( normalizedA , normalizedB ) ;
59- }
60-
6135export function nativeControlCreate (
6236 host : ControlDirectiveHost ,
6337 parent : FormField < unknown > ,
@@ -67,40 +41,42 @@ export function nativeControlCreate(
6741 validityMonitor : InputValidityMonitor ,
6842) : ( ) => void {
6943 let updateMode = false ;
70- // While true, a write that leaves the value untouched must not dirty the field.
71- //
72- // A native validity change isn't necessarily a user edit: the browser runs the `:valid` /
73- // `:invalid` animation as soon as the input is rendered, which would otherwise dirty every
74- // date-like field on load (#69632). Syncs that clear a parse error are exempt, because a parse
75- // error can only exist in response to the user typing something the input couldn't parse.
76- let dirtyOnlyIfValueChanged = false ;
7744 const input = parent . nativeFormElement ;
45+ // The input's value as the DOM itself holds it, recorded after each write we make.
46+ //
47+ // Comparing model values can't tell a user edit from the browser reshaping what we wrote: an
48+ // unparseable string or a `Date` the input can't represent is rejected and leaves the value
49+ // empty, and a `Date` carrying more precision than the input keeps is truncated. In each case the
50+ // value read back differs from the model through no action of the user, which would dirty every
51+ // date-like field on load (#69632). The DOM's own string is the one representation both sides
52+ // agree on.
53+ let lastWrittenDomValue = input . value ;
7854
7955 // TODO: (perf) ok to always create this?
8056 const parser = createParser (
8157 // Read from the model value
8258 ( ) => parent . state ( ) . value ( ) ,
8359 // Write to the buffered "control value"
84- ( rawValue : unknown ) => {
85- const state = parent . state ( ) ;
86- if ( dirtyOnlyIfValueChanged && controlValuesEqual ( untracked ( state . controlValue ) , rawValue ) ) {
87- return ;
88- }
89- // Outside of `dirtyOnlyIfValueChanged` we intentionally write even when the value is
90- // unchanged, so that re-entering the same value still dirties the field.
91- state . controlValue . set ( rawValue ) ;
92- } ,
60+ ( rawValue : unknown ) => parent . state ( ) . controlValue . set ( rawValue ) ,
9361 // Our parse function doesn't care about the raw value that gets passed in,
9462 // It just reads the newly parsed value directly off the input element.
9563 ( _rawValue : unknown ) => getNativeControlValue ( input , parent . state ( ) . value , validityMonitor ) ,
9664 ) ;
9765
9866 parseErrorsSource . set ( parser . errors ) ;
67+ // Writes to the DOM and records what the DOM ended up holding, which is not necessarily what we
68+ // asked for: the browser rejects values it can't parse and truncates ones carrying more precision
69+ // than the input type keeps.
70+ const writeNativeControlValue = ( value : unknown ) => {
71+ setNativeControlValue ( input , value ) ;
72+ lastWrittenDomValue = input . value ;
73+ } ;
74+
9975 parent . onReset = ( ) => {
10076 parser . reset ( ) ;
10177 const value = parent . state ( ) . value ( ) ;
10278 bindings [ 'controlValue' ] = value ;
103- setNativeControlValue ( input , value ) ;
79+ writeNativeControlValue ( value ) ;
10480 } ;
10581 // Pass undefined as the raw value since the parse function doesn't care about it.
10682 host . listenToDom ( 'input' , ( ) => {
@@ -115,14 +91,21 @@ export function nativeControlCreate(
11591 // TODO: move extraction to first update pass?
11692 if ( isInput ( input ) && inputRequiresValidityTracking ( input ) ) {
11793 validityMonitor . watchValidity ( parent . destroyRef , input , ( ) => {
118- // Resolving a parse error is always a user edit, so let those syncs dirty the field even
119- // when the parsed value is unchanged.
120- dirtyOnlyIfValueChanged = untracked ( parser . errors ) . length === 0 ;
121- try {
122- parser . setRawValue ( undefined ) ;
123- } finally {
124- dirtyOnlyIfValueChanged = false ;
94+ // The browser runs the `:valid` / `:invalid` animation as soon as the input renders, which is
95+ // not a user edit. If the DOM still holds exactly what we last wrote then nothing changed and
96+ // there is nothing to sync.
97+ //
98+ // Two states are exempt, because in both the user did edit the input while its `value` stayed
99+ // empty: entering text the input can't convert (`badInput`, which reports an empty `value`),
100+ // and clearing that text again, which resolves the parse error the previous sync recorded.
101+ if (
102+ untracked ( parser . errors ) . length === 0 &&
103+ ! validityMonitor . isBadInput ( input ) &&
104+ input . value === lastWrittenDomValue
105+ ) {
106+ return ;
125107 }
108+ parser . setRawValue ( undefined ) ;
126109 } ) ;
127110 }
128111
@@ -179,7 +162,7 @@ export function nativeControlCreate(
179162 input . type === 'radio' && bindingUpdated ( bindings , 'radioValue' , input . value ) ;
180163
181164 if ( controlValueChanged || radioValueChanged ) {
182- setNativeControlValue ( input , controlValue ) ;
165+ writeNativeControlValue ( controlValue ) ;
183166 }
184167
185168 updateMode = true ;
0 commit comments