Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Inputs, Pickers: Only mark Touched and fire FieldChanged on user interaction#13328

Merged
danielchalmers merged 7 commits into
MudBlazor:devfrom
danielchalmers:claude/blissful-mccarthy-574348
Jun 19, 2026
Merged

Inputs, Pickers: Only mark Touched and fire FieldChanged on user interaction#13328
danielchalmers merged 7 commits into
MudBlazor:devfrom
danielchalmers:claude/blissful-mccarthy-574348

Conversation

@danielchalmers

@danielchalmers danielchalmers commented Jun 19, 2026

Copy link
Copy Markdown
Member

Since v9.3.0 (#12892), supplying a non-default initial or async-loaded Value / Text / SelectedValues (or Date / Time / DateRange / color) to a form input marked it — and its surrounding MudForm — touched on load and fired FieldChanged, with no user interaction. Symptoms reported:

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) sets Touched = true whenever the resulting value/text is non-empty, and the @bind-Text path additionally fires the ungated FieldChanged in SetValueAndUpdateTextAsync. 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 on updateValue is wrong — user multiselect selection uses updateValue: false too).

Fix

A synchronous, instance-scoped _suppressInteractionEffects flag on MudFormComponent (where Touched is defined, so both MudBaseInput and MudPicker share it), set via SuppressInteractionEffectsWhileAsync(...) (try/finally) only around parameter-sync entry points — OnInitializedAsync, SetParametersAsync, OnValueParameterChangedAsync, culture/format/converter changes, ForceRender, OnTextParameterChangedAsync, MudSelect's SelectedValues/MultiSelection handlers, and each picker's value-property setter (Date/Time/DateRange) and external value-change handler (color) — and checked at the Touched and FieldChanged write sites.

  • User paths (typing, Select clicks, calendar/clock/selector picks, blur) are untouched and keep marking Touched/firing FieldChanged.
  • For MudSelect.OnSelectedValuesChangedAsync (programmatic only — user selection goes through SelectOption), FieldChanged now fires only on a wasTouched && !IsChildOriginatedChange external change, mirroring MudBaseInput.OnValueParameterChangedAsync — so an async-loaded multiselect no longer fires it.
  • Validation is never suppressed (externally-set values still validate; an untouched field just shows no required error).
  • This is a gate, not a relocation of FieldChanged/BeginValidateAsync out of the shared sinks — so it does not reintroduce the regressions that caused Inputs: Separate user input from parameter sync #13179 to be reverted.

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
@mudbot mudbot Bot added the bug Unexpected behavior or functionality not working as intended label Jun 19, 2026
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.
@danielchalmers danielchalmers changed the title Inputs: Only mark Touched and fire FieldChanged on user interaction Inputs & pickers: Only mark Touched and fire FieldChanged on user interaction Jun 19, 2026
@danielchalmers

danielchalmers commented Jun 19, 2026

Copy link
Copy Markdown
Member Author

Dropped the #12997 (MudTextField.SetTextAsync) change; making SetTextAsync not mark the field touched is a deliberate behavior change (not a regression fix like #13246/#13064), and it changes an existing test's expectations (FieldChangedEventShouldTrigger, which relies on SetTextAsync firing FieldChanged). That deserves its own decision + review rather than riding along with the regression fixes, so I've reverted it here — SetTextAsync once again marks touched / fires FieldChanged as before.

@danielchalmers danielchalmers changed the title Inputs & pickers: Only mark Touched and fire FieldChanged on user interaction Inputs, Pickers: Only mark Touched and fire FieldChanged on user interaction Jun 19, 2026
…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).
@danielchalmers

Copy link
Copy Markdown
Member Author

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 ForceRender ran the suppression wrapper fire-and-forget, 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 — notably a calendar pick, whose commit path has no independent Touched write — would read the leaked flag and silently skip Touched/FieldChanged. Fixed by threading suppression as an explicit argument (SetDateAsync/SetTimeAsync/SetDateRangeAsync(..., suppressInteraction)) so interleaved operations can't observe each other's intent; the leak is eliminated by construction. ForceRender reverts to its pre-PR (un-suppressed) behavior, removing the last fire-and-forget site. Also corrected the (previously inaccurate) comment on the flag and dropped a dead guard.

Pre-existing, NOT introduced by this PR (left out of scope): MudRadioGroup fires FieldChanged on an initial/async-loaded Value; picker DateFormat changes and the ColorPicker alpha-parameter toggle mark touched. All three are byte-identical to dev — the same #13246-class gap in components this PR doesn't touch. Happy to do them as a small follow-up if you'd like the behavior to be fully consistent.

Cleared by the review (no change needed): the suppression flag's save/restore arithmetic; all MudSelect user paths (SelectOption/SelectAll/Clear fire FieldChanged outside the suppressed handler); the wasTouched capture; and #12012 / #13174 / #13002 / #13266 / #13096 are all preserved.

Verification: full unit suite green (5160 passing, 2 pre-existing skips); interactive WASM runtime checks confirm async-loaded values stay untouched and fire no FieldChanged, while a real user calendar pick / text edit touches and fires FieldChanged. (A minimal Blazor Server harness was blocked by an unrelated .NET 10 framework-static-asset serving quirk — blazor.web.js served as 0 bytes — so Server confidence rests on the render-mode-agnostic logic, the unit suite, the WASM runtime, and the structural elimination of the only Server-specific risk.)

@danielchalmers danielchalmers merged commit 0d92cdc into MudBlazor:dev Jun 19, 2026
10 checks passed
@danielchalmers danielchalmers deleted the claude/blissful-mccarthy-574348 branch June 19, 2026 18:10
ArieGato added a commit to ArieGato/MudBlazor that referenced this pull request Jun 20, 2026
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]>
dmitriy-bty added a commit to dmitriy-bty/MudBlazor that referenced this pull request Jul 7, 2026
…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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Unexpected behavior or functionality not working as intended

Projects

None yet

Development

Successfully merging this pull request may close these issues.

some form components fire FieldChanged unnecessarily MudForm IsTouched state is set to True on initial load of MudSelect value

1 participant