8 lines
18 KiB
JSON
8 lines
18 KiB
JSON
{
|
||
"index": 199,
|
||
"slug": "editorial-2022-06-field-form-errors",
|
||
"title": "Когда форма показывает старую ошибку: snapshots, retry и безопасный rollback",
|
||
"excerpt": "Поздний ответ сервера может вернуть ошибку уже исправленного поля и стереть полезный контекст. Разбираем связь между значениями, попыткой и ответом, а затем проверяем retry, duplicate и локальный rollback.",
|
||
"contentHtml": "<p>Пользователь исправляет email и нажимает «Отправить». Через секунду форма снова показывает ошибку для старого значения. Иногда рядом появляется второй результат: кнопка разрешила два retry, а после успеха старый ответ снова очистил поле. Цена ошибки — не только раздражение. Человек повторяет уже правильное действие, теряет введённые данные и может несколько раз отправить одну операцию.</p>\n<p>Корень проблемы обычно не в тексте сообщения и не в одной кнопке. Интерфейс применяет ответ без проверки того, к какой попытке и к каким значениям он относится. Переменная <code>isLoading</code> говорит только о наличии работы. Она не отвечает на три важных вопроса: какой снимок ушёл, активна ли ещё попытка и имеет ли этот ответ право менять текущую форму.</p>\n<p>Надёжное правило простое: ответ меняет форму только после проверки <code>attemptId</code> и версии значений. Локальный retry создаёт новую попытку. Локальный rollback возвращает последний принятый снимок, но не отменяет запись на сервере. Эти действия должны иметь разные переходы и разные проверки.</p>\n<h2>Наблюдаемый сбой и его механизм</h2>\n<p>Рассмотрим форму с одним полем <code>email</code>. В момент отправки приложение сохраняет не копию ссылки на объект, а неизменяемый снимок: значение, версию поля и идентификатор попытки. Пусть текущий снимок имеет <code>emailVersion = 4</code>, а отправка получает <code>attemptId = 17</code>. Пользователь меняет email. Версия становится 5, но ответ попытки 17 всё ещё несёт версию 4.</p>\n<p>Если обработчик сравнивает только имя поля, он применит старую ошибку к новому значению. Если обработчик сравнивает только <code>attemptId</code>, он не увидит, что поле уже изменилось. Нужны обе проверки. Ответ старой попытки с несовпадающей версией получает статус <code>stale-response-ignored</code> и не создаёт сообщение в интерфейсе.</p>\n<p>Серверная ошибка и локальная ошибка тоже решают разные задачи. Браузер может остановить submit из-за пустого или неверно записанного email. Сервер может отклонить уже синтаксически правильное значение, например потому, что адрес занят. В первом случае запрос не должен создаваться. Во втором сообщение должно быть связано с конкретным полем и объяснять следующий шаг.</p>\n<table><caption>Диагностика ошибки формы</caption><thead><tr><th>Симптом</th><th>Причина</th><th>Проверка</th><th>Действие</th></tr></thead><tbody><tr><td>Старая ошибка вернулась после edit</td><td>Ответ сопоставили только с именем поля</td><td>Сравнить версию снимка и текущую версию</td><td>Пометить ответ устаревшим и не менять UI</td></tr><tr><td>Два retry ушли подряд</td><td>Хранится только boolean loading</td><td>Найти активный <code>attemptId</code> в state</td><td>Отклонить второй retry до завершения первой попытки</td></tr><tr><td>Ошибка есть, но поле не названо</td><td>Общий toast заменил связь с контролом</td><td>Проверить <code>field</code>, текст и идентификатор сообщения</td><td>Показать ошибку у поля и общий статус отдельно</td></tr><tr><td>Успех применился дважды</td><td>Завершённая попытка осталась активной</td><td>Повторно передать тот же ответ</td><td>Игнорировать duplicate без изменения принятого снимка</td></tr><tr><td>Rollback обещает отмену операции</td><td>Локальный undo смешали с API-компенсацией</td><td>Проверить, вызывает ли rollback сетевой adapter</td><td>Ограничить его локальными значениями или описать отдельный API</td></tr></tbody></table>\n<h2>Три состояния, которые нельзя заменять одним флагом</h2>\n<p>Первое состояние — текущие значения формы. Они меняются при вводе. Второе — активная попытка с собственным идентификатором и снимком версий. Третье — последний принятый снимок. Он нужен, если интерфейс предлагает вернуть локальное состояние после неудачной правки. У каждого состояния свой жизненный цикл.</p>\n<p>При submit приложение сначала проверяет локальные ограничения. Если email некорректен, оно показывает ошибку и не создаёт попытку. Если попытка уже активна, retry останавливается. Иначе создаётся новый снимок. Ответ можно применить только при совпадении идентификатора и версий. После успеха попытка закрывается, а её значения становятся последним принятым снимком.</p>\n<p>При изменении поля версия увеличивается. Это важнее, чем очистить старый текст через <code>setError(null)</code>. Очистка меняет видимый результат, но не создаёт доказательство, что поздний callback больше не подходит. Версия даёт обработчику такое доказательство.</p>\n<pre><code>type Snapshot = {\n values: { email: string };\n versions: { email: number };\n attemptId: number;\n};\n\nfunction acceptResponse(state, response) {\n const active = state.activeAttempt;\n if (!active || active.attemptId !== response.attemptId) {\n return { ...state, log: [...state.log, 'duplicate-or-unknown'] };\n }\n if (active.snapshot.versions.email !== state.versions.email) {\n return { ...state, activeAttempt: null, log: [...state.log, 'stale-response-ignored'] };\n }\n if (response.ok) {\n return {\n ...state,\n activeAttempt: null,\n accepted: active.snapshot,\n error: null\n };\n }\n return {\n ...state,\n activeAttempt: null,\n error: { field: 'email', message: response.message }\n };\n}</code></pre>\n<p>Это учебный фрагмент reducer. Он не выполняет HTTP-запрос, не задаёт формат API и не доказывает exactly-once доставку. Его задача — показать границу, на которой интерфейс решает, может ли ответ изменить локальное состояние. В реальном компоненте ответ адаптера должен попасть в этот же контракт, а не напрямую вызвать очистку поля.</p>\n<h2>Схема переходов</h2>\n<figure><img src=\"/assets/editorial/2022/form-errors-diagnosis-rollback-2022.svg\" alt=\"Схема проверки формы: локальная ошибка блокирует submit, активная попытка блокирует второй retry, несовпадающая версия делает ответ устаревшим, совпавший ответ принимает ошибку или успех, локальный rollback разрешён один раз\" /><figcaption>Ответ проверяет попытку и версию до того, как меняет форму. Rollback возвращает только локальный принятый снимок.</figcaption></figure>\n<p>На схеме важна отрицательная ветка. Устаревший ответ не является новой ошибкой пользователя. Он может попасть в технический журнал, но не должен снова показываться в поле. Duplicate тоже не является новым успехом. После закрытия попытки повторный callback не имеет активного адресата.</p>\n<h2>Пример последовательности</h2>\n<p>Сначала пользователь вводит неправильный email. Локальная проверка выставляет <code>aria-invalid</code>, связывает сообщение с контролом и блокирует submit. Затем пользователь исправляет значение. Приложение очищает сообщение, увеличивает версию и создаёт попытку 18 со снимком версии 5.</p>\n<p>Пока попытка 18 активна, второй клик не создаёт попытку 19. Это не только защита от двойного клика. Новый идентификатор усложнил бы причинную связь: оба ответа могли бы менять одно поле, а порядок callback не обязан совпадать с порядком кликов.</p>\n<p>После изменения email версия становится 6. Ответ попытки 18 приходит с версией 5 и получает <code>stale-response-ignored</code>. Пользователь не видит старое сообщение. Если сервер должен проверить новое значение, retry создаёт новую попытку 19 и новый снимок. Успешный ответ закрывает её и сохраняет принятые значения.</p>\n<p>Если тот же успешный ответ приходит повторно, обработчик не должен повторно очищать ошибки, запускать переход или менять accepted snapshot. Такой ответ можно учесть в локальном журнале как duplicate. Это проверка идемпотентности reducer, а не гарантия сети.</p>\n<ol><li><strong>Зафиксируйте симптом.</strong> Выберите один путь: edit после submit, два retry или повторный ответ. Запишите порядок событий и видимый текст.</li><li><strong>Снимите входы.</strong> Сохраните значения и версии в момент submit. Без этого нельзя доказать, что ответ устарел.</li><li><strong>Разделите локальную и серверную проверку.</strong> Некорректное поле не создаёт попытку. Серверный отказ приходит только после принятого локального ввода.</li><li><strong>Добавьте активную попытку.</strong> Храните <code>attemptId</code> и snapshot. Второй retry должен останавливаться до создания нового идентификатора.</li><li><strong>Проверяйте ответ в два шага.</strong> Сначала сравните <code>attemptId</code>, затем версии. Только после этого применяйте error или success.</li><li><strong>Свяжите ошибку с полем.</strong> Храните имя поля, понятный текст и следующий шаг. Общий статус формы не заменяет сообщение у контрола.</li><li><strong>Отделите rollback.</strong> Возвращайте последний локальный accepted snapshot, увеличивайте версии и разрешайте действие один раз. Не называйте это отменой серверной операции.</li><li><strong>Проверьте настоящий интерфейс.</strong> Пройдите клавиатурой и в целевых браузерах. Отдельно проверьте фокус, объявление сообщения и сетевой adapter.</li></ol>\n<h2>Ограничения</h2>\n<p>Эта модель не решает upload, debounce, optimistic update, автоматические повторы, отмену HTTP, несколько вкладок и составные операции. У них есть дополнительные идентификаторы, границы владения и правила конфликтов. Для заказа, платежа или изменения прав локального rollback недостаточно: нужен серверный статус, политика отмены и журнал операции.</p>\n<p>Учебный код не моделирует реальную сеть, задержку, браузер, screen reader или production telemetry. Положительный результат его проверок означает только то, что перечисленные переходы reducer соблюдают заданные правила. Он не подтверждает доступность готового UI и не измеряет число ошибок пользователей.</p>\n<h2>Проверяемый критерий готовности</h2>\n<p>Изменение готово, если тесты отдельно подтверждают четыре отрицательных перехода: локально неверное значение не создаёт попытку; retry поверх активной попытки не создаёт второй запрос; поздний ответ не меняет исправленное поле; duplicate не меняет принятый снимок. Затем ручная проверка подтверждает, что сообщение связано с полем и не теряется при смене фокуса.</p>\n<p>Если хотя бы один переход проверяется только через happy path, причина сбоя остаётся недоказанной. Сначала зафиксируйте контракт состояния. Потом подключайте транспорт и проверяйте его отдельно. Так сообщение формы остаётся следствием актуального ввода, а не случайного порядка callback.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://html.spec.whatwg.org/multipage/form-control-infrastructure.html\" target=\"_blank\" rel=\"noopener noreferrer\">WHATWG HTML Standard: form control infrastructure и constraint validation</a></li><li><a href=\"https://www.w3.org/TR/wai-aria-1.2/#aria-invalid\" target=\"_blank\" rel=\"noopener noreferrer\">W3C WAI-ARIA 1.2: состояние aria-invalid</a></li><li><a href=\"https://www.w3.org/TR/WCAG22/#error-identification\" target=\"_blank\" rel=\"noopener noreferrer\">W3C WCAG 2.2: Error Identification, критерий 3.3.1</a></li></ul>"
|
||
}
|