Inputs, Pickers: Only mark Touched and fire FieldChanged on user interaction#13328
Conversation
Since v9.3.0 (MudBlazor#12892), supplying a non-default initial or async-loaded Value/Text/SelectedValues to a form input marked it - and its MudForm - touched on load and fired FieldChanged, with no user interaction. The shared value->text sync (SetTextAndUpdateValueAsync, OnTextParameterChangedAsync, MudSelect.SetCustomizedTextAsync) sets Touched, and the @bind-Text path additionally fires the ungated FieldChanged in SetValueAndUpdateTextAsync. Both are reached by programmatic/parameter-driven syncs as well as genuine user input, so they could not self-discriminate. Add a synchronous, instance-scoped _suppressInteractionEffects flag, set via SuppressInteractionEffectsWhileAsync only around parameter-sync entry points (OnInitialized, SetParameters, OnValueParameterChanged, culture/format/converter changes, ForceRender, OnTextParameterChanged, SetTextAsync, and MudSelect's SelectedValues/MultiSelection handlers) and checked at the Touched and FieldChanged write sites. User paths (typing, Select clicks, blur) keep touching and notifying, and validation is never suppressed - so this is a gate, not a relocation of the form plumbing. Adds regression tests for non-default initial values, async-loaded values, programmatic SetTextAsync, and a guard that genuine user edits still touch and fire FieldChanged. Fixes MudBlazor#13246 Fixes MudBlazor#13064 Fixes MudBlazor#12997
Extends the fix to MudDatePicker, MudTimePicker, MudDateRangePicker and MudColorPicker, which exhibit the identical defect: a non-default initial or async-loaded value (or an external Value change for the color picker) marked the picker touched and fired FieldChanged on load with no user interaction. The pickers descend from MudPicker/MudFormComponent rather than MudBaseInput, so the _suppressInteractionEffects flag and SuppressInteractionEffectsWhileAsync helper move up to MudFormComponent (where Touched is defined) and are shared by both hierarchies. Each picker wraps its value-property setter (Date/Time/DateRange, and the color picker's external-value change handler) with the flag and gates the Touched/FieldChanged writes in the shared sync method, so user calendar/clock/text/selector interaction keeps touching and notifying. Adds picker regression tests (initial value, async-loaded value including an external MudColorPicker Value change, and a guard that user text input still touches). Re-baselines FormIsUnTouchedWhenNestedFormTouched, which previously relied on a date picker being erroneously touched on initial load.
…anges OnSelectedValuesChangedAsync handles only programmatic SelectedValues parameter changes (user selection goes through SelectOption). It was firing FieldChanged(_selectedValues) for any change after the first render, so an async-loaded SelectedValues fired FieldChanged without user interaction; for MudSelect<string> multiselect, the typeof(T)==string branch also ran SetValueAndUpdateTextAsync outside the suppression block, firing FieldChanged even on initial load. Wrap the whole handler in SuppressInteractionEffectsWhileAsync and gate the explicit FieldChanged on `wasTouched && !IsChildOriginatedChange` (mirroring MudBaseInput.OnValueParameterChangedAsync), so an initial or async-loaded selection on an untouched select no longer fires FieldChanged while an external change to an already-touched select still notifies the form. Extends the repro component with a MultiSelection @bind-SelectedValues case so the existing initial-load and async-load tests cover this path.
Reverts the MudTextField.SetTextAsync suppression: SetTextAsync once again marks the field touched and fires FieldChanged. MudBlazor#12997 is a deliberate behavior change (programmatic SetTextAsync should not touch) that also changes an existing test's expectations, so it should be decided and reviewed on its own rather than riding along with the MudBlazor#13246/MudBlazor#13064 regression fixes. Restores FieldChangedEventShouldTrigger to use SetTextAsync, removes the SetTextAsyncDoesNotTouchOrFireFieldChanged test, and drops the MudBlazor#12997 reference from the suppression-flag comment. The flag/helper itself stays - it is what the regression and picker fixes use.
|
Dropped the #12997 ( |
…anged MudTimePicker.SubmitAsync committed the picked time via `Time = TimeIntermediate`, i.e. through the Time parameter setter — which this PR wrapped in SuppressInteractionEffectsWhileAsync so a parameter-driven value doesn't touch the picker. As a result a genuine user submit (keyboard Enter/Space, auto-submit) updated the value while suppressing Touched and MudForm.FieldChanged. Call SetTimeAsync(TimeIntermediate, true) directly instead, bypassing the suppressed setter — matching MudDatePicker.SubmitAsync and MudDateRangePicker.SubmitAsync, which already call SetDateAsync/SetDateRangeAsync directly. (MudColorPicker is unaffected: it has no submit path and user edits call SetColorAsync directly, never the suppressed Value parameter handler.) Adds a regression test that opens the picker, changes the time via keyboard, and asserts the submit marks the picker Touched and fires FieldChanged.
The suppression mechanism was over-commented: a variant of "this is a programmatic sync, don't touch (MudBlazor#13246, MudBlazor#13064)" was repeated at nearly every wrap and gate site, duplicating what the _suppressInteractionEffects flag and SuppressInteractionEffectsWhileAsync helper names already convey, and scattering issue numbers that would rot. Keep one canonical explanation on the flag declaration (the single place issue refs live) plus a concise helper summary, and keep the two genuinely non-obvious comments that prevent reintroducing a bug: MudTimePicker.SubmitAsync committing directly instead of through the suppressed setter, and MudSelect's wasTouched FieldChanged gate. Remove the rest. No behavior change.
… ambient flag An adversarial review found a Blazor Server re-entrancy hole: the date/time picker value-property setters and MudBaseInput.ForceRender ran the suppression wrapper fire-and-forget (SuppressInteractionEffectsWhileAsync(...).CatchAndLog()), so the instance _suppressInteractionEffects flag stayed set across the awaits inside the sync (DateChanged.InvokeAsync, async BeginValidateAsync). On a Server circuit a concurrent user interaction landing in that window - e.g. a calendar pick, whose commit path has no independent Touched write - would read the leaked flag and silently skip Touched + FieldChanged. Thread the suppression explicitly: SetDateAsync/SetTimeAsync/SetDateRangeAsync take a suppressInteraction parameter that the value-property setters pass as true, while user-commit paths (calendar/clock pick, text input, Submit) use the default false. Two interleaved operations on the same instance can no longer observe each other's intent, so the leak is eliminated by construction. ForceRender reverts to its pre-PR behavior (no suppression), removing the last fire-and-forget site. Also corrects the now-accurate comment on _suppressInteractionEffects (it is only toggled around awaited parameter-change handlers) and drops a dead guard in OnTextParameterChangedAsync. The flag now remains only on awaited paths. Verified: full unit suite (5160 passing) plus interactive WASM runtime checks (async-loaded values stay untouched / fire no FieldChanged; user calendar pick touches and fires FieldChanged).
|
Ran a fresh multi-perspective adversarial review of the whole diff plus full + runtime testing. It surfaced one genuine new defect, now fixed: Fire-and-forget suppression leak (Blazor Server re-entrancy). The date/time picker value setters and Pre-existing, NOT introduced by this PR (left out of scope): Cleared by the review (no change needed): the suppression flag's save/restore arithmetic; all MudSelect user paths ( Verification: full unit suite green (5160 passing, 2 pre-existing skips); interactive WASM runtime checks confirm async-loaded values stay untouched and fire no |
Resolved conflicts in the generic DatePicker work against dev's recent Inputs/Pickers/TimeProvider changes: - MudDatePicker.SetDateAsync / MudDateRangePicker.SetDateRangeAsync: keep the generic T?/DateRange<T>? signatures, add dev's suppressInteraction parameter (MudBlazor#13328 Touched/FieldChanged-on-user-interaction). - MudBaseDatePicker year/calendar helpers: keep generic ToDateTime/ ToDateTimeLimit, adopt dev's TimeProvider.GetLocalNow() over DateTime.Today. - MudDateRangePicker GetMonthStart/GetCalendarYear: keep generic versions (already TimeProvider-based). - CurrentDate_ShouldBeMarked: take dev's deterministic FakeTimeProvider rewrite, keep generic DateRange<DateTime?> assertion. - Generify FormInitialPickerValuesNotTouchedTest and FormTests picker lookups to MudDatePicker<DateTime?>/MudDateRangePicker<DateTime?>. - Migrate DatePicker/DateRangePicker offset tests to Context.AddFakeTimeProvider() (bunit upgrade dropped Services.AddSingleton). - Drop dev's Other/DateRangeTests.cs; branch's generic Components/DateRangeTests.cs supersedes it. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…et from a parent (MudBlazor#10834) Follow-up to MudBlazor#13428. MudDatePicker.Date and MudTimePicker.Time have the same feedback-loop bug that MudBlazor#13428 fixed for MudDateRangePicker.DateRange: their setters call SetDateAsync / SetTimeAsync, which always invoked DateChanged.InvokeAsync / TimeChanged.InvokeAsync. Because Blazor drives a parent/programmatic parameter assignment through the setter, the change event fired even when the value came from the parent - echoing back an event the parent never triggered. The setters already pass suppressInteraction: true as an explicit argument (NOT an instance flag) so it cannot leak across the awaits into a concurrent user pick on Blazor Server (PR MudBlazor#13328). This adds a second explicit argument in the same spirit: - Set{Date,Time}Async gains `bool notify = true`. The change event is now raised only when notify is true. - The Date / Time setters pass notify: false, so a parent/programmatic assignment updates the display (value, text, highlighted date / intermediate time) WITHOUT raising the change event. - Every other caller (user calendar/clock selection, clear, text edit, submit) keeps the default notify: true, preserving the two-way-binding write-back and all existing user-facing behavior. The public API is unchanged. Because the display update still runs unconditionally in the setter, a parent assignment (including a null assignment that clears stale invalid text) is fully reflected - so DataPicker_ShouldClearText_WhenDateSetNull and the other existing parameter-assignment tests keep passing. Tests: add DateChanged_ShouldNotFire_WhenDateSetFromParent, TimeChanged_ShouldNotFire_WhenTimeSetFromParent, and TimeChanged_ShouldFire_WhenUserPicksTime. Update IsDateDisabledFunc_SettingDateToAnEnabledDateYieldsTheDate to assert the new contract (a parent assignment sets the value but does not fire DateChanged).
Since v9.3.0 (#12892), supplying a non-default initial or async-loaded
Value/Text/SelectedValues(orDate/Time/DateRange/ color) to a form input marked it — and its surroundingMudForm— touched on load and firedFieldChanged, with no user interaction. Symptoms reported:MudForm.IsTouchedistrueon initial load when aMudSelect/MudAutocompletegets an initial or async-loaded value (MudForm IsTouched state is set to True on initial load of MudSelect value #13064) — breaks the common "enable Save/Cancel only once the user changed something" pattern.MudNumericField(non-zero),MudSelect(enum), andMudTextField(bound toText) fireFieldChangedon initial render so you can't tell whether the field actually changed (some form components fire FieldChanged unnecessarily #13246).References
The pickers (
MudDatePicker/MudTimePicker/MudDateRangePicker/MudColorPicker) exhibit the identical defect via their value parameters, so they're fixed here too.Cause
The shared value→text sync (
SetTextAndUpdateValueAsync,OnTextParameterChangedAsync,MudSelect.SetCustomizedTextAsync, and the pickers'SetDateAsync/SetTimeAsync/SetDateRangeAsync/SetColorAsync) setsTouched = truewhenever the resulting value/text is non-empty, and the@bind-Textpath additionally fires the ungatedFieldChangedinSetValueAndUpdateTextAsync. These sinks are reached by both programmatic/parameter-driven syncs and genuine user input, so they can't self-discriminate (which is also why gating onupdateValueis wrong — user multiselect selection usesupdateValue: falsetoo).Fix
A synchronous, instance-scoped
_suppressInteractionEffectsflag onMudFormComponent(whereTouchedis defined, so bothMudBaseInputandMudPickershare it), set viaSuppressInteractionEffectsWhileAsync(...)(try/finally) only around parameter-sync entry points —OnInitializedAsync,SetParametersAsync,OnValueParameterChangedAsync, culture/format/converter changes,ForceRender,OnTextParameterChangedAsync,MudSelect'sSelectedValues/MultiSelectionhandlers, and each picker's value-property setter (Date/Time/DateRange) and external value-change handler (color) — and checked at theTouchedandFieldChangedwrite sites.Touched/firingFieldChanged.MudSelect.OnSelectedValuesChangedAsync(programmatic only — user selection goes throughSelectOption),FieldChangednow fires only on awasTouched && !IsChildOriginatedChangeexternal change, mirroringMudBaseInput.OnValueParameterChangedAsync— so an async-loaded multiselect no longer fires it.FieldChanged/BeginValidateAsyncout of the shared sinks — so it does not reintroduce the regressions that caused Inputs: Separate user input from parameter sync #13179 to be reverted.