File: EditContext.cs
Web Access
Project: src\aspnetcore\src\Components\Forms\src\Microsoft.AspNetCore.Components.Forms.csproj (Microsoft.AspNetCore.Components.Forms)
// 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 synchronous validation.
    /// </summary>
    /// <remarks>
    /// For a given source of validation, a validator component should subscribe to exactly one
    /// of <see cref="OnValidationRequested"/> or <see cref="OnValidationRequestedAsync"/>.
    /// Subscribing to both causes the same validator to run twice on <see cref="ValidateAsync"/>,
    /// with the second pass overwriting the first.
    /// <para>
    /// New validator implementations are recommended to subscribe to
    /// <see cref="OnValidationRequestedAsync"/> instead. This event remains supported for
    /// existing sync validators and continues to be raised by both <see cref="Validate"/> and
    /// <see cref="ValidateAsync"/>.
    /// </para>
    /// </remarks>
    public event EventHandler<ValidationRequestedEventArgs>? OnValidationRequested;

    /// <summary>
    /// An async event that is raised when validation is requested. Validator components
    /// subscribe to this event to perform async validation (e.g., database lookups, remote API calls).
    /// Handlers are awaited by <see cref="ValidateAsync"/>. <see cref="Validate"/> also invokes
    /// these handlers but requires each to complete synchronously; if any returns an incomplete
    /// <see cref="Task"/>, <see cref="Validate"/> throws <see cref="InvalidOperationException"/>.
    /// </summary>
    /// <remarks>
    /// For a given source of validation, a validator component should subscribe to exactly one
    /// of <see cref="OnValidationRequested"/> or <see cref="OnValidationRequestedAsync"/>.
    /// </remarks>
    public event Func<object, ValidationRequestedEventArgs, Task>? OnValidationRequestedAsync;

    /// <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 &lt;input&gt; 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"/>.
    /// </summary>
    /// <returns>True if there are no validation messages after validation; otherwise false.</returns>
    /// <remarks>
    /// Validation must not be re-entered. Do not call <see cref="Validate"/> or
    /// <see cref="ValidateAsync"/> from inside an <see cref="OnValidationRequested"/>,
    /// <see cref="OnValidationRequestedAsync"/>, or <see cref="OnValidationStateChanged"/>
    /// handler attached to the same <see cref="EditContext"/>; doing so produces undefined
    /// behavior (in particular, it can cause infinite recursion via the validator's own
    /// <see cref="NotifyValidationStateChanged"/> calls).
    /// </remarks>
    /// <exception cref="InvalidOperationException">
    /// Thrown when an <see cref="OnValidationRequestedAsync"/> handler does not complete synchronously.
    /// Use <see cref="ValidateAsync"/> instead when async validators are registered.
    /// </exception>
    public bool Validate()
    {
        OnValidationRequested?.Invoke(this, ValidationRequestedEventArgs.Empty);

        if (OnValidationRequestedAsync is { } asyncHandler)
        {
            var delegates = asyncHandler.GetInvocationList();
            for (var i = 0; i < delegates.Length; i++)
            {
                var task = ((Func<object, ValidationRequestedEventArgs, Task>)delegates[i])
                    .Invoke(this, ValidationRequestedEventArgs.Empty)
                    ?? Task.CompletedTask;

                if (!task.IsCompleted)
                {
                    throw new InvalidOperationException(
                        $"An asynchronous validation handler did not complete synchronously. " +
                        $"Use {nameof(ValidateAsync)} instead of {nameof(Validate)} when async validators are registered.");
                }

                // Surface synchronous faults. GetResult unwraps single exceptions (no AggregateException).
                task.GetAwaiter().GetResult();
            }
        }

        return !GetValidationMessages().Any();
    }

    /// <summary>
    /// Validates this <see cref="EditContext"/> asynchronously.
    /// Cancels any pending field-level async validation tasks, invokes the synchronous
    /// <see cref="OnValidationRequested"/> handlers, then invokes and awaits the asynchronous
    /// <see cref="OnValidationRequestedAsync"/> handlers concurrently. Exceptions from synchronous
    /// handlers propagate to the caller, matching <see cref="Validate"/>. Any non-cancellation
    /// exception thrown by an asynchronous handler is contained: the form is marked as faulted
    /// (observable via the parameterless <see cref="IsValidationFaulted()"/>) and the method
    /// returns <c>false</c>.
    /// While the asynchronous portion is in flight, the parameterless
    /// <see cref="IsValidationPending()"/> returns <c>true</c> so applications can show a global
    /// "validating..." indicator without wrapping the call themselves. The form-level
    /// <see cref="IsValidationFaulted()"/> result is updated only when a pass completes; it is
    /// preserved across caller-cancelled passes.
    /// </summary>
    /// <param name="cancellationToken">A token that signals cancellation of this validation pass.
    /// The token is exposed to async handlers via <see cref="ValidationRequestedEventArgs.CancellationToken"/>.
    /// If the caller cancels the token, this method throws <see cref="OperationCanceledException"/>;
    /// the form is not marked as faulted in that case and the previous form-level fault state is preserved.
    /// The token bounds the in-flight pass only; field-level validation tasks that start independently
    /// during the awaited window (for example, from user edits) are not linked to this token and
    /// continue running.</param>
    /// <returns>True if there are no validation messages after validation and no async handler
    /// faulted; otherwise false.</returns>
    /// <remarks>
    /// Validation must not be re-entered. Do not call <see cref="Validate"/> or
    /// <see cref="ValidateAsync"/> from inside an <see cref="OnValidationRequested"/>,
    /// <see cref="OnValidationRequestedAsync"/>, or <see cref="OnValidationStateChanged"/>
    /// handler attached to the same <see cref="EditContext"/>; doing so produces undefined
    /// behavior.
    /// </remarks>
    /// <exception cref="OperationCanceledException">Thrown when <paramref name="cancellationToken"/>
    /// is cancelled before or during the validation pass.</exception>
    public async Task<bool> ValidateAsync(CancellationToken cancellationToken = default)
    {
        // Cancel all pending field-level async tasks - form-submit validation supersedes them
        CancelAllPendingValidationTasks();

        // Mark the pass as in-flight so apps can show a global "validating..." state via
        // IsValidationPending() without wrapping this call. Notify so subscribers re-render.
        _isFormValidationPending = true;
        NotifyValidationStateChanged();

        var faultedThisPass = false;

        try
        {
            // Sync handlers run unchanged - their exceptions propagate to the caller, matching Validate().
            // This guarantees observable parity for apps that haven't introduced any async validators.
            OnValidationRequested?.Invoke(this, ValidationRequestedEventArgs.Empty);

            if (OnValidationRequestedAsync is { } asyncHandler)
            {
                // Only allocate a new args instance when the caller actually supplied a cancellable token;
                // otherwise reuse the shared Empty instance with None token.
                var args = cancellationToken.CanBeCanceled
                    ? new ValidationRequestedEventArgs(cancellationToken)
                    : ValidationRequestedEventArgs.Empty;

                var delegates = asyncHandler.GetInvocationList();
                var tasks = new Task[delegates.Length];

                for (var i = 0; i < delegates.Length; i++)
                {
                    try
                    {
                        tasks[i] = ((Func<object, ValidationRequestedEventArgs, Task>)delegates[i])
                            .Invoke(this, args)
                            ?? Task.CompletedTask;
                    }
                    catch (OperationCanceledException oce)
                    {
                        // Sync throw of OCE before the handler's first await - normalize to a
                        // Canceled task (not Faulted) so the classification loop's IsFaulted scan
                        // naturally excludes it, matching the post-await OCE semantics.
                        tasks[i] = Task.FromCanceled(oce.CancellationToken.IsCancellationRequested
                            ? oce.CancellationToken
                            : new CancellationToken(canceled: true));
                    }
                    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);
                    }
                }

                // Await completion; per-task outcomes are inspected below, so the aggregate
                // exception WhenAll would surface adds no information.
                try
                {
                    await Task.WhenAll(tasks);
                }
                catch
                {
                    // Swallowed; classification happens below. Caller cancellation is re-thrown
                    // via ThrowIfCancellationRequested before classification runs.
                }

                // Caller-requested cancellation propagates to the caller. The previous form-level
                // fault state is preserved since no new result was produced. The finally block
                // still clears the pending flag.
                cancellationToken.ThrowIfCancellationRequested();

                // A handler-internal OperationCanceledException leaves its task in the Canceled
                // state (IsFaulted == false), so it is naturally excluded from this scan.
                //
                // TODO (oroztocil): consider symmetry with the field-level fast path in AddValidationTask,
                // which treats a Canceled task as a fault when our owning CTS was not the source of
                // cancellation (e.g. an HttpClient timeout via the validator's own token). The
                // form-level path here silently swallows such Canceled outcomes.
                for (var i = 0; i < tasks.Length; i++)
                {
                    if (tasks[i].IsFaulted)
                    {
                        faultedThisPass = true;
                        break;
                    }
                }
            }

            // Pass completed (success, invalid, or contained handler fault). Assign the new fault
            // result atomically so that consumers querying IsValidationFaulted() during the pass
            // continue to see the previous result instead of a brief reset-then-set flicker.
            _isFormValidationFaulted = faultedThisPass;
        }
        finally
        {
            // Pending flag flips to false unconditionally - whether we completed normally,
            // were cancelled by the caller, or a sync handler threw. Single end-of-pass
            // notification covers both the pending change and any fault assignment above.
            _isFormValidationPending = false;
            NotifyValidationStateChanged();
        }

        return !faultedThisPass && !GetValidationMessages().Any();
    }

    /// <summary>
    /// Registers an async validation task for a specific field. The task is tracked for
    /// pending/faulted state queries via <see cref="IsValidationPending(in FieldIdentifier)"/>
    /// and <see cref="IsValidationFaulted(in FieldIdentifier)"/>. If a task is already tracked
    /// for this field, the previously registered <see cref="CancellationTokenSource"/> is cancelled
    /// and the new task replaces it. The <see cref="EditContext"/> takes ownership of the supplied
    /// <paramref name="cts"/>: it will be cancelled if a subsequent validation supersedes this one,
    /// and disposed once <paramref name="task"/> completes.
    /// </summary>
    /// <remarks>
    /// If <paramref name="task"/> is already completed, it is settled synchronously: the field is
    /// not parked in the pending state, a faulted task is surfaced via
    /// <see cref="IsValidationFaulted(in FieldIdentifier)"/>, and <paramref name="cts"/> is disposed.
    /// <para>
    /// Validators backing <paramref name="task"/> are expected to clear any prior validation
    /// messages for the field up-front (before awaiting), and to avoid writing partial results
    /// to a <see cref="ValidationMessageStore"/> on a path that may subsequently throw. If a
    /// validator does write partial state and then throws, those messages remain in the store
    /// until cleared by a subsequent successful validation.
    /// </para>
    /// </remarks>
    /// <param name="fieldIdentifier">Identifies the field being validated.</param>
    /// <param name="task">The async validation task to track.</param>
    /// <param name="cts">The <see cref="CancellationTokenSource"/> that can cancel the task.</param>
    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 semantics:
            //   - Faulted task    => fault flag set, exception observed to suppress UnobservedTaskException.
            //   - Canceled task by our CTS (caller pre-cancelled or superseded) => no fault.
            //   - Canceled task NOT by our CTS (validator threw OCE from an unrelated source,
            //     e.g. HttpClient timeout) => treat as fault, same as the asynchronous path.
            //   - Successful task => no fault.
            if (task.IsFaulted)
            {
                _ = task.Exception; // observe to suppress UnobservedTaskException
            }
            var faulted = task.IsFaulted || (task.IsCanceled && !cts.IsCancellationRequested);
            var faultFlagChanged = faulted != state.IsValidationFaulted;
            state.IsValidationFaulted = faulted;

            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);
    }

    /// <summary>
    /// Returns <c>true</c> if the specified field has a pending async validation task.
    /// A task is "pending" until the framework's observer has settled its outcome and cleared the
    /// slot (i.e., not only until the task itself completes) so a consumer that waits for
    /// <see cref="IsValidationPending(in FieldIdentifier)"/> 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>() =&gt; 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.
    /// Suitable for driving form-wide UI such as disabling a submit button or showing a
    /// "validating..." indicator for the current submission. Does not consider field-level pending
    /// tasks (those are superseded when the next form-level pass starts); use the
    /// <see cref="IsValidationPending(in FieldIdentifier)"/> overload for per-field state.
    /// </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>() =&gt; 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 <see cref="ValidateAsync"/> pass observed an
    /// unhandled exception from any <see cref="OnValidationRequestedAsync"/> handler. A subsequent
    /// successful <see cref="ValidateAsync"/> pass clears the flag; a caller-cancelled pass
    /// preserves it. Use this to detect that validation itself failed (not just produced validation messages).
    /// For per-field validator faults from <see cref="AddValidationTask"/>, use the <see cref="IsValidationFaulted(in FieldIdentifier)"/> overload.
    /// </summary>
    /// <returns><c>true</c> if the most recent <see cref="ValidateAsync"/> 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)
            {
                // Field had a previously-faulted task that already settled (so its CTS was
                // already cleared). Clear the lingering fault flag at the start of the new
                // validation pass so per-field IsValidationFaulted reflects only the current
                // pass's outcome.
                state.IsValidationFaulted = false;
                changed = true;
            }
        }

        if (changed)
        {
            // The pending/faulted state of one or more fields just changed. Notify so UI components
            // observing IsValidationPending or IsValidationFaulted re-render even if no subsequent
            // handler raises its own notification.
            NotifyValidationStateChanged();
        }
    }

    private async Task ObserveValidationTaskAsync(FieldState state, Task task, CancellationTokenSource cts)
    {
        var faulted = false;

        try
        {
            try
            {
                await task;
            }
            catch (OperationCanceledException) when (cts.IsCancellationRequested)
            {
                // Cancellation initiated by us (field re-edited or form submitted) is silent.
                // OperationCanceledException from another source (e.g. an HttpClient timeout
                // inside the validator) falls through to the catch (Exception) block below
                // so the field is correctly marked as faulted.
            }
            catch (Exception)
            {
                // Infrastructure fault.
                faulted = true;
            }

            if (ReferenceEquals(state.PendingValidationTask, task))
            {
                state.IsValidationFaulted = faulted;
                state.PendingValidationTask = null;
                state.PendingValidationCts = null;
                NotifyValidationStateChanged();
            }
        }
        finally
        {
            // Always dispose the CTS we own. Safe whether or not the slot is still current,
            // and only happens after the validator task has observably settled, so the validator
            // can never observe a disposed token.
            cts.Dispose();
        }
    }
}