Files
progcode/web/scripts/upgrade-2019-04.mjs
huncode 7c5b19c960
Build and deploy / deploy (push) Successful in 18s
edit full article archive to publication standard
2026-07-31 23:08:19 +03:00

642 lines
72 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
function paragraph(text) {
return '<p>' + text + '</p>';
}
function heading(text) {
return '<h2>' + text + '</h2>';
}
function codeBlock(code) {
return '<pre><code>' + escapeHtml(String(code).trim()) + '</code></pre>';
}
function figure(src, alt, caption) {
return '<figure><img src="' + src + '" alt="' + alt + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
}
function orderedList(items) {
return '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
}
function bulletList(items) {
return '<ul>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ul>';
}
function dataTable(caption, headers, rows) {
const head = '<thead><tr>' + headers.map((header) => '<th scope="col">' + header + '</th>').join('') + '</tr></thead>';
const body = '<tbody>' + rows.map((row) => '<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>').join('') + '</tbody>';
return '<div class="table-scroll"><table><caption>' + caption + '</caption>' + head + body + '</table></div>';
}
function sourceList(items) {
return '<ul>' + items.map((item) => '<li><a href="' + item.url + '" target="_blank" rel="noopener noreferrer">' + item.title + '</a> — ' + item.note + '</li>').join('') + '</ul>';
}
function visibleText(html) {
return html
.replace(/<[^>]*>/g, ' ')
.replaceAll('&nbsp;', ' ')
.replaceAll('&quot;', '"')
.replaceAll('&#039;', "'")
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&amp;', '&')
.replace(/\s+/g, ' ')
.trim();
}
function proseText(html) {
return visibleText(
html
.replace(/<pre><code>[\s\S]*?<\/code><\/pre>/g, '')
.replace(/<figure>[\s\S]*?<\/figure>/g, '')
.replace(/<div class="table-scroll">[\s\S]*?<\/div>/g, ''),
);
}
function createRevision(meta, bodyParts, sources) {
const bodyHtml = bodyParts.join('\n');
const proseLength = proseText(bodyHtml).length;
if (proseLength < 5000 || proseLength > 15000) {
throw new Error(meta.slug + ': prose length must be 5000–15000, got ' + proseLength);
}
if (sources.length < 2) {
throw new Error(meta.slug + ': at least two primary sources are required');
}
const contentHtml = [
bodyHtml,
heading('Проверяемые источники'),
sourceList(sources),
].join('\n');
return {
...meta,
contentHtml,
proseLength,
};
}
const htmlConstraints = {
title: 'HTML Standard: Constraints and Constraint Validation API',
url: 'https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#the-constraint-validation-api',
note: 'модель ограничений формы, validatable-контролы, <code>validity</code>, <code>setCustomValidity()</code>, <code>checkValidity()</code> и <code>reportValidity()</code>',
};
const htmlFormSubmission = {
title: 'HTML Standard: Form submission',
url: 'https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#form-submission-algorithm',
note: 'отправка формы — отдельный алгоритм браузера; клиентские ограничения не подменяют проверку на сервере',
};
const ariaDescription = {
title: 'WAI-ARIA 1.1: aria-describedby',
url: 'https://www.w3.org/TR/wai-aria-1.1/#aria-describedby',
note: 'связь контрола с одним или несколькими элементами описания по ID',
};
const ariaError = {
title: 'WAI-ARIA 1.1: aria-errormessage',
url: 'https://www.w3.org/TR/wai-aria-1.1/#aria-errormessage',
note: '<code>aria-errormessage</code> связан с <code>aria-invalid</code>; актуальный текст ошибки должен быть доступен пользователю',
};
const ariaAlert = {
title: 'WAI-ARIA 1.1: alert role',
url: 'https://www.w3.org/TR/wai-aria-1.1/#alert',
note: 'семантика срочного, но не переносящего фокус сообщения; применять только к краткому изменению статуса',
};
const domAbort = {
title: 'DOM Standard: AbortController',
url: 'https://dom.spec.whatwg.org/#abortcontroller',
note: 'контроллер создаёт <code>AbortSignal</code> и посылает ему abort; это отмена транспорта, а не проверка актуальности состояния сама по себе',
};
const fetchSpec = {
title: 'Fetch Standard',
url: 'https://fetch.spec.whatwg.org/',
note: 'модель запроса, ответа и интеграция с abort signal для клиентов, поддерживающих Fetch',
};
function delayResult(value, delayMs) {
return new Promise((resolveDelay) => {
setTimeout(() => {
resolveDelay({
value,
available: value !== 'ivan',
});
}, delayMs);
});
}
async function runDelayedResponseFixture() {
let state = {
value: '',
requestId: 0,
phase: 'editing',
fieldError: '',
};
function check(value, delayMs) {
const requestId = state.requestId + 1;
state = {
value,
requestId,
phase: 'checking',
fieldError: '',
};
return delayResult(value, delayMs).then((answer) => {
if (state.requestId !== requestId) {
return { requestId, applied: false, ignored: 'stale-response' };
}
state = {
value: answer.value,
requestId,
phase: answer.available ? 'valid' : 'invalid',
fieldError: answer.available ? '' : 'Этот логин уже занят',
};
return { requestId, applied: true, phase: state.phase };
});
}
const oldRequest = check('ivan', 30);
const newRequest = check('ivanka', 5);
const results = await Promise.all([oldRequest, newRequest]);
return { results, finalState: state };
}
const practiceArticle = createRevision(
{
slug: 'editorial-2019-04-practice-forms-validation',
title: 'Форма без расхождения правил: клиентская проверка, серверная ошибка и поле',
categories: ['JavaScript', 'HTML', 'UX', 'Практика'],
cover: '/assets/editorial/2019/forms-validation-contract-2019.svg',
excerpt: 'Поле проходит проверку в браузере, а сервер возвращает 422. Собираем контракт правил и ошибок так, чтобы сообщение оказалось у нужного поля и поздний ответ не затёр новое значение.',
readingMinutes: 12,
},
[
paragraph('Симптом знакомый: почта в форме стала зелёной, пользователь нажал «Сохранить», а сервер вернул ошибку формата или занятости. Ещё хуже, когда ответ приходит, но текст попадает в общий баннер, а не к полю. Человек исправляет значение наугад, повторяет запрос и может создать дубль. Причина обычно не в одном регулярном выражении: у клиента, сервера и представления ошибки разные правила и разные владельцы состояния. Цена ошибки — лишняя отправка формы и неверное решение пользователя.'),
paragraph('В апреле 2019 я бы не пытался строить «универсальный валидатор». Для одной формы достаточно зафиксировать короткий контракт: какие ограничения браузер проверяет сразу, какие условия знает только сервер, в каком виде сервер возвращает ошибки и кто имеет право менять состояние поля. Ниже учебный вариант без привязки к фреймворку. Он показывает маршрут проверки; он не является результатом запуска на чужом API или браузерной трассой.'),
heading('Сначала разделяем три вида проверки'),
paragraph('Клиентская проверка нужна, чтобы не отправлять пустую почту или строку с очевидно неверной формой. HTML уже знает часть ограничений: <code>required</code>, <code>type="email"</code>, <code>minlength</code>, <code>pattern</code>. У контрола есть <code>validity</code>, а <code>checkValidity()</code> отвечает на конкретный вопрос: проходит ли элемент его ограничения. Это удобный ранний фильтр, но не источник истины о пользователе, правах, занятости логина или правилах, которые меняются на сервере.'),
paragraph('Серверная проверка владеет данными и бизнес-условием. Если API считает, что адрес уже связан с другим аккаунтом, браузер не может опровергнуть это своим <code>input[type=email]</code>. Представление, в свою очередь, владеет тем, где сообщение видно: у поля, у формы или в статусе отправки. Ошибка возникает именно на этой границе, когда JSON от сервера складывают в один текст «Не удалось сохранить», а компонент уже не знает, какой input пометить.'),
dataTable(
'Короткая карта ответственности для формы регистрации',
['Слой', 'Что проверяет', 'Чего не обещает', 'Проверяемый результат'],
[
['HTML-контрол', '<code>required</code>, формат email, длину и pattern', 'Занятость адреса, права, транзакцию', '<code>input.validity.valid</code> и понятная локальная подсказка'],
['Клиентский код', 'Порядок состояний, актуальность ответа, привязку поля к ошибке', 'Достоверность данных в базе', 'У ошибки есть ключ поля, а старый ответ не меняет новый ввод'],
['API', 'Нормализацию, занятость, права и правила сохранения', 'Как экран озвучит текст', 'Структурированный ответ с кодом и ключом поля'],
['Разметка', 'Label, описание, видимость ошибки, семантику invalid', 'Проверку бизнес-правила', 'Ошибка доступна зрительно и связана с нужным контролом'],
],
),
paragraph('Такое деление сразу уменьшает расхождение. Не нужно копировать серверное правило в JavaScript, если клиенту достаточно проверить непустое значение и форму строки. Но имя поля и код ошибки должны быть стабильны. Сервер может изменить человеческий текст по локали, а <code>email_taken</code> и ключ <code>email</code> остаются данными, по которым интерфейс выбирает место вывода.'),
heading('Минимальный договор между формой и API'),
paragraph('Практичный ответ ошибки не обязан повторять спецификацию целиком. Важно, чтобы в нём не смешивались поле и общий сбой. В этом примере массив <code>fields</code> хранит ошибки, которые можно показать рядом с input, а <code>form</code> — ошибку, не принадлежащую конкретному полю: например, конфликт состояния формы. HTTP-статус и общая структура ответа — договор API; показанный ключ <code>fields</code> — локальное решение команды, а не поле, навязанное браузером.'),
codeBlock(String.raw`
// Пример ответа API при POST /api/account.
// Это контракт приложения, а не встроенный формат браузера.
{
"code": "VALIDATION_FAILED",
"fields": {
"email": [
{ "code": "email_taken", "message": "Этот адрес уже используется" }
]
},
"form": []
}
`),
paragraph('На клиенте не стоит искать текстом «адрес» или «занят». Нужен небольшой адаптер, который принимает этот контракт и отдаёт одну карту ошибок. Если сервер прислал неизвестный ключ, адаптер не должен silently приклеивать его к первому полю. Его лучше оставить в <code>form</code>, записать в диагностический лог проекта и добавить явную обработку после согласования контракта. Так опечатка <code>e-mail</code> вместо <code>email</code> не превратится в ложное зелёное состояние.'),
codeBlock(String.raw`
function mapServerErrors(payload, knownFields) {
var mapped = { fields: {}, form: [] };
var fieldErrors = payload && payload.fields ? payload.fields : {};
Object.keys(fieldErrors).forEach(function (name) {
var first = fieldErrors[name] && fieldErrors[name][0];
var message = first && first.message;
if (knownFields.indexOf(name) === -1 || !message) {
mapped.form.push('Сервер вернул ошибку без известного поля');
return;
}
mapped.fields[name] = message;
});
return mapped;
}
var errors = mapServerErrors(apiPayload, ['email', 'password']);
// errors.fields.email === 'Этот адрес уже используется'
`),
paragraph('У этого кода есть намеренное ограничение: он не решает локализацию, несколько сообщений на поле или вложенные массивы. Для конкретной формы сначала договоритесь, нужен ли один первый текст или список. Если API всегда отдаёт список, не обрезайте его случайно; если продукту нужен один короткий совет, пусть сервер или отдельный formatter выбирает его явно. Главное — не передавать сырое сообщение в HTML как разметку: текст ошибки должен остаться текстом.'),
heading('Разметка: ошибка должна принадлежать полю'),
paragraph('Красная рамка сама по себе не объясняет проблему. У поля должен быть видимый <code>label</code>, постоянная подсказка и отдельный контейнер ошибки. <code>aria-describedby</code> связывает input с описывающими элементами по ID. Когда состояние невалидно, добавляем <code>aria-invalid="true"</code> и ссылку <code>aria-errormessage</code> на видимый текст. В WAI-ARIA эти атрибуты работают вместе: сообщение не нужно прятать от человека, который пользуется ассистивной технологией.'),
codeBlock(String.raw`
<label for="email">Почта</label>
<input
id="email"
name="email"
type="email"
required
autocomplete="email"
aria-describedby="email-hint email-error"
aria-errormessage="email-error"
aria-invalid="true"
value="user@example.test"
>
<p id="email-hint">Укажем адрес для входа.</p>
<p id="email-error" role="alert">Этот адрес уже используется</p>
`),
paragraph('В валидном состоянии <code>aria-invalid</code> убираем или ставим в <code>false</code>, а контейнер ошибки не оставляем с пустой ролью alert. Если строка ошибки меняется динамически, короткое уведомление может быть живой областью, но не надо превращать каждое нажатие клавиши в срочное объявление. Для проверки формы на отправке достаточно показать текст у поля и, при необходимости, дать краткий общий статус. Фокус переносим только по осознанному правилу интерфейса, обычно на первое невалидное поле после submit.'),
paragraph('Ниже не снимок Accessibility tree из браузера. Это ожидаемая семантическая структура той разметки, которую нужно проверить в DevTools и реальным скринридером проекта. Такой список полезен до запуска: он показывает, какое доказательство искать, и не выдаёт ожидание за измерение.'),
dataTable(
'Ожидаемая семантика разметки при серверной ошибке',
['Узел', 'Имя или состояние', 'Откуда берётся', 'Что проверять в реальном браузере'],
[
['Текстовое поле', 'Имя «Почта», значение user@example.test, invalid', '<code>label</code> и <code>aria-invalid</code>', 'Поле доступно по Tab и имеет имя label'],
['Описание', '«Укажем адрес для входа»', '<code>aria-describedby</code>', 'Подсказка связана с тем же ID, что указан у input'],
['Ошибка', '«Этот адрес уже используется»', '<code>aria-errormessage</code> и видимый контейнер', 'Текст не скрыт и относится к email, а не к соседнему input'],
['Кнопка', '«Сохранить» и её доступное состояние', 'Нативный <code>button</code>', 'Клавиатурная отправка не блокирует возможность исправить поле'],
],
),
heading('Поздний ответ не имеет права менять новый ввод'),
paragraph('Другая частая причина «прыгающей» ошибки — асинхронная проверка. Пользователь ввёл <code>ivan</code>, запрос ушёл на сервер; затем он быстро исправил на <code>ivanka</code>. Если первый ответ, «логин занят», возвращается последним, наивный <code>then()</code> запишет красную ошибку поверх нового значения. Скорость сети не даёт порядка, на который можно опереться. Владельцем актуальности должен быть идентификатор версии поля или запроса.'),
paragraph('Отмена через <code>AbortController</code> полезна, чтобы не тратить работу, когда пользователь продолжил ввод. Но отмена транспорта не заменяет защиту состояния: к моменту abort ответ уже мог разрешиться, библиотека могла не использовать signal, а локальная проверка вообще не имеет сетевого запроса. Поэтому сначала сравниваем номер запроса, а затем при наличии Fetch добавляем abort как оптимизацию.'),
codeBlock(String.raw`
var lastRequestId = 0;
function checkLogin(value, checkAvailability) {
var requestId = lastRequestId + 1;
lastRequestId = requestId;
render({ value: value, phase: 'checking', error: '' });
return checkAvailability(value).then(function (answer) {
if (requestId !== lastRequestId) return; // ответ относится к старому вводу
render({
value: value,
phase: answer.available ? 'valid' : 'invalid',
error: answer.available ? '' : 'Этот логин уже занят',
});
});
}
`),
paragraph('Номер запроса должен жить рядом с состоянием конкретного поля или формы, а не в глобальной переменной всего сайта. Для нескольких строк таблицы, двух вкладок редактора или нескольких экземпляров компонента глобальный счётчик снова создаст чужое влияние. В простом модуле это замыкание; во фреймворке — состояние экземпляра. Критерий тот же: обработчик ответа проверяет, что он всё ещё работает с текущей версией значения.'),
figure('/assets/editorial/2019/forms-validation-contract-2019.svg', 'Схема договора валидации формы: HTML даёт раннюю проверку, API возвращает ошибку с ключом поля, а клиент привязывает её к доступной разметке и отвергает устаревший ответ', 'Граница проходит не между «клиентом и сервером вообще», а между ограничением, контрактом ошибки и отображением конкретного поля.'),
heading('Маршрут внедрения на одной форме'),
orderedList([
'Выписать поля формы и отдельно назвать: проверка браузера, проверка API, общий сбой формы. Не начинаем с копирования всей серверной логики в JavaScript.',
'Согласовать с API стабильные ключи полей и коды ошибок. Для каждого ключа выбрать место в UI; неизвестный ключ не приклеивать к произвольному input.',
'Добавить нативные ограничения там, где они честны: <code>required</code>, тип, длина, pattern. Проверить, что <code>disabled</code> не исключает нужное поле из constraint validation по ошибке.',
'Сделать у каждого поля label, описание и контейнер ошибки с устойчивыми ID. На невалидном состоянии выставлять <code>aria-invalid</code> и показывать текст, а не только менять цвет.',
'Для асинхронной проверки хранить номер актуального запроса. Создать fixture, где старый ответ приходит позже нового, и ожидать, что он не меняет состояние.',
'На submit проверить сначала HTML-ограничения, затем ответ API, затем фокус и текст первого поля с ошибкой. Готовность — ошибка видна у правильного поля и исчезает только после новой валидной версии значения.',
]),
heading('Что проверить до выпуска'),
paragraph('Нужно проверить не один счастливый submit, а границы. Отправьте пустое поле, неверный формат, ошибку API с известным ключом, ошибку API с неизвестным ключом и два ответа в обратном порядке. Проверьте клавиатуру: label не потерян, ошибка не видна только по цвету, а после submit понятно, что именно требует исправления. Если форма живёт в модальном окне, дополнительно проверьте, что фокус не уходит под него при появлении текста ошибки.'),
bulletList([
'HTML-валидация может отличаться между браузерами в тексте встроенного сообщения. Если нужен единый текст, используйте свою видимую ошибку, но не отменяйте полезные нативные ограничения без причины.',
'Сервер всё равно проверяет вход. Нельзя считать <code>checkValidity()</code> защитой API: запрос можно составить вне вашей страницы.',
'Асинхронную проверку не стоит запускать на каждую букву без порога и задержки. Сначала убедитесь, что локальный формат уже проходит; затем используйте debounce и защиту версии.',
'Не ставьте <code>role="alert"</code> на целую форму или длинный список. Это даст шум вместо понятного сообщения; у поля нужен конкретный текст.',
'Ответ API с несколькими ошибками требует решения о порядке. Полезно сохранить порядок полей формы, а не полагаться на порядок ключей объекта.',
]),
heading('Итог'),
paragraph('Если сервер отказывается сохранять значение, которое клиент уже сделал зелёным, проблема не лечится новой регуляркой. Сначала отделяем локальное ограничение от условия базы, затем фиксируем ключ поля в контракте ошибки, привязываем видимый текст к input и не даём старому ответу менять новый ввод. После этого у формы есть проверяемый результат: известно, кто владеет каждым правилом, и ошибка оказывается у того поля, которое пользователь действительно может исправить.'),
],
[htmlConstraints, htmlFormSubmission, ariaDescription, ariaError, ariaAlert, domAbort, fetchSpec],
);
const mechanismArticle = createRevision(
{
slug: 'editorial-2019-04-mechanism-forms-validation',
title: 'Валидация формы как конечный автомат: состояние, версия и ошибка поля',
categories: ['JavaScript', 'HTML', 'Архитектура'],
cover: '/assets/editorial/2019/forms-validation-state-machine-2019.svg',
excerpt: 'Почему boolean isValid не объясняет форму с серверной проверкой. Разбираем состояния editing, checking, invalid и submitting, а также правило, которое не даёт позднему ответу перезаписать новое значение.',
readingMinutes: 13,
},
[
paragraph('Симптом: у формы есть <code>isValid</code>, <code>isLoading</code> и строка <code>error</code>, но после двух быстрых изменений поля кнопка становится активной в неправильный момент, а поздний ответ возвращает старый текст. Цена такой ошибки — не только лишний запрос. Пользователь видит состояние, которое относится к уже несуществующему значению, и команда начинает добавлять ещё один флаг вместо объяснения переходов.'),
paragraph('Причина в том, что валидация — это не один boolean. У поля есть значение, локальная проверка, ожидание ответа, серверная ошибка, успешная готовность и попытка отправки. В 2019 для небольшой формы не нужен отдельный автоматный фреймворк. Нужна простая таблица переходов и правило актуальности: обработчик асинхронного результата меняет состояние только тогда, когда его версия совпадает с текущей версией ввода.'),
heading('Какие данные принадлежат состоянию'),
paragraph('Начнём с одного поля логина. Значение <code>value</code> — то, что редактирует человек. <code>touched</code> помогает решить, когда впервые показывать локальную ошибку. <code>localError</code> отвечает за синтаксис и обязательность. <code>remoteError</code> приходит от условия, которое знает сервер, например «логин занят». <code>version</code> растёт при каждом изменении value. Наконец, <code>phase</code> делает состояние читаемым: <code>editing</code>, <code>client-invalid</code>, <code>checking</code>, <code>remote-invalid</code> или <code>ready</code>.'),
paragraph('Не все поля обязаны иметь такую же схему. Пароль может не требовать сетевой проверки, а форма как целое дополнительно имеет <code>submitting</code>, <code>submit-failed</code> и <code>submitted</code>. Важна не одинаковость, а явная граница. Если один флаг одновременно означает «строка соответствует pattern», «запрос уже ушёл» и «сервер согласен», он неизбежно станет неправдой в одном из промежуточных моментов.'),
dataTable(
'Состояния одного поля и разрешённые действия',
['Фаза', 'Что видно пользователю', 'Какие данные достоверны', 'Следующий переход'],
[
['<code>editing</code>', 'Текущее значение, без окончательного вердикта', 'Значение и его version', 'input → локальная проверка'],
['<code>client-invalid</code>', 'Ошибка формата или обязательности', '<code>localError</code>; сетевой запрос не нужен', 'input → editing или проверка'],
['<code>checking</code>', 'Короткое «Проверяем…», поле всё ещё можно менять', 'version запроса и очищенная старая remoteError', 'ответ той же версии → ready или remote-invalid'],
['<code>remote-invalid</code>', 'Серверный текст у поля', '<code>remoteError</code> только для текущего value', 'input → editing'],
['<code>ready</code>', 'Значение прошло известные проверки', 'Локальная и текущая remote-проверка', 'input → editing; submit → submitting формы'],
],
),
paragraph('Эта таблица не требует показывать пользователю слово <code>phase</code>. Она нужна разработчику, чтобы заранее исключить нелепые комбинации: <code>ready</code> и старый <code>remoteError</code>, <code>checking</code> для пустой строки, <code>client-invalid</code> после положительного ответа другой версии. При чтении кода полезно задавать один вопрос: какое событие имеет право перевести поле из этой фазы в следующую?'),
heading('Переход input сначала очищает старый результат'),
paragraph('Когда человек меняет символ, прежний серверный ответ больше не характеризует значение. Поэтому переход <code>input</code> увеличивает version, очищает remoteError и возвращает поле в <code>editing</code> или <code>client-invalid</code>. Ошибка «логин занят» не должна оставаться рядом с <code>ivanka</code>, если она относилась к <code>ivan</code>. Это простое правило часто важнее debounce: даже если запрос ещё не сделан, экран уже не показывает вердикт для старого входа.'),
codeBlock(String.raw`
function localLoginError(value) {
if (!value) return 'Введите логин';
if (!/^[a-z0-9_]{3,20}$/i.test(value)) {
return 'От 3 до 20 букв, цифр или _';
}
return '';
}
function onInput(state, nextValue) {
var localError = localLoginError(nextValue);
return {
value: nextValue,
version: state.version + 1,
phase: localError ? 'client-invalid' : 'editing',
localError: localError,
remoteError: '',
};
}
`),
paragraph('Состояние здесь является обычным объектом. Его легко использовать и с jQuery-формой, и с React, и с собственным рендером. Регулярное выражение — пример локального UX-правила, а не обещание, что сервер принимает те же символы. Если сервер нормализует регистр или допускает Unicode, этот факт должен быть отдельно описан в API-контракте. Клиенту не следует придумывать более строгую форму, которая запрещает корректные серверные данные.'),
heading('Асинхронный переход должен нести версию'),
paragraph('После локально корректного input можно начать асинхронную проверку. Обработчик сохраняет <code>checkedVersion</code> до вызова API. Когда promise завершается, он сравнивает сохранённую версию с текущей. Несовпадение означает не «сервер ошибся», а «результат больше не относится к текущему значению». Его не нужно превращать в ошибку, логировать как отказ или показывать человеку. Его надо молча отбросить как устаревший.'),
codeBlock(String.raw`
function startRemoteCheck(state, checkAvailability) {
if (state.phase === 'client-invalid') return Promise.resolve(state);
var checkedVersion = state.version;
var checking = {
value: state.value,
version: checkedVersion,
phase: 'checking',
localError: '',
remoteError: '',
};
return checkAvailability(checking.value).then(function (answer) {
return {
checkedVersion: checkedVersion,
answer: answer,
};
});
}
function applyRemoteAnswer(current, result) {
if (current.version !== result.checkedVersion) return current;
return {
value: current.value,
version: current.version,
phase: result.answer.available ? 'ready' : 'remote-invalid',
localError: '',
remoteError: result.answer.available ? '' : 'Этот логин уже занят',
};
}
`),
paragraph('В рабочем коде <code>current</code> берётся из единственного владельца состояния в момент ответа, а не из замыкания старого рендера. Это различие важно: замыкание может держать объект первой версии, и его сравнение само с собой всегда даст «актуально». Хранилище, экземпляр компонента или reducer должен дать актуальное состояние. Если архитектура уже использует action-ы, полезно передавать <code>checkedVersion</code> прямо в action <code>REMOTE_CHECK_RESOLVED</code>.'),
paragraph('Ниже минимальный fixture. Он намеренно задаёт задержки вручную: проверка <code>ivan</code> отвечает через 30 мс и считает логин занятым, проверка <code>ivanka</code> отвечает через 5 мс и считает его свободным. Это выполняемая модель порядка ответов, а не запись Network или тест настоящего браузера. Правильный результат: первый ответ отмечен как устаревший, а финальное состояние содержит <code>ivanka</code> и фазу <code>valid</code>.'),
codeBlock(String.raw`
// Запускается автономно командой:
// node web/scripts/upgrade-2019-04.mjs --run-fixture
// Ожидаемая форма результата:
{
"results": [
{ "requestId": 1, "applied": false, "ignored": "stale-response" },
{ "requestId": 2, "applied": true, "phase": "valid" }
],
"finalState": {
"value": "ivanka",
"requestId": 2,
"phase": "valid",
"fieldError": ""
}
}
`),
paragraph('Fixture проверяет именно правило версии. Он не доказывает поддержку конкретного браузера, не измеряет задержку API и не проверяет доступность разметки. В реальном проекте рядом нужны отдельный тест клиента с настоящим адаптером API и ручная проверка семантики поля. Разделение доказательств важно: хороший результат promise не говорит ничего о том, услышит ли ошибку пользователь со скринридером.'),
figure('/assets/editorial/2019/forms-validation-state-machine-2019.svg', 'Диаграмма конечного автомата поля формы: editing переходит в client-invalid или checking, ответ текущей версии приводит к ready или remote-invalid, а любой новый input очищает старый результат', 'Версия привязана к input: стрелка старого ответа обрывается до изменения состояния, если пользователь уже ввёл новое значение.'),
heading('Где в автомате живёт HTML-валидация'),
paragraph('Нативный Constraint Validation API не обязан быть конкурентом состоянию приложения. Его можно использовать на границе input и submit. Например, <code>input.validity.valid</code> быстро показывает, проходит ли контрол объявленные атрибуты; <code>setCustomValidity()</code> позволяет добавить локальный текст. Но если сервер вернул занятость логина, не подменяйте этим факт HTML-ограничение навсегда. Серверная ошибка относится к версии данных и должна исчезнуть на следующем input, а не жить как искусственный <code>patternMismatch</code>.'),
paragraph('На submit форма собирает состояния полей. Если хоть одно поле находится в <code>client-invalid</code>, отправка не начинается: показываем ошибки и фокусируем первое проблемное поле. Если есть <code>checking</code>, команда должна выбрать правило явно: дождаться, отключить submit на короткое время или повторить серверную проверку в запросе сохранения. Нельзя назвать форму <code>ready</code> только потому, что локальная регулярка прошла, пока ответ проверки ещё не завершился.'),
dataTable(
'Решения для submit во время remote-check',
['Политика', 'Когда подходит', 'Плюс', 'Цена и обязательная проверка'],
[
['Ждать текущую проверку', 'Короткий запрос и одно поле', 'Меньше дублей запросов', 'Показать доступное «Проверяем…»; убедиться, что интерфейс не завис при ошибке сети'],
['Отправлять и проверять на сервере', 'Сервер всё равно проверяет условие атомарно', 'Один окончательный ответ для сохранения', 'Клиент не обещает готовность раньше ответа; сервер возвращает field error'],
['Отключать кнопку до завершения', 'Проверка быстрая и смысл кнопки ясен', 'Простой путь без гонки submit', 'Кнопка не должна быть единственным носителем объяснения; поле всё ещё доступно для правки'],
],
),
heading('Доступность — тоже переход состояния'),
paragraph('Для человека, который видит цвет, <code>remote-invalid</code> — это рамка и текст. Для другого пользователя это должно стать доступным состоянием поля. В разметке у input остаётся label; вспомогательный текст и ошибка имеют устойчивые ID; при ошибке есть <code>aria-invalid="true"</code> и ссылка на сообщение. <code>aria-describedby</code> описывает контрол, а <code>aria-errormessage</code> указывает на текст ошибки при невалидном состоянии. Это не повод полностью заменить нативный HTML ARIA-атрибутами: сначала используем нативный input и label.'),
paragraph('Ниже приведено ожидаемое дерево, которое следует сверить в инструментах доступности после интеграции. Это не утверждение, что оно было снято в конкретном браузере. Разные движки и скринридеры по-разному представляют детали, поэтому проверяется не буквальный порядок строк, а инварианты: поле имеет имя, получает invalid при ошибке и связано с видимым описанием.'),
dataTable(
'Инварианты ожидаемого Accessibility tree для фазы remote-invalid',
['Инвариант', 'Разметка', 'Наблюдаемый смысл', 'Не является доказательством'],
[
['У поля есть имя', '<code>&lt;label for&gt;</code>', 'Пользователь понимает, что правит логин', 'Одинаковое слово в каждом screen reader'],
['Поле отмечено invalid', '<code>aria-invalid="true"</code>', 'Ошибка относится к текущему контролу', 'Качество текста ошибки'],
['Ошибка достижима', '<code>aria-errormessage</code> и видимый элемент', '«Этот логин уже занят» не спрятан в цвете', 'Автоматическое озвучивание в любой паре браузер/скринридер'],
['Подсказка не исчезла', '<code>aria-describedby</code>', 'Правило ввода остаётся доступно рядом с ошибкой', 'Корректность серверного условия'],
],
),
heading('Порядок проектирования и проверки'),
orderedList([
'Для каждого поля назвать локальное условие, удалённое условие и текст, который видит пользователь. Если условия нет, не создаём искусственный async-check.',
'Описать фазы и запретить невалидные комбинации: старый remoteError не живёт после input, а checking не означает ready.',
'Добавить version в действие input и сохранять его при старте запроса. В обработчике ответа сравнить версию с текущим состоянием до любого render.',
'Запустить fixture с двумя задержками в обратном порядке. Зафиксировать ожидаемое finalState, а не только отсутствие необработанного promise.',
'Привязать ошибку к input через label, описание, <code>aria-invalid</code> и видимый контейнер сообщения. Проверить клавиатуру и дерево доступности на реальном контуре отдельно.',
'Прогнать submit при client-invalid, checking, remote-invalid и готовом состоянии. Серверный ответ остаётся окончательным решением для сохранения.',
]),
heading('Ограничения модели'),
bulletList([
'Номер версии защищает только владельца UI-состояния. Он не отменяет запись на сервере и не заменяет идемпотентность операции сохранения.',
'<code>AbortController</code> можно добавить для экономии ресурсов, если используемый транспорт принимает signal. Даже после abort сравнение версии остаётся обязательным.',
'Проверка «логин свободен» до submit не гарантирует свободность во время сохранения: другой запрос может занять его между двумя операциями. Сервер должен проверять условие снова.',
'Фазы в статье описаны для одного поля. Сложная форма с зависимыми полями может хранить отдельный автомат формы и отдельные автоматы полей, но не должна прятать связь между ними.',
'ARIA-атрибуты не компенсируют отсутствие текста, label или правильного фокуса. Семантика проверяется вместе с интерфейсом, а не строковым поиском по HTML.',
]),
heading('Итог'),
paragraph('Форма становится предсказуемой не тогда, когда в ней появилось больше флагов, а когда у каждого ответа есть право на переход. Value меняет версию, локальная ошибка не вызывает сеть, async-ответ сравнивает свою версию, а серверная ошибка привязана к полю и доступной разметке. Такой автомат невелик, но он превращает «иногда приходит не та ошибка» в конкретную проверку: старый ответ не может изменить состояние нового значения.'),
],
[htmlConstraints, htmlFormSubmission, ariaDescription, ariaError, ariaAlert, domAbort, fetchSpec],
);
const fieldArticle = createRevision(
{
slug: 'editorial-2019-04-field-forms-validation',
title: 'Почему поздняя проверка логина стирает новое состояние формы',
categories: ['JavaScript', 'UX', 'Разбор'],
cover: '/assets/editorial/2019/forms-validation-late-response-2019.svg',
excerpt: 'Разбираем конкретную гонку: ответ «логин занят» для старого значения приходит позже, перезаписывает новое поле и оставляет ошибку без связи с input. Исправляем версией запроса и доступной разметкой.',
readingMinutes: 12,
},
[
paragraph('Разбор начинается с симптома, а не с библиотеки. Пользователь вводит логин <code>ivan</code>; форма отправляет проверку. Через мгновение он меняет значение на <code>ivanka</code>. Новый ответ говорит «свободно», экран становится зелёным. Затем приходит старый ответ «занято» и рисует красную строку уже под <code>ivanka</code>. Пользователь видит противоречие, а поддержка получает скриншот, по которому невозможно понять, какое значение проверял сервер. Цена ошибки — заблокировать корректный ввод или отправить устаревший результат.'),
paragraph('Причина — гонка двух корректных по отдельности promise. Код записывает любой завершившийся ответ в одно состояние поля и не хранит, к какому вводу он относится. Вторая проблема обычно рядом: строка ошибки лежит в общем баннере, поэтому даже настоящий серверный отказ нельзя быстро привязать к input. Ниже учебный fixture и маршрут расследования. Он не описывает production-трассу, не заявляет о запуске браузера и не заменяет проверку конкретного API.'),
heading('Реконструкция гонки без настоящей сети'),
paragraph('Для расследования нам не нужен медленный сервер. Достаточно детерминированно задать два ответа в обратном порядке. Функция <code>delayResult</code> в автономном пакете считает <code>ivan</code> занятым и возвращает его спустя 30 мс; <code>ivanka</code> свободен и возвращается спустя 5 мс. Две проверки стартуют одна за другой. Если код применяет всё подряд, первый результат перезапишет второй. Если он сравнивает идентификатор, первый результат станет <code>stale-response</code> и не изменит поле.'),
codeBlock(String.raw`
function delayResult(value, delayMs) {
return new Promise(function (resolve) {
setTimeout(function () {
resolve({ value: value, available: value !== 'ivan' });
}, delayMs);
});
}
var latestRequestId = 0;
function check(value, delayMs) {
var requestId = latestRequestId + 1;
latestRequestId = requestId;
return delayResult(value, delayMs).then(function (answer) {
if (requestId !== latestRequestId) {
return { requestId: requestId, applied: false, ignored: 'stale-response' };
}
return {
requestId: requestId,
applied: true,
phase: answer.available ? 'valid' : 'invalid',
};
});
}
`),
paragraph('В исходном пакете этот fixture запускается отдельной командой и печатает JSON. Он не требует HTTP, DOM или внешней базы: это плюс для проверки правила порядка, но и граница доказательства. Он не отвечает, как конкретный браузер отменяет fetch, как реальный сервер нормализует логин или как экран озвучивает ошибку. Эти вопросы проверяются другими средствами, поэтому в статье они не подменены одним удачным console output.'),
dataTable(
'Порядок событий в fixture',
['Момент', 'Действие', 'Текущая версия', 'Ожидаемый эффект'],
[
['t0', 'Старт проверки <code>ivan</code> с задержкой 30 мс', '1', 'Поле checking для ivan'],
['t1', 'Старт проверки <code>ivanka</code> с задержкой 5 мс', '2', 'Поле checking для ivanka; старая ошибка очищена'],
['t2', 'Ответ ivanka: available', '2', 'Ответ применён, фаза valid, значение ivanka'],
['t3', 'Ответ ivan: occupied', '2', 'Ответ отклонён как stale-response; текст не меняется'],
],
),
paragraph('Проверять нужно не только фразу «старый ответ проигнорирован». Полезно записать финальное состояние целиком: <code>value: "ivanka"</code>, <code>requestId: 2</code>, <code>phase: "valid"</code>, пустая ошибка поля. Если после исправления тест смотрит лишь на boolean <code>applied</code>, можно пропустить баг, где значение осталось от новой версии, а текст ошибки — от старой. Состояние должно быть согласованным одной версии.'),
heading('Плохой обработчик и минимальная правка'),
paragraph('В наивном варианте callback знает только ответ. Он не знает input, который был актуален на старте запроса. Поэтому последний по времени ответ побеждает независимо от того, что было введено. Отключение кнопки не решает эту гонку: человек всё ещё может менять поле, а ответ может завершиться после повторного открытия формы или смены шага.'),
codeBlock(String.raw`
// Плохо: любой ответ безусловно меняет одно и то же поле.
function applyAnswer(answer) {
state.phase = answer.available ? 'valid' : 'invalid';
state.error = answer.available ? '' : 'Этот логин уже занят';
render(state);
}
// Лучше: requestId закреплён в момент отправки.
function applyAnswerFor(requestId, answer) {
if (requestId !== state.requestId) return;
state.phase = answer.available ? 'valid' : 'invalid';
state.error = answer.available ? '' : 'Этот логин уже занят';
render(state);
}
`),
paragraph('Это не магический token. Он просто превращает неявное допущение «ответы придут по порядку» в явное условие. Текущее состояние — единственный источник версии. Любое событие input увеличивает её до начала следующей проверки. Если форма уничтожается при закрытии модального окна, экземпляр состояния также должен перестать принимать ответы: можно увеличить версию при teardown или проверить, что компонент ещё смонтирован. Выбор зависит от архитектуры, но последний callback не должен оживлять закрытую форму.'),
heading('Где и как показывать серверную ошибку'),
paragraph('Вторая часть диагноза — место ошибки. Ответ «этот логин занят» относится к полю логина, а не к кнопке и не к невидимому тосту. Для формы нужен контракт <code>fields.login</code> → текст. Если API вернул код <code>login_taken</code>, клиент сопоставляет его известному полю. Если же API вернул общий отказ, например закончилась сессия, это уже ошибка формы или маршрута, и её нельзя маскировать под ошибку логина.'),
dataTable(
'Классификация ответов API до рендера',
['Ответ', 'Куда идёт', 'Что видит пользователь', 'Что не делать'],
[
['<code>fields.login[0]</code>', 'Контейнер login-error', 'Текст под логином, invalid-состояние input', 'Не выводить только общий «Ошибка сохранения»'],
['<code>fields.email[0]</code>', 'Контейнер email-error', 'Текст под почтой', 'Не приклеивать к текущему активному полю'],
['<code>form[0]</code>', 'Общий статус формы', 'Краткое сообщение перед кнопкой или заголовком', 'Не ставить <code>aria-invalid</code> на все поля'],
['Неизвестный ключ', 'Безопасный общий путь и диагностика', 'Нейтральное сообщение без ложного указания', 'Не игнорировать молча и не выбирать первое поле'],
],
),
paragraph('Текст ошибки должен быть видимым, но это не значит, что его нужно дублировать по всему экрану. Один контейнер с устойчивым ID остаётся рядом с input. На ошибке input получает <code>aria-invalid="true"</code>; <code>aria-describedby</code> связывает его с постоянной подсказкой, а <code>aria-errormessage</code> — с отдельным текстом ошибки. WAI-ARIA прямо связывает <code>aria-errormessage</code> с состоянием invalid и требует, чтобы релевантное сообщение было доступно пользователю.'),
codeBlock(String.raw`
<label for="login">Логин</label>
<input
id="login"
name="login"
required
pattern="[A-Za-z0-9_]{3,20}"
aria-describedby="login-hint login-error"
aria-errormessage="login-error"
aria-invalid="true"
value="ivanka"
>
<p id="login-hint">От 3 до 20 букв, цифр или _.</p>
<p id="login-error" role="alert">Этот логин уже занят</p>
`),
paragraph('Здесь есть тонкость: пример показывает разметку для фазы <code>remote-invalid</code>, поэтому текст «занят» относится к текущему значению. При следующем input обработчик сначала убирает <code>aria-invalid</code>, очищает <code>login-error</code> и только потом запускает новый запрос. Иначе a11y-семантика тоже будет отставать: зритель увидит новое значение, а ассистивная технология получит старый текст как описание нового поля.'),
heading('Ожидаемое дерево доступности — план проверки, не отчёт'),
paragraph('У этой разметки есть ожидаемая семантика. В дереве должен быть textbox с именем «Логин», текущим значением, состоянием invalid и связью с описанием/ошибкой. Ошибка должна быть видимой и достижимой, а не скрытым span, на который указывает ID. Это не результат снятого Accessibility tree: в этой задаче браузерный прогон не выполнялся. Ниже — чек-лист, который надо подтвердить DevTools и выбранным скринридером после встраивания в реальный экран.'),
dataTable(
'Ожидаемые признаки дерева доступности',
['Признак', 'Как создаётся', 'Как подтвердить на контуре', 'Граница вывода'],
[
['Имя «Логин»', '<code>label for="login"</code>', 'Открыть Accessibility tree и пройти поле с клавиатуры', 'Не обещает одинаковую формулировку во всех скринридерах'],
['Состояние invalid', '<code>aria-invalid="true"</code> только при ошибке', 'Сменить старое/новое значение и проверить сброс состояния', 'Не заменяет серверную проверку'],
['Связанный текст ошибки', '<code>aria-errormessage="login-error"</code>', 'Убедиться, что элемент существует и видим', 'Не гарантирует timing озвучивания без реального прогона'],
['Постоянная подсказка', '<code>aria-describedby</code>', 'Проверить ID после рендера формы', 'Не доказывает корректность регулярного выражения'],
],
),
paragraph('HTML Constraint Validation API дополняет этот контракт, но не заменяет его. Нативный <code>required</code> и <code>pattern</code> могут остановить очевидно плохой submit. Однако серверная занятость не становится свойством <code>patternMismatch</code>. Храните её отдельно как remoteError, чтобы следующий input мог однозначно очистить результат и запустить проверку актуальной версии. Если нужен единый текст ошибки, <code>setCustomValidity()</code> применяйте к локальному правилу осмысленно и очищайте его на input.'),
figure('/assets/editorial/2019/forms-validation-late-response-2019.svg', 'Временная диаграмма формы: медленный ответ «ivan занят» приходит после быстрого «ivanka свободен», но сравнение requestId 1 и 2 не даёт старому ответу изменить поле', 'Время ответа не равно актуальности. Право обновить UI имеет только ответ, чья версия совпала с текущим вводом.'),
heading('Порядок расследования в реальном проекте'),
orderedList([
'Записать два конкретных значения и порядок: что ввели первым, что вторым, какой текст появился в конце. Не начинать с добавления debounce.',
'Найти единственное место, где меняется состояние поля после promise. Проверить, хранит ли оно значение или requestId, захваченные на старте запроса.',
'Собрать минимальный fixture с обратными задержками. Ожидаемый результат должен содержать финальное value, phase и error, а не только факт выполнения callback.',
'При input увеличить версию и очистить remoteError до нового render. Убедиться, что обработчик устаревшего ответа возвращает состояние без изменений.',
'Проверить контракт API: field error имеет известный ключ, а общий отказ не попадает в произвольное поле. Согласовать неизвестные ключи отдельно.',
'На реальном экране пройти форму клавиатурой и посмотреть Accessibility tree: label, invalid, описание, видимый error. Этот шаг делает семантику доказательством, а не ожиданием.',
]),
heading('Почему debounce и abort не закрывают вопрос сами'),
paragraph('Debounce уменьшает число запросов, но не меняет порядок тех запросов, которые уже ушли. Abort может остановить Fetch, если транспорт принимает сигнал, но к моменту отмены ответ уже может быть готов, а отмена не привязывает старый callback к новому value автоматически. Поэтому requestId — условие корректности, debounce — защита API от шума, abort — оптимизация отменяемой работы. Их можно сочетать, но менять одно на другое нельзя.'),
paragraph('Для сохранения действует ещё одно ограничение. Даже если проверка логина сказала «свободен», между check и POST другой пользователь мог занять это имя. Сервер должен повторить правило и вернуть field error при конфликте. Клиент после POST применяет ответ только к версии формы, которая была отправлена; если пользователь уже изменил input, показывать старый ответ над новой формой так же неверно, как в проверке логина.'),
heading('Итог'),
paragraph('В этом случае нет загадочной «нестабильности фронтенда». Есть старый ответ без права менять новый ввод и ошибка без чёткой привязки к полю. Исправление состоит из маленьких проверяемых частей: version при input, сравнение requestId перед render, field-contract API, label и видимый контейнер ошибки. После этого fixture ловит обратный порядок ответов, а реальный экран можно проверить отдельно на клавиатуре и в Accessibility tree без выдуманного отчёта о браузерном прогоне.'),
],
[htmlConstraints, htmlFormSubmission, ariaDescription, ariaError, ariaAlert, domAbort, fetchSpec],
);
export const revisions = [practiceArticle, mechanismArticle, fieldArticle]
.map(({ proseLength, ...revision }) => revision);
const isDirectRun = process.argv[1]
&& resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isDirectRun) {
if (process.argv.includes('--print-revisions')) {
process.stdout.write(JSON.stringify(revisions, null, 2) + '\n');
} else if (process.argv.includes('--run-fixture')) {
runDelayedResponseFixture()
.then((result) => process.stdout.write(JSON.stringify(result, null, 2) + '\n'))
.catch((error) => {
process.stderr.write(error.stack + '\n');
process.exitCode = 1;
});
} else {
process.stderr.write('Usage: node web/scripts/upgrade-2019-04.mjs --print-revisions | --run-fixture\n');
process.exitCode = 1;
}
}