From 768b37d6de97af184b48d0d3bc5f34cbbe70f32b Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Thu, 3 Sep 2026 23:44:02 +0300 Subject: [PATCH] =?UTF-8?q?EDITORIAL-313:=20=D1=83=D1=82=D0=BE=D1=87=D0=BD?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA?= =?UTF-8?q?=D1=83=20=D1=84=D0=BE=D1=80=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- editorial/agent-rewrites/313.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/editorial/agent-rewrites/313.json b/editorial/agent-rewrites/313.json index 40c9183..a2f3e1f 100644 --- a/editorial/agent-rewrites/313.json +++ b/editorial/agent-rewrites/313.json @@ -3,5 +3,5 @@ "slug": "editorial-2019-04-field-forms-validation", "title": "Почему поздняя проверка логина ломает состояние формы", "excerpt": "Асинхронная проверка логина может показать ошибку для уже изменённого значения. Разбираем гонку ответов, версионирование состояния, привязку ошибки к полю и границы клиентской валидации.", - "contentHtml": "

Пользователь вводит ivan. Форма отправляет проверку. Через мгновение он меняет значение на ivanka. Быстрый ответ сообщает, что новое имя свободно. Интерфейс становится зелёным. Затем приходит медленный ответ для старого значения и рисует ошибку «логин занят» уже под ivanka. Пользователь не понимает, что исправлять. Корректный ввод блокируется, а команда получает противоречивое состояние, которое трудно воспроизвести.

\n

Причина не в случайности сети. Два запроса завершились в допустимом порядке, но обработчик применил любой ответ к текущему полю. Ответ не нёс доказательства, что его значение всё ещё актуально. Тезис простой: асинхронный ответ получает право менять интерфейс только тогда, когда его версия совпадает с версией текущего ввода. Клиентская проверка помогает человеку, но сервер всё равно повторяет правило при сохранении.

\n

Механизм гонки

\n

У поля есть как минимум три независимых факта: текущее значение, состояние локальной проверки и результат удалённой проверки. Ошибка логина относится к конкретному значению. Если хранить только общий флаг isValid, связь теряется. Если хранить только последний ответ, порядок доставки подменяет порядок ввода.

\n

Версия делает эту связь явной. При каждом новом вводе версия увеличивается. Запрос получает копию версии в момент отправки. Обработчик сравнивает копию с текущей версией перед изменением состояния. Старый ответ можно дополнительно отменить через AbortController, но отмена экономит работу транспорта. Она не заменяет проверку версии: ответ мог уже завершиться, а сервер мог принять запрос до отмены.

\n

Учебный пример с обратными задержками

\n

Ниже — автономный учебный пример. Он не обращается к API и не показывает результат production-системы. Задержки намеренно заданы в коде: старая проверка приходит позже новой. Такой fixture проверяет только право ответа менять состояние.

\n
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\ncheckLogin('ivan', 30);\ncheckLogin('ivanka', 5);
\n

После запуска второй ответ может примениться первым. Он устанавливает value: 'ivanka' и фазу valid. Первый ответ возвращает stale-response и не меняет состояние. Проверять нужно весь итоговый объект, а не только флаг applied: значение, номер версии, фазу и текст ошибки. Иначе тест может пропустить смешение нового значения со старой ошибкой.

\n

В рабочем коде состояние обычно живёт в React, Vue, небольшой машине состояний или собственном контроллере. Название инструмента не меняет правило. На новом input нужно сначала зафиксировать новое значение, увеличить версию и очистить удалённую ошибку. Только после этого следует отправлять проверку. При закрытии формы экземпляр также должен перестать принимать ответы. Для этого подходит отмена запроса, увеличение версии при уничтожении или проверка активного экземпляра.

\n

Плохой обработчик и минимальная правка

\n
// Плохо: последний по времени ответ всегда меняет поле.\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}
\n

Проверка версии — не блокировка и не гарантия доступности имени. Она защищает только границу между вводом и рендером. Сервер может ответить «свободно», а другой клиент займёт имя до отправки формы. Поэтому POST повторяет правило и возвращает ошибку конфликта, если имя уже занято. Клиент применяет такой ответ к той версии формы, которая была отправлена. Если человек успел изменить поле, старый результат снова нельзя показывать над новым значением.

\n

Куда помещать ошибку API

\n

