DarkRiDDeR13 мин

Контракт ошибки формы: значение, версия и semantic payload должны относиться к одной попытке

FrontendАрхитектура

Форма может показывать понятный красный текст и всё равно вести человека не туда. Поле уже содержит исправленный 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 ошибки и его границы
Поле recordЗначение в fixtureЗачем нужноЧего не доказывает
attemptId2привязывает ответ к одной отправкеуникальность на сервере или идемпотентность API
versions.email2сравнивает ответ с текущим вводомреальную последовательность сетевых пакетов
serverError.messageукажите другой адресдаёт конкретный следующий шагпонятность формулировки для всех пользователей
semantic.errorMessageIdemail-server-errorдаёт разметке явную ссылкупостроенное accessibility tree или озвучивание
statusserver-errorостанавливает повтор до осознанного retryHTTP 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 должны меняться согласованно.

Схема связи одной совпавшей server error: attempt 2 содержит version email 2; совпавший ответ создаёт serverError и declared payload с invalid=true, email-server-error и текстом следующего шага. При следующей правке version становится 3 и payload ошибки очищается. Рамка снизу указывает, что DOM и accessibility tree не создавались.
Связь на схеме — контракт данных для реализации. Она не фиксирует фактическое чтение ошибки технологией ассистивного доступа.

Проверка инвариантов до UI-теста

  1. Симптом. Ошибка остаётся после правки или не объясняет, какое поле исправлять.
  2. Причина. Message, visual invalid state и request result принадлежат разным branches состояния.
  3. Проверка входа. Для каждого submit сохраните values и versions, а у active attempt — один attemptId.
  4. Проверка ответа. Перед применением сравните response attemptId с active и сохранённые versions с текущими.
  5. Действие. При совпадении создайте serverError и semantic payload одним reducer transition; при edit очистите оба.
  6. Проверка платформы. В отдельном 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 или заявления о доступности готового продукта.

Проверяемые источники