Skip to content

validate() re-runs the sync validator on every call; only async rounds are shared - #95

Draft
Albert221 wants to merge 1 commit into
mainfrom
fix/validate-stale-within-turn
Draft

Albert221 wants to merge 1 commit into
mainfrom
fix/validate-stale-within-turn

Conversation

@Albert221

Copy link
Copy Markdown
Member

Problem

If you call validate() twice in the same synchronous turn, the second call gets a stale answer when something the validator reads changed in between. Only the field's own value invalidates the shared run, so a change to a field it depends on is missed.

0.2.2's skill ("When a prefilled field MUST react anyway") recommends calling dependent.validate() from a listener on the watched field. Put that together with an eager validate() in the form constructor and it silently returns the old verdict. We ran into this while moving two production forms, including an aircraft booking form, to 0.2.2 and following that advice.

class EligibilityForm extends AdvancedFormController {
  EligibilityForm() : super(validationMode: ValidationMode.onUserInteraction) {
    registerFields([aircraft, instructor]);
    instructor.subscribeToFields([aircraft]);
  }
  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);
}

final form = EligibilityForm();
form.instructor.validate();   // eager check, e.g. in a constructor
form.aircraft.select('B');    // makes the instructor ineligible
form.instructor.validate();   // the documented pattern
form.instructor.error;        // 0.2.2: null (stale). With an `await` between the calls: 'not eligible'.

Root cause

Both controllers route validate() through SharedCall, which clears its in-flight future in a .then callback. That happens a microtask later, even when the validator finished synchronously and there is no async validator. Until then, every call joins the first run, and its verdict was computed before the dependency changed.

Fix

SharedCall is gone and validate() calls runValidate directly on both controllers:

  • Every call re-runs the sync validator against the current value. The result lands before validate() returns.
  • Only the async round is shared. runValidate already did this: an in-flight round is awaited rather than restarted, a debouncing round is flushed, and a settled verdict for an unchanged value is reused.
  • Without asyncValidation, calls don't share anything. Each call is independent.
  • A sync error found by a later call aborts the in-flight round. Its answer can't land over the error, and anyone awaiting it gets false. This is the existing sync-failure path in runValidate; I only added a comment.

Nothing else changes: debounce, abort and verdict handling, a disposed field completing false, and a superseded round reporting false.

AdvancedFormController.validate() had the same bug. It shared through its own SharedCall, so calling form.validate() twice in one turn after a value change returned the first result. I removed that SharedCall too, which also removes the invalidate() that ran when the mode changed. form.validate() calls every field's validate(), and those now share their async rounds. So a double-tapped submit still makes one set of async calls; the existing "concurrent calls share one async round" test still checks there is exactly one validator call.

SharedCall had no other users, so I deleted it along with its unit test.

Behaviour changes to note

  • Two concurrent calls no longer return the identical Future object. They still resolve to the same result and share one async round. Two tests asserted identical(first, second), so I dropped that assertion and kept the one-validator-call check.
  • A form disposed while a validate() is in flight now returns false from later calls instead of that in-flight future. The in-flight future resolves false anyway, because disposing aborts the fields' rounds. The test is now "a disposed form with a call in flight completes false".

Tests

New tests:

  • field_dependencies_test.dart, "validate on an untouched dependent":
    • the repro above, with no await between the calls;
    • the same with a turn in between (a control);
    • the skill's listener pattern with an eager validate() in the constructor.
  • field_validate_test.dart:
    • a second call in the same turn re-runs the sync validator, and its result lands synchronously;
    • a sync error found by a second call is not overwritten when the shared async round lands, and the async validator runs only once.
  • form_controller_test.dart: a second form.validate() in the same turn sees a value that changed in between.

All five bug-case tests fail against 0.2.2 and pass with this change. The existing tests for sharing an async round, debounce flush, verdict reuse and disposal still pass. flutter analyze is clean, dart format has nothing to change, and all 283 tests in flutter test pass.

Docs

  • Updated the validate() dartdoc on both controllers.
  • In the skill, rewrote the "Two traps" paragraph under "When a prefilled field MUST react anyway" and the "a second call joins the first" line. The async trap now says the server check runs once, then the verdict for the unchanged value is reused.
  • Changed "same result / same future" wording in the README, MIGRATION.md, docs/validation/validate.mdx, docs/internals/validate-and-failures.mdx and docs/faq.mdx.
  • Added a CHANGELOG entry under ## Unreleased.

Unrelated: example/test/screens/step_form_test.dart has one test, "turning the switch back off clears the invoice errors", that already fails on main. It fails the same way with this branch, and CI doesn't run the example tests.

🤖 Generated with Claude Code

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) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Docs preview: https://advanced-forms-fjnmf8qj0-leancode.vercel.app

Built from a53446c; the landing page is at /, the docs under /docs.

@Albert221
Albert221 marked this pull request as draft September 23, 2026 15:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant