8 lines
21 KiB
JSON
8 lines
21 KiB
JSON
{
|
||
"index": 313,
|
||
"slug": "editorial-2019-04-field-forms-validation",
|
||
"title": "Почему поздняя проверка логина ломает состояние формы",
|
||
"excerpt": "Асинхронная проверка логина может показать ошибку для уже изменённого значения. Разбираем гонку ответов, версионирование состояния, привязку ошибки к полю и границы клиентской валидации.",
|
||
"contentHtml": "<p>Пользователь вводит <code>ivan</code>. Форма отправляет проверку. Через мгновение он меняет значение на <code>ivanka</code>. Быстрый ответ сообщает, что новое имя свободно. Интерфейс становится зелёным. Затем приходит медленный ответ для старого значения и рисует ошибку «логин занят» уже под <code>ivanka</code>. Пользователь не понимает, что исправлять. Корректный ввод блокируется, а команда получает противоречивое состояние, которое трудно воспроизвести.</p>\n<p>Причина не в случайности сети. Два запроса завершились в допустимом порядке, но обработчик применил любой ответ к текущему полю. Ответ не нёс доказательства, что его значение всё ещё актуально. Правило простое: асинхронный ответ получает право менять интерфейс только тогда, когда его версия совпадает с версией текущего ввода. Клиентская проверка помогает человеку, но сервер всё равно повторяет правило при сохранении.</p>\n<h2>Механизм гонки</h2>\n<p>У поля есть как минимум три независимых факта: текущее значение, состояние локальной проверки и результат удалённой проверки. Ошибка логина относится к конкретному значению. Если хранить только общий флаг <code>isValid</code>, связь теряется. Если хранить только последний ответ, порядок доставки подменяет порядок ввода.</p>\n<p>Версия делает эту связь явной. При каждом новом вводе версия увеличивается. Запрос получает копию версии в момент отправки. Обработчик сравнивает копию с текущей версией перед изменением состояния. Старый ответ можно дополнительно отменить через <code>AbortController</code>, но отмена экономит работу транспорта. Она не заменяет проверку версии: ответ мог уже завершиться, а сервер мог принять запрос до отмены.</p>\n<h2>Учебный пример с обратными задержками</h2>\n<p>Ниже — автономный учебный пример. Он не обращается к API и не показывает результат production-системы. Задержки намеренно заданы в коде: старая проверка приходит позже новой. Такой fixture проверяет только право ответа менять состояние.</p>\n<pre><code>function delayResult(value, delayMs) {\n return new Promise(function (resolve) {\n setTimeout(function () {\n resolve({ value: value, available: value !== 'ivan' });\n }, delayMs);\n });\n}\n\nvar state = { value: '', requestId: 0, phase: 'idle', error: '' };\n\nfunction checkLogin(value, delayMs) {\n var requestId = state.requestId + 1;\n state = { value: value, requestId: requestId, phase: 'checking', error: '' };\n\n return delayResult(value, delayMs).then(function (answer) {\n if (requestId !== state.requestId || value !== state.value) {\n return { applied: false, reason: 'stale-response' };\n }\n\n state = {\n value: value,\n requestId: requestId,\n phase: answer.available ? 'valid' : 'invalid',\n error: answer.available ? '' : 'Этот логин уже занят'\n };\n\n return { applied: true, state: state };\n });\n}\n\nvar firstCheck = checkLogin('ivan', 30);\nvar secondCheck = checkLogin('ivanka', 5);\n\nPromise.all([firstCheck, secondCheck]).then(function (results) {\n if (results[0].reason !== 'stale-response' || results[1].state.value !== 'ivanka' || results[1].state.error !== '') {\n throw new Error('Проверка актуальности ответа не прошла');\n }\n console.log('PASS', results, state);\n});</code></pre>\n<p>При заданных задержках ответ для <code>ivanka</code> приходит раньше ответа для <code>ivan</code>. Второй ответ устанавливает <code>value: 'ivanka'</code> и фазу <code>valid</code>, а первый возвращает <code>stale-response</code> и не меняет состояние. <code>Promise.all</code> сохраняет порядок обещаний, поэтому проверяем оба результата и итоговое состояние, а не только флаг <code>applied</code>. Иначе тест может пропустить смешение нового значения со старой ошибкой.</p>\n<p>В рабочем коде состояние обычно живёт в React, Vue, небольшой машине состояний или собственном контроллере. Название инструмента не меняет правило. На новом <code>input</code> нужно сначала зафиксировать новое значение, увеличить версию и очистить удалённую ошибку. Только после этого следует отправлять проверку. При закрытии формы экземпляр также должен перестать принимать ответы. Для этого подходит отмена запроса, увеличение версии при уничтожении или проверка активного экземпляра.</p>\n<h2>Плохой обработчик и минимальная правка</h2>\n<pre><code>// Плохо: последний по времени ответ всегда меняет поле.\nfunction applyAnswer(answer) {\n state.phase = answer.available ? 'valid' : 'invalid';\n state.error = answer.available ? '' : 'Этот логин уже занят';\n render(state);\n}\n\n// Лучше: ответ меняет поле только для текущей версии.\nfunction applyAnswer(requestId, value, answer) {\n if (requestId !== state.requestId || value !== state.value) return;\n\n state.phase = answer.available ? 'valid' : 'invalid';\n state.error = answer.available ? '' : 'Этот логин уже занят';\n render(state);\n}</code></pre>\n<p>Проверка версии — не блокировка и не гарантия доступности имени. Она защищает только границу между вводом и рендером. Сервер может ответить «свободно», а другой клиент займёт имя до отправки формы. Поэтому POST повторяет правило и возвращает ошибку конфликта, если имя уже занято. Клиент применяет такой ответ к той версии формы, которая была отправлена. Если человек успел изменить поле, старый результат снова нельзя показывать над новым значением.</p>\n<h2>Куда помещать ошибку API</h2>\n<p>Ответ <code>login_taken</code> относится к полю логина. Его нужно преобразовать в ошибку конкретного поля, а не выводить только в общий toast. В учебном контракте приложения <code>fields.login</code> содержит список сообщений, <code>form</code> — общие ошибки, а неизвестный ключ попадает в безопасный общий путь и журнал диагностики. Это проектное соглашение, а не обязательное поле HTML или WAI-ARIA. Нельзя молча приклеивать неизвестную ошибку к первому полю.</p>\n<table><caption>Симптом → причина → проверка → действие</caption><thead><tr><th scope='col'>Симптом</th><th scope='col'>Причина</th><th scope='col'>Проверка</th><th scope='col'>Действие</th></tr></thead><tbody><tr><td>Красная ошибка появляется после зелёного состояния</td><td>Старый ответ не связан с версией ввода</td><td>Записать value и requestId каждого запроса</td><td>Сравнивать версию перед render</td></tr><tr><td>Ошибка видна, но непонятно, какое поле исправлять</td><td>API-ошибка выведена только в общем баннере</td><td>Проверить ключ поля в ответе</td><td>Рендерить field error рядом с input</td></tr><tr><td>Новый ввод сохраняет старую ошибку</td><td>remoteError не очищается на input</td><td>Изменить значение после отказа и проверить DOM</td><td>Очистить ошибку до запуска новой проверки</td></tr><tr><td>Debounce уменьшил запросы, но гонка осталась</td><td>Ушедшие запросы всё ещё завершаются в любом порядке</td><td>Задать две обратные задержки</td><td>Оставить version check; debounce считать оптимизацией</td></tr><tr><td>Поле зелёное, но POST отклонён</td><td>Предварительная проверка не резервирует имя</td><td>Повторить конфликт на сервере при сохранении</td><td>Обработать серверную field error</td></tr><tr><td>После закрытия модального окна меняется экран</td><td>Ответ записывает состояние уничтоженной формы</td><td>Закрыть форму до завершения запроса</td><td>Отменить запрос или инвалидировать версию</td></tr></tbody></table>\n<h2>Доступная разметка ошибки</h2>\n<p>Ошибка должна быть рядом с полем и связана с ним в DOM. Метка задаёт имя элемента. <code>aria-invalid</code> отражает текущее ошибочное состояние. Устойчивый ID связывает поле с текстом ошибки. В учебной разметке ниже ошибка показана для актуального значения <code>ivanka</code>. В реальном экране эти атрибуты меняются вместе с состоянием поля.</p>\n<pre><code><label for='login'>Логин</label>\n<input\n id='login'\n name='login'\n required\n pattern='[A-Za-z0-9_]{3,20}'\n aria-describedby='login-hint'\n aria-errormessage='login-error'\n aria-invalid='true'\n value='ivanka'\n>\n<p id='login-hint'>От 3 до 20 латинских букв, цифр или _.</p>\n<p id='login-error' role='alert'>Этот логин уже занят</p></code></pre>\n<p>При следующем вводе нужно снять <code>aria-invalid</code>, очистить текст ошибки и удалить ссылку <code>aria-errormessage</code> либо скрыть пустой контейнер, и только затем запустить новый запрос. Иначе зрячий пользователь увидит новое значение, а скринридер получит старое сообщение как описание этого значения. Контейнер ошибки должен существовать предсказуемо, быть видимым при ошибке и скрытым, когда ошибка неактуальна.</p>\n<p>Нативные ограничения HTML помогают отсеять пустое или явно неверное значение. <code>required</code> и <code>pattern</code> не проверяют занятость имени на сервере; показанный <code>pattern</code> принимает только латинские буквы, цифры и подчёркивание. Удалённый отказ храните отдельно от <code>ValidityState</code>. Если применяется <code>setCustomValidity()</code>, очищайте его на новом вводе и не используйте его как замену серверному правилу.</p>\n<figure><img src='/assets/editorial/2019/forms-validation-late-response-2019.svg' alt='Временная диаграмма проверки логина: быстрый ответ для ivanka приходит раньше медленного ответа для ivan, а сравнение версий отклоняет старый результат' loading='lazy' /><figcaption>Время ответа не определяет его актуальность. Интерфейс обновляет только ответ, чья версия совпала с текущим вводом.</figcaption></figure>\n<h2>Порядок действий</h2>\n<ol><li>Запишите точную последовательность: первое значение, второе значение, порядок ответов и текст, который увидел пользователь.</li><li>Найдите единственное место, где promise меняет состояние поля. Проверьте, передаёт ли оно value и requestId.</li><li>Соберите учебный fixture с обратными задержками. В итоговом результате храните value, phase, requestId и error.</li><li>Увеличивайте версию на каждом input. Очищайте remoteError до нового render и до запуска проверки.</li><li>Сравнивайте requestId и value в обработчике ответа. При несовпадении возвращайте состояние без изменений.</li><li>Проверьте ошибки API: известный ключ поля, общая ошибка формы и неизвестный ключ должны иметь разные маршруты.</li><li>Проверьте клавиатурный сценарий и Accessibility tree. У поля должны быть имя, актуальное значение, состояние invalid и видимая связанная ошибка.</li><li>Повторите проверку перед POST на сервере. Проверьте конфликт между предварительной проверкой и фактическим сохранением.</li></ol>\n<h2>Ограничения и отрицательный путь</h2>\n<p>Debounce сокращает частоту запросов, но не делает старый ответ безопасным. <code>AbortController</code> может отменить поддерживаемую операцию, но уже готовый ответ или серверный побочный эффект требуют отдельной защиты. Версия решает задачу согласованности UI. Она не решает авторизацию, резервирование имени, нормализацию регистра или правила хранения данных.</p>\n<p>Если API не возвращает имя поля, нельзя достоверно показать ошибку под конкретным input. Не угадывайте поле по тексту сообщения. Покажите нейтральную ошибку формы, сохраните технический код и договоритесь об изменении контракта. Если компонент уже уничтожен, поздний ответ должен завершиться без render. Если версия потеряна при восстановлении состояния, безопаснее остановить применение ответа, чем принять его как текущий.</p>\n<p>Учебный код не доказывает работу браузера, выбранного фреймворка или реального транспорта. Его проверяемый результат ограничен порядком двух promise. Браузерный прогон, клавиатурный сценарий, Accessibility tree и серверный конфликт требуют отдельных проверок на настоящем контуре. В статье нет production-метрик и нет утверждения о запуске такого контура.</p>\n<h2>Проверяемый критерий готовности</h2>\n<p>Исправление готово, если тест с двумя обратными задержками оставляет последнее значение, его requestId, корректную фазу и пустую старую ошибку. Ответ для прежнего значения не вызывает render. После изменения input ошибка очищается. Ответ API с известным ключом появляется у нужного поля, а неизвестный ключ не приклеивается к случайному input.</p>\n<p>На реальном экране проверяющий должен пройти форму клавиатурой, увидеть метку и ошибку, открыть Accessibility tree и подтвердить связи по ID. Отдельный тест должен показать, что сервер отклоняет конфликт даже после положительной предварительной проверки. Если хотя бы один из этих пунктов не проверен, готовность ограничивается локальной логикой и не распространяется на весь пользовательский сценарий.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href='https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#the-constraint-validation-api' target='_blank' rel='noopener noreferrer'>WHATWG HTML Standard: Constraint validation API</a> — описывает нативные ограничения и методы проверки.</li><li><a href='https://dom.spec.whatwg.org/#interface-abortcontroller' target='_blank' rel='noopener noreferrer'>WHATWG DOM Standard: AbortController</a> — задаёт сигнал отмены асинхронной операции.</li><li><a href='https://www.w3.org/TR/wai-aria-1.3/#aria-errormessage' target='_blank' rel='noopener noreferrer'>W3C WAI-ARIA 1.3: aria-errormessage</a> — задаёт назначение атрибута, связывающего поле с текстом ошибки, и его связь с <code>aria-invalid</code>.</li><li><a href='https://developer.mozilla.org/en-US/docs/Web/HTML/Guides/Constraint_validation' target='_blank' rel='noopener noreferrer'>MDN: Using HTML form validation and the Constraint Validation API</a> — показывает практическое применение и отдельно подчёркивает необходимость серверной проверки.</li></ul>"
|
||
}
|