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 говорит только о наличии работы. Она не отвечает на три важных вопроса: какой снимок ушёл, активна ли ещё попытка и имеет ли этот ответ право менять текущую форму.

\n

Надёжное правило простое: ответ меняет форму только после проверки attemptId и версии значений. Локальный retry создаёт новую попытку. Локальный rollback возвращает последний принятый снимок, но не отменяет запись на сервере. Эти действия должны иметь разные переходы и разные проверки.

\n

Наблюдаемый сбой и его механизм

\n

Рассмотрим форму с одним полем email. В момент отправки приложение сохраняет не копию ссылки на объект, а неизменяемый снимок: значение, версию поля и идентификатор попытки. Пусть текущий снимок имеет emailVersion = 4, а отправка получает attemptId = 17. Пользователь меняет email. Версия становится 5, но ответ попытки 17 всё ещё несёт версию 4.

\n

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

\n

Серверная ошибка и локальная ошибка тоже решают разные задачи. Браузер может остановить submit из-за пустого или неверно записанного email. Сервер может отклонить уже синтаксически правильное значение, например потому, что адрес занят. В первом случае запрос не должен создаваться. Во втором сообщение должно быть связано с конкретным полем и объяснять следующий шаг.

\n
Диагностика ошибки формы
СимптомПричинаПроверкаДействие
Старая ошибка вернулась после editОтвет сопоставили только с именем поляСравнить версию снимка и текущую версиюПометить ответ устаревшим и не менять UI
Два retry ушли подрядХранится только boolean loadingНайти активный attemptId в stateОтклонить второй retry до завершения первой попытки
Ошибка есть, но поле не названоОбщий toast заменил связь с контроломПроверить field, текст и идентификатор сообщенияПоказать ошибку у поля и общий статус отдельно
Успех применился дваждыЗавершённая попытка осталась активнойПовторно передать тот же ответИгнорировать duplicate без изменения принятого снимка
Rollback обещает отмену операцииЛокальный undo смешали с API-компенсациейПроверить, вызывает ли rollback сетевой adapterОграничить его локальными значениями или описать отдельный API
\n

Три состояния, которые нельзя заменять одним флагом

\n

Первое состояние — текущие значения формы. Они меняются при вводе. Второе — активная попытка с собственным идентификатором и снимком версий. Третье — последний принятый снимок. Он нужен, если интерфейс предлагает вернуть локальное состояние после неудачной правки. У каждого состояния свой жизненный цикл.

\n

При submit приложение сначала проверяет локальные ограничения. Если email некорректен, оно показывает ошибку и не создаёт попытку. Если попытка уже активна, retry останавливается. Иначе создаётся новый снимок. Ответ можно применить только при совпадении идентификатора и версий. После успеха попытка закрывается, а её значения становятся последним принятым снимком.

\n

При изменении поля версия увеличивается. Это важнее, чем очистить старый текст через setError(null). Очистка меняет видимый результат, но не создаёт доказательство, что поздний callback больше не подходит. Версия даёт обработчику такое доказательство.

\n
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

Схема переходов

\n
\"Схема
Ответ проверяет попытку и версию до того, как меняет форму. Rollback возвращает только локальный принятый снимок.
\n

На схеме важна отрицательная ветка. Устаревший ответ не является новой ошибкой пользователя. Он может попасть в технический журнал, но не должен снова показываться в поле. Duplicate тоже не является новым успехом. После закрытия попытки повторный callback не имеет активного адресата.

\n

Пример последовательности

\n

Сначала пользователь вводит неправильный email. Локальная проверка выставляет aria-invalid, связывает сообщение с контролом и блокирует submit. Затем пользователь исправляет значение. Приложение очищает сообщение, увеличивает версию и создаёт попытку 18 со снимком версии 5.

\n

Пока попытка 18 активна, второй клик не создаёт попытку 19. Это не только защита от двойного клика. Новый идентификатор усложнил бы причинную связь: оба ответа могли бы менять одно поле, а порядок callback не обязан совпадать с порядком кликов.

\n

После изменения email версия становится 6. Ответ попытки 18 приходит с версией 5 и получает stale-response-ignored. Пользователь не видит старое сообщение. Если сервер должен проверить новое значение, retry создаёт новую попытку 19 и новый снимок. Успешный ответ закрывает её и сохраняет принятые значения.

\n

Если тот же успешный ответ приходит повторно, обработчик не должен повторно очищать ошибки, запускать переход или менять accepted snapshot. Такой ответ можно учесть в локальном журнале как duplicate. Это проверка идемпотентности reducer, а не гарантия сети.

