diff --git a/editorial/agent-rewrites/201.json b/editorial/agent-rewrites/201.json index d1299af..98a55a8 100644 --- a/editorial/agent-rewrites/201.json +++ b/editorial/agent-rewrites/201.json @@ -3,5 +3,5 @@ "slug": "editorial-2022-06-practice-form-errors", "title": "Старая ошибка формы после новой правки: как связать ответ с тем вводом, который сервер проверял", "excerpt": "Если пользователь исправил поле, поздний ответ прежней отправки не должен менять текущую форму. Разбираем локальную проверку, версии полей, attemptId и честный retry.", - "contentHtml": "

Пользователь вводит old@example.test и нажимает «Сохранить». Сервер отвечает: «Адрес уже занят». Пока ответ идёт, пользователь меняет поле на new@example.test. Затем интерфейс показывает ту же ошибку рядом с новым адресом. Поле выглядит заполненным, но сообщение относится к другому значению.

\n

Цена ошибки измерима на уровне действия. Человек может повторно исправлять корректный адрес, закрыть форму или отправить её ещё раз, не понимая, какой запрос сейчас активен. В регистрациях, платежах и заказах такое поведение подрывает доверие и может привести к повторному действию с побочным эффектом.

\n

Тезис: ошибка принадлежит не имени поля, а снимку ввода и конкретной попытке отправки. Поэтому ответ можно применить только тогда, когда он относится к текущей версии поля. Для этого нужно разделить локальную проверку, состояние отправки и результат сервера; при каждой правке увеличивать версию поля; каждой отправке выдавать attemptId; устаревший ответ явно игнорировать.

\n

Как возникает рассинхронизация

\n

У формы есть два времени. Первое — время ввода. Значение меняется при каждом действии пользователя. Второе — время ответа. Запрос может закончиться после нескольких новых правок. Если обработчик знает только имя поля, он записывает результат в актуальное состояние, хотя сервер проверял прежний снимок.

\n

Правило «применяем последний пришедший ответ» не защищает форму: последним может прийти самый старый запрос. Правило «последняя правка побеждает» тоже неполно: оно способно скрыть ошибку, которая действительно относится к текущему значению. Нужна проверка принадлежности: совпадают ли версия поля и идентификатор попытки с теми, что сохранены в состоянии.

\n

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

\n

Контракт состояния

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Старый текст появился после правкиОтвет сопоставлен только по имени поляСравнить версии из запроса и текущего поляПометить ответ как stale и не создавать server error
Две отправки идут одновременноRetry не проверяет active attemptПроверить переходы submitting и retryЗаблокировать повтор до завершения попытки
Локальная ошибка ушла на серверConstraint validation смешана с submitУбедиться, что невалидный снимок не получил attemptIdПоказать причину у поля и остановить отправку
Ошибка осталась после изменения значенияServer error очищается только после ответаИзменить поле и проверить state до нового ответаСнять ошибку прежней версии
Повторный ответ меняет успехНет границы принятой попыткиПередать duplicate после successИгнорировать ответ без active attempt
\n

Минимальная запись поля может выглядеть так: { value, version, localError, serverError }. В состоянии формы отдельно нужны activeAttempt и последний принятый снимок. При правке меняются value и version, а ошибка сервера для прежней версии исчезает. При отправке код копирует значения и версии в attempt. Он не должен читать актуальное поле в момент ответа: это уже может быть другой ввод.

\n

Пример: ответ проверяется до изменения state

\n
function editEmail(form, value) {\n  const field = form.fields.email;\n  return { ...form, fields: { ...form.fields, email: {\n    value, version: field.version + 1,\n    localError: validateEmail(value), serverError: null\n  }}};\n}\n\nfunction beginSubmit(form) {\n  const email = form.fields.email;\n  if (email.localError) return { kind: 'blocked-local-error' };\n  const attempt = { id: form.nextAttemptId,\n    values: { email: email.value }, versions: { email: email.version } };\n  return { ...form, activeAttempt: attempt, nextAttemptId: attempt.id + 1 };\n}\n\nfunction receiveResult(form, result) {\n  const attempt = form.activeAttempt;\n  if (!attempt || attempt.id !== result.attemptId) return ignore(form);\n  const current = form.fields.email;\n  if (attempt.versions.email !== current.version) {\n    return { ...form, activeAttempt: null, log: [...form.log, 'stale-response-ignored'] };\n  }\n  if (!result.ok) return { ...form, activeAttempt: null,\n    fields: { ...form.fields, email: { ...current, serverError: result.message } } };\n  return { ...form, activeAttempt: null, accepted: attempt.values };\n}
\n

