validate() and failures
Verdict reuse, how validate() picks what to do, why concurrent calls coalesce, the failure model, and why form aggregates are derived on read.
Verdict reuse
A settled verdict is reused while it still describes the current value.
- A second submit press on an unchanged form makes zero network calls.
- One edit invalidates only that field's verdict.
- A round that failed records no verdict, so submit re-runs it. That is the retry, and it needs no API — a
revalidateFailed()was designed and then dropped for this reason. - The async validator is treated as a function of the value. A check depending on state outside the value must be
invalidated explicitly with
clearErrors(), and the dartdoc says so.
validate()
On a field: run the sync validator on the current value, always, and write what it concludes. If it failed, abort
any live round and return false — no network call for a value already known bad. Otherwise exactly one of, in order:
a round waiting out its debounce is flushed; a round in flight is awaited, not duplicated; a current verdict is
reused with no call; the check runs now. Return the status, not the error slots: a failed round leaves no
code unless failureToError supplied one, so reading error == null would report success for a check that never
completed.
On a form: validate every field and every subform concurrently with no short-circuit — every field must be
visited — then AND the results. Return true immediately when validationEnabled is false. Concurrent calls on
either coalesce: while one validate() is in flight the same future is returned, so a double-tapped submit button
cannot start two passes. A field disposed mid-flight completes false rather than hanging.
The failure model
failedValidation is not sticky; submit is the retry.
hasFailedValidationon the form drives one banner. No per-field message appears unless the app opts in withfailureToError, which exists so no app is forced to add a "could not verify" member to its error enum.failedValidationis notvalid, socanSubmitisfalsewhile it stands.timeoutdefaults tonull. On expiry the round is abandoned, the field goes tofailedValidation, andlastFailure.timedOutistrue. Without a timeout, a validator that never settles still hangsawait validate()— the package's guarantee is narrower and precise: every round it starts settles exactly once.lastFailurelives on the controller, deliberately outside the state and without value equality. State equality gates every write; an arbitrary exception inside==would hand third-party code control over whether a write happens, and excluding the field from==would make a failure-to-different-failure transition compare equal and silently keep the old exception. It is assigned in one place and the getter gates on the status.
Form aggregates are derived on read
bool get validating => _countedFields.any((f) => f.value.isInProgress);
bool get hasFailedValidation => _countedFields.any((f) => f.value.isFailedValidation);
bool get canSubmit => _countedFields.every((f) => f.value.isValid);No aggregate can outlive the child that justified it. validating used to be stored, which is how removing a subform
mid-validation once latched it true forever with zero fields left. _countedFields skips subtrees with validation
switched off, so canSubmit and validate() can never disagree. wasModified stays stored because it needs the
baseline captured at registerFields, and is recomputed on every membership change.
Rounds and error slots
An async round belongs to one value and may write state only while it is current. Which operations abort it, which slot each writer owns, and why there are two error slots.
Design decisions
What the package deliberately is not, the residue that is recorded rather than fixed, the decisions that will not change, and how all of it is verified.