Skip to content

Add built-in support for async form validation in Blazor#66526

Open
oroztocil wants to merge 18 commits intomainfrom
oroztocil/validation-async
Open

Add built-in support for async form validation in Blazor#66526
oroztocil wants to merge 18 commits intomainfrom
oroztocil/validation-async

Conversation

@oroztocil
Copy link
Copy Markdown
Member

@oroztocil oroztocil commented Apr 29, 2026

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 as pending/faulted per-field state. The existing sync Validate() 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 EditContext because the event model is synchronous.

After this change:

  • EditForm form-submit validation awaits async validators end-to-end. OnValidSubmit only fires once async validators have settled.
  • Validators can register per-field async tasks via EditContext.AddValidationTask(field, task, cts). The framework tracks them, cancels superseded tasks, and exposes pending/faulted state via IsValidationPending(field) and IsValidationFaulted(field).
  • Components can show "validating..." spinners and "infrastructure error" UI without writing their own bookkeeping.
  • The DataAnnotationsValidator component routes through the new pipeline when a Microsoft.Extensions.Validation IValidatableInfo is 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 event
Loading

Legend: solid arrow = method call; dashed arrow = event subscription.

There are two cooperating workflows:

  1. Form-level validation (e.g., on submit) is driven by 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.
  2. Per-field validation (e.g., as the user edits) is driven by validator components calling 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 invoke OnValidationRequestedAsync handlers, but throws InvalidOperationException if any handler returns an incomplete Task. This keeps the .NET 10 behavior (DataAnnotationsValidator running through Validate()) 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. EditContext gains the OnValidationRequestedAsync event, the ValidateAsync(CancellationToken) method, the AddValidationTask(field, task, cts) registration entry point, and three pairs of pending/faulted query overloads (per-field via FieldIdentifier / expression and parameterless form-level). ValidationRequestedEventArgs gains a CancellationToken constructor and property used to surface caller cancellation to async handlers.

EditContext internals. Two new private fields (_isFormValidationPending, _isFormValidationFaulted) drive the parameterless form-level queries. Each FieldState gains three internal slots (PendingValidationTask, PendingValidationCts, IsValidationFaulted) representing the per-field tracked task. ObserveValidationTaskAsync is a fire-and-forget helper that watches each registered task to its terminal state and updates the slot, with ReferenceEquals guards so a superseded task never stomps a newer slot.

Built-in DataAnnotationsValidator. DataAnnotationsEventSubscriptions now subscribes to both events and routes between sync- and async-only execution based on whether the model has an IValidatableInfo registered via Microsoft.Extensions.Validation. The new ValidationOptions.TryGetValidatablePropertyInfo(Type, string, out ValidatablePropertyInfo) public API on the Microsoft.Extensions.Validation side is used to look up per-property validation metadata when OnFieldChanged fires, 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.HandleSubmitAsync is updated to call ValidateAsync() instead of Validate(); this is the only Web-side change.

Form-level validation: ValidateAsync

sequenceDiagram
    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)
Loading

