Форма может показывать понятный красный текст и всё равно вести человека не туда. Поле уже содержит исправленный email, рядом остаётся старое серверное сообщение, а declared semantic properties ссылаются на этот текст. Визуально компонент «сообщает об ошибке», но причина и следующее действие больше не принадлежат одному вводу. Цена такой рассинхронизации — лишняя правка корректных данных и интерфейс, который сложно проверить после следующего refactor.
Причина — несколько владельцев одной ошибки. Handler ответа хранит message, поле хранит value, UI сам решает aria-invalid, а submit button знает только pending. Надёжнее собрать их вокруг одного record: какой attempt проверял какой version, есть ли локальная ошибка, какой текст можно объявить и какое поле нужно исправить. Эта статья строит data contract, а не готовый React-компонент. Она не читает accessibility tree, не создаёт aria-атрибуты и не говорит, что конкретная assistive technology произнесла сообщение.
Ошибка — не строка, а связанный набор данных
У server error есть как минимум четыре части: имя поля, текст, attemptId и версии входа. Для разметки полезен ещё semantic payload: invalid, список заявленных описаний, идентификатор сообщения и его текст. Когда эти части создаются одним переходом reducer, reviewer может проверить связь. Когда UI вычисляет их отдельно, можно получить aria-invalid=true без причины, message без поля или id ошибки, который живёт после новой правки.
| Поле record | Значение в fixture | Зачем нужно | Чего не доказывает |
|---|---|---|---|
| attemptId | 2 | привязывает ответ к одной отправке | уникальность на сервере или идемпотентность API |
| versions.email | 2 | сравнивает ответ с текущим вводом | реальную последовательность сетевых пакетов |
| serverError.message | укажите другой адрес | даёт конкретный следующий шаг | понятность формулировки для всех пользователей |
| semantic.errorMessageId | email-server-error | даёт разметке явную ссылку | построенное accessibility tree или озвучивание |
| status | server-error | останавливает повтор до осознанного retry | HTTP status и работу endpoint |
Local validation и server validation не соревнуются
Локальная validation отвечает на вопрос, можно ли создать снимок: есть ли у email базовая форма, достаточно ли символов в пароле. Серверная validation отвечает на другой вопрос: допустимы ли эти значения для его правил. В модели локальная ошибка блокирует beginSubmit и не создаёт attemptId. Серверная ошибка появляется только после совпавшего response record. Поэтому сообщение «Введите email» не следует помещать в тот же канал, что «Этот email уже занят»: у них разный источник и разный момент очистки.
Это разделение экономит не только состояние. Оно сохраняет честный маршрут для человека. Пока email не похож на адрес, форма может назвать локальную причину и не обещать, что сервер его проверял. Когда снимок ушёл и вернулась совпавшая server error, сообщение говорит, что поменять и что повторить. Если локальная правка случилась после отправки, прежняя server error очищается до прихода ответа; у нового значения ещё нет результата сервера, и UI не должен делать вид, что он есть.
const semanticError = {
field: 'email',
invalid: true,
describedBy: ['email-hint', 'email-server-error'],
errorMessageId: 'email-server-error',
message: 'Этот адрес уже используется. Укажите другой адрес и отправьте форму повторно.',
observation: 'declared-payload-not-accessibility-tree',
};
// Это контракт данных для разметки. Модель не создаёт aria-атрибут и не слушает screen reader.
Declared association не равна наблюдённому объявлению
WAI-ARIA 1.2 описывает aria-invalid и aria-errormessage, но одна запись в объекте JavaScript не становится автоматически разметкой. В fixture describedBy и errorMessageId — намеренно declared semantic payload. Они позволяют unit-тесту проверить, что ошибка поля получила стабильный id и что связь очищается при новой правке. Они не доказывают, что attribute попал в DOM, что id уникален на странице или что screen reader обработал изменение в конкретном порядке.
Такое ограничение полезнее ложной уверенности. В компоненте semantic payload надо преобразовать в выбранную native-разметку и затем проверить на реальной странице. Для одних control достаточно стандартной связи label и input; для других нужна дополнительная error association. Решение зависит от структуры формы и поддерживаемых сред. Модель не выбирает это вместо команды — она только не даёт забыть, что text, invalid state и attempt должны меняться согласованно.
Проверка инвариантов до UI-теста
- Симптом. Ошибка остаётся после правки или не объясняет, какое поле исправлять.
- Причина. Message, visual invalid state и request result принадлежат разным branches состояния.
- Проверка входа. Для каждого submit сохраните values и versions, а у active attempt — один attemptId.
- Проверка ответа. Перед применением сравните response attemptId с active и сохранённые versions с текущими.
- Действие. При совпадении создайте serverError и semantic payload одним reducer transition; при edit очистите оба.
- Проверка платформы. В отдельном DOM/e2e сценарии подтвердите выбранные id, native controls и доступное имя. Не переносите этот вывод из Node fixture.
Почему semantic payload стоит держать рядом с доменной ошибкой
Есть соблазн завести общий toast и передавать туда любую неудачу. Для ошибки формы это часто делает путь хуже: toast сообщает «Не удалось сохранить», но не знает, на каком поле остановиться. Обратный перекос — заставить каждую ошибку жить только около input. Тогда общий status не может сказать, что отправка не выполнена. В маленькой форме разумно хранить два значения: field-specific record с причиной и declared form status с маршрутом. Они создаются одной веткой, но не заменяют друг друга.
В fixture после совпавшей ошибки server-error создаёт текст «Исправьте поле, для которого заявлена ошибка сервера». Это не обещание, что интерфейс покажет эту фразу всем одинаково. Это проверяемый контракт: branch не считается accepted, поле получает конкретную причину, а retry не запускается одновременно с ещё активной попыткой. Платформенный слой может сделать текст короче или локализовать его, но не должен потерять поле, версию и условие очистки.
Что меняется при повторной отправке
Retry — не вызов того же обработчика без состояния. Он разрешён только когда active attempt снят и локальные правила проходят. Новый retry обязан получить новый attemptId и новый снимок версий. Иначе ответ первой попытки и ответ второй будут неразличимы, а duplicate delivery снова сможет переписать форму. В учебной модели duplicate result после accepted не находит active attempt и получает unknown-attempt-ignored. Это защита интерфейсного state, не доказательство exactly-once доставки.
Если API принимает idempotency key, он должен иметь собственный контракт на серверной границе. Не стоит передавать туда номер попытки UI как будто этого достаточно. UI attemptId короткоживущий и нужен, чтобы связать input со state одной формы. Серверный ключ защищает другую границу: повтор того же намерения между клиентом и backend. Названия похожи, но последствия ошибки разные; смешивать их в одной переменной опасно.
Ограничение и следующий проверяемый шаг
Модель не решает составные формы с зависимыми полями, file upload, оптимистическим сохранением или несколькими вкладками. Она также не проводит исследование понятности текста и не проверяет локализацию. Для этих задач понадобятся другой contract, реальные DOM-сценарии и, если есть основания, пользовательская проверка. Нельзя выводить их результат из того, что object содержит errorMessageId.
Следующий шаг — в одном компоненте добавить тест на четыре отрицательных ветки: локально невалидный input не создаёт attempt; retry не идёт поверх pending; старый response не создаёт ошибку; duplicate response не меняет accepted state. После этого можно подключать сетевой adapter. Его тест должен передавать в reducer тот же response record, а не делать вид, что порядок callback всегда совпадает с порядком кликов.
Историческая граница июня 2022
Для терминов разметки использованы только датированные материалы: HTML 5.2 Recommendation 2017 года, WAI-ARIA 1.2 CRD от 8 декабря 2021 года и APG 1.2 Group Note ноября 2021 года. Они не задают attemptId, version или этот текст ошибки. Эти поля — явно обозначенная проектная модель. В статье нет записи сессии screen reader, DOM-snapshot, user research или заявления о доступности готового продукта.
Проверяемые источники
- HTML 5.2, W3C Recommendation от 14 декабря 2017 года — неизменяемая нормативная версия периода: описывает form-associated элементы и constraint validation. Она не определяет проектные поля attemptId или правило повторной отправки.
- WAI-ARIA 1.2, W3C Candidate Recommendation Draft от 8 декабря 2021 года — датированный нормативный снимок, в котором определены aria-invalid и aria-errormessage. Модель ниже хранит только declared semantic payload, а не accessibility tree.
- WAI-ARIA Authoring Practices 1.2, W3C Group Note от 29 ноября 2021 года — датированное официальное руководство по предсказуемому поведению control. Оно не является результатом пользовательского теста этой учебной формы.