Ответ login_taken относится к полю логина. Его нужно преобразовать в ошибку конкретного поля, а не выводить только в общий toast. Удобный контракт может выглядеть так: fields.login содержит список сообщений, form содержит общие ошибки, а неизвестный ключ попадает в безопасный общий путь и журнал диагностики. Нельзя молча приклеивать неизвестную ошибку к первому полю.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Красная ошибка появляется после зелёного состоянияСтарый ответ не связан с версией вводаЗаписать value и requestId каждого запросаСравнивать версию перед render
Ошибка видна, но непонятно, какое поле исправлятьAPI-ошибка выведена только в общем баннереПроверить ключ поля в ответеРендерить field error рядом с input
Новый ввод сохраняет старую ошибкуremoteError не очищается на inputИзменить значение после отказа и проверить DOMОчистить ошибку до запуска новой проверки
Debounce уменьшил запросы, но гонка осталасьУшедшие запросы всё ещё завершаются в любом порядкеЗадать две обратные задержкиОставить version check; debounce считать оптимизацией
Поле зелёное, но POST отклонёнПредварительная проверка не резервирует имяПовторить конфликт на сервере при сохраненииОбработать серверную field error
После закрытия модального окна меняется экранОтвет записывает состояние уничтоженной формыЗакрыть форму до завершения запросаОтменить запрос или инвалидировать версию
\n

Доступная разметка ошибки

\n

Ошибка должна быть рядом с полем и связана с ним в DOM. Метка задаёт имя элемента. aria-invalid отражает текущее ошибочное состояние. Устойчивый ID связывает поле с текстом ошибки. В учебной разметке ниже ошибка показана для актуального значения ivanka. В реальном экране эти атрибуты меняются вместе с состоянием поля.

\n
<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 login-error'\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>
\n

При следующем вводе нужно снять aria-invalid, очистить текст ошибки и только затем запустить новый запрос. Иначе зрячий пользователь увидит новое значение, а скринридер получит старое сообщение как описание этого значения. Контейнер ошибки должен существовать предсказуемо, оставаться видимым при ошибке и не содержать текст от предыдущей версии.

\n

Нативные ограничения HTML помогают отсеять пустое или явно неверное значение. required и pattern не проверяют занятость имени на сервере. Удалённый отказ храните отдельно от ValidityState. Если применяется setCustomValidity(), очищайте его на новом вводе и не используйте его как замену серверному правилу.

\n
Временная диаграмма проверки логина: быстрый ответ для ivanka приходит раньше медленного ответа для ivan, а сравнение версий отклоняет старый результат
Время ответа не определяет его актуальность. Интерфейс обновляет только ответ, чья версия совпала с текущим вводом.
\n

Порядок действий

\n
  1. Запишите точную последовательность: первое значение, второе значение, порядок ответов и текст, который увидел пользователь.
  2. Найдите единственное место, где promise меняет состояние поля. Проверьте, передаёт ли оно value и requestId.
  3. Соберите учебный fixture с обратными задержками. В итоговом результате храните value, phase, requestId и error.
  4. Увеличивайте версию на каждом input. Очищайте remoteError до нового render и до запуска проверки.
  5. Сравнивайте requestId и value в обработчике ответа. При несовпадении возвращайте состояние без изменений.
  6. Проверьте ошибки API: известный ключ поля, общая ошибка формы и неизвестный ключ должны иметь разные маршруты.
  7. Проверьте клавиатурный сценарий и Accessibility tree. У поля должны быть имя, актуальное значение, состояние invalid и видимая связанная ошибка.
  8. Повторите проверку перед POST на сервере. Проверьте конфликт между предварительной проверкой и фактическим сохранением.
\n

Ограничения и отрицательный путь

\n

Debounce сокращает частоту запросов, но не делает старый ответ безопасным. AbortController отменяет поддерживаемую операцию, но уже готовый ответ или серверный побочный эффект требуют отдельной защиты. Версия решает задачу согласованности UI. Она не решает авторизацию, резервирование имени, нормализацию регистра или правила хранения данных.

\n

Если API не возвращает имя поля, нельзя достоверно показать ошибку под конкретным input. Не угадывайте поле по тексту сообщения. Покажите нейтральную ошибку формы, сохраните технический код и договоритесь об изменении контракта. Если компонент уже уничтожен, поздний ответ должен завершиться без render. Если версия потеряна при восстановлении состояния, безопаснее остановить применение ответа, чем принять его как текущий.

\n

Учебный код не доказывает работу браузера, выбранного фреймворка или реального транспорта. Его проверяемый результат ограничен порядком двух promise. Браузерный прогон, клавиатурный сценарий, Accessibility tree и серверный конфликт требуют отдельных проверок на настоящем контуре. В статье нет production-метрик и нет утверждения о запуске такого контура.

\n

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

\n

Исправление готово, если тест с двумя обратными задержками оставляет последнее значение, его requestId, корректную фазу и пустую старую ошибку. Ответ для прежнего значения не вызывает render. После изменения input ошибка очищается. Ответ API с известным ключом появляется у нужного поля, а неизвестный ключ не приклеивается к случайному input.

