Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

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

#66526
Merged
oroztocil merged 22 commits into
maindotnet/aspnetcore:mainfrom
oroztocil/validation-asyncdotnet/aspnetcore:oroztocil/validation-asyncCopy head branch name to clipboard
May 17, 2026
Merged

Add built-in support for async form validation in Blazor#66526
oroztocil merged 22 commits into
maindotnet/aspnetcore:mainfrom
oroztocil/validation-asyncdotnet/aspnetcore:oroztocil/validation-asyncCopy head branch name to clipboard

Conversation

@oroztocil

@oroztocil oroztocil commented Apr 29, 2026

Copy link
Copy Markdown
Member

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 as pending/faulted per-field state. The existing sync Validate() 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 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.
  • InputBase automatically emits "pending" or "faulted" CSS classes via FieldCssClassProvider so plain CSS can react to async state.
  • The DataAnnotationsValidator component routes through the new async 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 four 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 subscribes to OnFieldChanged and OnValidationRequestedAsync. Inside the async handler it routes between sync- and async-only execution based on whether the model has an IValidatableInfo registered via Microsoft.Extensions.Validation. Because the sync routing path completes synchronously inside the async handler, calling Validate() continues to work for models without MEV-registered async validators. The new [Experimental("ASP0029")] ValidationOptions.TryGetValidatablePropertyInfo(Type, string, out IValidatableInfo?) public API on Microsoft.Extensions.Validation 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.

Web-side updates (Microsoft.AspNetCore.Components.Web). EditForm.HandleSubmitAsync calls ValidateAsync() instead of Validate(). FieldCssClassProvider.GetFieldCssClass is extended to emit "pending" (while EditContext.IsValidationPending(field) is true) or "faulted" (while EditContext.IsValidationFaulted(field) is true), 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: 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; }

The framework's observer clears PendingValidationTask once the task settles, so a field is in one of three observable states:

State PendingValidationTask IsValidationFaulted Public queries
Idle null false both false
Pending non-null 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 not null).
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 OnFieldChanged and OnValidationRequestedAsync only. The async handler routes internally based on whether the model has an IValidatableInfo registered via Microsoft.Extensions.Validation:

Model registration OnValidationRequestedAsync (form-level) OnFieldChanged (per-field)
No IValidatableInfo runs Validator.TryValidateObject synchronously; returned Task is already completed Validator.TryValidateProperty (sync)
Has IValidatableInfo runs await IValidatableInfo.ValidateAsync(model, ct) runs IValidatableInfo.ValidateAsync and registers it via AddValidationTask

Because the no-IValidatableInfo form-level path completes synchronously inside the async handler, calling EditContext.Validate() continues to work for sync-only models. Models with MEV async validators require ValidateAsync(); Validate() throws InvalidOperationException in that case.

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

Microsoft.AspNetCore.Components.Forms

public sealed class EditContext
{
    // Async counterpart to the existing sync OnValidationRequested event.
    // For a given validator, subscribe to one of the two events, not both.
    public event Func<object, ValidationRequestedEventArgs, Task>? OnValidationRequestedAsync;

    // Async counterpart to Validate(). Cancels in-flight per-field tasks, raises the sync
    // and async events, awaits all async handlers, and returns whether validation succeeded.
    // The caller's token is surfaced to handlers via ValidationRequestedEventArgs.CancellationToken
    // and bounds the in-flight pass only.
    public Task<bool> ValidateAsync(CancellationToken cancellationToken = default);

    // Registers an in-flight async validation task for a specific field. If a task is
    // already tracked for the field, the prior CTS is cancelled and the new task replaces it.
    // EditContext owns the supplied CTS and disposes it when the task settles.
    public void AddValidationTask(
        in FieldIdentifier fieldIdentifier,
        Task task,
        CancellationTokenSource cts);

    public bool IsValidationPending(in FieldIdentifier fieldIdentifier);
    public bool IsValidationPending<TField>(Expression<Func<TField>> accessor);
    public bool IsValidationPending();   // true while a form-level ValidateAsync pass is in flight

    public bool IsValidationFaulted(in FieldIdentifier fieldIdentifier);
    public bool IsValidationFaulted<TField>(Expression<Func<TField>> accessor);
    public bool IsValidationFaulted();   // most recent form-level pass observed a handler exception
}

public sealed class ValidationRequestedEventArgs : EventArgs
{
    public ValidationRequestedEventArgs(CancellationToken cancellationToken);
    public CancellationToken CancellationToken { get; }
}

Microsoft.Extensions.Validation

public class ValidationOptions
{
    // Looks up the IValidatableInfo for a named property on a containing type, walking base
    // types so inherited properties are resolved. Used by DataAnnotationsValidator for
    // per-field async validation. Returns IValidatableInfo? (in practice the implementation
    // is a ValidatablePropertyInfo).
    [Experimental("ASP0029", UrlFormat = "https://aka.ms/aspnet/analyzer/{0}")]
    public bool TryGetValidatablePropertyInfo(
        Type type,
        string propertyName,
        [NotNullWhen(true)] out IValidatableInfo? validatablePropertyInfo);
}

Microsoft.AspNetCore.Components.Web

No new types. FieldCssClassProvider.GetFieldCssClass returns 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:

<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>An error occurred while validating. Please try again.</span>
}
else
{
    <ValidationMessage For="() => registration.Username" />
}

<button type="submit">Register</button>

See 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 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. 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 thread src/Components/Forms/src/EditContext.cs

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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

@javiercn javiercn left a comment

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.

Looks good, we need to do cleanup on the tests and some other aspects, but we can do that in P6.

Comment thread src/Components/Forms/src/EditContext.cs
Comment thread src/Components/Forms/src/EditContext.cs Outdated
Comment on lines +346 to +349
var delegates = asyncHandler.GetInvocationList();
var tasks = new Task[delegates.Length];

for (var i = 0; i < delegates.Length; i++)

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.

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()

Comment thread src/Components/Forms/src/EditContext.cs
Comment on lines +444 to +489
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);

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.

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

Comment thread src/Components/Forms/src/EditContext.cs Outdated
Comment on lines +606 to +609
catch (OperationCanceledException)
{
// Cancellation is silent - field was re-edited or form submitted
}

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.

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)

Comment thread src/Components/Web/test/Forms/EditFormAsyncSubmitTest.cs Outdated
Comment thread src/Components/Web/test/Forms/FieldCssClassProviderTest.cs Outdated
Comment thread src/Validation/src/ValidatableTypeInfo.cs
@oroztocil
oroztocil enabled auto-merge (squash) May 15, 2026 17:31
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.

Blazor form async validation enhancements

4 participants

Morty Proxy This is a proxified and sanitized view of the page, visit original site.