Add built-in support for async form validation in Blazor#66526
Open
Add built-in support for async form validation in Blazor#66526
Conversation
…OnFieldChange code path
d2868e2 to
0cd6936
Compare
Youssef1313
reviewed
Apr 29, 2026
Comment on lines
+359
to
+364
| catch (Exception ex) | ||
| { | ||
| // Sync throw before the handler's first await - normalize to a faulted Task | ||
| // so all handlers are observed uniformly via Task.WhenAll. | ||
| tasks[i] = Task.FromException(ex); | ||
| } |
Member
There was a problem hiding this comment.
I see the PR is draft and WIP, but leaving the question so I don't forget it later.
Should this catch OperationCanceledException and use Task.FromCanceled instead?
Member
Author
There was a problem hiding this comment.
Good point, I switched to using Task.FromCanceled.
Open
3 tasks
Contributor
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
This PR adds async form validation support to Blazor's
EditContext. Validator components can now subscribe to an 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 but is extended to invoke async handlers as well so async validators can never be silently skipped.Fixes #7680
Fixes #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).DataAnnotationsValidatorcomponent routes through the new 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 three 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.DataAnnotationsEventSubscriptionsnow subscribes to both events and routes between sync- and async-only execution based on whether the model has anIValidatableInforegistered viaMicrosoft.Extensions.Validation. The newValidationOptions.TryGetValidatablePropertyInfo(Type, string, out ValidatablePropertyInfo)public API on theMicrosoft.Extensions.Validationside is 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.EditForm.HandleSubmitAsyncis updated to callValidateAsync()instead ofValidate(); this is the only Web-side change.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:There are three observable states for a single field, defined entirely by the slot fields above:
PendingValidationTaskIsValidationFaultednull(or completed)falsefalse!IsCompletedfalseIsValidationPending(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 { IsCompleted: false }).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 to both events but routes between sync- and async-only execution internally based on whether the model has anIValidatableInforegistered viaMicrosoft.Extensions.Validation:OnValidationRequested(sync)OnValidationRequestedAsync(async)OnFieldChangedIValidatableInfoValidator.TryValidateObjectValidator.TryValidateProperty(sync)IValidatableInfoIValidatableInfo.ValidateAsync(model, ct)ValidateFieldAsync+AddValidationTaskThis lets a single
<DataAnnotationsValidator />work correctly whether the consumer callsValidate()(sync app) orValidateAsync()(async-aware app), and whether the model has async-capable validation metadata or not.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
See #66381.
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 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 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<bool>. 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.