\n

На реальном экране проверяющий должен пройти форму клавиатурой, увидеть метку и ошибку, открыть Accessibility tree и подтвердить связи по ID. Отдельный тест должен показать, что сервер отклоняет конфликт даже после положительной предварительной проверки. Если хотя бы один из этих пунктов не проверен, готовность ограничивается локальной логикой и не распространяется на весь пользовательский сценарий.

\n

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

\n" + "contentHtml": "

Пользователь вводит ivan. Форма отправляет проверку. Через мгновение он меняет значение на ivanka. Быстрый ответ сообщает, что новое имя свободно. Интерфейс становится зелёным. Затем приходит медленный ответ для старого значения и рисует ошибку «логин занят» уже под ivanka. Пользователь не понимает, что исправлять. Корректный ввод блокируется, а команда получает противоречивое состояние, которое трудно воспроизвести.

\n

Причина не в случайности сети. Два запроса завершились в допустимом порядке, но обработчик применил любой ответ к текущему полю. Ответ не нёс доказательства, что его значение всё ещё актуально. Правило простое: асинхронный ответ получает право менять интерфейс только тогда, когда его версия совпадает с версией текущего ввода. Клиентская проверка помогает человеку, но сервер всё равно повторяет правило при сохранении.

\n

Механизм гонки

\n

У поля есть как минимум три независимых факта: текущее значение, состояние локальной проверки и результат удалённой проверки. Ошибка логина относится к конкретному значению. Если хранить только общий флаг isValid, связь теряется. Если хранить только последний ответ, порядок доставки подменяет порядок ввода.

\n

Версия делает эту связь явной. При каждом новом вводе версия увеличивается. Запрос получает копию версии в момент отправки. Обработчик сравнивает копию с текущей версией перед изменением состояния. Старый ответ можно дополнительно отменить через AbortController, но отмена экономит работу транспорта. Она не заменяет проверку версии: ответ мог уже завершиться, а сервер мог принять запрос до отмены.

\n

Учебный пример с обратными задержками

\n

Ниже — автономный учебный пример. Он не обращается к API и не показывает результат production-системы. Задержки намеренно заданы в коде: старая проверка приходит позже новой. Такой fixture проверяет только право ответа менять состояние.

\n
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});
\n

При заданных задержках ответ для ivanka приходит раньше ответа для ivan. Второй ответ устанавливает value: 'ivanka' и фазу valid, а первый возвращает stale-response и не меняет состояние. Promise.all сохраняет порядок обещаний, поэтому проверяем оба результата и итоговое состояние, а не только флаг applied. Иначе тест может пропустить смешение нового значения со старой ошибкой.

\n

В рабочем коде состояние обычно живёт в React, Vue, небольшой машине состояний или собственном контроллере. Название инструмента не меняет правило. На новом input нужно сначала зафиксировать новое значение, увеличить версию и очистить удалённую ошибку. Только после этого следует отправлять проверку. При закрытии формы экземпляр также должен перестать принимать ответы. Для этого подходит отмена запроса, увеличение версии при уничтожении или проверка активного экземпляра.

\n

Плохой обработчик и минимальная правка

\n
// Плохо: последний по времени ответ всегда меняет поле.\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}
\n

Проверка версии — не блокировка и не гарантия доступности имени. Она защищает только границу между вводом и рендером. Сервер может ответить «свободно», а другой клиент займёт имя до отправки формы. Поэтому POST повторяет правило и возвращает ошибку конфликта, если имя уже занято. Клиент применяет такой ответ к той версии формы, которая была отправлена. Если человек успел изменить поле, старый результат снова нельзя показывать над новым значением.

\n

Куда помещать ошибку API

\n

Ответ login_taken относится к полю логина. Его нужно преобразовать в ошибку конкретного поля, а не выводить только в общий toast. В учебном контракте приложения fields.login содержит список сообщений, form — общие ошибки, а неизвестный ключ попадает в безопасный общий путь и журнал диагностики. Это проектное соглашение, а не обязательное поле HTML или WAI-ARIA. Нельзя молча приклеивать неизвестную ошибку к первому полю.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Красная ошибка появляется после зелёного состоянияСтарый ответ не связан с версией вводаЗаписать value и requestId каждого запросаСравнивать версию перед render
Ошибка видна, но непонятно, какое поле исправлятьAPI-ошибка выведена только в общем баннереПроверить ключ поля в ответеРендерить field error рядом с input
Новый ввод сохраняет старую ошибкуremoteError не очищается на inputИзменить значение после отказа и проверить DOMОчистить ошибку до запуска новой проверки
Debounce уменьшил запросы, но гонка осталасьУшедшие запросы всё ещё завершаются в любом порядкеЗадать две обратные задержкиОставить version check; debounce считать оптимизацией
Поле зелёное, но POST отклонёнПредварительная проверка не резервирует имяПовторить конфликт на сервере при сохраненииОбработать серверную field error
После закрытия модального окна меняется экранОтвет записывает состояние уничтоженной формыЗакрыть форму до завершения запросаОтменить запрос или инвалидировать версию
\n

