// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Linq;
using System.Linq.Expressions;
namespace Microsoft.AspNetCore.Components.Forms;
/// <summary>
/// Holds metadata related to a data editing process, such as flags to indicate which
/// fields have been modified and the current set of validation messages.
/// </summary>
public sealed class EditContext
{
// Note that EditContext tracks state for any FieldIdentifier you give to it, plus
// the underlying storage is sparse. As such, none of the APIs have a "field not found"
// error state. If you give us an unrecognized FieldIdentifier, that just means we
// didn't yet track any state for it, so we behave as if it's in the default state
// (valid and unmodified).
private readonly Dictionary<FieldIdentifier, FieldState> _fieldStates = new Dictionary<FieldIdentifier, FieldState>();
private bool _isFormValidationFaulted;
private bool _isFormValidationPending;
/// <summary>
/// Constructs an instance of <see cref="EditContext"/>.
/// </summary>
/// <param name="model">The model object for the <see cref="EditContext"/>. This object should hold the data being edited, for example as a set of properties.</param>
public EditContext(object model)
{
// The only reason we disallow null is because you'd almost always want one, and if you
// really don't, you can pass an empty object then ignore it. Ensuring it's nonnull
// simplifies things for all consumers of EditContext.
Model = model ?? throw new ArgumentNullException(nameof(model));
Properties = new EditContextProperties();
}
/// <summary>
/// An event that is raised when a field value changes.
/// </summary>
public event EventHandler<FieldChangedEventArgs>? OnFieldChanged;
/// <summary>
/// An event that is raised when validation is requested. Validator components subscribe
/// to this event to perform validation.
/// </summary>
/// <remarks>
/// Handlers run synchronously in subscription order. A validator that performs asynchronous work
/// keeps its handler synchronous and registers an async validator via
/// <see cref="ValidationRequestedEventArgs.AddAsyncValidator(Func{CancellationToken, Task})"/>, for example
/// <c>(sender, args) => args.AddAsyncValidator(token => ValidateAsyncCore(token))</c>.
/// <see cref="ValidateAsync(CancellationToken)"/> invokes every registered validator and awaits the
/// resulting tasks; <see cref="Validate"/> throws <see cref="InvalidOperationException"/> if a handler
/// registers asynchronous work. Do not use an <c>async void</c> handler: its work is not awaited and
/// is silently skipped.
/// </remarks>
public event EventHandler<ValidationRequestedEventArgs>? OnValidationRequested;
/// <summary>
/// An event that is raised when validation state has changed.
/// </summary>
public event EventHandler<ValidationStateChangedEventArgs>? OnValidationStateChanged;
/// <summary>
/// Supplies a <see cref="FieldIdentifier"/> corresponding to a specified field name
/// on this <see cref="EditContext"/>'s <see cref="Model"/>.
/// </summary>
/// <param name="fieldName">The name of the editable field.</param>
/// <returns>A <see cref="FieldIdentifier"/> corresponding to a specified field name on this <see cref="EditContext"/>'s <see cref="Model"/>.</returns>
public FieldIdentifier Field(string fieldName)
=> new FieldIdentifier(Model, fieldName);
/// <summary>
/// Gets the model object for this <see cref="EditContext"/>.
/// </summary>
public object Model { get; }
/// <summary>
/// Gets a collection of arbitrary properties associated with this instance.
/// </summary>
public EditContextProperties Properties { get; }
/// <summary>
/// Gets whether field identifiers should be generated for <input> elements.
/// </summary>
public bool ShouldUseFieldIdentifiers { get; set; } = !OperatingSystem.IsBrowser();
/// <summary>
/// Signals that the value for the specified field has changed.
/// </summary>
/// <param name="fieldIdentifier">Identifies the field whose value has been changed.</param>
public void NotifyFieldChanged(in FieldIdentifier fieldIdentifier)
{
GetOrAddFieldState(fieldIdentifier).IsModified = true;
OnFieldChanged?.Invoke(this, new FieldChangedEventArgs(fieldIdentifier));
}
/// <summary>
/// Signals that some aspect of validation state has changed.
/// </summary>
public void NotifyValidationStateChanged()
{
OnValidationStateChanged?.Invoke(this, ValidationStateChangedEventArgs.Empty);
}
/// <summary>
/// Clears any modification flag that may be tracked for the specified field.
/// </summary>
/// <param name="fieldIdentifier">Identifies the field whose modification flag (if any) should be cleared.</param>
public void MarkAsUnmodified(in FieldIdentifier fieldIdentifier)
{
if (_fieldStates.TryGetValue(fieldIdentifier, out var state))
{
state.IsModified = false;
}
}
/// <summary>
/// Clears all modification flags within this <see cref="EditContext"/>.
/// </summary>
public void MarkAsUnmodified()
{
foreach (var state in _fieldStates)
{
state.Value.IsModified = false;
}
}
/// <summary>
/// Determines whether any of the fields in this <see cref="EditContext"/> have been modified.
/// </summary>
/// <returns>True if any of the fields in this <see cref="EditContext"/> have been modified; otherwise false.</returns>
public bool IsModified()
{
// If necessary, we could consider caching the overall "is modified" state and only recomputing
// when there's a call to NotifyFieldModified/NotifyFieldUnmodified
foreach (var state in _fieldStates)
{
if (state.Value.IsModified)
{
return true;
}
}
return false;
}
/// <summary>
/// Gets the current validation messages across all fields.
///
/// This method does not perform validation itself. It only returns messages determined by previous validation actions.
/// </summary>
/// <returns>The current validation messages.</returns>
public IEnumerable<string> GetValidationMessages()
{
// Since we're only enumerating the fields for which we have a non-null state, the cost of this grows
// based on how many fields have been modified or have associated validation messages
foreach (var state in _fieldStates)
{
foreach (var message in state.Value.GetValidationMessages())
{
yield return message;
}
}
}
/// <summary>
/// Gets the current validation messages for the specified field.
///
/// This method does not perform validation itself. It only returns messages determined by previous validation actions.
/// </summary>
/// <param name="fieldIdentifier">Identifies the field whose current validation messages should be returned.</param>
/// <returns>The current validation messages for the specified field.</returns>
public IEnumerable<string> GetValidationMessages(FieldIdentifier fieldIdentifier)
{
if (_fieldStates.TryGetValue(fieldIdentifier, out var state))
{
foreach (var message in state.GetValidationMessages())
{
yield return message;
}
}
}
/// <summary>
/// Gets the current validation messages for the specified field.
///
/// This method does not perform validation itself. It only returns messages determined by previous validation actions.
/// </summary>
/// <param name="accessor">Identifies the field whose current validation messages should be returned.</param>
/// <returns>The current validation messages for the specified field.</returns>
public IEnumerable<string> GetValidationMessages(Expression<Func<object>> accessor)
=> GetValidationMessages(FieldIdentifier.Create(accessor));
/// <summary>
/// Determines whether the specified fields in this <see cref="EditContext"/> has been modified.
/// </summary>
/// <returns>True if the field has been modified; otherwise false.</returns>
public bool IsModified(in FieldIdentifier fieldIdentifier)
=> _fieldStates.TryGetValue(fieldIdentifier, out var state)
? state.IsModified
: false;
/// <summary>
/// Determines whether the specified fields in this <see cref="EditContext"/> has been modified.
/// </summary>
/// <param name="accessor">Identifies the field whose current validation messages should be returned.</param>
/// <returns>True if the field has been modified; otherwise false.</returns>
public bool IsModified(Expression<Func<object>> accessor)
=> IsModified(FieldIdentifier.Create(accessor));
/// <summary>
/// Determines whether the specified fields in this <see cref="EditContext"/> has no associated validation messages.
/// </summary>
/// <returns>True if the field has no associated validation messages after validation; otherwise false.</returns>
public bool IsValid(in FieldIdentifier fieldIdentifier)
=> !GetValidationMessages(fieldIdentifier).Any();
/// <summary>
/// Determines whether the specified fields in this <see cref="EditContext"/> has no associated validation messages.
/// </summary>
/// <param name="accessor">Identifies the field whose current validation messages should be returned.</param>
/// <returns>True if the field has no associated validation messages after validation; otherwise false.</returns>
public bool IsValid(Expression<Func<object>> accessor)
=> IsValid(FieldIdentifier.Create(accessor));
/// <summary>
/// Validates this <see cref="EditContext"/> synchronously.
/// </summary>
/// <returns><c>true</c> if there are no validation messages after validation; otherwise <c>false</c>.</returns>
/// <remarks>
/// Runs synchronous validators only. A handler that registers asynchronous work via
/// <see cref="ValidationRequestedEventArgs.AddAsyncValidator(Func{CancellationToken, Task})"/> causes an
/// <see cref="InvalidOperationException"/>; use <see cref="ValidateAsync"/> for async validators.
/// Do not call validation re-entrantly from a validation handler.
/// </remarks>
/// <exception cref="InvalidOperationException">A handler registered an asynchronous validation task.</exception>
[Obsolete("Validate is obsolete. Use ValidateAsync instead.")]
public bool Validate()
{
// Sync pass: args.IsAsync is false, so AddAsyncValidator throws. A handler exception propagates.
OnValidationRequested?.Invoke(this, new ValidationRequestedEventArgs());
// A clean sync pass produced no infrastructure fault.
_isFormValidationFaulted = false;
return !GetValidationMessages().Any();
}
/// <summary>
/// Validates this <see cref="EditContext"/> asynchronously, awaiting any validation tasks that handlers
/// register via <see cref="ValidationRequestedEventArgs.AddAsyncValidator(Func{CancellationToken, Task})"/>.
/// </summary>
/// <param name="cancellationToken">Cancels this validation pass; it is passed to each registered validator.
/// If cancelled (including already-cancelled on entry) this method throws <see cref="OperationCanceledException"/>
/// once the registered tasks settle, and the previous faulted state is preserved.</param>
/// <returns><c>true</c> if there are no validation messages and no registered task faulted; otherwise <c>false</c>.</returns>
/// <remarks>
/// Pending field-level validations are superseded. While tasks are in flight the parameterless
/// <see cref="IsValidationPending()"/> is <c>true</c>. A non-cancellation exception on a registered task is
/// contained (the form is marked faulted via <see cref="IsValidationFaulted()"/> and the method returns
/// <c>false</c>) but not surfaced. A validator that throws synchronously or returns a null task is a
/// programming bug and propagates to the caller. Do not call validation re-entrantly from a handler.
/// </remarks>
/// <exception cref="OperationCanceledException"><paramref name="cancellationToken"/> was cancelled.</exception>
public async Task<bool> ValidateAsync(CancellationToken cancellationToken = default)
{
var args = new ValidationRequestedEventArgs { IsAsync = true };
// Form submission supersedes any in-flight per-field async validation.
CancelAllPendingValidationTasks();
// A synchronous handler exception is captured into the returned task. Handlers only register
// validators during the raise; none have been invoked yet, so a throw leaves no work started.
OnValidationRequested?.Invoke(this, args);
// Invoke all registered validators to start their work concurrently. Collect only the tasks that
// still need observation: those that will suspend, or that already completed as faulted/cancelled
// and must be awaited to record the fault. A task that completed successfully needs no await, so a
// pass where every validator succeeds synchronously allocates nothing.
var validators = args.AsyncValidators;
List<Task>? tasksToAwait = null;
var anyPending = false;
for (var i = 0; i < validators.Count; i++)
{
var task = InvokeAsyncValidator(validators[i], cancellationToken);
if (!task.IsCompletedSuccessfully)
{
(tasksToAwait ??= []).Add(task);
anyPending |= !task.IsCompleted;
}
}
try
{
var faulted = false;
if (tasksToAwait is not null)
{
// Announce the pending transition only when some task actually suspends. If every task
// completed synchronously (including synchronous faults) the form never observably enters
// the pending state, which prevents spurious state change notifications and UI flashes.
if (anyPending)
{
_isFormValidationPending = true;
NotifyValidationStateChanged();
}
foreach (var task in tasksToAwait)
{
try
{
await task;
}
catch
{
// The validation faulted, or was cancelled by a source other than the caller's token.
// A caller-cancelled task is discarded below when ThrowIfCancellationRequested throws.
faulted = true;
}
}
}
// If the token is cancelled, the form is not marked as faulted
// and the previous form-level faulted state is preserved.
cancellationToken.ThrowIfCancellationRequested();
_isFormValidationFaulted = faulted;
return !faulted && !GetValidationMessages().Any();
}
finally
{
_isFormValidationPending = false;
NotifyValidationStateChanged();
}
static Task InvokeAsyncValidator(Func<CancellationToken, Task> validate, CancellationToken cancellationToken)
=> validate(cancellationToken)
?? throw new InvalidOperationException("The async validator returned a null task.");
}
/// <summary>
/// Registers an asynchronous validation for a specific field. The <paramref name="validator"/>
/// is invoked immediately with a <see cref="CancellationToken"/> owned by this <see cref="EditContext"/>,
/// and the returned <see cref="Task"/> is tracked for <see cref="IsValidationPending(in FieldIdentifier)"/>
/// and <see cref="IsValidationFaulted(in FieldIdentifier)"/>. A validation already tracked for the field
/// is cancelled and replaced.
/// </summary>
/// <remarks>
/// The <see cref="EditContext"/> owns the token source end to end; to also cancel from another source,
/// link inside <paramref name="validator"/> via
/// <see cref="CancellationTokenSource.CreateLinkedTokenSource(CancellationToken)"/>. Write
/// <paramref name="validator"/> as an <c>async</c> method so a pre-<c>await</c> throw is captured into the
/// task rather than thrown from this method. If <paramref name="validator"/> throws or returns
/// <see langword="null"/>, any prior validation for the field is still superseded before the exception
/// propagates. Validators should clear prior messages for the field up-front and avoid writing partial
/// results on a path that may throw.
/// </remarks>
/// <param name="fieldIdentifier">Identifies the field being validated.</param>
/// <param name="validator">Asynchronous validation logic that starts using the supplied
/// cancellation token and returns the in-flight <see cref="Task"/>.</param>
/// <exception cref="ArgumentNullException"><paramref name="validator"/> is <see langword="null"/>.</exception>
/// <exception cref="InvalidOperationException"><paramref name="validator"/> returned a <see langword="null"/> task.</exception>
public void RegisterAsyncFieldValidator(in FieldIdentifier fieldIdentifier, Func<CancellationToken, Task> validator)
{
ArgumentNullException.ThrowIfNull(validator);
var state = GetOrAddFieldState(fieldIdentifier);
// EditContext owns the token source: cancelled when a later validation supersedes this one, and
// disposed once the task settles (both handled by the observer).
var cts = new CancellationTokenSource();
Task task;
try
{
task = validator(cts.Token);
}
catch
{
// The new validation failed to start, so the observer will not run. Clear the prior
// validation so the field is not left stuck pending with an outdated validator in flight.
cts.Dispose();
ClearPendingFieldValidation(state);
throw;
}
if (task is null)
{
cts.Dispose();
ClearPendingFieldValidation(state);
throw new InvalidOperationException("The async validator returned a null task.");
}
// Supersession, settle, notification, and CTS disposal all flow through the observer below. For an
// already-completed task it resumes synchronously, so the field state is visible before returning.
_ = ObserveFieldValidationTask(state, task, cts);
}
// Cancels and clears any validation tracked for a field, leaving it idle. Used when a new validation
// fails to start. The prior observer disposes its own CTS and its ReferenceEquals guard prevents it
// from stomping the cleared slot.
private void ClearPendingFieldValidation(FieldState state)
{
var wasPending = state.PendingValidationTask is not null;
var wasFaulted = state.IsValidationFaulted;
state.PendingValidationCts?.Cancel();
state.PendingValidationTask = null;
state.PendingValidationCts = null;
state.IsValidationFaulted = false;
if (wasPending || wasFaulted)
{
NotifyValidationStateChanged();
}
}
/// <summary>
/// Returns <c>true</c> if the specified field has a pending async validation task. The task stays
/// "pending" until the framework's observer has settled its outcome, so a consumer that waits for
/// this to become <c>false</c> is guaranteed to also see the final
/// <see cref="IsValidationFaulted(in FieldIdentifier)"/> value.
/// </summary>
/// <param name="fieldIdentifier">Identifies the field to query.</param>
/// <returns><c>true</c> if async validation is in progress for the field; otherwise <c>false</c>.</returns>
public bool IsValidationPending(in FieldIdentifier fieldIdentifier)
=> _fieldStates.TryGetValue(fieldIdentifier, out var state) && state.PendingValidationTask is not null;
/// <summary>
/// Returns <c>true</c> if the field identified by the <paramref name="accessor"/> expression
/// has a pending async validation task.
/// </summary>
/// <typeparam name="TField">The type of the field.</typeparam>
/// <param name="accessor">An expression that identifies the field, e.g. <c>() => model.Email</c>.</param>
/// <returns><c>true</c> if async validation is in progress for the field; otherwise <c>false</c>.</returns>
public bool IsValidationPending<TField>(Expression<Func<TField>> accessor)
=> IsValidationPending(FieldIdentifier.Create(accessor));
/// <summary>
/// Returns <c>true</c> if a form-level <see cref="ValidateAsync"/> pass is currently in flight, for
/// driving form-wide UI such as disabling a submit button. Does not consider per-field pending tasks;
/// use the <see cref="IsValidationPending(in FieldIdentifier)"/> overload for those.
/// </summary>
/// <returns><c>true</c> if a form-level validation pass is in progress; otherwise <c>false</c>.</returns>
public bool IsValidationPending() => _isFormValidationPending;
/// <summary>
/// Returns <c>true</c> if the specified field's last async validation faulted
/// (threw a non-cancellation exception).
/// </summary>
/// <param name="fieldIdentifier">Identifies the field to query.</param>
/// <returns><c>true</c> if the field's last async validation faulted; otherwise <c>false</c>.</returns>
public bool IsValidationFaulted(in FieldIdentifier fieldIdentifier)
=> _fieldStates.TryGetValue(fieldIdentifier, out var state) && state.IsValidationFaulted;
/// <summary>
/// Returns <c>true</c> if the field identified by the <paramref name="accessor"/> expression's
/// last async validation faulted (threw a non-cancellation exception).
/// </summary>
/// <typeparam name="TField">The type of the field.</typeparam>
/// <param name="accessor">An expression that identifies the field, e.g. <c>() => model.Email</c>.</param>
/// <returns><c>true</c> if the field's last async validation faulted; otherwise <c>false</c>.</returns>
public bool IsValidationFaulted<TField>(Expression<Func<TField>> accessor)
=> IsValidationFaulted(FieldIdentifier.Create(accessor));
/// <summary>
/// Returns <c>true</c> if the most recent form-level pass faulted (a registered validation task threw),
/// as opposed to merely producing validation messages. A subsequent successful pass clears it; a
/// caller-cancelled <see cref="ValidateAsync"/> pass preserves it. For per-field faults use the
/// <see cref="IsValidationFaulted(in FieldIdentifier)"/> overload.
/// </summary>
/// <returns><c>true</c> if the most recent validation pass faulted; otherwise <c>false</c>.</returns>
public bool IsValidationFaulted() => _isFormValidationFaulted;
internal FieldState? GetFieldState(in FieldIdentifier fieldIdentifier)
{
_fieldStates.TryGetValue(fieldIdentifier, out var state);
return state;
}
internal FieldState GetOrAddFieldState(in FieldIdentifier fieldIdentifier)
{
if (!_fieldStates.TryGetValue(fieldIdentifier, out var state))
{
state = new FieldState(fieldIdentifier);
_fieldStates.Add(fieldIdentifier, state);
}
return state;
}
private void CancelAllPendingValidationTasks()
{
var changed = false;
foreach (var (_, state) in _fieldStates)
{
if (state.PendingValidationCts is { } cts)
{
// Request cancellation; the observer task disposes the CTS when the validator settles.
cts.Cancel();
state.PendingValidationTask = null;
state.PendingValidationCts = null;
state.IsValidationFaulted = false;
changed = true;
}
else if (state.IsValidationFaulted)
{
// A previously-faulted task already settled (CTS already cleared). Clear the lingering
// fault so per-field IsValidationFaulted reflects only the current pass.
state.IsValidationFaulted = false;
changed = true;
}
}
if (changed)
{
NotifyValidationStateChanged();
}
}
private async Task ObserveFieldValidationTask(FieldState state, Task task, CancellationTokenSource cts)
{
// Supersede any previously tracked task. Its own observer disposes its CTS when it settles, so
// we must not dispose it here while it may still be in flight.
state.PendingValidationCts?.Cancel();
var completedSynchronously = task.IsCompleted;
var hadPriorPendingTask = state.PendingValidationTask is { IsCompleted: false };
var priorFaulted = state.IsValidationFaulted;
state.PendingValidationTask = task;
state.PendingValidationCts = cts;
state.IsValidationFaulted = false;
// Announce the pending transition only when we will actually suspend; a completed task never
// observably enters the pending state.
if (!completedSynchronously)
{
NotifyValidationStateChanged();
}
var faulted = false;
try
{
try
{
await task;
}
catch (OperationCanceledException) when (cts.IsCancellationRequested)
{
// Cancellation initiated by us (field re-edited or form submitted) is silent.
}
catch
{
// Infrastructure fault, or an OperationCanceledException from a source other than our CTS
// (e.g. an HttpClient timeout). Recorded as a fault but not surfaced.
faulted = true;
}
// Only settle if we are still the current task: the ReferenceEquals guard makes a stale
// (superseded) task's settle a no-op so it cannot stomp newer state.
if (ReferenceEquals(state.PendingValidationTask, task))
{
state.PendingValidationTask = null;
state.PendingValidationCts = null;
state.IsValidationFaulted = faulted;
// A suspended task already announced pending, so settling is always a change. A completed
// task announced nothing, so notify only if pending-ness or fault changed.
if (!completedSynchronously || hadPriorPendingTask || faulted != priorFaulted)
{
NotifyValidationStateChanged();
}
}
}
finally
{
// Dispose the CTS we own. Only happens after the task has settled, so the validator can
// never observe a disposed token.
cts.Dispose();
}
}
}