Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## Unreleased

* Fixed `validate()` returning a stale answer when called twice in the same turn: the second call joined the first and never re-ran the sync validator, so a field whose rule reads another field missed a change made in between — e.g. calling `validate()` on a dependent field from a listener, as the skill recommends, after an eager `validate()`. Every call now re-runs the sync validators; only an async check already in flight is shared, so a double-tapped submit still makes one set of server calls.

## 0.2.2

* Conditional sections in one line: `addSubform(invoice, enabled: () => needsInvoice.fieldValue)` attaches and detaches the section as the checkbox flips, and its values survive.
Expand Down
2 changes: 1 addition & 1 deletion MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ Future<void> submit() async { // 0.2.0
}
```

Calling it again before the first call finishes gives you the same result, so a double-tapped submit button runs one pass. For a synchronous "can I enable the button?" read, use `form.value.canSubmit` — a snapshot of *known* errors, true on a form nobody has checked yet.
Calling it again while async checks are in flight awaits them instead of starting new ones, so a double-tapped submit button makes one set of server calls; the sync validators run again on every call, so the answer is never stale. For a synchronous "can I enable the button?" read, use `form.value.canSubmit` — a snapshot of *known* errors, true on a form nobody has checked yet.

### `autovalidate` is replaced by `ValidationMode`

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ The field makes and owns that `focusNode`. Pass `focusNode:` to the constructor

To write a value the user did not type — prefilling from a profile fetch, for instance — use `field.prefill(value)`. It stores the value and clears the errors without making the field count as edited.

`validate()` is asynchronous because it may have to wait on the server. **Await it** — the result is the only thing that says the values were actually checked. Calling it again before the first call finishes gives you the same result, so a double-tapped submit button runs one pass.
`validate()` is asynchronous because it may have to wait on the server. **Await it** — the result is the only thing that says the values were actually checked. Calling it again while async checks are in flight awaits them instead of starting new ones, so a double-tapped submit button makes one set of server calls; the sync validators run again on every call, so the answer is never stale.

```dart
Future<void> submit() async {
Expand Down
3 changes: 2 additions & 1 deletion docs/faq.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@ state-model change; see [Under the hood](./internals/decisions.mdx#deliberate-re
</Accordion>
<Accordion title="Can a submit be double-tapped?">

Not into two validation passes: concurrent `validate()` calls share one future. Your *save request* is another
Not into two rounds of server checks: a second `validate()` awaits the async checks already in flight (the cheap sync
validators run again, so the answer is current). Your *save request* is another
matter — the package knows nothing about it. Keep a flag on the controller and set it before the first `await`. See
[Form-level state](./form-state/submit-button.mdx).

Expand Down
6 changes: 4 additions & 2 deletions docs/internals/validate-and-failures.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,10 @@ 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.
either coalesce on the async round only: every call re-runs the sync validator, which is cheap and must never be stale
— its verdict can depend on other fields that changed since the last call — while a round in flight is awaited, so a
double-tapped submit button cannot start two server calls. A field disposed mid-flight completes `false` rather than
hanging.

## The failure model

Expand Down
5 changes: 3 additions & 2 deletions docs/validation/validate.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,9 @@ Future<void> submit() async {
- **Await it.** The result is the only thing that says the values were actually checked. `canSubmit` is a snapshot of
*known* errors and is `true` on a form nobody has checked yet. A bare `validate();` statement compiles and silently
ignores the result.
- **Concurrent calls coalesce.** Calling `validate()` again before the first call finishes returns the same future, so
a double-tapped submit button runs one pass.
- **Async checks are shared, sync ones are not.** Every call re-runs the sync validators against the current values,
so a call made right after another field changed sees the change. A second call while async checks are in flight
awaits them instead of starting new ones, so a double-tapped submit button makes one set of server calls.
- **The errors stay on the fields.** The form-level result is just a `bool` — "may this submit proceed?". The widgets
showing errors are already subscribed to their fields.

Expand Down
16 changes: 8 additions & 8 deletions lib/src/field/advanced_field_controller.dart
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import 'dart:async';

import 'package:advanced_forms/src/field/advanced_field_state.dart';
import 'package:advanced_forms/src/utils/shared_call.dart';
import 'package:advanced_forms/src/validation_mode.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
Expand Down Expand Up @@ -90,7 +89,6 @@ class AdvancedFieldController<T, E extends Object>
final T _initialValue;
final Validator<T, E> _validator;
final AsyncValidation<T, E>? _asyncValidation;
final _validateCall = SharedCall<bool>();
VoidCallback? _fieldsSubscriptionCleanup;
_ValidationRound<T, E>? _currentRound;
AsyncValidationFailure? _lastFailure;
Expand Down Expand Up @@ -224,12 +222,14 @@ class AdvancedFieldController<T, E extends Object>
/// 4. Otherwise a round runs immediately — which is also how a failed round
/// is retried.
///
/// Calling this again before the first call finishes gives you the same
/// result; it does not start a second round. A field disposed mid-round
/// completes `false`.
Future<bool> validate() => _isDisposed
? Future.value(false)
: _validateCall.run(_rounds.runValidate);
/// Every call runs the sync validator again on the current value, and its
/// result lands before this returns — so a call made right after a field it
/// depends on changed sees that change. Only the async round is shared:
/// calling this again while one is in flight for the same value awaits it,
/// it does not start a second one. A field disposed mid-round completes
/// `false`.
Future<bool> validate() =>
_isDisposed ? Future.value(false) : _rounds.runValidate();

/// Gives this field its own validation mode, ignoring the form's from now on.
/// Cannot be undone — the field no longer follows form mode changes.
Expand Down
3 changes: 2 additions & 1 deletion lib/src/field/validation_round.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ extension type _Rounds<T, E extends Object>(

if (validationError != null) {
// Sync error already failed — skip async. Keep any existing async verdict
// since the value did not change.
// since the value did not change. Aborting also stops a round still in
// flight from landing over this error; whoever awaits it gets false.
abort();
_setState(
_value.copyWithNullable(
Expand Down
20 changes: 5 additions & 15 deletions lib/src/form/advanced_form_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import 'dart:async';

import 'package:advanced_forms/src/field/advanced_field_controller.dart';
import 'package:advanced_forms/src/form/advanced_form_state.dart';
import 'package:advanced_forms/src/utils/shared_call.dart';
import 'package:advanced_forms/src/validation_mode.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
Expand Down Expand Up @@ -63,7 +62,6 @@ class AdvancedFormController
// Conditional sections: the `enabled` closure and its last result.
// Re-evaluated whenever a value in this tree changes; attaches and detaches.
final Map<AdvancedFormController, _SubformCondition> _subformConditions = {};
final _validateCall = SharedCall<bool>();

// null: follow the parent form's mode. Non-null: this form manages its own.
ValidationMode? _ownMode;
Expand Down Expand Up @@ -135,17 +133,11 @@ class AdvancedFormController
/// subtree with [AdvancedFormState.validationEnabled] false, `false` once
/// disposed.
///
/// Calling this again before the first call finishes returns the same
/// result; it does not start a second validation run.
Future<bool> validate() {
// Check in-flight first — callers mid-disposal still get the running
// validate() result.
if (_validateCall.inFlight case final inFlight?) {
return inFlight;
}

return isDisposed ? Future.value(false) : _validateCall.run(_runValidate);
}
/// Every call runs each field's sync validator again on its current value.
/// Calling this again while async validators are in flight does not start
/// them a second time — each field awaits its own round, so a double-tapped
/// submit button makes one set of async calls.
Future<bool> validate() => isDisposed ? Future.value(false) : _runValidate();

/// Re-runs the **sync** validator on every leaf field in the tree that its
/// mode and the interaction guarantee allow.
Expand Down Expand Up @@ -382,8 +374,6 @@ class AdvancedFormController
final enabledChanged = enabled != value.validationEnabled;

if (mode != value.validationMode || enabledChanged) {
// Settings changed — drop any validate() still running under the old ones.
_validateCall.invalidate();
_setState(
value.copyWith(validationMode: mode, validationEnabled: enabled),
);
Expand Down
52 changes: 0 additions & 52 deletions lib/src/utils/shared_call.dart

This file was deleted.

12 changes: 7 additions & 5 deletions skills/advanced_forms-build-forms/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,8 @@ logging, never read by the package) and `validateAll` (below).
**`validate()` ignores all of it.** `await form.validate()` validates every field and subform
— including ones the user never touched — and returns `false` if anything is invalid. It
neither consults nor changes the mode, so there is no escalation and no "live after the first
submit" for free. Double-tapping submit is safe: a second call joins the first. **Always
submit" for free. Double-tapping submit is safe: every call re-runs the sync validators, and a
second call awaits the async checks already in flight instead of starting new ones. **Always
`await validate()` before using the values** — `state.isValid` and `form.value.canSubmit` mean
"no error recorded right now", and a passing `validate()` is what licenses a `!` on a nullable
field value.
Expand Down Expand Up @@ -516,10 +517,11 @@ children.subscribeToFields([adults]);
});
```

Two traps: a `validate()` already in flight is shared, so a second call before it finishes
gets the first round's result — one value change per event-loop turn is fine, a synchronous
loop of writes is not; and `validate()` also runs `asyncValidation`, so a field with a server
check will hit the server on every change of the watched field.
Every call re-runs the sync validator against the current values, so this is safe even
right after another `validate()` in the same turn (an eager check in the constructor, say).
One trap: `validate()` also runs `asyncValidation`, so a field with a server check hits the
server the first time the watched field changes; after that the verdict for its unchanged
value is reused.

**Value depends on another field** ("when B changes, set A" — totals, mirroring, clearing a
dependent selection). Use the form's `addRelation(source, select, onChange)`, in the
Expand Down
69 changes: 69 additions & 0 deletions test/src/field/field_dependencies_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -137,4 +137,73 @@ void main() {
expect(field1.value.isValid, isTrue);
});
});

// A prefilled field the user never touched, which must still react when a
// field it depends on changes — the pattern the skill documents.
group('validate on an untouched dependent', () {
test('sees a dependency changed in the same turn', () async {
final form = _EligibilityForm();
addTearDown(form.dispose);

// An eager check, e.g. in the form constructor.
form.instructor.validate().ignore();
form.aircraft.select('B');
form.instructor.validate().ignore();

expect(form.instructor.error, 'not eligible');
});

test('sees it when a turn went by in between', () async {
final form = _EligibilityForm();
addTearDown(form.dispose);

form.instructor.validate().ignore();
await pumpEventQueue();
form.aircraft.select('B');
form.instructor.validate().ignore();

expect(form.instructor.error, 'not eligible');
});

test('reacts from a listener on the watched field', () async {
final form = _EligibilityForm(validateOnAircraftChange: true);
addTearDown(form.dispose);

form.aircraft.select('B');

expect(form.instructor.error, 'not eligible');
});
});
}

class _EligibilityForm extends AdvancedFormController {
_EligibilityForm({bool validateOnAircraftChange = false})
: super(validationMode: ValidationMode.onUserInteraction) {
registerFields([aircraft, instructor]);
instructor.subscribeToFields([aircraft]);

if (validateOnAircraftChange) {
instructor.validate().ignore();
var lastAircraft = aircraft.fieldValue;
aircraft.addListener(() {
if (aircraft.fieldValue == lastAircraft) {
return;
}
lastAircraft = aircraft.fieldValue;
instructor.validate().ignore();
});
}
}

final aircraft = AdvancedSingleSelectFieldController<String, String>(
initialValue: 'A',
options: const ['A', 'B'],
);

late final instructor = AdvancedSingleSelectFieldController<String, String>(
initialValue: 'rated-on-A',
options: const ['rated-on-A'],
validator: (value) =>
value != null && aircraft.fieldValue != 'A' ? 'not eligible' : null,
);
}
45 changes: 43 additions & 2 deletions test/src/field/field_validate_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ void main() {
expect(await result, isFalse);
});

test('two concurrent calls coalesce into one pass', () async {
test('two concurrent calls share one async round', () async {
final (:field, :validated) = makeAsyncField(
validatorDelay: const Duration(milliseconds: 50),
);
Expand All @@ -133,12 +133,53 @@ void main() {
final first = field.validate();
final second = field.validate();

expect(identical(first, second), isTrue);
expect(await first, true);
expect(await second, true);
expect(validated, const [initialValue]);
});

test('a second call in the same turn re-runs the sync validator', () async {
validator.validationResult = null;
final first = field.validate();

// What the validator reads changed, not the field's own value.
validator.validationResult = TestError.malformed;
final second = field.validate();

// Nothing has been awaited: the sync verdict lands in the call itself.
expect(field.error, TestError.malformed);
expect(await first, true);
expect(await second, false);
});

test('a sync error found by a second call survives the shared round',
() async {
TestError? syncError;
final (:field, :validated) = makeAsyncField(
validator: (_) => syncError,
validatorDelay: const Duration(milliseconds: 50),
mode: ValidationMode.manual,
);
addTearDown(field.dispose);

final first = field.validate();
expect(field.value.isValidating, isTrue);

syncError = TestError.malformed;
final second = field.validate();
expect(field.error, TestError.malformed);

expect(await first, false);
expect(await second, false);
// Let the validator settle: its answer must not land over the sync error.
await Future<void>.delayed(const Duration(milliseconds: 100));

expect(validated, const [initialValue]);
expect(field.value.validationError, TestError.malformed);
expect(field.value.asyncError, isNull);
expect(field.value.status, FieldStatus.invalid);
});

test('flushes a round still waiting out its debounce', () async {
final (:field, :validated) = makeAsyncField(
debounce: const Duration(seconds: 10),
Expand Down
Loading
Loading