\n
  1. Зафиксируйте симптом. Выберите один путь: edit после submit, два retry или повторный ответ. Запишите порядок событий и видимый текст.
  2. Снимите входы. Сохраните значения и версии в момент submit. Без этого нельзя доказать, что ответ устарел.
  3. Разделите локальную и серверную проверку. Некорректное поле не создаёт попытку. Серверный отказ приходит только после принятого локального ввода.
  4. Добавьте активную попытку. Храните attemptId и snapshot. Второй retry должен останавливаться до создания нового идентификатора.
  5. Проверяйте ответ в два шага. Сначала сравните attemptId, затем версии. Только после этого применяйте error или success.
  6. Свяжите ошибку с полем. Храните имя поля, понятный текст и следующий шаг. Общий статус формы не заменяет сообщение у контрола.
  7. Отделите rollback. Возвращайте последний локальный accepted snapshot, увеличивайте версии и разрешайте действие один раз. Не называйте это отменой серверной операции.
  8. Проверьте настоящий интерфейс. Пройдите клавиатурой и в целевых браузерах. Отдельно проверьте фокус, объявление сообщения и сетевой adapter.
\n

Ограничения

\n

Эта модель не решает upload, debounce, optimistic update, автоматические повторы, отмену HTTP, несколько вкладок и составные операции. У них есть дополнительные идентификаторы, границы владения и правила конфликтов. Для заказа, платежа или изменения прав локального rollback недостаточно: нужен серверный статус, политика отмены и журнал операции.

\n

Учебный код не моделирует реальную сеть, задержку, браузер, screen reader или production telemetry. Положительный результат его проверок означает только то, что перечисленные переходы reducer соблюдают заданные правила. Он не подтверждает доступность готового UI и не измеряет число ошибок пользователей.

\n

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

\n

Изменение готово, если тесты отдельно подтверждают четыре отрицательных перехода: локально неверное значение не создаёт попытку; retry поверх активной попытки не создаёт второй запрос; поздний ответ не меняет исправленное поле; duplicate не меняет принятый снимок. Затем ручная проверка подтверждает, что сообщение связано с полем и не теряется при смене фокуса.

\n

Если хотя бы один переход проверяется только через happy path, причина сбоя остаётся недоказанной. Сначала зафиксируйте контракт состояния. Потом подключайте транспорт и проверяйте его отдельно. Так сообщение формы остаётся следствием актуального ввода, а не случайного порядка callback.

\n

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

\n" + "title": "Форма застряла между retry и старым ответом: диагностика и обратимый возврат к принятому снимку", + "excerpt": "Полевой маршрут для формы с поздним ответом, дубликатом и повторной отправкой: какие факты собрать, что остановить и где допустим локальный rollback.", + "contentHtml": "

Сбой редко выглядит как «сломанная машина состояний». Обычно человек видит другое: после исправления поля снова появилась прежняя ошибка; кнопка разрешает два retry подряд; после успешной отправки тест не может восстановить понятный снимок. Эти симптомы часто чинят отдельными setError(null) и disabled-кнопкой. Цена такого патча — новый скрытый порядок: при следующем изменении обработчик снова не знает, какой ответ ещё имеет право менять форму.

\n

Диагностику лучше начать с одного пути и его отрицательных исходов. В этой статье есть локальная модель регистрации: сначала приходит ответ для старого email, затем совпавшая серверная ошибка, затем успешный retry и локальный rollback последнего принятого снимка. Это не история production-инцидента, не тест браузера и не имитация сети. Вызовы response выполняются вручную, чтобы проверить именно правила state, а не скорость транспорта.

\n

Шесть наблюдений, которые не стоит смешивать

\n
Диагностика ошибки формы без догадок о сети
НаблюдениеВероятная причинаМинимальный факт в stateБезопасное действие
Старый текст вернулся после editresponse применён только по имени поляversions active attempt и текущего поля различаютсяпометить response stale и не создавать error
Два retry ушли подрядactive attempt не блокирует кнопкув state уже есть attemptIdвернуть retry-blocked-active-attempt
Ошибка есть, но поле не названообщий toast потерял field recordserverError не содержит field/semantic idсоздать field-specific payload и общий status отдельно
Успех применён дваждыответ не снял active attemptповтор не находит active attemptигнорировать duplicate как unknown attempt
Откат меняет server stateлокальный undo выдан за компенсацию APIrollback не вызывает adapterограничить rollback только локальным accepted snapshot
После rollback снова можно откатывать бесконечноprior snapshot не помечен использованнымrollbackUsed не изменилсясделать rollback одноразовым
\n

Сначала нарисуйте три точки: снимок, активная попытка, принятие

\n

До submit форма имеет текущие values и их версии. В submit она создаёт immutable snapshot: values, versions, attemptId. После принятого ответа сохраняется последний accepted snapshot. Эти три точки не стоит заменять одной переменной isLoading. Она умеет сказать, что «что-то происходит», но не отвечает, с каким input связан ответ, что можно повторить и к какому значению возвращается локальная отмена.

