Разбор начинается с симптома, а не с библиотеки. Пользователь вводит логин ivan; форма отправляет проверку. Через мгновение он меняет значение на ivanka. Новый ответ говорит «свободно», экран становится зелёным. Затем приходит старый ответ «занято» и рисует красную строку уже под ivanka. Пользователь видит противоречие, а поддержка получает скриншот, по которому невозможно понять, какое значение проверял сервер. Цена ошибки — заблокировать корректный ввод или отправить устаревший результат.
Причина — гонка двух корректных по отдельности promise. Код записывает любой завершившийся ответ в одно состояние поля и не хранит, к какому вводу он относится. Вторая проблема обычно рядом: строка ошибки лежит в общем баннере, поэтому даже настоящий серверный отказ нельзя быстро привязать к input. Ниже учебный fixture и маршрут расследования. Он не описывает production-трассу, не заявляет о запуске браузера и не заменяет проверку конкретного API.
Реконструкция гонки без настоящей сети
Для расследования нам не нужен медленный сервер. Достаточно детерминированно задать два ответа в обратном порядке. Функция delayResult в автономном пакете считает ivan занятым и возвращает его спустя 30 мс; ivanka свободен и возвращается спустя 5 мс. Две проверки стартуют одна за другой. Если код применяет всё подряд, первый результат перезапишет второй. Если он сравнивает идентификатор, первый результат станет stale-response и не изменит поле.
function delayResult(value, delayMs) {
return new Promise(function (resolve) {
setTimeout(function () {
resolve({ value: value, available: value !== 'ivan' });
}, delayMs);
});
}
var latestRequestId = 0;
function check(value, delayMs) {
var requestId = latestRequestId + 1;
latestRequestId = requestId;
return delayResult(value, delayMs).then(function (answer) {
if (requestId !== latestRequestId) {
return { requestId: requestId, applied: false, ignored: 'stale-response' };
}
return {
requestId: requestId,
applied: true,
phase: answer.available ? 'valid' : 'invalid',
};
});
}
В исходном пакете этот fixture запускается отдельной командой и печатает JSON. Он не требует HTTP, DOM или внешней базы: это плюс для проверки правила порядка, но и граница доказательства. Он не отвечает, как конкретный браузер отменяет fetch, как реальный сервер нормализует логин или как экран озвучивает ошибку. Эти вопросы проверяются другими средствами, поэтому в статье они не подменены одним удачным console output.
| Момент | Действие | Текущая версия | Ожидаемый эффект |
|---|---|---|---|
| t0 | Старт проверки ivan с задержкой 30 мс | 1 | Поле checking для ivan |
| t1 | Старт проверки ivanka с задержкой 5 мс | 2 | Поле checking для ivanka; старая ошибка очищена |
| t2 | Ответ ivanka: available | 2 | Ответ применён, фаза valid, значение ivanka |
| t3 | Ответ ivan: occupied | 2 | Ответ отклонён как stale-response; текст не меняется |
Проверять нужно не только фразу «старый ответ проигнорирован». Полезно записать финальное состояние целиком: value: "ivanka", requestId: 2, phase: "valid", пустая ошибка поля. Если после исправления тест смотрит лишь на boolean applied, можно пропустить баг, где значение осталось от новой версии, а текст ошибки — от старой. Состояние должно быть согласованным одной версии.
Плохой обработчик и минимальная правка
В наивном варианте callback знает только ответ. Он не знает input, который был актуален на старте запроса. Поэтому последний по времени ответ побеждает независимо от того, что было введено. Отключение кнопки не решает эту гонку: человек всё ещё может менять поле, а ответ может завершиться после повторного открытия формы или смены шага.
// Плохо: любой ответ безусловно меняет одно и то же поле.
function applyAnswer(answer) {
state.phase = answer.available ? 'valid' : 'invalid';
state.error = answer.available ? '' : 'Этот логин уже занят';
render(state);
}
// Лучше: requestId закреплён в момент отправки.
function applyAnswerFor(requestId, answer) {
if (requestId !== state.requestId) return;
state.phase = answer.available ? 'valid' : 'invalid';
state.error = answer.available ? '' : 'Этот логин уже занят';
render(state);
}
Это не магический token. Он просто превращает неявное допущение «ответы придут по порядку» в явное условие. Текущее состояние — единственный источник версии. Любое событие input увеличивает её до начала следующей проверки. Если форма уничтожается при закрытии модального окна, экземпляр состояния также должен перестать принимать ответы: можно увеличить версию при teardown или проверить, что компонент ещё смонтирован. Выбор зависит от архитектуры, но последний callback не должен оживлять закрытую форму.
Где и как показывать серверную ошибку
Вторая часть диагноза — место ошибки. Ответ «этот логин занят» относится к полю логина, а не к кнопке и не к невидимому тосту. Для формы нужен контракт fields.login → текст. Если API вернул код login_taken, клиент сопоставляет его известному полю. Если же API вернул общий отказ, например закончилась сессия, это уже ошибка формы или маршрута, и её нельзя маскировать под ошибку логина.
| Ответ | Куда идёт | Что видит пользователь | Что не делать |
|---|---|---|---|
fields.login[0] | Контейнер login-error | Текст под логином, invalid-состояние input | Не выводить только общий «Ошибка сохранения» |
fields.email[0] | Контейнер email-error | Текст под почтой | Не приклеивать к текущему активному полю |
form[0] | Общий статус формы | Краткое сообщение перед кнопкой или заголовком | Не ставить aria-invalid на все поля |
| Неизвестный ключ | Безопасный общий путь и диагностика | Нейтральное сообщение без ложного указания | Не игнорировать молча и не выбирать первое поле |
Текст ошибки должен быть видимым, но это не значит, что его нужно дублировать по всему экрану. Один контейнер с устойчивым ID остаётся рядом с input. На ошибке input получает aria-invalid="true"; aria-describedby связывает его с постоянной подсказкой, а aria-errormessage — с отдельным текстом ошибки. WAI-ARIA прямо связывает aria-errormessage с состоянием invalid и требует, чтобы релевантное сообщение было доступно пользователю.
<label for="login">Логин</label>
<input
id="login"
name="login"
required
pattern="[A-Za-z0-9_]{3,20}"
aria-describedby="login-hint login-error"
aria-errormessage="login-error"
aria-invalid="true"
value="ivanka"
>
<p id="login-hint">От 3 до 20 букв, цифр или _.</p>
<p id="login-error" role="alert">Этот логин уже занят</p>
Здесь есть тонкость: пример показывает разметку для фазы remote-invalid, поэтому текст «занят» относится к текущему значению. При следующем input обработчик сначала убирает aria-invalid, очищает login-error и только потом запускает новый запрос. Иначе a11y-семантика тоже будет отставать: зритель увидит новое значение, а ассистивная технология получит старый текст как описание нового поля.
Ожидаемое дерево доступности — план проверки, не отчёт
У этой разметки есть ожидаемая семантика. В дереве должен быть textbox с именем «Логин», текущим значением, состоянием invalid и связью с описанием/ошибкой. Ошибка должна быть видимой и достижимой, а не скрытым span, на который указывает ID. Это не результат снятого Accessibility tree: в этой задаче браузерный прогон не выполнялся. Ниже — чек-лист, который надо подтвердить DevTools и выбранным скринридером после встраивания в реальный экран.
| Признак | Как создаётся | Как подтвердить на контуре | Граница вывода |
|---|---|---|---|
| Имя «Логин» | label for="login" | Открыть Accessibility tree и пройти поле с клавиатуры | Не обещает одинаковую формулировку во всех скринридерах |
| Состояние invalid | aria-invalid="true" только при ошибке | Сменить старое/новое значение и проверить сброс состояния | Не заменяет серверную проверку |
| Связанный текст ошибки | aria-errormessage="login-error" | Убедиться, что элемент существует и видим | Не гарантирует timing озвучивания без реального прогона |
| Постоянная подсказка | aria-describedby | Проверить ID после рендера формы | Не доказывает корректность регулярного выражения |
HTML Constraint Validation API дополняет этот контракт, но не заменяет его. Нативный required и pattern могут остановить очевидно плохой submit. Однако серверная занятость не становится свойством patternMismatch. Храните её отдельно как remoteError, чтобы следующий input мог однозначно очистить результат и запустить проверку актуальной версии. Если нужен единый текст ошибки, setCustomValidity() применяйте к локальному правилу осмысленно и очищайте его на input.
Порядок расследования в реальном проекте
- Записать два конкретных значения и порядок: что ввели первым, что вторым, какой текст появился в конце. Не начинать с добавления debounce.
- Найти единственное место, где меняется состояние поля после promise. Проверить, хранит ли оно значение или requestId, захваченные на старте запроса.
- Собрать минимальный fixture с обратными задержками. Ожидаемый результат должен содержать финальное value, phase и error, а не только факт выполнения callback.
- При input увеличить версию и очистить remoteError до нового render. Убедиться, что обработчик устаревшего ответа возвращает состояние без изменений.
- Проверить контракт API: field error имеет известный ключ, а общий отказ не попадает в произвольное поле. Согласовать неизвестные ключи отдельно.
- На реальном экране пройти форму клавиатурой и посмотреть Accessibility tree: label, invalid, описание, видимый error. Этот шаг делает семантику доказательством, а не ожиданием.
Почему debounce и abort не закрывают вопрос сами
Debounce уменьшает число запросов, но не меняет порядок тех запросов, которые уже ушли. Abort может остановить Fetch, если транспорт принимает сигнал, но к моменту отмены ответ уже может быть готов, а отмена не привязывает старый callback к новому value автоматически. Поэтому requestId — условие корректности, debounce — защита API от шума, abort — оптимизация отменяемой работы. Их можно сочетать, но менять одно на другое нельзя.
Для сохранения действует ещё одно ограничение. Даже если проверка логина сказала «свободен», между check и POST другой пользователь мог занять это имя. Сервер должен повторить правило и вернуть field error при конфликте. Клиент после POST применяет ответ только к версии формы, которая была отправлена; если пользователь уже изменил input, показывать старый ответ над новой формой так же неверно, как в проверке логина.
Итог
В этом случае нет загадочной «нестабильности фронтенда». Есть старый ответ без права менять новый ввод и ошибка без чёткой привязки к полю. Исправление состоит из маленьких проверяемых частей: version при input, сравнение requestId перед render, field-contract API, label и видимый контейнер ошибки. После этого fixture ловит обратный порядок ответов, а реальный экран можно проверить отдельно на клавиатуре и в Accessibility tree без выдуманного отчёта о браузерном прогоне.
Проверяемые источники
- HTML Standard: Constraints and Constraint Validation API — модель ограничений формы, validatable-контролы,
validity,setCustomValidity(),checkValidity()иreportValidity() - HTML Standard: Form submission — отправка формы — отдельный алгоритм браузера; клиентские ограничения не подменяют проверку на сервере
- WAI-ARIA 1.1: aria-describedby — связь контрола с одним или несколькими элементами описания по ID
- WAI-ARIA 1.1: aria-errormessage —
aria-errormessageсвязан сaria-invalid; актуальный текст ошибки должен быть доступен пользователю - WAI-ARIA 1.1: alert role — семантика срочного, но не переносящего фокус сообщения; применять только к краткому изменению статуса
- DOM Standard: AbortController — контроллер создаёт
AbortSignalи посылает ему abort; это отмена транспорта, а не проверка актуальности состояния сама по себе - Fetch Standard — модель запроса, ответа и интеграция с abort signal для клиентов, поддерживающих Fetch