Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
3fd9cac
Core implementation
oroztocil Apr 28, 2026
93ac4ba
Catch non-cancellation exceptions in ValidateAsync, add form-level fa…
oroztocil Apr 29, 2026
70fe8b8
Improve message clearing for cancelled/faulted async validations
oroztocil Apr 29, 2026
4466ea8
Add cancellation token to EditContext.ValidateAsync
oroztocil Apr 29, 2026
e94d89a
Call NotifyValidationStateChanged when a pending validation task chan…
oroztocil Apr 29, 2026
b7d0131
Improve form-level pending/faulted state indication
oroztocil Apr 29, 2026
c8c7d5b
Document Validate/ValidateAsync as non-reentrant
oroztocil Apr 29, 2026
a109c82
Move completed task shortcircuiting into AddValidationTask, simplify …
oroztocil Apr 29, 2026
0cd6936
Improve code comments
oroztocil Apr 29, 2026
470b9b4
Simplify ValidateAsync
oroztocil May 3, 2026
e0a68d3
Add integration tests
oroztocil May 3, 2026
7b362f8
Add pending/faulted CSS classes
oroztocil May 3, 2026
6a2f955
Simplify FieldCssClassProvider
oroztocil May 3, 2026
37e6752
Code review fixes
oroztocil May 4, 2026
8aa6147
Code review fixes
oroztocil May 4, 2026
b3c1f59
Code review fixes
oroztocil May 4, 2026
2ec6606
Move TryGetValidatablePropertyInfo to ValidationOptions
oroztocil May 4, 2026
e041968
Simplify DataAnnotationsEventSubscriptions, improve comments
oroztocil May 5, 2026
7eb3b3a
Fix race condition in IsValidationPending, apply minor review fixes
oroztocil May 15, 2026
3b2c305
Merge remote-tracking branch 'origin/main' into oroztocil/validation-…
oroztocil May 15, 2026
3e24609
Merge remote-tracking branch 'origin/main' into oroztocil/validation-…
oroztocil May 17, 2026
7a15fa3
Fix invalid parallelism in Blazor form validation tests
oroztocil May 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
385 changes: 384 additions & 1 deletion src/Components/Forms/src/EditContext.cs

Large diffs are not rendered by default.

