Add built-in support for async form validation in Blazor - #66526
#66526Add built-in support for async form validation in Blazor#66526oroztocil merged 22 commits intomaindotnet/aspnetcore:mainfrom oroztocil/validation-asyncdotnet/aspnetcore:oroztocil/validation-asyncCopy head branch name to clipboard
Conversation
…OnFieldChange code path
d2868e2 to
0cd6936
Compare
There was a problem hiding this comment.
Pull request overview
This PR adds first-class async validation support to Blazor forms by extending EditContext with an async validation event/method pair, per-field async task tracking (pending/faulted), and updated integration points so EditForm submit awaits async validators end-to-end.
Changes:
- Extend
EditContextwithOnValidationRequestedAsync,ValidateAsync,AddValidationTask, and pending/faulted state query APIs. - Update
EditFormsubmit flow andFieldCssClassProviderto reflect async validation state (pending/faulted). - Add/adjust DataAnnotations validation routing and introduce
ValidatableTypeInfo.GetProperty(string)plus comprehensive unit/component tests.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| src/Components/Forms/src/EditContext.cs | Implements async validation pipeline, per-field task tracking, and pending/faulted state queries. |
| src/Components/Forms/src/FieldState.cs | Adds internal slots for per-field async task/CTS tracking and fault state. |
| src/Components/Forms/src/ValidationRequestedEventArgs.cs | Adds cancellation-token-bearing event args for async validation passes. |
| src/Components/Forms/src/EditContextDataAnnotationsExtensions.cs | Routes DataAnnotations validation through sync vs async paths and uses per-field async validation tracking when available. |
| src/Components/Forms/src/PublicAPI.Unshipped.txt | Declares new public surface area for Forms async validation APIs. |
| src/Components/Web/src/Forms/EditForm.cs | Switches implicit submit validation to await EditContext.ValidateAsync(). |
| src/Components/Web/src/Forms/FieldCssClassProvider.cs | Emits pending/faulted CSS classes (optionally modified) based on async validation state. |
| src/Components/Web/test/Forms/EditFormAsyncSubmitTest.cs | Component tests covering async submit behavior and canceling in-flight field tasks. |
| src/Components/Web/test/Forms/FieldCssClassProviderTest.cs | Tests new pending/faulted CSS class behavior and precedence rules. |
| src/Components/Web/test/Forms/Helpers/TestAsyncValidatorComponent.cs | Test-only validator component to drive async validation behaviors in component tests. |
| src/Components/Forms/test/EditContextAsyncTest.cs | Unit tests for async validation semantics, cancellation, and per-field state machine behavior. |
| src/Components/Forms/test/Helpers/TestAsyncValidator.cs | Test helper to drive async validator behavior and per-field task registration in unit tests. |
| src/Validation/src/ValidatableTypeInfo.cs | Adds ValidatableTypeInfo.GetProperty(string) for per-property validation metadata lookup. |
| src/Validation/src/PublicAPI.Unshipped.txt | Declares the new Validation public API for ValidatableTypeInfo.GetProperty(string). |
eecaf94 to
2ec6606
Compare
javiercn
left a comment
There was a problem hiding this comment.
Looks good, we need to do cleanup on the tests and some other aspects, but we can do that in P6.
| var delegates = asyncHandler.GetInvocationList(); | ||
| var tasks = new Task[delegates.Length]; | ||
|
|
||
| for (var i = 0; i < delegates.Length; i++) |
There was a problem hiding this comment.
The event approach for the async validation with the list approach really feels hacky. We've used events in the past with delegates when we've expected one caller for the most part. I think we are better of handling this within the event handler and forcing people to register their tasks. Then this method only does the setup and Validate() can call ValidateAsync() and throw if the task isn't completed. If we want to, we can have the same shortcircuiting logic within RegisterPendingValidationTask to throw there when we are being called from bool Validate()
| public void AddValidationTask(in FieldIdentifier fieldIdentifier, Task task, CancellationTokenSource cts) | ||
| { | ||
| ArgumentNullException.ThrowIfNull(task); | ||
| ArgumentNullException.ThrowIfNull(cts); | ||
|
|
||
| var state = GetOrAddFieldState(fieldIdentifier); | ||
|
|
||
| // Cancel any previous pending task for this field. Its observer disposes its own CTS | ||
| // when the validator task settles, so we must not dispose it here while it may still be in flight. | ||
| state.PendingValidationCts?.Cancel(); | ||
|
|
||
| if (task.IsCompleted) | ||
| { | ||
| // Clear the slot so a stale prior task does not keep the field reported as pending, | ||
| // and so a still-pending prior task cannot mask the new completed result. The prior | ||
| // observer's ReferenceEquals guard ensures it cannot stomp this state when it later runs. | ||
| var hadPriorPending = state.PendingValidationTask is { IsCompleted: false }; | ||
| state.PendingValidationTask = null; | ||
| state.PendingValidationCts = null; | ||
|
|
||
| // Settle synchronously without parking the slot. Mirror the slow path: a successful | ||
| // or cancelled task clears any prior fault flag; a faulted task sets it. | ||
| if (task.IsFaulted) | ||
| { | ||
| _ = task.Exception; // observe to suppress UnobservedTaskException | ||
| } | ||
| var faultFlagChanged = task.IsFaulted != state.IsValidationFaulted; | ||
| state.IsValidationFaulted = task.IsFaulted; | ||
|
|
||
| if (hadPriorPending || faultFlagChanged) | ||
| { | ||
| NotifyValidationStateChanged(); | ||
| } | ||
|
|
||
| cts.Dispose(); | ||
| return; | ||
| } | ||
|
|
||
| state.PendingValidationTask = task; | ||
| state.PendingValidationCts = cts; | ||
| state.IsValidationFaulted = false; | ||
|
|
||
| NotifyValidationStateChanged(); | ||
|
|
||
| // Observe the task's completion to update state and dispose the CTS. | ||
| _ = ObserveValidationTaskAsync(state, task, cts); |
There was a problem hiding this comment.
Why can't everything go through ObserveValidationTaskAsync? You can do any synchronous work before awaiting the task and you can capture the pendingvalidationtask within ObserveValidationTask to perform any check you need.
Calling await will unwrap the task and run the continuation synchronously. Separating the code in this way makes it much harder to understand the state transitions
| catch (OperationCanceledException) | ||
| { | ||
| // Cancellation is silent - field was re-edited or form submitted | ||
| } |
There was a problem hiding this comment.
This isn't the only source of this exception, isn't it? What happens when an underlying check times out? (Say my DbQuery throws a TCS)
Description
This PR adds async form validation support to Blazor's
EditContext. Validator components can subscribe to a new async event, run async validators (database lookups, remote API calls, etc.), and have their work tracked, cancelled, and surfaced to the UI aspending/faultedper-field state. The existing syncValidate()continues to work and is extended to invoke async handlers as well, so async validators can never be silently skipped.Fixes #7680
API proposal: #66381
What this enables
Today, a form developer who needs async validation has to block on async work (deadlock-prone, broken on WebAssembly), or run validation outside the form pipeline and reconcile the result by hand. Libraries with first-class async support (FluentValidation) cannot integrate with
EditContextbecause the event model is synchronous.After this change:
EditFormform-submit validation awaits async validators end-to-end.OnValidSubmitonly fires once async validators have settled.EditContext.AddValidationTask(field, task, cts). The framework tracks them, cancels superseded tasks, and exposes pending/faulted state viaIsValidationPending(field)andIsValidationFaulted(field).InputBaseautomatically emits"pending"or"faulted"CSS classes viaFieldCssClassProviderso plain CSS can react to async state.DataAnnotationsValidatorcomponent routes through the new async pipeline when aMicrosoft.Extensions.ValidationIValidatableInfois registered for the model, so async[ValidationAttribute]s flow through automatically.How it works
flowchart LR EditForm["EditForm.HandleSubmitAsync"] ValAsync["EditContext.ValidateAsync"] SyncEvt(["OnValidationRequested<br/>(sync event)"]) AsyncEvt(["OnValidationRequestedAsync<br/>(async event)"]) AddTask["EditContext.AddValidationTask<br/>(per-field tracking)"] Validators["Validator components<br/>(DataAnnotationsValidator,<br/>FluentValidation, etc.)"] EditForm -->|calls| ValAsync ValAsync -->|raises| SyncEvt ValAsync -->|raises and awaits| AsyncEvt Validators -.->|subscribes| SyncEvt Validators -.->|subscribes| AsyncEvt Validators -->|calls on field change| AddTask classDef event fill:#fff3cd,stroke:#aa8800 class SyncEvt,AsyncEvt eventLegend: solid arrow = method call; dashed arrow = event subscription.
There are two cooperating workflows:
EditContext.ValidateAsync(CancellationToken). It cancels any in-flight per-field tasks, fires the sync event, then fans out and awaits all async event handlers concurrently.EditContext.AddValidationTask(field, task, cts). Each field gets a single tracked slot; new tasks supersede older ones via cancellation.Validate()(the existing sync API) is not marked[Obsolete]. It is extended to also invokeOnValidationRequestedAsynchandlers, but throwsInvalidOperationExceptionif any handler returns an incompleteTask. This keeps the .NET 10 behavior (DataAnnotationsValidatorrunning throughValidate()) intact for fully-sync validators while preventing silent skip of async ones.Solution details
The change touches four areas:
Public surface in
Microsoft.AspNetCore.Components.Forms.EditContextgains theOnValidationRequestedAsyncevent, theValidateAsync(CancellationToken)method, theAddValidationTask(field, task, cts)registration entry point, and three pairs of pending/faulted query overloads (per-field viaFieldIdentifier/ expression and parameterless form-level).ValidationRequestedEventArgsgains aCancellationTokenconstructor and property used to surface caller cancellation to async handlers.EditContextinternals. Two new private fields (_isFormValidationPending,_isFormValidationFaulted) drive the parameterless form-level queries. EachFieldStategains three internal slots (PendingValidationTask,PendingValidationCts,IsValidationFaulted) representing the per-field tracked task.ObserveValidationTaskAsyncis a fire-and-forget helper that watches each registered task to its terminal state and updates the slot, withReferenceEqualsguards so a superseded task never stomps a newer slot.Built-in
DataAnnotationsValidator.DataAnnotationsEventSubscriptionssubscribes toOnFieldChangedandOnValidationRequestedAsync. Inside the async handler it routes between sync- and async-only execution based on whether the model has anIValidatableInforegistered viaMicrosoft.Extensions.Validation. Because the sync routing path completes synchronously inside the async handler, callingValidate()continues to work for models without MEV-registered async validators. The new[Experimental("ASP0029")] ValidationOptions.TryGetValidatablePropertyInfo(Type, string, out IValidatableInfo?)public API onMicrosoft.Extensions.Validationis used to look up per-property validation metadata whenOnFieldChangedfires, so async per-field validation can run without re-validating the whole object graph. The lookup walks base types so inherited properties are resolved correctly.Web-side updates (
Microsoft.AspNetCore.Components.Web).EditForm.HandleSubmitAsynccallsValidateAsync()instead ofValidate().FieldCssClassProvider.GetFieldCssClassis extended to emit"pending"(whileEditContext.IsValidationPending(field)istrue) or"faulted"(whileEditContext.IsValidationFaulted(field)istrue), both with the existing"modified "prefix when applicable. These CSS classes supersede"valid"/"invalid"since neither outcome is known while pending or after a fault.Form-level validation:
ValidateAsyncsequenceDiagram participant Caller participant EC as EditContext participant Handlers as Async handlers Caller->>EC: ValidateAsync(ct) EC->>EC: cancel pending per-field tasks<br/>set _isFormValidationPending = true EC->>EC: raise OnValidationRequested (sync) EC->>Handlers: raise OnValidationRequestedAsync Handlers-->>EC: Task.WhenAll EC->>EC: classify outcome<br/>set _isFormValidationFaulted<br/>clear _isFormValidationPending EC-->>Caller: bool (or rethrow OCE)Behavior details (each handled in code, not shown in diagram for clarity):
ValidateAsyncbuilds aValidationRequestedEventArgscarrying it; otherwise it reusesValidationRequestedEventArgs.Empty. Async handlers should observeargs.CancellationTokenfor downstream I/O. The token bounds the in-flight pass only -- per-field tasks started independently during the awaited window are not linked to it.Task.WhenAll:OperationCanceledException; previous_isFormValidationFaultedpreserved.OperationCanceledException(caller's token not cancelled) → contained, not treated as fault.OperationCanceledException→faultedThisPass = true.!GetValidationMessages().Any()._isFormValidationFaultedis assigned once at the end of the pass, soIsValidationFaulted()does not flicker when a new pass starts.Per-field validation:
AddValidationTaskAddValidationTaskis the entry point validators use to register an in-flight async validation for a single field. The contract:AddValidationTaskagain cancels the previous slot's CTS before installing the new one.EditContexttakes ownership of the suppliedCancellationTokenSource-- it is cancelled if a subsequent task supersedes this one, and disposed once the task completes.IsValidationFaulted(field). Cancellation is silent.Field state machine
The per-field tracking in
FieldStateis driven by three fields:The framework's observer clears
PendingValidationTaskonce the task settles, so a field is in one of three observable states:PendingValidationTaskIsValidationFaultednullfalsefalsefalseIsValidationPending(field) == truenulltrueIsValidationFaulted(field) == trueTransitions:
stateDiagram-v2 [*] --> Idle Idle --> Pending: AddValidationTask Pending --> Idle: task succeeded or cancelled Pending --> Faulted: task threw (non-OCE) Faulted --> Pending: AddValidationTask Pending --> Pending: AddValidationTask<br/>(supersede) Faulted --> Idle: form-level pass starts Pending --> Idle: form-level pass startsEdge cases handled in code (not shown in the diagram):
AddValidationTaskcancels the prior CTS and replaces the slot. The prior observer will eventually fire but theReferenceEquals(state.PendingValidationTask, task)guard makes it a no-op so it cannot stomp the new slot.AddValidationTaskfor a still-pending task -- so a validator can never observe a disposed token.ValidateAsynccallsCancelAllPendingValidationTaskswhich cancels every per-field CTS and also clears any lingeringIsValidationFaultedflag from a prior pass, so each new form-level pass starts from a clean per-field baseline.Form-level state queries
EditContextalso exposes parameterlessIsValidationPending()/IsValidationFaulted()queries that are explicitly not unions over field-level state:IsValidationPending(field)PendingValidationTask is not null).IsValidationPending()ValidateAsyncpass is currently in flight (_isFormValidationPending).IsValidationFaulted(field)IsValidationFaulted()ValidateAsyncpass observed an unhandled handler exception. Set at completion; preserved across caller-cancelled passes.Splitting per-field and form-level state means apps can build a "submit button disabled while submit-validation runs" indicator without it flickering on every keystroke that triggers a per-field task, and vice versa.
Validate()behavior changeValidate()(the existing sync API) is kept and extended rather than marked[Obsolete]. Each call:OnValidationRequested. Sync exceptions propagate to the caller, matching prior behavior.OnValidationRequestedAsynchandler is subscribed, invokes each in turn:Taskis not yet completed, throwsInvalidOperationExceptiondirecting the caller toValidateAsync().task.GetAwaiter().GetResult()to surface any synchronous fault.!GetValidationMessages().Any().This is the .NET 10 routing pattern between
EditForm.ValidateandDataAnnotationsValidatorextended to all async handlers. Sync-only validation paths keep working as long as no async handler is registered (which was previously not supported). The advantage over[Obsolete]is that callers retaining the sync API cannot silently lose async validation results -- they get a loud runtime exception pointing atValidateAsyncinstead.Built-in
DataAnnotationsValidatorroutingDataAnnotationsEventSubscriptions(the engine behind<DataAnnotationsValidator>) subscribes toOnFieldChangedandOnValidationRequestedAsynconly. The async handler routes internally based on whether the model has anIValidatableInforegistered viaMicrosoft.Extensions.Validation:OnValidationRequestedAsync(form-level)OnFieldChanged(per-field)IValidatableInfoValidator.TryValidateObjectsynchronously; returnedTaskis already completedValidator.TryValidateProperty(sync)IValidatableInfoawait IValidatableInfo.ValidateAsync(model, ct)IValidatableInfo.ValidateAsyncand registers it viaAddValidationTaskBecause the no-
IValidatableInfoform-level path completes synchronously inside the async handler, callingEditContext.Validate()continues to work for sync-only models. Models with MEV async validators requireValidateAsync();Validate()throwsInvalidOperationExceptionin that case.The
OnFieldChangedpath usesValidationOptions.TryGetValidatablePropertyInfo(modelType, propertyName, out var info)(new public API onMicrosoft.Extensions.Validation) to look up per-property metadata without re-validating the whole object graph. The lookup walks base types so inherited properties are resolved correctly.Cancellation propagation
flowchart LR Caller["caller token"] -->|"ValidateAsync(ct)"| EC["EditContext"] EC -->|"args.CancellationToken"| Handler["async handler"] Handler -->|"linked / pass-through"| IO["DB / HTTP / etc."] EC -.->|"per-field CTS<br/>(separate lifetime)"| FieldState["FieldState"] Edit["next user edit"] -->|"AddValidationTask"| FieldState FieldState -->|"cancel prior cts"| OldTask["prior validator task"]args.CancellationTokenandValidateAsyncrethrowsOperationCanceledExceptionafter running the pending-flag cleanup.EditContext. They are independent of the caller's token. This keeps per-field validation lifecycles tied to user edits / form submission, not to the lifetime of any particularValidateAsynccall.EditFormintegrationEditForm.HandleSubmitAsyncnow callsEditContext.ValidateAsync()instead ofValidate(). Forms usingOnValidSubmit/OnInvalidSubmitautomatically benefit --OnValidSubmitonly fires after async validators have settled. Forms usingOnSubmitcontinue to callValidate()or migrate toawait context.ValidateAsync()at the developer's discretion.New public API surface
Microsoft.AspNetCore.Components.FormsMicrosoft.Extensions.ValidationMicrosoft.AspNetCore.Components.WebNo new types.
FieldCssClassProvider.GetFieldCssClassreturns the additional values"pending","modified pending","faulted", and"modified faulted"(no public API change, but observable from CSS). Custom subclasses can override this to map the new states to framework-specific class names.Usage
Implicit submit validation. No code change required for forms that already use
OnValidSubmit:Explicit submit validation. Apps using
OnSubmitcallValidateAsync:Per-field pending / faulted UI. The expression-based overloads match
ValidationMessagesyntax:See the full example of a validation status component.
Third-party validator integration. Library authors subscribe to the async event and use
AddValidationTaskfor field-level tracking:See the full example of FluentValidation-based form validation.
Testing
EditContextAsyncTest(50 unit tests insrc/Components/Forms/test) covers:Task.WhenAll,Validate()throwing on incomplete async handlers,ValidateAsyncreturning false when handlers fault, faulted-pass set-at-completion semantics,_isFormValidationPendinglifecycle.OperationCanceledException, handler-internalOCEis contained, fault state preserved across caller-cancel, token surfaced to handlers viaValidationRequestedEventArgs.CancellationToken, per-field tasks superseded at the start of every form-level pass.ReferenceEqualsguards in the observer, CTS ownership / disposal, and lingering-fault clear at the start of the next form-level pass.NotifyValidationStateChangednotifications: that a state transition produces exactly one notification per pass / transition, no flicker on faulted set-at-completion.EditFormAsyncSubmitTest(4 component tests insrc/Components/Web/test) covers theEditForm.HandleSubmitAsyncintegration:OnValidSubmitfiring only after async validators settle,OnInvalidSubmitfiring when an async validator reports an error, and that submitting cancels in-flight per-field tasks before running submit validation.Out of scope / follow-ups
ValidateFieldAsync(FieldIdentifier)onEditContext. Today, re-running per-field validation requires callingNotifyFieldChanged, which has the side effect of flippingIsModified. A dedicatedValidateFieldAsyncwould let callers re-validate a single field on demand without simulating an edit, returning aTask. We chose not to ship this in the initial PR; a follow-up can add it once we've validated the broader API surface.Validator.TryValidateObjectAsync/Validator.TryValidatePropertyAsyncin the BCL. The async DataAnnotations path for plain models (withoutAddValidation()) depends on these APIs (dotnet/runtime#121536). Until they ship, theMicrosoft.Extensions.Validationpath viaAddValidation()is required for async validators. Sync[ValidationAttribute]s work unchanged.