\n

Встроенный rollback нужен не для того, чтобы отменить серверную операцию. В этой модели он помогает безопасно вернуть form state к последнему принятому снимку после последующей локальной правки. При rollback увеличивается version, очищаются ошибки и отмечается rollbackUsed. Поэтому прежний response не сможет внезапно совпасть с новым state, а второй rollback не воспроизведёт действие. Если реальный продукт обещает отмену уже записанного на сервере изменения, это отдельный API contract с конфликтами и компенсацией.

\n
// Успешная отправка сохраняет снимок. 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'.
\n

Диаграмма: что остановить, а что можно вернуть

\n
\"Диагностическая
Схема помогает не перепутать три действия: игнорировать устаревший response, повторить текущий snapshot и вернуть только локальные значения.
\n

Сценарий проверки с отрицательными assertions

\n

Fixture проходит путь специально в неудобном порядке. Сначала email становится локально невалидным — submit блокируется и не получает attempt. Затем создаётся attempt 1, но retry поверх pending отвергается. После правки email response attempt 1 получает stale-response-ignored. Только потом создаётся attempt 2 с совпавшими versions, который законно ставит server error и declared association. Новая правка очищает эту ошибку, attempt 3 принимается, а duplicate этого ответа игнорируется.

\n

Отрицательные assertions здесь важнее happy path. Успешный submit легко написать так, чтобы он прошёл один раз. Риск появляется в переходах, которые не должны иметь эффекта: неправильный email не создаёт запрос в модели; старый response не создаёт message; повтор не подтверждает уже закрытую попытку; второй rollback ничего не меняет. Если такой тест отсутствует, кнопка может выглядеть правильно, но state будет зависеть от случайного порядка callback.

\n

Маршрут диагностики и безопасной правки

\n
  1. Симптом. Возьмите один воспроизводимый путь: edit после submit, два retry или duplicate result. Не объединяйте несколько дефектов в один большой «form bug».
  2. Снимок. Запишите values и versions в момент beginSubmit. Если их нет, поздний response невозможно классифицировать честно.
  3. Активность. Проверьте, что state хранит не boolean, а active attempt с id. Второй retry должен останавливаться до создания нового id.
  4. Ответ. Сначала сопоставьте attemptId, потом версии. Только после двух совпадений применяйте error или success.
  5. Сообщение. У совпавшей field error должны быть source, field, текст следующего шага и declared semantic payload. При edit они очищаются одной операцией.
  6. Откат. Если нужен локальный undo, возвращайте только последний accepted snapshot, увеличивайте version и разрешайте его один раз. Сетевую компенсацию проектируйте отдельно.
  7. Проверка реализации. После fixture пройдите настоящий UI в целевых браузерах, проверьте выбранную разметку и adapter. Не приписывайте этим результатам то, чего не измеряли.
\n

Почему stale response не надо считать ошибкой пользователя

\n

Поздний ответ — нормальная возможность в асинхронном интерфейсе, а не доказательство, что человек «слишком быстро печатает». Попытка 1 действительно могла быть обработана позже попытки 2. Задача UI — не угадать сеть, а сохранить причинную связь: server result может говорить только о тех values, которые были в его request snapshot. Если этой связи нет, форма вынуждает пользователя разбираться во внутреннем времени приложения.

\n

При этом stale response не всегда нужно скрывать от технической диагностики. В модели он попадает в log как response:stale:1. Это не telemetry и не production metric; это локальный след для теста. В реальном приложении команда может отдельно решить, нужен ли debug-log и какие данные допустимо записывать. Важно не использовать такой log как оправдание показа старого сообщения в UI.

\n

Граница rollback и retry

\n

Retry повторяет проверку текущего snapshot и получает новый attemptId. Rollback не повторяет ничего: он возвращает values, которые уже были приняты учебной моделью, и не посылает сообщений наружу. Эти действия нельзя объединять одной кнопкой «Отменить/повторить». У retry есть риск нового результата сервера; у rollback — риск потерять несохранённую локальную правку. Поэтому перед внедрением надо назвать, какое ожидание продукта защищает каждое действие.

\n

Если форма сохраняет не один email, а заказ, деньги или права доступа, локального rollback может быть недостаточно и даже вреден. Там нужен отдельный server-side статус, правила конфликта, права на отмену и audit trail. Учебная модель специально не делает этот шаг. Она показывает только безопасное минимальное правило: локальный интерфейс не должен бесконечно применять один и тот же result и не должен выдавать возвращение своего snapshot за отмену доменной операции.

\n

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

\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

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

\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

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

\n" }