DarkRiDDeR12 мин

Ошибка формы пришла после правки поля: как не вернуть пользователя к уже исправленному вводу

FrontendПрактика

Симптом знакомый: человек исправил email, нажал «Отправить» ещё раз, а форма внезапно показывает старое «Адрес уже занят». Сообщение может быть технически верным для первого ввода, но уже не связано с тем, что находится в поле. Цена ошибки — не только раздражение. Пользователь не понимает следующий шаг, повторяет действие или меняет корректное значение на случайное.

Причина обычно не в тексте ошибки. Форма хранит значение поля отдельно от попытки отправки, но ответ применяет только по имени поля. Поэтому поздний результат первой попытки перезаписывает состояние второй. Ниже — небольшой порядок для одной формы: локальная validation, версия каждого поля, номер попытки и явное правило stale-response. Это учебная in-memory модель; она не делает HTTP-запрос, не создаёт DOM и не измеряет фактическую задержку сервера.

Сначала разделите три вида ошибки

Локальная ошибка возникает до отправки: пустой обязательный input или email без доменной части. Её можно вычислить из текущего значения. Серверная ошибка относится к снимку значений, который ушёл в конкретной попытке. Ошибка транспорта или статуса ответа относится к доставке, а не к одному полю. Если сложить всё в строку form.error, код уже не знает, что очищать после правки и где показывать следующий шаг.

Что хранит форма до появления реального сетевого слоя
СигналВладелецКогда очищаетсяДействие
Локальная проверка emailтекущее значение поляпосле следующей правкине начинать submit, назвать поле для исправления
Серверная ошибка поляattemptId + versions снимкаесли поле изменилось или ответ устарелне переносить её на новый ввод
Активная отправкаsubmit stateпосле принятого, ошибочного или stale ответане запускать второй retry поверх неё
Последний принятый снимокrollback boundaryпосле одного rollback или нового successвернуть локальные значения без компенсирующего запроса

Минимальный контракт: version поля и attemptId

Для начала не нужен глобальный request manager. Достаточно увеличить version при каждой правке и сохранить снимок версий при beginSubmit. Попытка получает attemptId, например 1. Если пользователь меняет email, его version становится другой. Когда приходит ответ с attemptId 1, форма сравнивает не время и не «последний ответ вообще», а сохранённые версии с текущими значениями.

Совпали attemptId и версии — ответ может изменить state. Не совпали версии — ответ относится к прежнему вводу. Его нужно записать как ignored в учебный журнал и снять active attempt, но не класть его текст обратно в поле. Важно именно второе действие: игнорирование не означает вечный pending. После stale ответа пользователю должно быть разрешено отправить текущий снимок повторно.

// После правки поле получает новую version; старый ответ не имеет права вернуть ошибку.
changeField(form, 'email', 'person@example.test');
const oldAttempt = beginSubmit(form); // { attemptId: 1, versions: { email: 1, ... } }
changeField(form, 'email', 'corrected@example.test');

const result = receiveServerResult(form, { attemptId: oldAttempt.attemptId, versions: oldAttempt.versions, errors: { email: 'Адрес уже занят' } });
// result.kind === 'stale-response-ignored'; email.serverError остаётся null

node web/scripts/upgrade-2022-06.mjs --verify-fixture

В примере строка Адрес уже занят не приходит из сети: её передаёт вызывающий код как fixture data. Это сделано специально. Модель проверяет правило применения ответа, но не утверждает, что сервер отвечает за определённое число миллисекунд, что Fetch вернул конкретный status или что браузер отменил старый запрос. Эти факты появляются только в отдельной интеграционной проверке.

Автомат формы: где позднему ответу закрывают путь

Автомат учебной формы: ready переходит в submitting с attemptId и снимком version; правка во время отправки создаёт состояние editing-after-submit; ответ со старой version уходит в stale-response-ignored и не меняет ошибку поля; совпавшая серверная ошибка ведёт к server-error, а подтверждение — к accepted.
Схема показывает порядок данных, а не сетевой протокол. Стрелка stale не означает измеренную задержку и не описывает браузерную отмену запроса.

Маршрут правки без переписывания формы

  1. Симптом. Зафиксируйте один случай: поле уже исправлено, а интерфейс показал текст, относящийся к прежнему значению.
  2. Причина. Найдите место, где обработчик ответа пишет в поле без проверки attemptId и версии входа.
  3. Проверка. В локальном тесте начните submit, измените поле, затем вручную передайте ошибку для старого снимка. Error state не должен измениться.
  4. Действие. Сохраняйте attemptId, values и versions при отправке; очищайте server error при новой правке; stale ответ помечайте ignored.
  5. Retry. Блокируйте повтор, пока есть active attempt. После stale или server-error создавайте новую попытку только из текущего валидного снимка.
  6. Платформенная проверка. Отдельно проверьте реальный DOM, submit handler и выбранные браузеры. Fixture ниже не заменяет этот слой.

Почему не достаточно «показывать последнюю ошибку»

Правило «побеждает последний ответ» работает только при допущении, что ввод между ответами не менялся. В форме это допущение ломается самым обычным действием — набором текста. И наоборот, правило «последний input побеждает всё» тоже слишком грубое: оно может скрыть серверную ошибку, которая относится к текущему снимку. Версия решает именно этот вопрос: не кто был последним по времени, а совпадает ли ответ с теми данными, которые он проверял.

Не стоит удалять текст ошибки при любом рендере. Он должен исчезать по понятной причине: поле изменилось, успешная попытка принята или выполнен осознанный reset. Такой контракт помогает и в review. Вместо фразы «состояние иногда мерцает» можно проверить четыре значения: value, version, activeAttempt и источник ошибки. Если одно из них не названо, исправление останется угадыванием.

Ограничение и следующий проверяемый шаг

Эта схема не отвечает, нужно ли отменять настоящий запрос при правке: это зависит от API и от цены отмены. Она также не решает конфликты между вкладками, offline, rate limit, CSRF или порядок ответов нескольких серверов. AttemptId формы не является idempotency key сервера. Его задача уже: не применить к текущему полю ответ, который был проверен для другого снимка.

Следующий шаг — выбрать одну форму и выписать рядом с reducer: что меняет version, где хранится active attempt, какие поля входят в snapshot и что именно считается stale. Затем добавить отрицательный тест «старый ответ после правки не создаёт error». Только после него полезно добавлять отмену HTTP или общую библиотеку: сначала нужно защитить границу, которую библиотека будет обслуживать.

Историческая граница июня 2022

Нормативная рамка ограничена документами, доступными до июня 2022 года: HTML 5.2 Recommendation 2017 года и WAI-ARIA 1.2 Candidate Recommendation Draft декабря 2021 года. HTML описывает form controls и constraint validation, а WAI-ARIA определяет значения, которыми разметка может заявить ошибку. Поля attemptId, version, сообщения fixture и rollback — проектные решения этого пакета, не требования спецификаций и не отчёт о production-сценарии.

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