Это учебный пример для in-memory reducer. Он не отправляет HTTP-запрос, не отменяет fetch, не создаёт DOM и не доказывает порядок сетевых пакетов. Строка Адрес уже занят в тесте должна быть входом result.message, а не утверждением о production-ответе. В реальном приложении сетевой адаптер обязан передать в reducer тот же attemptId и снимок версий.

\n

Почему stale-ответ нужно завершать

\n

Устаревший ответ нельзя применять к полю. Но его нельзя и просто забыть, оставив форму в вечном pending. Если пользователь уже ввёл новое значение, активная попытка прежнего снимка больше не должна блокировать текущий retry. Переход stale-response-ignored снимает activeAttempt, пишет диагностический сигнал и не создаёт ошибку.

\n

После этого пользователь может отправить текущий валидный снимок. Новый retry получает новый attemptId и новые версии. Это не означает, что браузер отменил старый запрос. Отмена зависит от API и стоимости работы сервера. Даже отменённый запрос не заменяет проверку ответа: гонки и повторная доставка должны иметь безопасный результат.

\n
Автомат формы: отправка хранит attemptId и версии, правка делает ответ устаревшим, совпавший ответ ведёт к ошибке или успеху
Схема показывает контракт данных, а не измеренную задержку и не поведение конкретного HTTP-клиента.
\n

Порядок исправления

\n
  1. Зафиксируйте симптом. Воспроизведите отправку, правку до ответа и появление старой ошибки. Запишите значение поля до отправки и после правки.
  2. Найдите владельца. Отметьте обработчик ответа и место записи ошибки. Если запись использует только имя поля, граница недостаточна.
  3. Разделите проверки. Локальную невалидность обработайте до создания попытки. Ошибку сервера храните вместе с идентификатором попытки и версиями снимка.
  4. Добавьте версии. Увеличивайте версию при каждой правке. Копируйте версии в attempt, а не вычисляйте их после ответа.
  5. Закройте повтор. Пока есть active attempt, не запускайте второй submit. После stale, server error или success переход должен быть явным.
  6. Проверьте отрицательный путь. Передайте старый ответ после новой правки. Он не должен менять значение, ошибку или принятый снимок.
  7. Проверьте браузер. Отдельно подтвердите реальный DOM, фокус, сообщение и связь поля с ошибкой. Fixture этого не проверяет.
\n

Связь с разметкой ошибки

\n

Состояние данных не заменяет доступную разметку. Если интерфейс использует aria-invalid и aria-errormessage, атрибуты должны появляться только для ошибки текущей версии. При новой правке связь с прежним сообщением нужно убрать. Идентификатор попытки сам по себе не делает сообщение доступным и не подтверждает, что вспомогательная технология его объявила.

\n

Для нативных контролов сначала используйте возможности HTML: правильный тип, required, pattern и constraint validation. Скрипт может дополнить этот слой серверным результатом, но не должен подменять его общей строкой без указания поля. Сообщение должно объяснять причину и действие, если сервер действительно сообщает о проблеме текущего снимка.

\n

Ограничения

\n

Схема рассчитана на одну форму и одного владельца состояния. Она не решает конфликт между двумя вкладками, offline, rate limit, CSRF, составные поля, загрузку файлов и оптимистическое сохранение. Для нескольких полей версия должна быть частью снимка каждого поля или общей версии формы; выбор зависит от того, какие поля сервер проверяет вместе.

\n

attemptId интерфейса не является idempotency key. Он защищает применение ответа внутри формы. Идемпотентность на сервере требует отдельного контракта. Нельзя считать, что новый UI-идентификатор предотвращает повторную оплату или создание ресурса.

\n

Пример не содержит production-метрик. Его проверяемый результат уже: устаревший ответ не меняет текущий ввод; совпавший ответ меняет только соответствующий снимок; duplicate после success не меняет accepted state; локально невалидный ввод не создаёт попытку.

\n

Критерий готовности

\n

Исправление готово, если команда воспроизводит четыре сценария с однозначными переходами: локальная ошибка блокирует submit без attemptId; правка во время отправки делает прежний ответ stale; совпавший server error появляется только у проверенного значения; успешный retry создаёт новый accepted snapshot, а старый duplicate ничего не меняет. Браузерная проверка дополнительно подтверждает, что сообщение видно, связано с нужным контролом и очищается после новой правки.

\n

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

\n" + "contentHtml": "

На учебном стенде инженер отправил форму с old@example.test и открыл панель Network, чтобы дождаться ответа. Пока запрос был в пути, он изменил поле на new@example.test, а затем увидел рядом с новым адресом сообщение «Адрес уже занят». Сценарий воспроизводим: сервер проверял старое значение, но интерфейс показал ошибку у нового.

\n

Цена ошибки измерима на уровне действия. Человек может повторно исправлять корректный адрес, закрыть форму или отправить её ещё раз, не понимая, какой запрос сейчас активен. В регистрациях, платежах и заказах такое поведение подрывает доверие и может привести к повторному действию с побочным эффектом.

\n

Тезис: ошибка принадлежит не имени поля, а снимку ввода и конкретной попытке отправки. Поэтому ответ можно применить только тогда, когда он относится к текущей версии поля. Для этого нужно разделить локальную проверку, состояние отправки и результат сервера; при каждой правке увеличивать версию поля; каждой отправке выдавать attemptId; устаревший ответ явно игнорировать.

\n

Сценарий: как проверили рассинхронизацию

\n

Инженер повторил сценарий в тестовом стенде с задержанным ответом. Сначала отправил значение old@example.test и сохранил номер попытки. Затем изменил поле на new@example.test до ответа. После этого передал в reducer старый результат с ошибкой и проверил три состояния: новое значение осталось, ошибка не появилась, активная попытка завершилась. Так проверяется именно принадлежность ответа, а не удобный, но неверный порядок «последний ответ побеждает».

\n

Механизм рассинхронизации

\n

У формы есть два времени. Первое — время ввода. Значение меняется при каждом действии пользователя. Второе — время ответа. Запрос может закончиться после нескольких новых правок. Если обработчик знает только имя поля, он записывает результат в актуальное состояние, хотя сервер проверял прежний снимок.

\n

Правило «применяем последний пришедший ответ» не защищает форму: последним может прийти самый старый запрос. Правило «последняя правка побеждает» тоже неполно: оно способно скрыть ошибку, которая действительно относится к текущему значению. Нужна проверка принадлежности: совпадают ли версия поля и идентификатор попытки с теми, что сохранены в состоянии.

\n

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

\n

Контракт состояния

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Старый текст появился после правкиОтвет сопоставлен только по имени поляСравнить версии из запроса и текущего поляПометить ответ как stale и не создавать server error
Две отправки идут одновременноRetry не проверяет active attemptПроверить переходы submitting и retryЗаблокировать повтор до завершения попытки
Локальная ошибка ушла на серверConstraint validation смешана с submitУбедиться, что невалидный снимок не получил attemptIdПоказать причину у поля и остановить отправку
Ошибка осталась после изменения значенияServer error очищается только после ответаИзменить поле и проверить state до нового ответаСнять ошибку прежней версии
Повторный ответ меняет успехНет границы принятой попыткиПередать duplicate после successИгнорировать ответ без active attempt
\n

Минимальная запись поля может выглядеть так: { value, version, localError, serverError }. В состоянии формы отдельно нужны activeAttempt и последний принятый снимок. При правке меняются value и version, а ошибка сервера для прежней версии исчезает. При отправке код копирует значения и версии в attempt. Он не должен читать актуальное поле в момент ответа: это уже может быть другой ввод.

\n

Пример: ответ проверяется до изменения state

\n
function editEmail(form, value) {\n  const field = form.fields.email;\n  return { ...form, fields: { ...form.fields, email: {\n    value, version: field.version + 1,\n    localError: validateEmail(value), serverError: null\n  }}};\n}\n\nfunction beginSubmit(form) {\n  const email = form.fields.email;\n  if (form.activeAttempt) {\n    return { ...form, lastAction: 'blocked-active-attempt' };\n  }\n  if (email.localError) {\n    return { ...form, lastAction: 'blocked-local-error' };\n  }\n  const attempt = { id: form.nextAttemptId,\n    values: { email: email.value }, versions: { email: email.version } };\n  return { ...form, activeAttempt: attempt, nextAttemptId: attempt.id + 1 };\n}\n\nfunction ignore(form) {\n  return { ...form, lastAction: 'ignored-unmatched-attempt' };\n}\n\nfunction receiveResult(form, result) {\n  const attempt = form.activeAttempt;\n  if (!attempt || attempt.id !== result.attemptId) return ignore(form);\n  const current = form.fields.email;\n  if (attempt.versions.email !== current.version) {\n    return { ...form, activeAttempt: null, log: [...form.log, 'stale-response-ignored'] };\n  }\n  if (!result.ok) return { ...form, activeAttempt: null,\n    fields: { ...form.fields, email: { ...current, serverError: result.message } } };\n  return { ...form, activeAttempt: null, accepted: attempt.values };\n}
\n