Доступная разметка ошибки

\n

Ошибка должна быть рядом с полем и связана с ним в DOM. Метка задаёт имя элемента. aria-invalid отражает текущее ошибочное состояние. Устойчивый ID связывает поле с текстом ошибки. В учебной разметке ниже ошибка показана для актуального значения ivanka. В реальном экране эти атрибуты меняются вместе с состоянием поля.

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

При следующем вводе нужно снять aria-invalid, очистить текст ошибки и удалить ссылку aria-errormessage либо скрыть пустой контейнер, и только затем запустить новый запрос. Иначе зрячий пользователь увидит новое значение, а скринридер получит старое сообщение как описание этого значения. Контейнер ошибки должен существовать предсказуемо, быть видимым при ошибке и скрытым, когда ошибка неактуальна.

\n

Нативные ограничения HTML помогают отсеять пустое или явно неверное значение. required и pattern не проверяют занятость имени на сервере; показанный pattern принимает только латинские буквы, цифры и подчёркивание. Удалённый отказ храните отдельно от ValidityState. Если применяется setCustomValidity(), очищайте его на новом вводе и не используйте его как замену серверному правилу.

\n
Временная диаграмма проверки логина: быстрый ответ для ivanka приходит раньше медленного ответа для ivan, а сравнение версий отклоняет старый результат
Время ответа не определяет его актуальность. Интерфейс обновляет только ответ, чья версия совпала с текущим вводом.
\n

Порядок действий

\n
  1. Запишите точную последовательность: первое значение, второе значение, порядок ответов и текст, который увидел пользователь.
  2. Найдите единственное место, где promise меняет состояние поля. Проверьте, передаёт ли оно value и requestId.
  3. Соберите учебный fixture с обратными задержками. В итоговом результате храните value, phase, requestId и error.
  4. Увеличивайте версию на каждом input. Очищайте remoteError до нового render и до запуска проверки.
  5. Сравнивайте requestId и value в обработчике ответа. При несовпадении возвращайте состояние без изменений.
  6. Проверьте ошибки API: известный ключ поля, общая ошибка формы и неизвестный ключ должны иметь разные маршруты.
  7. Проверьте клавиатурный сценарий и Accessibility tree. У поля должны быть имя, актуальное значение, состояние invalid и видимая связанная ошибка.
  8. Повторите проверку перед POST на сервере. Проверьте конфликт между предварительной проверкой и фактическим сохранением.
\n

Ограничения и отрицательный путь

\n

Debounce сокращает частоту запросов, но не делает старый ответ безопасным. AbortController может отменить поддерживаемую операцию, но уже готовый ответ или серверный побочный эффект требуют отдельной защиты. Версия решает задачу согласованности UI. Она не решает авторизацию, резервирование имени, нормализацию регистра или правила хранения данных.

\n

Если API не возвращает имя поля, нельзя достоверно показать ошибку под конкретным input. Не угадывайте поле по тексту сообщения. Покажите нейтральную ошибку формы, сохраните технический код и договоритесь об изменении контракта. Если компонент уже уничтожен, поздний ответ должен завершиться без render. Если версия потеряна при восстановлении состояния, безопаснее остановить применение ответа, чем принять его как текущий.

\n

Учебный код не доказывает работу браузера, выбранного фреймворка или реального транспорта. Его проверяемый результат ограничен порядком двух promise. Браузерный прогон, клавиатурный сценарий, Accessibility tree и серверный конфликт требуют отдельных проверок на настоящем контуре. В статье нет production-метрик и нет утверждения о запуске такого контура.

\n

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

\n

Исправление готово, если тест с двумя обратными задержками оставляет последнее значение, его requestId, корректную фазу и пустую старую ошибку. Ответ для прежнего значения не вызывает render. После изменения input ошибка очищается. Ответ API с известным ключом появляется у нужного поля, а неизвестный ключ не приклеивается к случайному input.

\n

На реальном экране проверяющий должен пройти форму клавиатурой, увидеть метку и ошибку, открыть Accessibility tree и подтвердить связи по ID. Отдельный тест должен показать, что сервер отклоняет конфликт даже после положительной предварительной проверки. Если хотя бы один из этих пунктов не проверен, готовность ограничивается локальной логикой и не распространяется на весь пользовательский сценарий.

\n

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

\n" }