154 changes: 109 additions & 45 deletions src/Components/Forms/src/EditContextDataAnnotationsExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ public DataAnnotationsEventSubscriptions(EditContext editContext, IServiceProvid
#pragma warning restore ASP0029 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
_editContext.OnFieldChanged += OnFieldChanged;
_editContext.OnValidationRequested += OnValidationRequested;
_editContext.OnValidationRequestedAsync += OnValidationRequestedAsync;

if (MetadataUpdater.IsSupported)
{
Expand All @@ -79,44 +80,53 @@ public DataAnnotationsEventSubscriptions(EditContext editContext, IServiceProvid
private void OnFieldChanged(object? sender, FieldChangedEventArgs eventArgs)
{
var fieldIdentifier = eventArgs.FieldIdentifier;
if (TryGetValidatableProperty(fieldIdentifier, out var propertyInfo))
{
var propertyValue = propertyInfo.GetValue(fieldIdentifier.Model);
var validationContext = new ValidationContext(fieldIdentifier.Model, _serviceProvider, items: null)
{
MemberName = propertyInfo.Name
};
var results = new List<ValidationResult>();

Validator.TryValidateProperty(propertyValue, validationContext, results);
_messages.Clear(fieldIdentifier);
foreach (var result in CollectionsMarshal.AsSpan(results))
{
_messages.Add(fieldIdentifier, result.ErrorMessage!);
}

// We have to notify even if there were no messages before and are still no messages now,
// because the "state" that changed might be the completion of some async validation task
_editContext.NotifyValidationStateChanged();
#pragma warning disable ASP0029 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
if (_validatorTypeInfo is ValidatableTypeInfo typeInfo &&
typeInfo.GetProperty(fieldIdentifier.FieldName) is ValidatablePropertyInfo validatablePropertyInfo)
{
var cts = new CancellationTokenSource();
var task = ValidateFieldAsync(fieldIdentifier, validatablePropertyInfo, cts.Token);
Comment thread
oroztocil marked this conversation as resolved.
Outdated
_editContext.AddValidationTask(fieldIdentifier, task, cts);
}
#pragma warning restore ASP0029 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
else if (TryGetValidatableProperty(fieldIdentifier, out var propertyInfo))
{
ValidateField(fieldIdentifier, propertyInfo);
}
}

[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Model types are expected to be defined in assemblies that do not get trimmed.")]
private void OnValidationRequested(object? sender, ValidationRequestedEventArgs e)
{
var validationContext = new ValidationContext(_editContext.Model, _serviceProvider, items: null);
if (_validatorTypeInfo is not null)
{
// The IValidatableInfo path runs through OnValidationRequestedAsync.
// EditContext.Validate() drains async handlers synchronously and throws if any
// are truly async, preserving the behavior where sync IValidatableInfo validators
// run during Validate().
return;
}

ValidateForm();
_editContext.NotifyValidationStateChanged();
}

if (!TryValidateTypeInfo(validationContext))
private async Task OnValidationRequestedAsync(object sender, ValidationRequestedEventArgs e)
{
if (_validatorTypeInfo is null)
{
ValidateWithDefaultValidator(validationContext);
// The async path requires IValidatableInfo for the model.
return;
}

await ValidateFormAsync(_validatorTypeInfo, e.CancellationToken);
_editContext.NotifyValidationStateChanged();
}

[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Model types are expected to be defined in assemblies that do not get trimmed.")]
private void ValidateWithDefaultValidator(ValidationContext validationContext)
private void ValidateForm()
{
var validationContext = new ValidationContext(_editContext.Model, _serviceProvider, items: null);
var validationResults = new List<ValidationResult>();
Validator.TryValidateObject(_editContext.Model, validationContext, validationResults, true);

Expand Down Expand Up @@ -144,34 +154,29 @@ private void ValidateWithDefaultValidator(ValidationContext validationContext)
}

#pragma warning disable ASP0029 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
private bool TryValidateTypeInfo(ValidationContext validationContext)
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Model types are expected to be defined in assemblies that do not get trimmed.")]
private async Task ValidateFormAsync(IValidatableInfo validatableInfo, CancellationToken cancellationToken)
{
if (_validatorTypeInfo is null)
{
return false;
}

var validationContext = new ValidationContext(_editContext.Model, _serviceProvider, items: null);
var validateContext = new ValidateContext
{
ValidationOptions = _validationOptions!,
ValidationContext = validationContext,
};

// Clear stale messages up-front. If the validator throws partway through, the form
// shows no per-field messages (form-level fault state is signaled separately via
// EditContext.IsValidationFaulted). Stale messages from a prior pass would be misleading
// because they reflect a different model state.
_messages.Clear();

try
{
validateContext.OnValidationError += AddMapping;

var validationTask = _validatorTypeInfo.ValidateAsync(_editContext.Model, validateContext, CancellationToken.None);
if (!validationTask.IsCompleted)
{
throw new InvalidOperationException("Async validation is not supported");
}
await validatableInfo.ValidateAsync(_editContext.Model, validateContext, cancellationToken);

var validationErrors = validateContext.ValidationErrors;

// Transfer results to the ValidationMessageStore
_messages.Clear();

if (validationErrors is not null && validationErrors.Count > 0)
if (validateContext.ValidationErrors is { Count: > 0 } validationErrors)
{
foreach (var (fieldKey, messages) in validationErrors)
{
Expand All @@ -180,19 +185,77 @@ private bool TryValidateTypeInfo(ValidationContext validationContext)
}
}
}
catch (Exception)
{
throw;
}
finally
{
validateContext.OnValidationError -= AddMapping;
_validationPathToFieldIdentifierMapping.Clear();
}
}

return true;
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Model types are expected to be defined in assemblies that do not get trimmed.")]
private void ValidateField(FieldIdentifier fieldIdentifier, PropertyInfo propertyInfo)
{
var propertyValue = propertyInfo.GetValue(fieldIdentifier.Model);
var validationContext = new ValidationContext(fieldIdentifier.Model, _serviceProvider, items: null)
{
MemberName = propertyInfo.Name
};
var results = new List<ValidationResult>();

Validator.TryValidateProperty(propertyValue, validationContext, results);
_messages.Clear(fieldIdentifier);
foreach (var result in CollectionsMarshal.AsSpan(results))
{
_messages.Add(fieldIdentifier, result.ErrorMessage!);
}

// We have to notify even if there were no messages before and are still no messages now,
// because the "state" that changed might be the completion of some async validation task
_editContext.NotifyValidationStateChanged();
}

[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Model types are expected to be defined in assemblies that do not get trimmed.")]
private async Task ValidateFieldAsync(
FieldIdentifier fieldIdentifier,
ValidatablePropertyInfo validatableInfo,
CancellationToken cancellationToken)
{
var validationContext = new ValidationContext(fieldIdentifier.Model, _serviceProvider, items: null);
var validateContext = new ValidateContext
{
ValidationOptions = _validationOptions!,
ValidationContext = validationContext,
};

// Clear stale messages up-front so the field shows neutral state during validation
// and shows neutral state after a throw or cancellation. Any faulted state is signaled
// separately via EditContext.IsValidationFaulted.
_messages.Clear(fieldIdentifier);
_editContext.NotifyValidationStateChanged();

try
{
await validatableInfo.ValidateAsync(fieldIdentifier.Model, validateContext, cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Task was cancelled (user re-edited field or form is submitting). Messages were
// already cleared above; just notify so the UI re-renders.
_editContext.NotifyValidationStateChanged();
return;
Comment thread
oroztocil marked this conversation as resolved.
Outdated
}

if (validateContext.ValidationErrors is { Count: > 0 } validationErrors)
{
foreach (var (_, messages) in validationErrors)
{
_messages.Add(fieldIdentifier, messages);
Comment thread
oroztocil marked this conversation as resolved.
}
}

_editContext.NotifyValidationStateChanged();
}

private void AddMapping(ValidationErrorContext context)
{
_validationPathToFieldIdentifierMapping[context.Path] =
Expand All @@ -204,6 +267,7 @@ public void Dispose()
_messages.Clear();
_editContext.OnFieldChanged -= OnFieldChanged;
_editContext.OnValidationRequested -= OnValidationRequested;
_editContext.OnValidationRequestedAsync -= OnValidationRequestedAsync;
_editContext.NotifyValidationStateChanged();

if (MetadataUpdater.IsSupported)
Expand Down
6 changes: 6 additions & 0 deletions src/Components/Forms/src/FieldState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ public FieldState(FieldIdentifier fieldIdentifier)

public bool IsModified { get; set; }

// Async validation tracking: the currently-tracked async validation task for this field,
// the CancellationTokenSource that can cancel it, and whether the last task faulted.
internal Task? PendingValidationTask { get; set; }
internal CancellationTokenSource? PendingValidationCts { get; set; }
internal bool IsValidationFaulted { get; set; }

public IEnumerable<string> GetValidationMessages()
{
if (_validationMessageStores != null)
Expand Down
11 changes: 11 additions & 0 deletions src/Components/Forms/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
#nullable enable
Microsoft.AspNetCore.Components.Forms.ValidationRequestedEventArgs.ValidationRequestedEventArgs(System.Threading.CancellationToken cancellationToken) -> void
Microsoft.AspNetCore.Components.Forms.ValidationRequestedEventArgs.CancellationToken.get -> System.Threading.CancellationToken
*REMOVED*static Microsoft.AspNetCore.Components.Forms.EditContextDataAnnotationsExtensions.AddDataAnnotationsValidation(this Microsoft.AspNetCore.Components.Forms.EditContext! editContext) -> Microsoft.AspNetCore.Components.Forms.EditContext!
*REMOVED*static Microsoft.AspNetCore.Components.Forms.EditContextDataAnnotationsExtensions.EnableDataAnnotationsValidation(this Microsoft.AspNetCore.Components.Forms.EditContext! editContext) -> System.IDisposable!
Microsoft.AspNetCore.Components.Forms.EditContext.OnValidationRequestedAsync -> System.Func<object!, Microsoft.AspNetCore.Components.Forms.ValidationRequestedEventArgs!, System.Threading.Tasks.Task!>?
Microsoft.AspNetCore.Components.Forms.EditContext.ValidateAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<bool>!
Microsoft.AspNetCore.Components.Forms.EditContext.AddValidationTask(in Microsoft.AspNetCore.Components.Forms.FieldIdentifier fieldIdentifier, System.Threading.Tasks.Task! task, System.Threading.CancellationTokenSource! cts) -> void
Microsoft.AspNetCore.Components.Forms.EditContext.IsValidationPending(in Microsoft.AspNetCore.Components.Forms.FieldIdentifier fieldIdentifier) -> bool
Microsoft.AspNetCore.Components.Forms.EditContext.IsValidationPending<TField>(System.Linq.Expressions.Expression<System.Func<TField>!>! accessor) -> bool
Microsoft.AspNetCore.Components.Forms.EditContext.IsValidationPending() -> bool
Microsoft.AspNetCore.Components.Forms.EditContext.IsValidationFaulted(in Microsoft.AspNetCore.Components.Forms.FieldIdentifier fieldIdentifier) -> bool
Microsoft.AspNetCore.Components.Forms.EditContext.IsValidationFaulted<TField>(System.Linq.Expressions.Expression<System.Func<TField>!>! accessor) -> bool
Microsoft.AspNetCore.Components.Forms.EditContext.IsValidationFaulted() -> bool
19 changes: 19 additions & 0 deletions src/Components/Forms/src/ValidationRequestedEventArgs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,23 @@ public sealed class ValidationRequestedEventArgs : EventArgs
public ValidationRequestedEventArgs()
{
}

/// <summary>
/// Creates a new instance of <see cref="ValidationRequestedEventArgs"/> with the specified
/// <see cref="System.Threading.CancellationToken"/>.
/// </summary>
/// <param name="cancellationToken">A token that signals when the caller has requested
/// cancellation of the in-flight async validation pass.</param>
public ValidationRequestedEventArgs(CancellationToken cancellationToken)
{
CancellationToken = cancellationToken;
}

/// <summary>
/// Gets a token that signals when the caller has requested cancellation of the in-flight
/// async validation pass. Synchronous handlers can ignore this; async handlers that perform
/// long-running work (database lookups, remote API calls) should pass it to their downstream
/// APIs so the work can be aborted promptly.
/// </summary>
public CancellationToken CancellationToken { get; }
}
2 changes: 1 addition & 1 deletion src/Components/Web/src/Forms/EditForm.cs
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ private async Task HandleSubmitAsync()
else
{
// Otherwise, the system implicitly runs validation on form submission
var isValid = _editContext.Validate(); // This will likely become ValidateAsync later
var isValid = await _editContext.ValidateAsync();

if (isValid && OnValidSubmit.HasDelegate)
{
Expand Down
1 change: 1 addition & 0 deletions src/Validation/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
#nullable enable
Microsoft.Extensions.Validation.ValidatableTypeInfo.GetProperty(string! propertyName) -> Microsoft.Extensions.Validation.ValidatablePropertyInfo?
18 changes: 18 additions & 0 deletions src/Validation/src/ValidatableTypeInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,24 @@ protected ValidatableTypeInfo(
/// </summary>
internal IReadOnlyList<ValidatablePropertyInfo> Members { get; }

/// <summary>
/// Finds a validatable property by name.
/// </summary>
/// <param name="propertyName">The property name to look up.</param>
/// <returns>The <see cref="ValidatablePropertyInfo"/> for the property, or <see langword="null"/> if not found.</returns>
public ValidatablePropertyInfo? GetProperty(string propertyName)
{
for (var i = 0; i < _membersCount; i++)
{
if (string.Equals(Members[i].Name, propertyName, StringComparison.Ordinal))
{
return Members[i];
}
}

return null;
}
Comment thread
oroztocil marked this conversation as resolved.
Outdated

/// <inheritdoc />
public virtual async Task ValidateAsync(object? value, ValidateContext context, CancellationToken cancellationToken)
{
Expand Down
Loading