Это учебный пример для in-memory reducer. Он не отправляет HTTP-запрос, не отменяет fetch, не создаёт DOM и не доказывает порядок сетевых пакетов. Строка Адрес уже занят в тесте должна быть входом result.message, а не утверждением о production-ответе. В реальном приложении сетевой адаптер обязан передать в reducer тот же attemptId и снимок версий.

\n

Две ранние проверки в beginSubmit закрывают разные границы. activeAttempt блокирует второй submit до завершения первого, а localError не создаёт попытку для заведомо невалидного ввода. Функция ignore оставляет форму неизменной по данным и записывает только диагностическое действие, поэтому поздний или повторный ответ не превращается в исключение.

\n

Почему stale-ответ нужно завершать

\n

Устаревший ответ нельзя применять к полю. Но его нельзя и просто забыть, оставив форму в вечном pending. Если пользователь уже ввёл новое значение, активная попытка прежнего снимка больше не должна блокировать текущий retry. Переход stale-response-ignored снимает activeAttempt, пишет диагностический сигнал и не создаёт ошибку.

\n

После этого пользователь может отправить текущий валидный снимок. Новый retry получает новый attemptId и новые версии. Это не означает, что браузер отменил старый запрос. Отмена зависит от API и стоимости работы сервера. Даже отменённый запрос не заменяет проверку ответа: гонки и повторная доставка должны иметь безопасный результат.

\n
Автомат формы: отправка хранит attemptId и версии, правка делает ответ устаревшим, совпавший ответ ведёт к ошибке или успеху
Схема показывает контракт данных, а не измеренную задержку и не поведение конкретного HTTP-клиента.
\n

Порядок исправления

\n
  1. Зафиксируйте симптом. Воспроизведите отправку, правку до ответа и появление старой ошибки. Запишите значение поля до отправки и после правки.
  2. Найдите владельца. Отметьте обработчик ответа и место записи ошибки. Если запись использует только имя поля, граница недостаточна.
  3. Разделите проверки. Локальную невалидность обработайте до создания попытки. Ошибку сервера храните вместе с идентификатором попытки и версиями снимка.
  4. Добавьте версии. Увеличивайте версию при каждой правке. Копируйте версии в attempt, а не вычисляйте их после ответа.
  5. Закройте повтор. Пока есть active attempt, не запускайте второй submit. После stale, server error или success переход должен быть явным.
  6. Проверьте отрицательный путь. Передайте старый ответ после новой правки. Он не должен менять значение, ошибку или принятый снимок.
  7. Проверьте браузер. Отдельно подтвердите реальный DOM, фокус, сообщение и связь поля с ошибкой. Fixture этого не проверяет.
\n

Связь с разметкой ошибки

\n

Состояние данных не заменяет доступную разметку. Если интерфейс использует aria-invalid и aria-errormessage, атрибуты должны появляться только для ошибки текущей версии. При новой правке связь с прежним сообщением нужно убрать. Идентификатор попытки сам по себе не делает сообщение доступным и не подтверждает, что вспомогательная технология его объявила.

\n

Для нативных контролов сначала используйте возможности HTML: правильный тип, required, pattern и constraint validation. Скрипт может дополнить этот слой серверным результатом, но не должен подменять его общей строкой без указания поля. Сообщение должно объяснять причину и действие, если сервер действительно сообщает о проблеме текущего снимка.

\n

Ограничения

\n

Схема рассчитана на одну форму и одного владельца состояния. Она не решает конфликт между двумя вкладками, offline, rate limit, CSRF, составные поля, загрузку файлов и оптимистическое сохранение. Для нескольких полей версия должна быть частью снимка каждого поля или общей версии формы; выбор зависит от того, какие поля сервер проверяет вместе.

\n

attemptId интерфейса не является idempotency key. Он защищает применение ответа внутри формы. Идемпотентность на сервере требует отдельного контракта. Нельзя считать, что новый UI-идентификатор предотвращает повторную оплату или создание ресурса.

\n

Пример не содержит production-метрик. Его проверяемый результат уже: устаревший ответ не меняет текущий ввод; совпавший ответ меняет только соответствующий снимок; duplicate после success не меняет accepted state; локально невалидный ввод не создаёт попытку.

\n

Критерий готовности

\n

Исправление готово, если команда воспроизводит четыре сценария с однозначными переходами: локальная ошибка блокирует submit без attemptId; правка во время отправки делает прежний ответ stale; совпавший server error появляется только у проверенного значения; успешный retry создаёт новый accepted snapshot, а старый duplicate ничего не меняет. Браузерная проверка дополнительно подтверждает, что сообщение видно, связано с нужным контролом и очищается после новой правки.

\n

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

\n" }