Сбой редко выглядит как «сломанная машина состояний». Обычно человек видит другое: после исправления поля снова появилась прежняя ошибка; кнопка разрешает два retry подряд; после успешной отправки тест не может восстановить понятный снимок. Эти симптомы часто чинят отдельными setError(null) и disabled-кнопкой. Цена такого патча — новый скрытый порядок: при следующем изменении обработчик снова не знает, какой ответ ещё имеет право менять форму.
Диагностику лучше начать с одного пути и его отрицательных исходов. В этой статье есть локальная модель регистрации: сначала приходит ответ для старого email, затем совпавшая серверная ошибка, затем успешный retry и локальный rollback последнего принятого снимка. Это не история production-инцидента, не тест браузера и не имитация сети. Вызовы response выполняются вручную, чтобы проверить именно правила state, а не скорость транспорта.
Шесть наблюдений, которые не стоит смешивать
| Наблюдение | Вероятная причина | Минимальный факт в state | Безопасное действие |
|---|---|---|---|
| Старый текст вернулся после edit | response применён только по имени поля | versions active attempt и текущего поля различаются | пометить response stale и не создавать error |
| Два retry ушли подряд | active attempt не блокирует кнопку | в state уже есть attemptId | вернуть retry-blocked-active-attempt |
| Ошибка есть, но поле не названо | общий toast потерял field record | serverError не содержит field/semantic id | создать field-specific payload и общий status отдельно |
| Успех применён дважды | ответ не снял active attempt | повтор не находит active attempt | игнорировать duplicate как unknown attempt |
| Откат меняет server state | локальный undo выдан за компенсацию API | rollback не вызывает adapter | ограничить rollback только локальным accepted snapshot |
| После rollback снова можно откатывать бесконечно | prior snapshot не помечен использованным | rollbackUsed не изменился | сделать rollback одноразовым |
Сначала нарисуйте три точки: снимок, активная попытка, принятие
До submit форма имеет текущие values и их версии. В submit она создаёт immutable snapshot: values, versions, attemptId. После принятого ответа сохраняется последний accepted snapshot. Эти три точки не стоит заменять одной переменной isLoading. Она умеет сказать, что «что-то происходит», но не отвечает, с каким input связан ответ, что можно повторить и к какому значению возвращается локальная отмена.
Встроенный rollback нужен не для того, чтобы отменить серверную операцию. В этой модели он помогает безопасно вернуть form state к последнему принятому снимку после последующей локальной правки. При rollback увеличивается version, очищаются ошибки и отмечается rollbackUsed. Поэтому прежний response не сможет внезапно совпасть с новым state, а второй rollback не воспроизведёт действие. Если реальный продукт обещает отмену уже записанного на сервере изменения, это отдельный API contract с конфликтами и компенсацией.
// Успешная отправка сохраняет снимок. Rollback разрешён один раз и не отправляет компенсирующий запрос.
changeField(form, 'email', 'corrected@example.test');
const retry = retrySubmit(form);
receiveServerResult(form, { attemptId: retry.attemptId, versions: retry.versions, ok: true });
changeField(form, 'email', 'typo@example.test');
const rollback = rollbackLastAccepted(form);
// rollback.kind === 'rolled-back'; email.value === 'corrected@example.test'
// Второй rollback возвращает 'nothing-to-rollback'.
Диаграмма: что остановить, а что можно вернуть
Сценарий проверки с отрицательными assertions
Fixture проходит путь специально в неудобном порядке. Сначала email становится локально невалидным — submit блокируется и не получает attempt. Затем создаётся attempt 1, но retry поверх pending отвергается. После правки email response attempt 1 получает stale-response-ignored. Только потом создаётся attempt 2 с совпавшими versions, который законно ставит server error и declared association. Новая правка очищает эту ошибку, attempt 3 принимается, а duplicate этого ответа игнорируется.
Отрицательные assertions здесь важнее happy path. Успешный submit легко написать так, чтобы он прошёл один раз. Риск появляется в переходах, которые не должны иметь эффекта: неправильный email не создаёт запрос в модели; старый response не создаёт message; повтор не подтверждает уже закрытую попытку; второй rollback ничего не меняет. Если такой тест отсутствует, кнопка может выглядеть правильно, но state будет зависеть от случайного порядка callback.
Маршрут диагностики и безопасной правки
- Симптом. Возьмите один воспроизводимый путь: edit после submit, два retry или duplicate result. Не объединяйте несколько дефектов в один большой «form bug».
- Снимок. Запишите values и versions в момент beginSubmit. Если их нет, поздний response невозможно классифицировать честно.
- Активность. Проверьте, что state хранит не boolean, а active attempt с id. Второй retry должен останавливаться до создания нового id.
- Ответ. Сначала сопоставьте attemptId, потом версии. Только после двух совпадений применяйте error или success.
- Сообщение. У совпавшей field error должны быть source, field, текст следующего шага и declared semantic payload. При edit они очищаются одной операцией.
- Откат. Если нужен локальный undo, возвращайте только последний accepted snapshot, увеличивайте version и разрешайте его один раз. Сетевую компенсацию проектируйте отдельно.
- Проверка реализации. После fixture пройдите настоящий UI в целевых браузерах, проверьте выбранную разметку и adapter. Не приписывайте этим результатам то, чего не измеряли.
Почему stale response не надо считать ошибкой пользователя
Поздний ответ — нормальная возможность в асинхронном интерфейсе, а не доказательство, что человек «слишком быстро печатает». Попытка 1 действительно могла быть обработана позже попытки 2. Задача UI — не угадать сеть, а сохранить причинную связь: server result может говорить только о тех values, которые были в его request snapshot. Если этой связи нет, форма вынуждает пользователя разбираться во внутреннем времени приложения.
При этом stale response не всегда нужно скрывать от технической диагностики. В модели он попадает в log как response:stale:1. Это не telemetry и не production metric; это локальный след для теста. В реальном приложении команда может отдельно решить, нужен ли debug-log и какие данные допустимо записывать. Важно не использовать такой log как оправдание показа старого сообщения в UI.
Граница rollback и retry
Retry повторяет проверку текущего snapshot и получает новый attemptId. Rollback не повторяет ничего: он возвращает values, которые уже были приняты учебной моделью, и не посылает сообщений наружу. Эти действия нельзя объединять одной кнопкой «Отменить/повторить». У retry есть риск нового результата сервера; у rollback — риск потерять несохранённую локальную правку. Поэтому перед внедрением надо назвать, какое ожидание продукта защищает каждое действие.
Если форма сохраняет не один email, а заказ, деньги или права доступа, локального rollback может быть недостаточно и даже вреден. Там нужен отдельный server-side статус, правила конфликта, права на отмену и audit trail. Учебная модель специально не делает этот шаг. Она показывает только безопасное минимальное правило: локальный интерфейс не должен бесконечно применять один и тот же result и не должен выдавать возвращение своего snapshot за отмену доменной операции.
Ограничение и следующий проверяемый шаг
Здесь нет upload, debounce, optimistic update, автоматического retry, HTTP abort, реальных status codes, маршрутизации фокуса и проверки речи screen reader. У каждого из этих механизмов свой порядок ошибок. Добавлять их к этой модели можно только с новым fixture и явно расширенной границей. Иначе простой тест начнёт делать обещания о браузере и сети, которых его входы не содержат.
Следующий шаг — взять один reducer реального компонента и сопоставить его переходы с таблицей выше. Добавьте сначала test на stale response и duplicate result, затем отдельный тест выбранного HTTP adapter. Для доступности подготовьте конкретную разметку и проверку в согласованных средах. Так форма получит две независимые опоры: unit contract для порядка state и платформенную проверку для того, что реально видит пользователь.
Историческая граница июня 2022
Технические ссылки остаются в границе времени: HTML 5.2 Recommendation 2017 года, WAI-ARIA 1.2 Candidate Recommendation Draft декабря 2021 года и APG 1.2 Group Note ноября 2021 года. Они дают терминологию form controls и semantic properties, но не описывают конкретный retry алгоритм. Вся последовательность attempts, versions, labels и rollback в статье — детерминированная fixture, а не запись сети, incident report или пользовательское исследование.
Проверяемые источники
- 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. Оно не является результатом пользовательского теста этой учебной формы.