diff --git a/editorial/agent-rewrites/199.json b/editorial/agent-rewrites/199.json index 47a86ae..de34836 100644 --- a/editorial/agent-rewrites/199.json +++ b/editorial/agent-rewrites/199.json @@ -1,7 +1,7 @@ { "index": 199, "slug": "editorial-2022-06-field-form-errors", - "title": "Когда форма показывает старую ошибку: snapshots, retry и безопасный rollback", - "excerpt": "Поздний ответ сервера может вернуть ошибку уже исправленного поля и стереть полезный контекст. Разбираем связь между значениями, попыткой и ответом, а затем проверяем retry, duplicate и локальный rollback.", - "contentHtml": "
Пользователь исправляет email и нажимает «Отправить». Через секунду форма снова показывает ошибку для старого значения. Иногда рядом появляется второй результат: кнопка разрешила два retry, а после успеха старый ответ снова очистил поле. Цена ошибки — не только раздражение. Человек повторяет уже правильное действие, теряет введённые данные и может несколько раз отправить одну операцию.
\nКорень проблемы обычно не в тексте сообщения и не в одной кнопке. Интерфейс применяет ответ без проверки того, к какой попытке и к каким значениям он относится. Переменная isLoading говорит только о наличии работы. Она не отвечает на три важных вопроса: какой снимок ушёл, активна ли ещё попытка и имеет ли этот ответ право менять текущую форму.
Надёжное правило простое: ответ меняет форму только после проверки attemptId и версии значений. Локальный retry создаёт новую попытку. Локальный rollback возвращает последний принятый снимок, но не отменяет запись на сервере. Эти действия должны иметь разные переходы и разные проверки.
Рассмотрим форму с одним полем email. В момент отправки приложение сохраняет не копию ссылки на объект, а неизменяемый снимок: значение, версию поля и идентификатор попытки. Пусть текущий снимок имеет emailVersion = 4, а отправка получает attemptId = 17. Пользователь меняет email. Версия становится 5, но ответ попытки 17 всё ещё несёт версию 4.
Если обработчик сравнивает только имя поля, он применит старую ошибку к новому значению. Если обработчик сравнивает только attemptId, он не увидит, что поле уже изменилось. Нужны обе проверки. Ответ старой попытки с несовпадающей версией получает статус stale-response-ignored и не создаёт сообщение в интерфейсе.
Серверная ошибка и локальная ошибка тоже решают разные задачи. Браузер может остановить submit из-за пустого или неверно записанного email. Сервер может отклонить уже синтаксически правильное значение, например потому, что адрес занят. В первом случае запрос не должен создаваться. Во втором сообщение должно быть связано с конкретным полем и объяснять следующий шаг.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Старая ошибка вернулась после edit | Ответ сопоставили только с именем поля | Сравнить версию снимка и текущую версию | Пометить ответ устаревшим и не менять UI |
| Два retry ушли подряд | Хранится только boolean loading | Найти активный attemptId в state | Отклонить второй retry до завершения первой попытки |
| Ошибка есть, но поле не названо | Общий toast заменил связь с контролом | Проверить field, текст и идентификатор сообщения | Показать ошибку у поля и общий статус отдельно |
| Успех применился дважды | Завершённая попытка осталась активной | Повторно передать тот же ответ | Игнорировать duplicate без изменения принятого снимка |
| Rollback обещает отмену операции | Локальный undo смешали с API-компенсацией | Проверить, вызывает ли rollback сетевой adapter | Ограничить его локальными значениями или описать отдельный API |
Первое состояние — текущие значения формы. Они меняются при вводе. Второе — активная попытка с собственным идентификатором и снимком версий. Третье — последний принятый снимок. Он нужен, если интерфейс предлагает вернуть локальное состояние после неудачной правки. У каждого состояния свой жизненный цикл.
\nПри submit приложение сначала проверяет локальные ограничения. Если email некорректен, оно показывает ошибку и не создаёт попытку. Если попытка уже активна, retry останавливается. Иначе создаётся новый снимок. Ответ можно применить только при совпадении идентификатора и версий. После успеха попытка закрывается, а её значения становятся последним принятым снимком.
\nПри изменении поля версия увеличивается. Это важнее, чем очистить старый текст через setError(null). Очистка меняет видимый результат, но не создаёт доказательство, что поздний callback больше не подходит. Версия даёт обработчику такое доказательство.
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}\nЭто учебный фрагмент reducer. Он не выполняет HTTP-запрос, не задаёт формат API и не доказывает exactly-once доставку. Его задача — показать границу, на которой интерфейс решает, может ли ответ изменить локальное состояние. В реальном компоненте ответ адаптера должен попасть в этот же контракт, а не напрямую вызвать очистку поля.
\nНа схеме важна отрицательная ветка. Устаревший ответ не является новой ошибкой пользователя. Он может попасть в технический журнал, но не должен снова показываться в поле. Duplicate тоже не является новым успехом. После закрытия попытки повторный callback не имеет активного адресата.
\nСначала пользователь вводит неправильный email. Локальная проверка выставляет aria-invalid, связывает сообщение с контролом и блокирует submit. Затем пользователь исправляет значение. Приложение очищает сообщение, увеличивает версию и создаёт попытку 18 со снимком версии 5.
Пока попытка 18 активна, второй клик не создаёт попытку 19. Это не только защита от двойного клика. Новый идентификатор усложнил бы причинную связь: оба ответа могли бы менять одно поле, а порядок callback не обязан совпадать с порядком кликов.
\nПосле изменения email версия становится 6. Ответ попытки 18 приходит с версией 5 и получает stale-response-ignored. Пользователь не видит старое сообщение. Если сервер должен проверить новое значение, retry создаёт новую попытку 19 и новый снимок. Успешный ответ закрывает её и сохраняет принятые значения.
Если тот же успешный ответ приходит повторно, обработчик не должен повторно очищать ошибки, запускать переход или менять accepted snapshot. Такой ответ можно учесть в локальном журнале как duplicate. Это проверка идемпотентности reducer, а не гарантия сети.
\nattemptId и snapshot. Второй retry должен останавливаться до создания нового идентификатора.attemptId, затем версии. Только после этого применяйте error или success.Эта модель не решает upload, debounce, optimistic update, автоматические повторы, отмену HTTP, несколько вкладок и составные операции. У них есть дополнительные идентификаторы, границы владения и правила конфликтов. Для заказа, платежа или изменения прав локального rollback недостаточно: нужен серверный статус, политика отмены и журнал операции.
\nУчебный код не моделирует реальную сеть, задержку, браузер, screen reader или production telemetry. Положительный результат его проверок означает только то, что перечисленные переходы reducer соблюдают заданные правила. Он не подтверждает доступность готового UI и не измеряет число ошибок пользователей.
\nИзменение готово, если тесты отдельно подтверждают четыре отрицательных перехода: локально неверное значение не создаёт попытку; retry поверх активной попытки не создаёт второй запрос; поздний ответ не меняет исправленное поле; duplicate не меняет принятый снимок. Затем ручная проверка подтверждает, что сообщение связано с полем и не теряется при смене фокуса.
\nЕсли хотя бы один переход проверяется только через happy path, причина сбоя остаётся недоказанной. Сначала зафиксируйте контракт состояния. Потом подключайте транспорт и проверяйте его отдельно. Так сообщение формы остаётся следствием актуального ввода, а не случайного порядка callback.
\nСбой редко выглядит как «сломанная машина состояний». Обычно человек видит другое: после исправления поля снова появилась прежняя ошибка; кнопка разрешает два retry подряд; после успешной отправки тест не может восстановить понятный снимок. Эти симптомы часто чинят отдельными setError(null) и disabled-кнопкой. Цена такого патча — новый скрытый порядок: при следующем изменении обработчик снова не знает, какой ответ ещё имеет право менять форму.
Диагностику лучше начать с одного пути и его отрицательных исходов. В этой статье есть локальная модель регистрации: сначала приходит ответ для старого email, затем совпавшая серверная ошибка, затем успешный retry и локальный rollback последнего принятого снимка. Это не история production-инцидента, не тест браузера и не имитация сети. Вызовы response выполняются вручную, чтобы проверить именно правила state, а не скорость транспорта.
\n| Наблюдение | Вероятная причина | Минимальный факт в state | Безопасное действие |
|---|---|---|---|
| Старый текст вернулся после edit | response применён только по имени поля | versions active attempt и текущего поля различаются | пометить response stale и не создавать error |
| Два retry ушли подряд | active attempt не блокирует кнопку | в state уже есть attemptId | вернуть retry-blocked-active-attempt |
| Ошибка есть, но поле не названо | общий toast потерял field record | serverError не содержит field/semantic id | создать field-specific payload и общий status отдельно |
| Успех применён дважды | ответ не снял active attempt | повтор не находит active attempt | игнорировать duplicate как unknown attempt |
| Откат меняет server state | локальный undo выдан за компенсацию API | rollback не вызывает 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 разрешён один раз и не отправляет компенсирующий запрос.\nchangeField(form, 'email', 'corrected@example.test');\nconst retry = retrySubmit(form);\nreceiveServerResult(form, { attemptId: retry.attemptId, versions: retry.versions, ok: true });\nchangeField(form, 'email', 'typo@example.test');\nconst rollback = rollbackLastAccepted(form);\n// rollback.kind === 'rolled-back'; email.value === 'corrected@example.test'\n// Второй rollback возвращает 'nothing-to-rollback'.\nFixture проходит путь специально в неудобном порядке. Сначала 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.
\nПоздний ответ — нормальная возможность в асинхронном интерфейсе, а не доказательство, что человек «слишком быстро печатает». Попытка 1 действительно могла быть обработана позже попытки 2. Задача UI — не угадать сеть, а сохранить причинную связь: server result может говорить только о тех values, которые были в его request snapshot. Если этой связи нет, форма вынуждает пользователя разбираться во внутреннем времени приложения.
\nПри этом stale response не всегда нужно скрывать от технической диагностики. В модели он попадает в log как response:stale:1. Это не telemetry и не production metric; это локальный след для теста. В реальном приложении команда может отдельно решить, нужен ли debug-log и какие данные допустимо записывать. Важно не использовать такой log как оправдание показа старого сообщения в UI.
Retry повторяет проверку текущего snapshot и получает новый attemptId. Rollback не повторяет ничего: он возвращает values, которые уже были приняты учебной моделью, и не посылает сообщений наружу. Эти действия нельзя объединять одной кнопкой «Отменить/повторить». У retry есть риск нового результата сервера; у rollback — риск потерять несохранённую локальную правку. Поэтому перед внедрением надо назвать, какое ожидание продукта защищает каждое действие.
\nЕсли форма сохраняет не один email, а заказ, деньги или права доступа, локального rollback может быть недостаточно и даже вреден. Там нужен отдельный server-side статус, правила конфликта, права на отмену и audit trail. Учебная модель специально не делает этот шаг. Она показывает только безопасное минимальное правило: локальный интерфейс не должен бесконечно применять один и тот же result и не должен выдавать возвращение своего snapshot за отмену доменной операции.
\nЗдесь нет upload, debounce, optimistic update, автоматического retry, HTTP abort, реальных status codes, маршрутизации фокуса и проверки речи screen reader. У каждого из этих механизмов свой порядок ошибок. Добавлять их к этой модели можно только с новым fixture и явно расширенной границей. Иначе простой тест начнёт делать обещания о браузере и сети, которых его входы не содержат.
\nСледующий шаг — взять один reducer реального компонента и сопоставить его переходы с таблицей выше. Добавьте сначала test на stale response и duplicate result, затем отдельный тест выбранного HTTP adapter. Для доступности подготовьте конкретную разметку и проверку в согласованных средах. Так форма получит две независимые опоры: unit contract для порядка state и платформенную проверку для того, что реально видит пользователь.
\nТехнические ссылки остаются в границе времени: 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 или пользовательское исследование.
\n