8 lines
18 KiB
JSON
8 lines
18 KiB
JSON
{
|
||
"index": 199,
|
||
"slug": "editorial-2022-06-field-form-errors",
|
||
"title": "Форма застряла между retry и старым ответом: диагностика и обратимый возврат к принятому снимку",
|
||
"excerpt": "Полевой маршрут для формы с поздним ответом, дубликатом и повторной отправкой: какие факты собрать, что остановить и где допустим локальный rollback.",
|
||
"contentHtml": "<p>Сбой редко выглядит как «сломанная машина состояний». Обычно человек видит другое: после исправления поля снова появилась прежняя ошибка; кнопка разрешает два retry подряд; после успешной отправки тест не может восстановить понятный снимок. Эти симптомы часто чинят отдельными <code>setError(null)</code> и disabled-кнопкой. Цена такого патча — новый скрытый порядок: при следующем изменении обработчик снова не знает, какой ответ ещё имеет право менять форму.</p>\n<p>Диагностику лучше начать с одного пути и его отрицательных исходов. В этой статье есть локальная модель регистрации: сначала приходит ответ для старого email, затем совпавшая серверная ошибка, затем успешный retry и локальный rollback последнего принятого снимка. Это не история production-инцидента, не тест браузера и не имитация сети. Вызовы response выполняются вручную, чтобы проверить именно правила state, а не скорость транспорта.</p>\n<h2>Шесть наблюдений, которые не стоит смешивать</h2>\n<div class=\"table-scroll\"><table><caption>Диагностика ошибки формы без догадок о сети</caption><thead><tr><th scope=\"col\">Наблюдение</th><th scope=\"col\">Вероятная причина</th><th scope=\"col\">Минимальный факт в state</th><th scope=\"col\">Безопасное действие</th></tr></thead><tbody><tr><td>Старый текст вернулся после edit</td><td>response применён только по имени поля</td><td>versions active attempt и текущего поля различаются</td><td>пометить response stale и не создавать error</td></tr><tr><td>Два retry ушли подряд</td><td>active attempt не блокирует кнопку</td><td>в state уже есть attemptId</td><td>вернуть retry-blocked-active-attempt</td></tr><tr><td>Ошибка есть, но поле не названо</td><td>общий toast потерял field record</td><td>serverError не содержит field/semantic id</td><td>создать field-specific payload и общий status отдельно</td></tr><tr><td>Успех применён дважды</td><td>ответ не снял active attempt</td><td>повтор не находит active attempt</td><td>игнорировать duplicate как unknown attempt</td></tr><tr><td>Откат меняет server state</td><td>локальный undo выдан за компенсацию API</td><td>rollback не вызывает adapter</td><td>ограничить rollback только локальным accepted snapshot</td></tr><tr><td>После rollback снова можно откатывать бесконечно</td><td>prior snapshot не помечен использованным</td><td>rollbackUsed не изменился</td><td>сделать rollback одноразовым</td></tr></tbody></table></div>\n<h2>Сначала нарисуйте три точки: снимок, активная попытка, принятие</h2>\n<p>До submit форма имеет текущие values и их версии. В submit она создаёт immutable snapshot: values, versions, attemptId. После принятого ответа сохраняется последний accepted snapshot. Эти три точки не стоит заменять одной переменной <code>isLoading</code>. Она умеет сказать, что «что-то происходит», но не отвечает, с каким input связан ответ, что можно повторить и к какому значению возвращается локальная отмена.</p>\n<p>Встроенный rollback нужен не для того, чтобы отменить серверную операцию. В этой модели он помогает безопасно вернуть form state к последнему принятому снимку после последующей локальной правки. При rollback увеличивается version, очищаются ошибки и отмечается <code>rollbackUsed</code>. Поэтому прежний response не сможет внезапно совпасть с новым state, а второй rollback не воспроизведёт действие. Если реальный продукт обещает отмену уже записанного на сервере изменения, это отдельный API contract с конфликтами и компенсацией.</p>\n<pre><code>// Успешная отправка сохраняет снимок. 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'.</code></pre>\n<h2>Диаграмма: что остановить, а что можно вернуть</h2>\n<figure><img src=\"/assets/editorial/2022/form-errors-diagnosis-rollback-2022.svg\" alt=\"Диагностическая схема: local invalid блокирует submit; активный attempt блокирует второй retry; изменение поля делает поздний ответ stale; совпавшая ошибка создаёт semantic payload; success сохраняет accepted snapshot; локальный rollback один раз возвращает values и увеличивает version. Боковая рамка отделяет это от HTTP, browser и server rollback.\" loading=\"lazy\" /><figcaption>Схема помогает не перепутать три действия: игнорировать устаревший response, повторить текущий snapshot и вернуть только локальные значения.</figcaption></figure>\n<h2>Сценарий проверки с отрицательными assertions</h2>\n<p>Fixture проходит путь специально в неудобном порядке. Сначала email становится локально невалидным — submit блокируется и не получает attempt. Затем создаётся attempt 1, но retry поверх pending отвергается. После правки email response attempt 1 получает <code>stale-response-ignored</code>. Только потом создаётся attempt 2 с совпавшими versions, который законно ставит server error и declared association. Новая правка очищает эту ошибку, attempt 3 принимается, а duplicate этого ответа игнорируется.</p>\n<p>Отрицательные assertions здесь важнее happy path. Успешный submit легко написать так, чтобы он прошёл один раз. Риск появляется в переходах, которые не должны иметь эффекта: неправильный email не создаёт запрос в модели; старый response не создаёт message; повтор не подтверждает уже закрытую попытку; второй rollback ничего не меняет. Если такой тест отсутствует, кнопка может выглядеть правильно, но state будет зависеть от случайного порядка callback.</p>\n<h2>Маршрут диагностики и безопасной правки</h2>\n<ol><li><strong>Симптом.</strong> Возьмите один воспроизводимый путь: edit после submit, два retry или duplicate result. Не объединяйте несколько дефектов в один большой «form bug».</li><li><strong>Снимок.</strong> Запишите values и versions в момент beginSubmit. Если их нет, поздний response невозможно классифицировать честно.</li><li><strong>Активность.</strong> Проверьте, что state хранит не boolean, а active attempt с id. Второй retry должен останавливаться до создания нового id.</li><li><strong>Ответ.</strong> Сначала сопоставьте attemptId, потом версии. Только после двух совпадений применяйте error или success.</li><li><strong>Сообщение.</strong> У совпавшей field error должны быть source, field, текст следующего шага и declared semantic payload. При edit они очищаются одной операцией.</li><li><strong>Откат.</strong> Если нужен локальный undo, возвращайте только последний accepted snapshot, увеличивайте version и разрешайте его один раз. Сетевую компенсацию проектируйте отдельно.</li><li><strong>Проверка реализации.</strong> После fixture пройдите настоящий UI в целевых браузерах, проверьте выбранную разметку и adapter. Не приписывайте этим результатам то, чего не измеряли.</li></ol>\n<h2>Почему stale response не надо считать ошибкой пользователя</h2>\n<p>Поздний ответ — нормальная возможность в асинхронном интерфейсе, а не доказательство, что человек «слишком быстро печатает». Попытка 1 действительно могла быть обработана позже попытки 2. Задача UI — не угадать сеть, а сохранить причинную связь: server result может говорить только о тех values, которые были в его request snapshot. Если этой связи нет, форма вынуждает пользователя разбираться во внутреннем времени приложения.</p>\n<p>При этом stale response не всегда нужно скрывать от технической диагностики. В модели он попадает в <code>log</code> как <code>response:stale:1</code>. Это не telemetry и не production metric; это локальный след для теста. В реальном приложении команда может отдельно решить, нужен ли debug-log и какие данные допустимо записывать. Важно не использовать такой log как оправдание показа старого сообщения в UI.</p>\n<h2>Граница rollback и retry</h2>\n<p>Retry повторяет проверку текущего snapshot и получает новый attemptId. Rollback не повторяет ничего: он возвращает values, которые уже были приняты учебной моделью, и не посылает сообщений наружу. Эти действия нельзя объединять одной кнопкой «Отменить/повторить». У retry есть риск нового результата сервера; у rollback — риск потерять несохранённую локальную правку. Поэтому перед внедрением надо назвать, какое ожидание продукта защищает каждое действие.</p>\n<p>Если форма сохраняет не один email, а заказ, деньги или права доступа, локального rollback может быть недостаточно и даже вреден. Там нужен отдельный server-side статус, правила конфликта, права на отмену и audit trail. Учебная модель специально не делает этот шаг. Она показывает только безопасное минимальное правило: локальный интерфейс не должен бесконечно применять один и тот же result и не должен выдавать возвращение своего snapshot за отмену доменной операции.</p>\n<h2>Ограничение и следующий проверяемый шаг</h2>\n<p>Здесь нет upload, debounce, optimistic update, автоматического retry, HTTP abort, реальных status codes, маршрутизации фокуса и проверки речи screen reader. У каждого из этих механизмов свой порядок ошибок. Добавлять их к этой модели можно только с новым fixture и явно расширенной границей. Иначе простой тест начнёт делать обещания о браузере и сети, которых его входы не содержат.</p>\n<p>Следующий шаг — взять один reducer реального компонента и сопоставить его переходы с таблицей выше. Добавьте сначала test на stale response и duplicate result, затем отдельный тест выбранного HTTP adapter. Для доступности подготовьте конкретную разметку и проверку в согласованных средах. Так форма получит две независимые опоры: unit contract для порядка state и платформенную проверку для того, что реально видит пользователь.</p>\n<h2>Историческая граница июня 2022</h2>\n<p>Технические ссылки остаются в границе времени: 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 или пользовательское исследование.</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-associated элементы и constraint validation. Она не определяет проектные поля attemptId или правило повторной отправки.</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> — датированный нормативный снимок, в котором определены aria-invalid и aria-errormessage. Модель ниже хранит только declared semantic payload, а не accessibility tree.</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> — датированное официальное руководство по предсказуемому поведению control. Оно не является результатом пользовательского теста этой учебной формы.</li></ul>"
|
||
}
|