DarkRiDDeR13 мин

Форма застряла между retry и старым ответом: диагностика и обратимый возврат к принятому снимку

FrontendТестирование

Сбой редко выглядит как «сломанная машина состояний». Обычно человек видит другое: после исправления поля снова появилась прежняя ошибка; кнопка разрешает два retry подряд; после успешной отправки тест не может восстановить понятный снимок. Эти симптомы часто чинят отдельными setError(null) и disabled-кнопкой. Цена такого патча — новый скрытый порядок: при следующем изменении обработчик снова не знает, какой ответ ещё имеет право менять форму.

Диагностику лучше начать с одного пути и его отрицательных исходов. В этой статье есть локальная модель регистрации: сначала приходит ответ для старого email, затем совпавшая серверная ошибка, затем успешный retry и локальный rollback последнего принятого снимка. Это не история production-инцидента, не тест браузера и не имитация сети. Вызовы response выполняются вручную, чтобы проверить именно правила state, а не скорость транспорта.

Шесть наблюдений, которые не стоит смешивать

Диагностика ошибки формы без догадок о сети
НаблюдениеВероятная причинаМинимальный факт в stateБезопасное действие
Старый текст вернулся после editresponse применён только по имени поляversions active attempt и текущего поля различаютсяпометить response stale и не создавать error
Два retry ушли подрядactive attempt не блокирует кнопкув state уже есть attemptIdвернуть retry-blocked-active-attempt
Ошибка есть, но поле не названообщий toast потерял field recordserverError не содержит field/semantic idсоздать field-specific payload и общий status отдельно
Успех применён дваждыответ не снял active attemptповтор не находит active attemptигнорировать duplicate как unknown attempt
Откат меняет server stateлокальный undo выдан за компенсацию APIrollback не вызывает 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'.

Диаграмма: что остановить, а что можно вернуть

Диагностическая схема: local invalid блокирует submit; активный attempt блокирует второй retry; изменение поля делает поздний ответ stale; совпавшая ошибка создаёт semantic payload; success сохраняет accepted snapshot; локальный rollback один раз возвращает values и увеличивает version. Боковая рамка отделяет это от HTTP, browser и server rollback.
Схема помогает не перепутать три действия: игнорировать устаревший response, повторить текущий snapshot и вернуть только локальные значения.

Сценарий проверки с отрицательными 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.

Маршрут диагностики и безопасной правки

  1. Симптом. Возьмите один воспроизводимый путь: edit после submit, два retry или duplicate result. Не объединяйте несколько дефектов в один большой «form bug».
  2. Снимок. Запишите values и versions в момент beginSubmit. Если их нет, поздний response невозможно классифицировать честно.
  3. Активность. Проверьте, что state хранит не boolean, а active attempt с id. Второй retry должен останавливаться до создания нового id.
  4. Ответ. Сначала сопоставьте attemptId, потом версии. Только после двух совпадений применяйте error или success.
  5. Сообщение. У совпавшей field error должны быть source, field, текст следующего шага и declared semantic payload. При edit они очищаются одной операцией.
  6. Откат. Если нужен локальный undo, возвращайте только последний accepted snapshot, увеличивайте version и разрешайте его один раз. Сетевую компенсацию проектируйте отдельно.
  7. Проверка реализации. После 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 или пользовательское исследование.

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