Debugging асинхронной валидации

Асинхронная валидация в связке Yup и resolver-адаптеров (чаще всего используемых в формах React Hook Form) представляет собой один из наиболее сложных для отладки сценариев. Основная трудность заключается в том, что поток выполнения становится нелинейным: результат валидации зависит от промисов, внешних источников данных и порядка их разрешения.

В типичном случае YupResolver преобразует схему Yup в формат, понятный форме, и возвращает либо синхронный результат, либо Promise, содержащий ошибки. Именно асинхронная ветка поведения становится источником скрытых багов: гонок запросов, устаревших ошибок, несогласованного состояния формы.

Механика выполнения асинхронной валидации

При использовании асинхронных методов Yup, например test с промисом или when с динамическими зависимостями, схема перестаёт быть чисто декларативной.

Типичный поток выглядит следующим образом:

  1. Пользователь изменяет значение поля
  2. Формируется snapshot состояния формы
  3. YupResolver запускает schema.validate
  4. Валидация возвращает Promise
  5. React Hook Form ожидает результат
  6. При завершении Promise результат маппится в errors

Ключевая проблема возникает между шагами 3 и 6: состояние формы может измениться несколько раз, но предыдущие промисы продолжают выполняться.

Гонки асинхронных запросов

Наиболее частая проблема — race condition между валидациями одного и того же поля.

Пример сценария:

  • пользователь вводит a
  • запускается асинхронная проверка (например, проверка уникальности имени)
  • пользователь быстро вводит ab
  • запускается вторая проверка
  • первая проверка завершается позже второй

Если результат не синхронизирован с актуальным значением, возможна ситуация:

  • ошибка от первого запроса перезаписывает корректное состояние второго

Причина

YupResolver не отменяет предыдущие промисы. Каждая валидация — независимая операция.

Симптомы

  • “мигающие” ошибки
  • несоответствие UI и введённых данных
  • случайные ошибки при быстром вводе

Потеря актуальности состояния формы

Асинхронная валидация работает с “снимком” значений. Если внутри test используется внешний источник или переменная, изменяющаяся во времени, возможна рассинхронизация.

Пример проблемного паттерна:

yup.string().test("check-db", async function (value) {
  const isTaken = await api.check(value);
  return !isTaken;
});

Проблема проявляется при частых изменениях значения: ответ API относится к уже устаревшему input.

Диагностика

  • логирование значения value внутри test
  • сравнение с текущим значением формы через getValues()
  • проверка временных меток запросов

Особенности работы YupResolver с Promise-цепочками

YupResolver агрегирует ошибки через ValidationError и возвращает структуру:

  • errors — объект ошибок по полям
  • values — валидные данные (если нет ошибок)

При асинхронных тестах важно учитывать, что:

  • ошибки формируются после завершения всех Promise внутри схемы
  • порядок выполнения тестов не гарантирован
  • параллельные проверки могут завершаться в произвольной последовательности

Инструменты диагностики

Логирование этапов валидации

Одним из базовых способов является внедрение логов прямо в test:

yup.string().test("debug", async function (value) {
  console.log("start validation", value);

  const result = await api.validate(value);

  console.log("end validation", value, result);

  return result;
});

Такой подход позволяет выявить:

  • повторные вызовы
  • устаревшие значения
  • задержки API

Сравнение актуального значения формы

Для выявления гонок полезно сравнивать значение внутри теста с текущим состоянием формы:

test("check-current", async function (value) {
  const current = this.options.context?.getValues?.("field");

  if (current !== value) {
    return true;
  }

  return await api.check(value);
});

Это позволяет “отсеивать” устаревшие проверки.

Использование временных меток

Добавление timestamp в замыкание теста позволяет отслеживать порядок выполнения:

let lastCall = 0;

yup.string().test("timestamp", async function (value) {
  const callId = Date.now();
  lastCall = callId;

  const result = await api.check(value);

  if (callId !== lastCall) {
    return true;
  }

  return result;
});

Подход помогает выявить устаревшие асинхронные ответы.

Проблема неконсистентных ошибок

Иногда YupResolver возвращает ошибки, которые не соответствуют текущему состоянию формы. Это происходит из-за того, что:

  • старый Promise завершился позже нового
  • React Hook Form применил результат без проверки актуальности

Проявления

  • ошибки появляются после исправления поля
  • UI показывает устаревшую валидацию
  • isValid не соответствует реальному состоянию

Управление частотой валидации

Асинхронная валидация особенно чувствительна к частоте вызовов. При каждом onChange может запускаться новый запрос.

Debounce на уровне формы

Один из способов стабилизации — уменьшение частоты вызовов:

  • задержка onChange
  • использование mode: "onBlur"
  • ручной контроль trigger

Однако это снижает отзывчивость интерфейса.

Отмена запросов через AbortController

Если асинхронная валидация опирается на API, поддерживающий отмену, можно снизить эффект гонок:

let controller;

yup.string().test("abortable", async function (value) {
  if (controller) controller.abort();

  controller = new AbortController();

  const result = await fetch("/validate", {
    signal: controller.signal
  });

  return result.ok;
});

Это не решает проблему на уровне YupResolver полностью, но уменьшает количество лишних завершённых запросов.

Изоляция побочных эффектов

Асинхронная валидация должна быть максимально чистой функцией. Любые побочные эффекты внутри test приводят к трудноотлавливаемым состояниям:

  • изменение глобальных переменных
  • вызовы setState
  • запись в стор вне контекста формы

Такие действия нарушают предсказуемость YupResolver и усложняют диагностику.

Наблюдение за жизненным циклом валидации

Для глубокого анализа полезно рассматривать каждый вызов test как отдельную операцию жизненного цикла:

  • инициализация
  • выполнение запроса
  • ожидание результата
  • завершение или отмена

При наличии логов по этим этапам можно выявить:

  • утечки промисов
  • лишние повторные вызовы
  • конкурирующие проверки одного поля

Поведение при массовых обновлениях формы

При setValue или reset сразу для нескольких полей YupResolver может запустить каскад асинхронных проверок.

Это приводит к:

  • перегрузке API
  • накоплению “висящих” промисов
  • временной неконсистентности ошибок

Особенно заметно при загрузке данных формы из сервера.

Разделение синхронной и асинхронной логики

Стабильная архитектура предполагает разделение:

  • синхронные проверки (формат, длина, обязательность)
  • асинхронные проверки (уникальность, доступность, серверная валидация)

Смешивание этих типов внутри одного test усложняет отладку и увеличивает вероятность гонок.

Проблема повторных вызовов YupResolver

React Hook Form может вызывать resolver несколько раз:

  • при каждом изменении поля
  • при сабмите
  • при программных изменениях состояния

Если асинхронная логика не защищена, это приводит к множественным параллельным запросам.

Характерный симптом

  • одинаковые запросы уходят несколько раз
  • порядок ответов не совпадает с порядком вызовов
  • UI “дрожит” между состояниями

Поведенческая модель отладки

Для анализа сложных случаев полезно рассматривать систему как поток событий:

  • Input event
  • Validation start
  • Async boundary
  • Resolution
  • State commit

Ошибка возникает на границе между async boundary и state commit, когда результат уже не соответствует актуальному input state.

Стратегии стабилизации

На уровне архитектуры применяются следующие подходы:

  • хранение requestId для каждой валидации
  • игнорирование устаревших ответов
  • минимизация async внутри Yup
  • перенос серверной валидации в отдельный слой (например, react-query)

Каждый подход направлен на устранение несоответствия между временем запроса и временем применения результата.