Behavior details (each handled in code, not shown in diagram for clarity):

  • Per-field supersession. The first step cancels any pending per-field tasks so submit-time validation always wins over in-flight per-field validation.
  • Token surface. When the caller passes a cancellable token, ValidateAsync builds a ValidationRequestedEventArgs carrying it; otherwise it reuses ValidationRequestedEventArgs.Empty. Async handlers should observe args.CancellationToken for 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.
  • Outcome classification after Task.WhenAll:
    • Caller-cancelled (token tripped) → rethrow OperationCanceledException; previous _isFormValidationFaulted preserved.
    • Handler-internal OperationCanceledException (caller's token not cancelled) → contained, not treated as fault.
    • Any task faulted with a non-OperationCanceledExceptionfaultedThisPass = true.
    • Otherwise → success; pass returns !GetValidationMessages().Any().
  • Set-at-completion fault flag. _isFormValidationFaulted is assigned once at the end of the pass, so IsValidationFaulted() does not flicker when a new pass starts.

Per-field validation: AddValidationTask

AddValidationTask is the entry point validators use to register an in-flight async validation for a single field. The contract:

  • Each field has at most one tracked slot.
  • Calling AddValidationTask again cancels the previous slot's CTS before installing the new one.
  • The EditContext takes ownership of the supplied CancellationTokenSource — it is cancelled if a subsequent task supersedes this one, and disposed once the task completes.
  • A faulted task surfaces field-level fault via IsValidationFaulted(field). Cancellation is silent.
  • A task that is already completed at registration time is settled synchronously without parking the slot (a faulted completed task still surfaces the field-level fault).

Field state machine

The per-field tracking in FieldState is driven by three fields:

internal Task? PendingValidationTask { get; set; }
internal CancellationTokenSource? PendingValidationCts { get; set; }
internal bool IsValidationFaulted { get; set; }

There are three observable states for a single field, defined entirely by the slot fields above:

State PendingValidationTask IsValidationFaulted Public queries
Idle null (or completed) false both false
Pending non-null, !IsCompleted false IsValidationPending(field) == true
Faulted null true IsValidationFaulted(field) == true

Transitions:

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 starts
Loading

Edge cases handled in code (not shown in the diagram):

  • Completed task at registration. If the supplied task is already completed, the slot is not parked. A completed faulted task surfaces field-level fault directly (Idle/Pending/Faulted → Faulted); a completed successful or cancelled task is a no-op.
  • Supersession. A new AddValidationTask cancels the prior CTS and replaces the slot. The prior observer will eventually fire but the ReferenceEquals(state.PendingValidationTask, task) guard makes it a no-op so it cannot stomp the new slot.
  • CTS ownership. Each parked task's CTS is always disposed by its own observer when the task settles — never by AddValidationTask for a still-pending task — so a validator can never observe a disposed token.
  • Form-level reset. ValidateAsync calls CancelAllPendingValidationTasks which cancels every per-field CTS and also clears any lingering IsValidationFaulted flag from a prior pass, so each new form-level pass starts from a clean per-field baseline.

Form-level state queries

EditContext also exposes parameterless IsValidationPending() / IsValidationFaulted() queries that are explicitly not unions over field-level state:

Query Meaning
IsValidationPending(field) Per-field pending task is in flight (PendingValidationTask is { IsCompleted: false }).
IsValidationPending() A form-level ValidateAsync pass is currently in flight (_isFormValidationPending).
IsValidationFaulted(field) The field's tracked task settled with a non-cancellation exception.
IsValidationFaulted() The most recent form-level ValidateAsync pass 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 change

Validate() (the existing sync API) is kept and extended rather than marked [Obsolete]. Each call:

  1. Raises OnValidationRequested. Sync exceptions propagate to the caller, matching prior behavior.
  2. If any OnValidationRequestedAsync handler is subscribed, invokes each in turn:
    • If the returned Task is not yet completed, throws InvalidOperationException directing the caller to ValidateAsync().
    • Otherwise calls task.GetAwaiter().GetResult() to surface any synchronous fault.
  3. Returns !GetValidationMessages().Any().

This is the .NET 10 routing pattern between EditForm.Validate and DataAnnotationsValidator extended 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 at ValidateAsync instead.

Built-in DataAnnotationsValidator routing

DataAnnotationsEventSubscriptions (the engine behind <DataAnnotationsValidator />) subscribes to both events but routes between sync- and async-only execution internally based on whether the model has an IValidatableInfo registered via Microsoft.Extensions.Validation:

Model registration OnValidationRequested (sync) OnValidationRequestedAsync (async) Per-field on OnFieldChanged
No IValidatableInfo runs Validator.TryValidateObject no-op Validator.TryValidateProperty (sync)
Has IValidatableInfo no-op runs IValidatableInfo.ValidateAsync(model, ct) ValidateFieldAsync + AddValidationTask

This lets a single <DataAnnotationsValidator /> work correctly whether the consumer calls Validate() (sync app) or ValidateAsync() (async-aware app), and whether the model has async-capable validation metadata or not.

The OnFieldChanged path uses ValidationOptions.TryGetValidatablePropertyInfo(modelType, propertyName, out var info) (new public API on Microsoft.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"]
Loading
  • The caller's token bounds the form-level pass only. If the caller cancels, the awaited handlers receive cancellation through args.CancellationToken and ValidateAsync rethrows OperationCanceledException after running the pending-flag cleanup.
  • Per-field tasks are cancelled by their own CTS, owned by 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 particular ValidateAsync call.

EditForm integration

EditForm.HandleSubmitAsync now calls EditContext.ValidateAsync() instead of Validate(). Forms using OnValidSubmit / OnInvalidSubmit automatically benefit — OnValidSubmit only fires after async validators have settled. Forms using OnSubmit continue to call Validate() or migrate to await 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:

<EditForm Model="@registration" OnValidSubmit="HandleValidSubmit">
    <DataAnnotationsValidator />
    <InputText @bind-Value="registration.Username" />
    <button type="submit">Register</button>
    <ValidationSummary />
</EditForm>

@code {
    private async Task HandleValidSubmit(EditContext context)
    {
        // Only fires after ALL validation (including async) has passed.
        await UserService.CreateAsync(registration);
    }
}

Explicit submit validation. Apps using OnSubmit call ValidateAsync:

<EditForm Model="@registration" OnSubmit="HandleSubmit">
    <DataAnnotationsValidator />
    <!-- ... -->
</EditForm>

@code {
    private async Task HandleSubmit(EditContext context)
    {
        if (await context.ValidateAsync())
        {
            await UserService.CreateAsync(registration);
        }
    }
}

Per-field pending / faulted UI. The expression-based overloads match ValidationMessage syntax:

<InputText @bind-Value="registration.Username" />

@if (editContext.IsValidationPending(() => registration.Username))
{
    <span class="spinner">Checking availability…</span>
}
else if (editContext.IsValidationFaulted(() => registration.Username))
{
    <span class="text-warning">An error occurred while validating. Please try again.</span>
}
else
{
    <ValidationMessage For="() => registration.Username" />
}

<button type="submit" disabled="@editContext.IsValidationPending()">Register</button>

See the the full example of a validation status component.

Third-party validator integration. Library authors subscribe to the async event and use AddValidationTask for field-level tracking:

public class FluentValidationValidator : ComponentBase
{
    [CascadingParameter] EditContext EditContext { get; set; } = default!;

    protected override void OnInitialized()
    {
        EditContext.OnValidationRequestedAsync += async (sender, args) =>
        {
            var validator = GetValidatorForModel(EditContext.Model);
            var result = await validator.ValidateAsync(EditContext.Model, args.CancellationToken);
            // Populate ValidationMessageStore with results
        };

        EditContext.OnFieldChanged += (sender, args) =>
        {
            var cts = new CancellationTokenSource();
            var task = RunFieldValidationAsync(args.FieldIdentifier, cts.Token);
            EditContext.AddValidationTask(args.FieldIdentifier, task, cts);
        };
    }
}

See the the full example of FluentValidation-based form validation.

Testing

EditContextAsyncTest (50 unit tests in src/Components/Forms/test) covers:

  • Form-level workflow: invocation order (sync handlers before async), multicast fan-out via Task.WhenAll, Validate() throwing on incomplete async handlers, ValidateAsync returning false when handlers fault, faulted-pass set-at-completion semantics, _isFormValidationPending lifecycle.
  • Cancellation: caller token rethrows OperationCanceledException, handler-internal OCE is contained, fault state preserved across caller-cancel, token surfaced to handlers via ValidationRequestedEventArgs.CancellationToken, per-field tasks superseded at the start of every form-level pass.
  • Per-field state machine: every transition in the table above, including the completed-task short-circuits, the ReferenceEquals guards in the observer, CTS ownership / disposal, and lingering-fault clear at the start of the next form-level pass.
  • NotifyValidationStateChanged notifications: that a state transition produces exactly one notification per pass / transition, no flicker on faulted set-at-completion.

EditFormAsyncSubmitTest (4 component tests in src/Components/Web/test) covers the EditForm.HandleSubmitAsync integration: OnValidSubmit firing only after async validators settle, OnInvalidSubmit firing 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) on EditContext. Today, re-running per-field validation requires calling NotifyFieldChanged, which has the side effect of flipping IsModified. A dedicated ValidateFieldAsync would let callers re-validate a single field on demand without simulating an edit, returning a Task<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.TryValidatePropertyAsync in the BCL. The async DataAnnotations path for plain models (without AddValidation()) depends on these APIs (dotnet/runtime#121536). Until they ship, the Microsoft.Extensions.Validation path via AddValidation() is required for async validators. Sync [ValidationAttribute]s work unchanged.

@github-actions github-actions Bot added the area-blazor Includes: Blazor, Razor Components label Apr 29, 2026
@oroztocil oroztocil force-pushed the oroztocil/validation-async branch from d2868e2 to 0cd6936 Compare April 29, 2026 13:58
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);
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, I switched to using Task.FromCanceled.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 EditContext with OnValidationRequestedAsync, ValidateAsync, AddValidationTask, and pending/faulted state query APIs.
  • Update EditForm submit flow and FieldCssClassProvider to 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).

Comment thread src/Components/Forms/src/EditContext.cs
Comment thread src/Validation/src/ValidatableTypeInfo.cs Outdated
Comment thread src/Components/Forms/src/EditContextDataAnnotationsExtensions.cs Outdated
Comment thread src/Components/Forms/src/EditContext.cs
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 15 out of 15 changed files in this pull request and generated 4 comments.

Comment thread src/Components/Forms/src/EditContext.cs Outdated
Comment thread src/Components/Forms/src/EditContext.cs Outdated
Comment thread src/Components/Forms/src/EditContext.cs
Comment thread src/Components/Forms/src/EditContextDataAnnotationsExtensions.cs Outdated
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 15 out of 15 changed files in this pull request and generated 4 comments.

Comment thread src/Components/Web/test/Forms/EditFormAsyncSubmitTest.cs Outdated
Comment thread src/Components/Forms/src/EditContext.cs Outdated
Comment thread src/Components/Forms/src/EditContextDataAnnotationsExtensions.cs Outdated
Comment thread src/Components/Forms/src/EditContextDataAnnotationsExtensions.cs
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 17 out of 17 changed files in this pull request and generated 2 comments.

Comment thread src/Components/Forms/src/EditContext.cs
Comment thread src/Validation/src/ValidationOptions.cs
@oroztocil oroztocil marked this pull request as ready for review May 4, 2026 16:53
@oroztocil oroztocil requested a review from a team as a code owner May 4, 2026 16:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-blazor Includes: Blazor, Razor Components

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[API Proposal] Async form validation support in Blazor Blazor form async validation enhancements

3 participants