8 lines
19 KiB
JSON
8 lines
19 KiB
JSON
{
|
||
"index": 201,
|
||
"slug": "editorial-2022-06-practice-form-errors",
|
||
"title": "Старая ошибка формы после новой правки: как связать ответ с тем вводом, который сервер проверял",
|
||
"excerpt": "Если пользователь исправил поле, поздний ответ прежней отправки не должен менять текущую форму. Разбираем локальную проверку, версии полей, attemptId и честный retry.",
|
||
"contentHtml": "<p>На учебном стенде инженер отправил форму с <code>old@example.test</code> и открыл панель Network, чтобы дождаться ответа. Пока запрос был в пути, он изменил поле на <code>new@example.test</code>, а затем увидел рядом с новым адресом сообщение «Адрес уже занят». Сценарий воспроизводим: сервер проверял старое значение, но интерфейс показал ошибку у нового.</p>\n<p>Цена ошибки измерима на уровне действия. Человек может повторно исправлять корректный адрес, закрыть форму или отправить её ещё раз, не понимая, какой запрос сейчас активен. В регистрациях, платежах и заказах такое поведение подрывает доверие и может привести к повторному действию с побочным эффектом.</p>\n<p><strong>Тезис:</strong> ошибка принадлежит не имени поля, а снимку ввода и конкретной попытке отправки. Поэтому ответ можно применить только тогда, когда он относится к текущей версии поля. Для этого нужно разделить локальную проверку, состояние отправки и результат сервера; при каждой правке увеличивать версию поля; каждой отправке выдавать <code>attemptId</code>; устаревший ответ явно игнорировать.</p>\n<h2>Сценарий: как проверили рассинхронизацию</h2>\n<p>Инженер повторил сценарий в тестовом стенде с задержанным ответом. Сначала отправил значение <code>old@example.test</code> и сохранил номер попытки. Затем изменил поле на <code>new@example.test</code> до ответа. После этого передал в reducer старый результат с ошибкой и проверил три состояния: новое значение осталось, ошибка не появилась, активная попытка завершилась. Так проверяется именно принадлежность ответа, а не удобный, но неверный порядок «последний ответ побеждает».</p>\n<h2>Механизм рассинхронизации</h2>\n<p>У формы есть два времени. Первое — время ввода. Значение меняется при каждом действии пользователя. Второе — время ответа. Запрос может закончиться после нескольких новых правок. Если обработчик знает только имя поля, он записывает результат в актуальное состояние, хотя сервер проверял прежний снимок.</p>\n<p>Правило «применяем последний пришедший ответ» не защищает форму: последним может прийти самый старый запрос. Правило «последняя правка побеждает» тоже неполно: оно способно скрыть ошибку, которая действительно относится к текущему значению. Нужна проверка принадлежности: совпадают ли версия поля и идентификатор попытки с теми, что сохранены в состоянии.</p>\n<p>Локальная ошибка имеет другой источник. Пустое обязательное поле или строку без доменной части email можно проверить до отправки. В этом случае запрос не создаётся. Серверная ошибка появляется только после ответа на конкретный снимок. Ошибка транспорта описывает доставку ответа, а не значение одного поля. Если положить всё в <code>form.error</code>, код потеряет правило очистки и следующий шаг.</p>\n<h2>Контракт состояния</h2>\n<table><caption>Симптом → причина → проверка → действие</caption><thead><tr><th>Симптом</th><th>Причина</th><th>Проверка</th><th>Действие</th></tr></thead><tbody><tr><td>Старый текст появился после правки</td><td>Ответ сопоставлен только по имени поля</td><td>Сравнить версии из запроса и текущего поля</td><td>Пометить ответ как stale и не создавать server error</td></tr><tr><td>Две отправки идут одновременно</td><td>Retry не проверяет active attempt</td><td>Проверить переходы submitting и retry</td><td>Заблокировать повтор до завершения попытки</td></tr><tr><td>Локальная ошибка ушла на сервер</td><td>Constraint validation смешана с submit</td><td>Убедиться, что невалидный снимок не получил attemptId</td><td>Показать причину у поля и остановить отправку</td></tr><tr><td>Ошибка осталась после изменения значения</td><td>Server error очищается только после ответа</td><td>Изменить поле и проверить state до нового ответа</td><td>Снять ошибку прежней версии</td></tr><tr><td>Повторный ответ меняет успех</td><td>Нет границы принятой попытки</td><td>Передать duplicate после success</td><td>Игнорировать ответ без active attempt</td></tr></tbody></table>\n<p>Минимальная запись поля может выглядеть так: <code>{ value, version, localError, serverError }</code>. В состоянии формы отдельно нужны <code>activeAttempt</code> и последний принятый снимок. При правке меняются <code>value</code> и <code>version</code>, а ошибка сервера для прежней версии исчезает. При отправке код копирует значения и версии в attempt. Он не должен читать актуальное поле в момент ответа: это уже может быть другой ввод.</p>\n<h2>Пример: ответ проверяется до изменения state</h2>\n<pre><code>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}</code></pre>\n<p>Это учебный пример для in-memory reducer. Он не отправляет HTTP-запрос, не отменяет <code>fetch</code>, не создаёт DOM и не доказывает порядок сетевых пакетов. Строка <code>Адрес уже занят</code> в тесте должна быть входом <code>result.message</code>, а не утверждением о production-ответе. В реальном приложении сетевой адаптер обязан передать в reducer тот же <code>attemptId</code> и снимок версий.</p>\n<p>Две ранние проверки в <code>beginSubmit</code> закрывают разные границы. <code>activeAttempt</code> блокирует второй submit до завершения первого, а <code>localError</code> не создаёт попытку для заведомо невалидного ввода. Функция <code>ignore</code> оставляет форму неизменной по данным и записывает только диагностическое действие, поэтому поздний или повторный ответ не превращается в исключение.</p>\n<h2>Почему stale-ответ нужно завершать</h2>\n<p>Устаревший ответ нельзя применять к полю. Но его нельзя и просто забыть, оставив форму в вечном <code>pending</code>. Если пользователь уже ввёл новое значение, активная попытка прежнего снимка больше не должна блокировать текущий retry. Переход <code>stale-response-ignored</code> снимает <code>activeAttempt</code>, пишет диагностический сигнал и не создаёт ошибку.</p>\n<p>После этого пользователь может отправить текущий валидный снимок. Новый retry получает новый <code>attemptId</code> и новые версии. Это не означает, что браузер отменил старый запрос. Отмена зависит от API и стоимости работы сервера. Даже отменённый запрос не заменяет проверку ответа: гонки и повторная доставка должны иметь безопасный результат.</p>\n<figure><img src='/assets/editorial/2022/form-errors-state-machine-2022.svg' alt='Автомат формы: отправка хранит attemptId и версии, правка делает ответ устаревшим, совпавший ответ ведёт к ошибке или успеху' /><figcaption>Схема показывает контракт данных, а не измеренную задержку и не поведение конкретного HTTP-клиента.</figcaption></figure>\n<h2>Порядок исправления</h2>\n<ol><li><strong>Зафиксируйте симптом.</strong> Воспроизведите отправку, правку до ответа и появление старой ошибки. Запишите значение поля до отправки и после правки.</li><li><strong>Найдите владельца.</strong> Отметьте обработчик ответа и место записи ошибки. Если запись использует только имя поля, граница недостаточна.</li><li><strong>Разделите проверки.</strong> Локальную невалидность обработайте до создания попытки. Ошибку сервера храните вместе с идентификатором попытки и версиями снимка.</li><li><strong>Добавьте версии.</strong> Увеличивайте версию при каждой правке. Копируйте версии в attempt, а не вычисляйте их после ответа.</li><li><strong>Закройте повтор.</strong> Пока есть active attempt, не запускайте второй submit. После stale, server error или success переход должен быть явным.</li><li><strong>Проверьте отрицательный путь.</strong> Передайте старый ответ после новой правки. Он не должен менять значение, ошибку или принятый снимок.</li><li><strong>Проверьте браузер.</strong> Отдельно подтвердите реальный DOM, фокус, сообщение и связь поля с ошибкой. Fixture этого не проверяет.</li></ol>\n<h2>Связь с разметкой ошибки</h2>\n<p>Состояние данных не заменяет доступную разметку. Если интерфейс использует <code>aria-invalid</code> и <code>aria-errormessage</code>, атрибуты должны появляться только для ошибки текущей версии. При новой правке связь с прежним сообщением нужно убрать. Идентификатор попытки сам по себе не делает сообщение доступным и не подтверждает, что вспомогательная технология его объявила.</p>\n<p>Для нативных контролов сначала используйте возможности HTML: правильный тип, <code>required</code>, <code>pattern</code> и constraint validation. Скрипт может дополнить этот слой серверным результатом, но не должен подменять его общей строкой без указания поля. Сообщение должно объяснять причину и действие, если сервер действительно сообщает о проблеме текущего снимка.</p>\n<h2>Ограничения</h2>\n<p>Схема рассчитана на одну форму и одного владельца состояния. Она не решает конфликт между двумя вкладками, offline, rate limit, CSRF, составные поля, загрузку файлов и оптимистическое сохранение. Для нескольких полей версия должна быть частью снимка каждого поля или общей версии формы; выбор зависит от того, какие поля сервер проверяет вместе.</p>\n<p><code>attemptId</code> интерфейса не является idempotency key. Он защищает применение ответа внутри формы. Идемпотентность на сервере требует отдельного контракта. Нельзя считать, что новый UI-идентификатор предотвращает повторную оплату или создание ресурса.</p>\n<p>Пример не содержит production-метрик. Его проверяемый результат уже: устаревший ответ не меняет текущий ввод; совпавший ответ меняет только соответствующий снимок; duplicate после success не меняет accepted state; локально невалидный ввод не создаёт попытку.</p>\n<h2>Критерий готовности</h2>\n<p>Исправление готово, если команда воспроизводит четыре сценария с однозначными переходами: локальная ошибка блокирует submit без attemptId; правка во время отправки делает прежний ответ stale; совпавший server error появляется только у проверенного значения; успешный retry создаёт новый accepted snapshot, а старый duplicate ничего не меняет. Браузерная проверка дополнительно подтверждает, что сообщение видно, связано с нужным контролом и очищается после новой правки.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href='https://www.w3.org/TR/2017/REC-html52-20171214/' target='_blank' rel='noopener noreferrer'>HTML 5.2, W3C Recommendation, 14 декабря 2017 года</a> — form controls и constraint validation.</li><li><a href='https://www.w3.org/TR/2021/CRD-wai-aria-1.2-20211208/' target='_blank' rel='noopener noreferrer'>WAI-ARIA 1.2, W3C Candidate Recommendation Draft, 8 декабря 2021 года</a> — состояния <code>aria-invalid</code> и связь ошибки через <code>aria-errormessage</code>.</li><li><a href='https://www.w3.org/TR/2021/NOTE-wai-aria-practices-1.2-20211129/' target='_blank' rel='noopener noreferrer'>WAI-ARIA Authoring Practices 1.2, W3C Group Note, 29 ноября 2021 года</a> — официальные patterns для проектирования доступной связи контрола и сообщения.</li></ul>"
|
||
}
|