From a53446c1babea48ad7161071a81563428300f455 Mon Sep 17 00:00:00 2001 From: Albert Wolszon Date: Wed, 23 Sep 2026 17:04:10 +0200 Subject: [PATCH] validate() re-runs the sync validator on every call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two validate() calls in one synchronous turn shared one run: SharedCall cleared its in-flight future in a .then callback, a microtask later, even when the field had no async validator. The second call got a verdict computed before a dependency changed — exactly what the skill's "prefilled field must react" listener pattern runs into after an eager validate() in a form constructor. Drop SharedCall from both controllers. runValidate already shares what is worth sharing: an in-flight async round is awaited, a settled verdict reused. A sync error found by a later call aborts the round, so its answer cannot land over the error. The form's validate() calls every field's validate(), so a double-tapped submit still makes one set of async calls. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 4 + MIGRATION.md | 2 +- README.md | 2 +- docs/faq.mdx | 3 +- docs/internals/validate-and-failures.mdx | 6 +- docs/validation/validate.mdx | 5 +- lib/src/field/advanced_field_controller.dart | 16 +- lib/src/field/validation_round.dart | 3 +- lib/src/form/advanced_form_controller.dart | 20 +-- lib/src/utils/shared_call.dart | 52 ------- skills/advanced_forms-build-forms/SKILL.md | 12 +- test/src/field/field_dependencies_test.dart | 69 +++++++++ test/src/field/field_validate_test.dart | 45 +++++- test/src/form/form_controller_test.dart | 40 ++++- test/src/utils/shared_call_test.dart | 151 ------------------- 15 files changed, 181 insertions(+), 249 deletions(-) delete mode 100644 lib/src/utils/shared_call.dart delete mode 100644 test/src/utils/shared_call_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 6aba5f2..b971b2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/MIGRATION.md b/MIGRATION.md index 7bd9037..1bd44e8 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -72,7 +72,7 @@ Future 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` diff --git a/README.md b/README.md index 0985b31..d15e274 100644 --- a/README.md +++ b/README.md @@ -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 submit() async { diff --git a/docs/faq.mdx b/docs/faq.mdx index c0ebf9a..a854b2b 100644 --- a/docs/faq.mdx +++ b/docs/faq.mdx @@ -63,7 +63,8 @@ state-model change; see [Under the hood](./internals/decisions.mdx#deliberate-re -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). diff --git a/docs/internals/validate-and-failures.mdx b/docs/internals/validate-and-failures.mdx index 4fb81fe..729cb16 100644 --- a/docs/internals/validate-and-failures.mdx +++ b/docs/internals/validate-and-failures.mdx @@ -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 diff --git a/docs/validation/validate.mdx b/docs/validation/validate.mdx index 5e212ec..142a02b 100644 --- a/docs/validation/validate.mdx +++ b/docs/validation/validate.mdx @@ -26,8 +26,9 @@ Future 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. diff --git a/lib/src/field/advanced_field_controller.dart b/lib/src/field/advanced_field_controller.dart index accfecd..5b498a8 100644 --- a/lib/src/field/advanced_field_controller.dart +++ b/lib/src/field/advanced_field_controller.dart @@ -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'; @@ -90,7 +89,6 @@ class AdvancedFieldController final T _initialValue; final Validator _validator; final AsyncValidation? _asyncValidation; - final _validateCall = SharedCall(); VoidCallback? _fieldsSubscriptionCleanup; _ValidationRound? _currentRound; AsyncValidationFailure? _lastFailure; @@ -224,12 +222,14 @@ class AdvancedFieldController /// 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 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 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. diff --git a/lib/src/field/validation_round.dart b/lib/src/field/validation_round.dart index ded0dd3..8f00d91 100644 --- a/lib/src/field/validation_round.dart +++ b/lib/src/field/validation_round.dart @@ -17,7 +17,8 @@ extension type _Rounds( 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( diff --git a/lib/src/form/advanced_form_controller.dart b/lib/src/form/advanced_form_controller.dart index 883b247..4d9d8dc 100644 --- a/lib/src/form/advanced_form_controller.dart +++ b/lib/src/form/advanced_form_controller.dart @@ -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'; @@ -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 _subformConditions = {}; - final _validateCall = SharedCall(); // null: follow the parent form's mode. Non-null: this form manages its own. ValidationMode? _ownMode; @@ -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 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 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. @@ -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), ); diff --git a/lib/src/utils/shared_call.dart b/lib/src/utils/shared_call.dart deleted file mode 100644 index 304acf4..0000000 --- a/lib/src/utils/shared_call.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'dart:async'; - -import 'package:meta/meta.dart'; - -/// One run of an operation, shared by everyone who asks for it while it is -/// still going — so a double-tapped submit button cannot start two validation -/// passes. -@internal -class SharedCall { - Future? _inFlight; - - /// The run currently in flight, or null when nothing is running. - Future? get inFlight => _inFlight; - - /// Returns the run already in flight, or starts [body] and holds onto it. - /// All callers of one run get the same future. - Future run(Future Function() body) { - if (_inFlight case final inFlight?) { - return inFlight; - } - - final completer = Completer(); - final future = completer.future; - _inFlight = future; - - // Only clears its own run: [invalidate] may already have replaced it. - void clear() { - if (identical(_inFlight, future)) { - _inFlight = null; - } - } - - // Clear in callbacks (not whenComplete) to avoid an unhandled async error. - // Future.sync lets sync throws reject without leaving _inFlight set forever. - Future.sync(body).then( - (value) { - clear(); - completer.complete(value); - }, - onError: (Object error, StackTrace stackTrace) { - clear(); - completer.completeError(error, stackTrace); - }, - ); - - return future; - } - - /// Drops the run in flight, so the next [run] starts a fresh one. Whoever - /// already awaited the dropped run still gets its result. - void invalidate() => _inFlight = null; -} diff --git a/skills/advanced_forms-build-forms/SKILL.md b/skills/advanced_forms-build-forms/SKILL.md index 03da8bf..b336072 100644 --- a/skills/advanced_forms-build-forms/SKILL.md +++ b/skills/advanced_forms-build-forms/SKILL.md @@ -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. @@ -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 diff --git a/test/src/field/field_dependencies_test.dart b/test/src/field/field_dependencies_test.dart index 83a5dfb..31712e9 100644 --- a/test/src/field/field_dependencies_test.dart +++ b/test/src/field/field_dependencies_test.dart @@ -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( + initialValue: 'A', + options: const ['A', 'B'], + ); + + late final instructor = AdvancedSingleSelectFieldController( + initialValue: 'rated-on-A', + options: const ['rated-on-A'], + validator: (value) => + value != null && aircraft.fieldValue != 'A' ? 'not eligible' : null, + ); } diff --git a/test/src/field/field_validate_test.dart b/test/src/field/field_validate_test.dart index cca0298..cdd448c 100644 --- a/test/src/field/field_validate_test.dart +++ b/test/src/field/field_validate_test.dart @@ -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), ); @@ -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.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), diff --git a/test/src/form/form_controller_test.dart b/test/src/form/form_controller_test.dart index 8356c42..4bd2c0c 100644 --- a/test/src/form/form_controller_test.dart +++ b/test/src/form/form_controller_test.dart @@ -427,7 +427,7 @@ void main() { subformField.dispose(); }); - test('concurrent calls coalesce into one pass', () async { + test('concurrent calls share one async round', () async { var calls = 0; final asyncField = AdvancedTextFieldController<_Error1>( initialValue: _initialValue1, @@ -449,13 +449,37 @@ void main() { final first = form.validate(); final second = form.validate(); - expect(identical(first, second), isTrue); expect(await first, true); expect(await second, true); expect(calls, 1); }); - test('a disabled form does not occupy the coalescing slot', () async { + test('a second call in the same turn sees a value changed in between', + () async { + final source = AdvancedFieldController(initialValue: 0); + final dependent = AdvancedFieldController( + initialValue: 0, + validator: (_) => source.fieldValue == 0 ? null : _Error2.malformed, + ); + final dependentForm = AdvancedFormController() + ..registerFields([source, dependent]); + addTearDown(dependentForm.dispose); + addTearDown(form.dispose); + addTearDown(field1.dispose); + addTearDown(field2.dispose); + addTearDown(subform.dispose); + addTearDown(subformField.dispose); + + final first = dependentForm.validate(); + source.setValue(1); + final second = dependentForm.validate(); + + expect(dependent.value.error, _Error2.malformed); + expect(await first, isTrue); + expect(await second, isFalse); + }); + + test('a call on a disabled form does not answer a later one', () async { form.registerFields([field1]); addTearDown(form.dispose); addTearDown(field2.dispose); @@ -465,15 +489,15 @@ void main() { form.setValidationEnabled(false); // Fire and forget, as a UI handler would. This returns `true` without - // validating anything, so it must not become the run a later call - // coalesces onto. + // validating anything, so it must not become the answer to a later + // call. form.validate().ignore(); form.setValidationEnabled(true); expect(await form.validate(), isFalse); }); - test('a disposed form with a call in flight returns that call', () async { + test('a disposed form with a call in flight completes false', () async { final asyncField = AdvancedTextFieldController<_Error1>( initialValue: _initialValue1, asyncValidation: AsyncValidation( @@ -492,8 +516,8 @@ void main() { final first = form.validate(); form.dispose(); - expect(identical(form.validate(), first), isTrue); - await first; + expect(await form.validate(), isFalse); + expect(await first, isFalse); // `first` resolves at dispose, so drain the validator before the next // test starts — this suite runs on wall-clock delays. await Future.delayed(const Duration(milliseconds: 80)); diff --git a/test/src/utils/shared_call_test.dart b/test/src/utils/shared_call_test.dart deleted file mode 100644 index 06a0445..0000000 --- a/test/src/utils/shared_call_test.dart +++ /dev/null @@ -1,151 +0,0 @@ -import 'dart:async'; - -import 'package:advanced_forms/src/utils/shared_call.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - late SharedCall call; - - setUp(() { - call = SharedCall(); - }); - - group('run', () { - test('joins the run in flight instead of running the body twice', () async { - final gate = Completer(); - var calls = 0; - Future body() { - calls++; - return gate.future; - } - - final first = call.run(body); - final second = call.run(body); - - expect(identical(first, second), isTrue); - expect(calls, 1); - - gate.complete(7); - expect(await first, 7); - expect(await second, 7); - }); - - test('a re-entrant call from inside the body joins the same run', () async { - var calls = 0; - late Future reentrant; - Future body() async { - calls++; - // The slot is claimed before the body runs, so a validator that - // synchronously asks for another pass joins this one. - reentrant = call.run(body); - return 1; - } - - final first = call.run(body); - - expect(await first, 1); - expect(identical(reentrant, first), isTrue); - expect(calls, 1); - }); - - test('runs the body again once the earlier run has settled', () async { - var calls = 0; - Future body() async { - calls++; - return calls; - } - - expect(await call.run(body), 1); - expect(await call.run(body), 2); - expect(calls, 2); - }); - - test('runs the body again once the earlier run has rejected', () async { - var calls = 0; - Future body() async { - calls++; - if (calls == 1) { - throw StateError('body exploded'); - } - return calls; - } - - await expectLater(call.run(body), throwsStateError); - expect(call.inFlight, isNull); - - expect(await call.run(body), 2); - }); - - test('rejects every joined caller with the same error', () async { - final gate = Completer(); - final failure = StateError('body exploded'); - var calls = 0; - Future body() { - calls++; - return gate.future; - } - - final first = call.run(body); - final second = call.run(body); - gate.completeError(failure, StackTrace.current); - - await expectLater(first, throwsA(same(failure))); - await expectLater(second, throwsA(same(failure))); - expect(calls, 1); - // A duplicated error would arrive as an unhandled async error, which - // fails the test — so let the queue drain before it ends. - await pumpEventQueue(); - }); - - test('does not leak a rejected run as an unhandled async error', () async { - final uncaught = []; - - await runZonedGuarded( - () async { - final seen = []; - // Only one caller listens, so any second copy of the error has - // nowhere to go but the zone. - await call - .run(() async => throw StateError('body exploded')) - .then((_) {}, onError: seen.add); - await pumpEventQueue(); - - expect(seen, hasLength(1)); - }, - (error, stackTrace) => uncaught.add(error), - ); - - expect(uncaught, isEmpty); - }); - - test('a body that throws synchronously rejects and frees the slot', - () async { - final failure = StateError('body exploded'); - // An unguarded body would throw out of `run` itself, right here. - final result = call.run(() => throw failure); - - await expectLater(result, throwsA(same(failure))); - expect(call.inFlight, isNull); - expect(await call.run(() async => 1), 1); - }); - }); - - group('inFlight', () { - test('is null when nothing is running', () { - expect(call.inFlight, isNull); - }); - - test('is the shared run until the body settles', () async { - final gate = Completer(); - final first = call.run(() => gate.future); - - // Callers that join through `inFlight` must get this exact future. - expect(identical(call.inFlight, first), isTrue); - - gate.complete(1); - await first; - - expect(call.inFlight, isNull); - }); - }); -}