Files
progcode/web/scripts/upgrade-2022-06.mjs
huncode 2bfc9d929f
Build and deploy / deploy (push) Successful in 16s
revise June 2022 form errors articles
2026-07-31 13:48:48 +03:00

459 lines
60 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.
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(lines) { return '<pre><code>' + escapeHtml(Array.isArray(lines) ? lines.join('\n') : lines) + '</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 dataTable(caption, headers, rows) {
return '<div class="table-scroll"><table><caption>' + caption + '</caption><thead><tr>'
+ headers.map((item) => '<th scope="col">' + item + '</th>').join('')
+ '</tr></thead><tbody>' + rows.map((row) => '<tr>' + row.map((item) => '<td>' + item + '</td>').join('') + '</tr>').join('')
+ '</tbody></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 plainText(content) {
return content.replace(/<[^>]+>/g, ' ').replaceAll('&nbsp;', ' ').replaceAll('&quot;', '"')
.replaceAll('&#039;', "'").replaceAll('&lt;', '<').replaceAll('&gt;', '>').replaceAll('&amp;', '&')
.replace(/\s+/g, ' ').trim();
}
function bodyText(content) { return plainText(content.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, '')); }
function createRevision(meta, parts, sources) {
if (sources.length < 2) throw new Error(meta.slug + ': нужны минимум два официальных источника');
const contentHtml = parts.join('\n') + '\n' + heading('Проверяемые источники') + '\n' + sourceList(sources);
const proseLength = bodyText(contentHtml).length;
if (proseLength < 5000 || proseLength > 15000) throw new Error(meta.slug + ': основной текст вне диапазона: ' + proseLength);
return { ...meta, contentHtml, proseLength };
}
const html52 = {
title: 'HTML 5.2, W3C Recommendation от 14 декабря 2017 года',
url: 'https://www.w3.org/TR/2017/REC-html52-20171214/',
note: 'неизменяемая нормативная версия периода: описывает form-associated элементы и constraint validation. Она не определяет проектные поля attemptId или правило повторной отправки.',
};
const aria12 = {
title: 'WAI-ARIA 1.2, W3C Candidate Recommendation Draft от 8 декабря 2021 года',
url: 'https://www.w3.org/TR/2021/CRD-wai-aria-1.2-20211208/',
note: 'датированный нормативный снимок, в котором определены aria-invalid и aria-errormessage. Модель ниже хранит только declared semantic payload, а не accessibility tree.',
};
const apg12 = {
title: 'WAI-ARIA Authoring Practices 1.2, W3C Group Note от 29 ноября 2021 года',
url: 'https://www.w3.org/TR/2021/NOTE-wai-aria-practices-1.2-20211129/',
note: 'датированное официальное руководство по предсказуемому поведению control. Оно не является результатом пользовательского теста этой учебной формы.',
};
const commonSources = [html52, aria12, apg12];
const fixtureCommand = [
'# Модель выполняется только в памяти Node.js.',
'node web/scripts/upgrade-2022-06.mjs --verify-fixture',
'',
'# PASS fixture: 18/18 assertions',
'# Здесь нет HTTP, DOM, браузера, таймера, screen reader или сетевого статуса.',
].join('\n');
const practiceExample = [
'// После правки поле получает новую version; старый ответ не имеет права вернуть ошибку.',
"changeField(form, 'email', 'person@example.test');",
'const oldAttempt = beginSubmit(form); // { attemptId: 1, versions: { email: 1, ... } }',
"changeField(form, 'email', 'corrected@example.test');",
'',
"const result = receiveServerResult(form, { attemptId: oldAttempt.attemptId, versions: oldAttempt.versions, errors: { email: 'Адрес уже занят' } });",
"// result.kind === 'stale-response-ignored'; email.serverError остаётся null",
'',
'node web/scripts/upgrade-2022-06.mjs --verify-fixture',
].join('\n');
const mechanismExample = [
"const semanticError = {",
" field: 'email',",
" invalid: true,",
" describedBy: ['email-hint', 'email-server-error'],",
" errorMessageId: 'email-server-error',",
" message: 'Этот адрес уже используется. Укажите другой адрес и отправьте форму повторно.',",
" observation: 'declared-payload-not-accessibility-tree',",
'};',
'',
'// Это контракт данных для разметки. Модель не создаёт aria-атрибут и не слушает screen reader.',
].join('\n');
const fieldExample = [
'// Успешная отправка сохраняет снимок. Rollback разрешён один раз и не отправляет компенсирующий запрос.',
"changeField(form, 'email', 'corrected@example.test');",
'const retry = retrySubmit(form);',
'receiveServerResult(form, { attemptId: retry.attemptId, versions: retry.versions, ok: true });',
"changeField(form, 'email', 'typo@example.test');",
'const rollback = rollbackLastAccepted(form);',
"// rollback.kind === 'rolled-back'; email.value === 'corrected@example.test'",
"// Второй rollback возвращает 'nothing-to-rollback'.",
].join('\n');
function cloneState(state) {
return {
boundary: state.boundary,
fields: Object.fromEntries(Object.entries(state.fields).map(([name, field]) => [name, {
...field,
localError: field.localError ? { ...field.localError } : null,
serverError: field.serverError ? { ...field.serverError } : null,
semantic: { ...field.semantic, describedBy: [...field.semantic.describedBy] },
}])),
submit: {
...state.submit,
active: state.submit.active ? { ...state.submit.active, versions: { ...state.submit.active.versions }, values: { ...state.submit.active.values } } : null,
lastAccepted: state.submit.lastAccepted ? { ...state.submit.lastAccepted, values: { ...state.submit.lastAccepted.values } } : null,
},
log: [...state.log],
};
}
function localMessage(name, value) {
if (name === 'email' && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value)) return 'Укажите email в формате name@example.test.';
if (name === 'password' && value.length < 8) return 'Пароль должен содержать не меньше 8 символов.';
return null;
}
function semanticPayload(name, message) {
const errorId = name + '-server-error';
return {
invalid: Boolean(message),
describedBy: message ? [name + '-hint', errorId] : [name + '-hint'],
errorMessageId: message ? errorId : null,
message: message || '',
observation: 'declared-payload-not-accessibility-tree',
};
}
/**
* Детерминированная учебная форма. В ней только объекты, массивы и ручные
* вызовы функций. Она не создаёт DOM, form submission, Fetch, HTTP-ответ,
* браузер, timer, screen reader, accessibility tree или сетевую задержку.
* `attemptId`, version и semantic payload — проектные данные, не telemetry.
*/
function createForm() {
return {
boundary: Object.freeze({
kind: 'teaching-form-errors-v1', dom: 'not-created', http: 'not-performed', fetch: 'not-called',
browser: 'not-run', timer: 'not-created', screenReader: 'not-observed', accessibilityTree: 'not-read',
}),
fields: {
email: { value: 'taken@example.test', version: 0, localError: null, serverError: null, semantic: semanticPayload('email', null) },
password: { value: 'correct-horse', version: 0, localError: null, serverError: null, semantic: semanticPayload('password', null) },
},
submit: { nextAttemptId: 1, active: null, status: 'ready', lastAccepted: null, rollbackUsed: false, declaredStatus: '' },
log: [],
};
}
function changeField(form, name, value) {
if (!Object.hasOwn(form.fields, name)) return { kind: 'unknown-field', name };
const field = form.fields[name];
field.value = value;
field.version += 1;
field.localError = localMessage(name, value) ? { source: 'local', message: localMessage(name, value) } : null;
field.serverError = null;
field.semantic = semanticPayload(name, null);
form.submit.status = form.submit.active ? 'editing-after-submit' : 'ready';
form.submit.declaredStatus = '';
form.log.push('change:' + name + ':v' + field.version);
return { kind: 'changed', name, version: field.version, localError: field.localError?.message || null };
}
function currentVersions(form) { return Object.fromEntries(Object.entries(form.fields).map(([name, field]) => [name, field.version])); }
function currentValues(form) { return Object.fromEntries(Object.entries(form.fields).map(([name, field]) => [name, field.value])); }
function hasLocalErrors(form) { return Object.values(form.fields).some((field) => field.localError); }
function beginSubmit(form) {
if (form.submit.active) return { kind: 'submit-already-active', attemptId: form.submit.active.attemptId };
if (hasLocalErrors(form)) {
form.submit.status = 'local-error';
form.submit.declaredStatus = 'Исправьте поля, отмеченные локальной проверкой.';
form.log.push('submit:blocked-local-error');
return { kind: 'blocked-local-error' };
}
const attempt = { attemptId: form.submit.nextAttemptId++, versions: currentVersions(form), values: currentValues(form) };
form.submit.active = attempt;
form.submit.status = 'submitting';
form.submit.declaredStatus = 'Отправка заявлена; результат не наблюдался.';
form.log.push('submit:start:' + attempt.attemptId);
return { kind: 'submit-started', ...attempt };
}
function retrySubmit(form) {
if (form.submit.active) return { kind: 'retry-blocked-active-attempt', attemptId: form.submit.active.attemptId };
form.log.push('retry:requested');
return beginSubmit(form);
}
function receiveServerResult(form, response) {
const active = form.submit.active;
if (!active || response.attemptId !== active.attemptId) {
form.log.push('response:ignored-unknown-attempt:' + response.attemptId);
return { kind: 'unknown-attempt-ignored' };
}
const stale = Object.entries(active.versions).some(([name, version]) => form.fields[name].version !== version)
|| Object.entries(response.versions || {}).some(([name, version]) => active.versions[name] !== version);
form.submit.active = null;
if (stale) {
form.submit.status = 'ready';
form.submit.declaredStatus = 'Ответ относится к прежнему вводу и не применён.';
form.log.push('response:stale:' + response.attemptId);
return { kind: 'stale-response-ignored' };
}
if (response.ok === true) {
form.submit.status = 'accepted';
form.submit.lastAccepted = { attemptId: active.attemptId, values: { ...active.values } };
form.submit.rollbackUsed = false;
form.submit.declaredStatus = 'Отправка принята в учебной модели.';
form.log.push('response:accepted:' + response.attemptId);
return { kind: 'accepted' };
}
const errors = response.errors || {};
for (const [name, message] of Object.entries(errors)) {
if (!Object.hasOwn(form.fields, name)) continue;
form.fields[name].serverError = { source: 'server-declared', attemptId: active.attemptId, message };
form.fields[name].semantic = semanticPayload(name, message);
}
form.submit.status = 'server-error';
form.submit.declaredStatus = 'Исправьте поле, для которого заявлена ошибка сервера.';
form.log.push('response:server-error:' + response.attemptId);
return { kind: 'server-error-applied', fields: Object.keys(errors) };
}
function rollbackLastAccepted(form) {
if (form.submit.active) return { kind: 'rollback-blocked-active-attempt' };
if (!form.submit.lastAccepted || form.submit.rollbackUsed) return { kind: 'nothing-to-rollback' };
for (const [name, value] of Object.entries(form.submit.lastAccepted.values)) {
const field = form.fields[name];
field.value = value;
field.version += 1;
field.localError = null;
field.serverError = null;
field.semantic = semanticPayload(name, null);
}
form.submit.rollbackUsed = true;
form.submit.status = 'rolled-back';
form.submit.declaredStatus = 'Значения возвращены к последнему принятому снимку.';
form.log.push('rollback:' + form.submit.lastAccepted.attemptId);
return { kind: 'rolled-back', values: currentValues(form) };
}
function runFormErrorsFixture() {
const form = createForm();
changeField(form, 'email', 'bad-address');
const localBlocked = beginSubmit(form);
changeField(form, 'email', 'taken@example.test');
const first = beginSubmit(form);
const retryWhilePending = retrySubmit(form);
changeField(form, 'email', 'corrected@example.test');
const versionAfterEdit = form.fields.email.version;
const stale = receiveServerResult(form, { attemptId: first.attemptId, versions: first.versions, errors: { email: 'Этот адрес уже используется.' } });
const staleServerError = form.fields.email.serverError;
const staleActiveAttempt = form.submit.active;
const second = retrySubmit(form);
const freshError = receiveServerResult(form, { attemptId: second.attemptId, versions: second.versions, errors: { email: 'Этот адрес уже используется. Укажите другой адрес и отправьте форму повторно.' } });
const declaredError = form.fields.email.semantic;
changeField(form, 'email', 'free@example.test');
const serverErrorAfterNewEdit = form.fields.email.serverError;
const semanticAfterNewEdit = form.fields.email.semantic;
const third = retrySubmit(form);
const accepted = receiveServerResult(form, { attemptId: third.attemptId, versions: third.versions, ok: true });
const duplicate = receiveServerResult(form, { attemptId: third.attemptId, versions: third.versions, ok: true });
changeField(form, 'email', 'typo@example.test');
const rollback = rollbackLastAccepted(form);
const secondRollback = rollbackLastAccepted(form);
const assertions = Object.freeze({
localInvalidBlocksSubmit: localBlocked.kind === 'blocked-local-error',
localInvalidCreatesNoAttempt: form.log.includes('submit:blocked-local-error'),
firstAttemptGetsStableId: first.attemptId === 1,
pendingAttemptBlocksRetry: retryWhilePending.kind === 'retry-blocked-active-attempt',
editChangesFieldVersion: first.versions.email !== versionAfterEdit,
oldResponseIsIgnored: stale.kind === 'stale-response-ignored',
oldResponseDoesNotBecomeError: staleServerError === null && staleActiveAttempt === null && !form.log.includes('response:server-error:1'),
retryGetsNewAttemptId: second.attemptId === 2,
matchingResponseAppliesServerError: freshError.kind === 'server-error-applied',
errorIsAssociatedAsDeclaredPayload: declaredError.errorMessageId === 'email-server-error' && declaredError.describedBy.includes('email-server-error'),
semanticPayloadDoesNotClaimTree: declaredError.observation === 'declared-payload-not-accessibility-tree',
editClearsMatchingServerError: serverErrorAfterNewEdit === null && semanticAfterNewEdit.invalid === false,
successfulRetryIsAccepted: accepted.kind === 'accepted',
duplicateAnswerDoesNotApplyTwice: duplicate.kind === 'unknown-attempt-ignored',
rollbackRestoresAcceptedValue: rollback.kind === 'rolled-back' && form.fields.email.value === 'free@example.test',
rollbackRaisesVersionInsteadOfReplayingAttempt: form.fields.email.version > third.versions.email,
rollbackIsSingleUse: secondRollback.kind === 'nothing-to-rollback',
boundaryStaysInMemory: form.boundary.http === 'not-performed' && form.boundary.dom === 'not-created',
});
return { assertions, log: [...form.log], boundary: form.boundary };
}
const practiceArticle = createRevision(
{
slug: 'editorial-2022-06-practice-form-errors',
title: 'Ошибка формы пришла после правки поля: как не вернуть пользователя к уже исправленному вводу',
categories: ['Frontend', 'Практика'],
cover: '/assets/editorial/2022/form-errors-state-machine-2022.svg',
excerpt: 'Порядок для формы, где ответ относится к старому вводу: локальная проверка, version поля, attemptId и честный retry без ложного сообщения.',
readingMinutes: 12,
},
[
paragraph('Симптом знакомый: человек исправил email, нажал «Отправить» ещё раз, а форма внезапно показывает старое «Адрес уже занят». Сообщение может быть технически верным для первого ввода, но уже не связано с тем, что находится в поле. Цена ошибки — не только раздражение. Пользователь не понимает следующий шаг, повторяет действие или меняет корректное значение на случайное.'),
paragraph('Причина обычно не в тексте ошибки. Форма хранит значение поля отдельно от попытки отправки, но ответ применяет только по имени поля. Поэтому поздний результат первой попытки перезаписывает состояние второй. Ниже — небольшой порядок для одной формы: локальная validation, версия каждого поля, номер попытки и явное правило stale-response. Это учебная in-memory модель; она не делает HTTP-запрос, не создаёт DOM и не измеряет фактическую задержку сервера.'),
heading('Сначала разделите три вида ошибки'),
paragraph('Локальная ошибка возникает до отправки: пустой обязательный input или email без доменной части. Её можно вычислить из текущего значения. Серверная ошибка относится к снимку значений, который ушёл в конкретной попытке. Ошибка транспорта или статуса ответа относится к доставке, а не к одному полю. Если сложить всё в строку <code>form.error</code>, код уже не знает, что очищать после правки и где показывать следующий шаг.'),
dataTable('Что хранит форма до появления реального сетевого слоя', ['Сигнал', 'Владелец', 'Когда очищается', 'Действие'], [
['Локальная проверка email', 'текущее значение поля', 'после следующей правки', 'не начинать submit, назвать поле для исправления'],
['Серверная ошибка поля', 'attemptId + versions снимка', 'если поле изменилось или ответ устарел', 'не переносить её на новый ввод'],
['Активная отправка', 'submit state', 'после принятого, ошибочного или stale ответа', 'не запускать второй retry поверх неё'],
['Последний принятый снимок', 'rollback boundary', 'после одного rollback или нового success', 'вернуть локальные значения без компенсирующего запроса'],
]),
heading('Минимальный контракт: version поля и attemptId'),
paragraph('Для начала не нужен глобальный request manager. Достаточно увеличить <code>version</code> при каждой правке и сохранить снимок версий при <code>beginSubmit</code>. Попытка получает <code>attemptId</code>, например 1. Если пользователь меняет email, его version становится другой. Когда приходит ответ с attemptId 1, форма сравнивает не время и не «последний ответ вообще», а сохранённые версии с текущими значениями.'),
paragraph('Совпали attemptId и версии — ответ может изменить state. Не совпали версии — ответ относится к прежнему вводу. Его нужно записать как ignored в учебный журнал и снять active attempt, но не класть его текст обратно в поле. Важно именно второе действие: игнорирование не означает вечный pending. После stale ответа пользователю должно быть разрешено отправить текущий снимок повторно.'),
codeBlock(practiceExample),
paragraph('В примере строка <code>Адрес уже занят</code> не приходит из сети: её передаёт вызывающий код как fixture data. Это сделано специально. Модель проверяет правило применения ответа, но не утверждает, что сервер отвечает за определённое число миллисекунд, что Fetch вернул конкретный status или что браузер отменил старый запрос. Эти факты появляются только в отдельной интеграционной проверке.'),
heading('Автомат формы: где позднему ответу закрывают путь'),
figure('/assets/editorial/2022/form-errors-state-machine-2022.svg', 'Автомат учебной формы: ready переходит в submitting с attemptId и снимком version; правка во время отправки создаёт состояние editing-after-submit; ответ со старой version уходит в stale-response-ignored и не меняет ошибку поля; совпавшая серверная ошибка ведёт к server-error, а подтверждение — к accepted.', 'Схема показывает порядок данных, а не сетевой протокол. Стрелка stale не означает измеренную задержку и не описывает браузерную отмену запроса.'),
heading('Маршрут правки без переписывания формы'),
orderedList([
'<strong>Симптом.</strong> Зафиксируйте один случай: поле уже исправлено, а интерфейс показал текст, относящийся к прежнему значению.',
'<strong>Причина.</strong> Найдите место, где обработчик ответа пишет в поле без проверки attemptId и версии входа.',
'<strong>Проверка.</strong> В локальном тесте начните submit, измените поле, затем вручную передайте ошибку для старого снимка. Error state не должен измениться.',
'<strong>Действие.</strong> Сохраняйте <code>attemptId</code>, values и versions при отправке; очищайте server error при новой правке; stale ответ помечайте ignored.',
'<strong>Retry.</strong> Блокируйте повтор, пока есть active attempt. После stale или server-error создавайте новую попытку только из текущего валидного снимка.',
'<strong>Платформенная проверка.</strong> Отдельно проверьте реальный DOM, submit handler и выбранные браузеры. Fixture ниже не заменяет этот слой.',
]),
heading('Почему не достаточно «показывать последнюю ошибку»'),
paragraph('Правило «побеждает последний ответ» работает только при допущении, что ввод между ответами не менялся. В форме это допущение ломается самым обычным действием — набором текста. И наоборот, правило «последний input побеждает всё» тоже слишком грубое: оно может скрыть серверную ошибку, которая относится к текущему снимку. Версия решает именно этот вопрос: не кто был последним по времени, а совпадает ли ответ с теми данными, которые он проверял.'),
paragraph('Не стоит удалять текст ошибки при любом рендере. Он должен исчезать по понятной причине: поле изменилось, успешная попытка принята или выполнен осознанный reset. Такой контракт помогает и в review. Вместо фразы «состояние иногда мерцает» можно проверить четыре значения: <code>value</code>, <code>version</code>, <code>activeAttempt</code> и источник ошибки. Если одно из них не названо, исправление останется угадыванием.'),
heading('Ограничение и следующий проверяемый шаг'),
paragraph('Эта схема не отвечает, нужно ли отменять настоящий запрос при правке: это зависит от API и от цены отмены. Она также не решает конфликты между вкладками, offline, rate limit, CSRF или порядок ответов нескольких серверов. AttemptId формы не является idempotency key сервера. Его задача уже: не применить к текущему полю ответ, который был проверен для другого снимка.'),
paragraph('Следующий шаг — выбрать одну форму и выписать рядом с reducer: что меняет version, где хранится active attempt, какие поля входят в snapshot и что именно считается stale. Затем добавить отрицательный тест «старый ответ после правки не создаёт error». Только после него полезно добавлять отмену HTTP или общую библиотеку: сначала нужно защитить границу, которую библиотека будет обслуживать.'),
heading('Историческая граница июня 2022'),
paragraph('Нормативная рамка ограничена документами, доступными до июня 2022 года: HTML 5.2 Recommendation 2017 года и WAI-ARIA 1.2 Candidate Recommendation Draft декабря 2021 года. HTML описывает form controls и constraint validation, а WAI-ARIA определяет значения, которыми разметка может заявить ошибку. Поля <code>attemptId</code>, <code>version</code>, сообщения fixture и rollback — проектные решения этого пакета, не требования спецификаций и не отчёт о production-сценарии.'),
], commonSources,
);
const mechanismArticle = createRevision(
{
slug: 'editorial-2022-06-mechanism-form-errors',
title: 'Контракт ошибки формы: значение, версия и semantic payload должны относиться к одной попытке',
categories: ['Frontend', 'Архитектура'],
cover: '/assets/editorial/2022/form-errors-semantic-association-2022.svg',
excerpt: 'Почему текст ошибки, aria-связь и попытка отправки нельзя собирать из разных копий state — и что именно способен доказать unit fixture.',
readingMinutes: 13,
},
[
paragraph('Форма может показывать понятный красный текст и всё равно вести человека не туда. Поле уже содержит исправленный email, рядом остаётся старое серверное сообщение, а declared semantic properties ссылаются на этот текст. Визуально компонент «сообщает об ошибке», но причина и следующее действие больше не принадлежат одному вводу. Цена такой рассинхронизации — лишняя правка корректных данных и интерфейс, который сложно проверить после следующего refactor.'),
paragraph('Причина — несколько владельцев одной ошибки. Handler ответа хранит message, поле хранит value, UI сам решает <code>aria-invalid</code>, а submit button знает только pending. Надёжнее собрать их вокруг одного record: какой attempt проверял какой version, есть ли локальная ошибка, какой текст можно объявить и какое поле нужно исправить. Эта статья строит data contract, а не готовый React-компонент. Она не читает accessibility tree, не создаёт aria-атрибуты и не говорит, что конкретная assistive technology произнесла сообщение.'),
heading('Ошибка — не строка, а связанный набор данных'),
paragraph('У server error есть как минимум четыре части: имя поля, текст, attemptId и версии входа. Для разметки полезен ещё semantic payload: <code>invalid</code>, список заявленных описаний, идентификатор сообщения и его текст. Когда эти части создаются одним переходом reducer, reviewer может проверить связь. Когда UI вычисляет их отдельно, можно получить <code>aria-invalid=true</code> без причины, message без поля или id ошибки, который живёт после новой правки.'),
dataTable('Минимальный record ошибки и его границы', ['Поле record', 'Значение в fixture', 'Зачем нужно', 'Чего не доказывает'], [
['attemptId', '2', 'привязывает ответ к одной отправке', 'уникальность на сервере или идемпотентность API'],
['versions.email', '2', 'сравнивает ответ с текущим вводом', 'реальную последовательность сетевых пакетов'],
['serverError.message', 'укажите другой адрес', 'даёт конкретный следующий шаг', 'понятность формулировки для всех пользователей'],
['semantic.errorMessageId', 'email-server-error', 'даёт разметке явную ссылку', 'построенное accessibility tree или озвучивание'],
['status', 'server-error', 'останавливает повтор до осознанного retry', 'HTTP status и работу endpoint'],
]),
heading('Local validation и server validation не соревнуются'),
paragraph('Локальная validation отвечает на вопрос, можно ли создать снимок: есть ли у email базовая форма, достаточно ли символов в пароле. Серверная validation отвечает на другой вопрос: допустимы ли эти значения для его правил. В модели локальная ошибка блокирует <code>beginSubmit</code> и не создаёт attemptId. Серверная ошибка появляется только после совпавшего response record. Поэтому сообщение «Введите email» не следует помещать в тот же канал, что «Этот email уже занят»: у них разный источник и разный момент очистки.'),
paragraph('Это разделение экономит не только состояние. Оно сохраняет честный маршрут для человека. Пока email не похож на адрес, форма может назвать локальную причину и не обещать, что сервер его проверял. Когда снимок ушёл и вернулась совпавшая server error, сообщение говорит, что поменять и что повторить. Если локальная правка случилась после отправки, прежняя server error очищается до прихода ответа; у нового значения ещё нет результата сервера, и UI не должен делать вид, что он есть.'),
codeBlock(mechanismExample),
heading('Declared association не равна наблюдённому объявлению'),
paragraph('WAI-ARIA 1.2 описывает <code>aria-invalid</code> и <code>aria-errormessage</code>, но одна запись в объекте JavaScript не становится автоматически разметкой. В fixture <code>describedBy</code> и <code>errorMessageId</code> — намеренно declared semantic payload. Они позволяют unit-тесту проверить, что ошибка поля получила стабильный id и что связь очищается при новой правке. Они не доказывают, что attribute попал в DOM, что id уникален на странице или что screen reader обработал изменение в конкретном порядке.'),
paragraph('Такое ограничение полезнее ложной уверенности. В компоненте semantic payload надо преобразовать в выбранную native-разметку и затем проверить на реальной странице. Для одних control достаточно стандартной связи label и input; для других нужна дополнительная error association. Решение зависит от структуры формы и поддерживаемых сред. Модель не выбирает это вместо команды — она только не даёт забыть, что text, invalid state и attempt должны меняться согласованно.'),
figure('/assets/editorial/2022/form-errors-semantic-association-2022.svg', 'Схема связи одной совпавшей server error: attempt 2 содержит version email 2; совпавший ответ создаёт serverError и declared payload с invalid=true, email-server-error и текстом следующего шага. При следующей правке version становится 3 и payload ошибки очищается. Рамка снизу указывает, что DOM и accessibility tree не создавались.', 'Связь на схеме — контракт данных для реализации. Она не фиксирует фактическое чтение ошибки технологией ассистивного доступа.'),
heading('Проверка инвариантов до UI-теста'),
orderedList([
'<strong>Симптом.</strong> Ошибка остаётся после правки или не объясняет, какое поле исправлять.',
'<strong>Причина.</strong> Message, visual invalid state и request result принадлежат разным branches состояния.',
'<strong>Проверка входа.</strong> Для каждого submit сохраните values и versions, а у active attempt — один attemptId.',
'<strong>Проверка ответа.</strong> Перед применением сравните response attemptId с active и сохранённые versions с текущими.',
'<strong>Действие.</strong> При совпадении создайте serverError и semantic payload одним reducer transition; при edit очистите оба.',
'<strong>Проверка платформы.</strong> В отдельном DOM/e2e сценарии подтвердите выбранные id, native controls и доступное имя. Не переносите этот вывод из Node fixture.',
]),
heading('Почему semantic payload стоит держать рядом с доменной ошибкой'),
paragraph('Есть соблазн завести общий toast и передавать туда любую неудачу. Для ошибки формы это часто делает путь хуже: toast сообщает «Не удалось сохранить», но не знает, на каком поле остановиться. Обратный перекос — заставить каждую ошибку жить только около input. Тогда общий status не может сказать, что отправка не выполнена. В маленькой форме разумно хранить два значения: field-specific record с причиной и declared form status с маршрутом. Они создаются одной веткой, но не заменяют друг друга.'),
paragraph('В fixture после совпавшей ошибки <code>server-error</code> создаёт текст «Исправьте поле, для которого заявлена ошибка сервера». Это не обещание, что интерфейс покажет эту фразу всем одинаково. Это проверяемый контракт: branch не считается accepted, поле получает конкретную причину, а retry не запускается одновременно с ещё активной попыткой. Платформенный слой может сделать текст короче или локализовать его, но не должен потерять поле, версию и условие очистки.'),
heading('Что меняется при повторной отправке'),
paragraph('Retry — не вызов того же обработчика без состояния. Он разрешён только когда active attempt снят и локальные правила проходят. Новый retry обязан получить новый attemptId и новый снимок версий. Иначе ответ первой попытки и ответ второй будут неразличимы, а duplicate delivery снова сможет переписать форму. В учебной модели duplicate result после accepted не находит active attempt и получает <code>unknown-attempt-ignored</code>. Это защита интерфейсного state, не доказательство exactly-once доставки.'),
paragraph('Если API принимает idempotency key, он должен иметь собственный контракт на серверной границе. Не стоит передавать туда номер попытки UI как будто этого достаточно. UI attemptId короткоживущий и нужен, чтобы связать input со state одной формы. Серверный ключ защищает другую границу: повтор того же намерения между клиентом и backend. Названия похожи, но последствия ошибки разные; смешивать их в одной переменной опасно.'),
heading('Ограничение и следующий проверяемый шаг'),
paragraph('Модель не решает составные формы с зависимыми полями, file upload, оптимистическим сохранением или несколькими вкладками. Она также не проводит исследование понятности текста и не проверяет локализацию. Для этих задач понадобятся другой contract, реальные DOM-сценарии и, если есть основания, пользовательская проверка. Нельзя выводить их результат из того, что object содержит <code>errorMessageId</code>.'),
paragraph('Следующий шаг — в одном компоненте добавить тест на четыре отрицательных ветки: локально невалидный input не создаёт attempt; retry не идёт поверх pending; старый response не создаёт ошибку; duplicate response не меняет accepted state. После этого можно подключать сетевой adapter. Его тест должен передавать в reducer тот же response record, а не делать вид, что порядок callback всегда совпадает с порядком кликов.'),
heading('Историческая граница июня 2022'),
paragraph('Для терминов разметки использованы только датированные материалы: HTML 5.2 Recommendation 2017 года, WAI-ARIA 1.2 CRD от 8 декабря 2021 года и APG 1.2 Group Note ноября 2021 года. Они не задают attemptId, version или этот текст ошибки. Эти поля — явно обозначенная проектная модель. В статье нет записи сессии screen reader, DOM-snapshot, user research или заявления о доступности готового продукта.'),
], commonSources,
);
const fieldArticle = createRevision(
{
slug: 'editorial-2022-06-field-form-errors',
title: 'Форма застряла между retry и старым ответом: диагностика и обратимый возврат к принятому снимку',
categories: ['Frontend', 'Тестирование'],
cover: '/assets/editorial/2022/form-errors-diagnosis-rollback-2022.svg',
excerpt: 'Полевой маршрут для формы с поздним ответом, дубликатом и повторной отправкой: какие факты собрать, что остановить и где допустим локальный rollback.',
readingMinutes: 13,
},
[
paragraph('Сбой редко выглядит как «сломанная машина состояний». Обычно человек видит другое: после исправления поля снова появилась прежняя ошибка; кнопка разрешает два retry подряд; после успешной отправки тест не может восстановить понятный снимок. Эти симптомы часто чинят отдельными <code>setError(null)</code> и disabled-кнопкой. Цена такого патча — новый скрытый порядок: при следующем изменении обработчик снова не знает, какой ответ ещё имеет право менять форму.'),
paragraph('Диагностику лучше начать с одного пути и его отрицательных исходов. В этой статье есть локальная модель регистрации: сначала приходит ответ для старого email, затем совпавшая серверная ошибка, затем успешный retry и локальный rollback последнего принятого снимка. Это не история production-инцидента, не тест браузера и не имитация сети. Вызовы response выполняются вручную, чтобы проверить именно правила state, а не скорость транспорта.'),
heading('Шесть наблюдений, которые не стоит смешивать'),
dataTable('Диагностика ошибки формы без догадок о сети', ['Наблюдение', 'Вероятная причина', 'Минимальный факт в state', 'Безопасное действие'], [
['Старый текст вернулся после edit', 'response применён только по имени поля', 'versions active attempt и текущего поля различаются', 'пометить response stale и не создавать error'],
['Два retry ушли подряд', 'active attempt не блокирует кнопку', 'в state уже есть attemptId', 'вернуть retry-blocked-active-attempt'],
['Ошибка есть, но поле не названо', 'общий toast потерял field record', 'serverError не содержит field/semantic id', 'создать field-specific payload и общий status отдельно'],
['Успех применён дважды', 'ответ не снял active attempt', 'повтор не находит active attempt', 'игнорировать duplicate как unknown attempt'],
['Откат меняет server state', 'локальный undo выдан за компенсацию API', 'rollback не вызывает adapter', 'ограничить rollback только локальным accepted snapshot'],
['После rollback снова можно откатывать бесконечно', 'prior snapshot не помечен использованным', 'rollbackUsed не изменился', 'сделать rollback одноразовым'],
]),
heading('Сначала нарисуйте три точки: снимок, активная попытка, принятие'),
paragraph('До submit форма имеет текущие values и их версии. В submit она создаёт immutable snapshot: values, versions, attemptId. После принятого ответа сохраняется последний accepted snapshot. Эти три точки не стоит заменять одной переменной <code>isLoading</code>. Она умеет сказать, что «что-то происходит», но не отвечает, с каким input связан ответ, что можно повторить и к какому значению возвращается локальная отмена.'),
paragraph('Встроенный rollback нужен не для того, чтобы отменить серверную операцию. В этой модели он помогает безопасно вернуть form state к последнему принятому снимку после последующей локальной правки. При rollback увеличивается version, очищаются ошибки и отмечается <code>rollbackUsed</code>. Поэтому прежний response не сможет внезапно совпасть с новым state, а второй rollback не воспроизведёт действие. Если реальный продукт обещает отмену уже записанного на сервере изменения, это отдельный API contract с конфликтами и компенсацией.'),
codeBlock(fieldExample),
heading('Диаграмма: что остановить, а что можно вернуть'),
figure('/assets/editorial/2022/form-errors-diagnosis-rollback-2022.svg', 'Диагностическая схема: local invalid блокирует submit; активный attempt блокирует второй retry; изменение поля делает поздний ответ stale; совпавшая ошибка создаёт semantic payload; success сохраняет accepted snapshot; локальный rollback один раз возвращает values и увеличивает version. Боковая рамка отделяет это от HTTP, browser и server rollback.', 'Схема помогает не перепутать три действия: игнорировать устаревший response, повторить текущий snapshot и вернуть только локальные значения.'),
heading('Сценарий проверки с отрицательными assertions'),
paragraph('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 этого ответа игнорируется.'),
paragraph('Отрицательные assertions здесь важнее happy path. Успешный submit легко написать так, чтобы он прошёл один раз. Риск появляется в переходах, которые не должны иметь эффекта: неправильный email не создаёт запрос в модели; старый response не создаёт message; повтор не подтверждает уже закрытую попытку; второй rollback ничего не меняет. Если такой тест отсутствует, кнопка может выглядеть правильно, но state будет зависеть от случайного порядка callback.'),
heading('Маршрут диагностики и безопасной правки'),
orderedList([
'<strong>Симптом.</strong> Возьмите один воспроизводимый путь: edit после submit, два retry или duplicate result. Не объединяйте несколько дефектов в один большой «form bug».',
'<strong>Снимок.</strong> Запишите values и versions в момент beginSubmit. Если их нет, поздний response невозможно классифицировать честно.',
'<strong>Активность.</strong> Проверьте, что state хранит не boolean, а active attempt с id. Второй retry должен останавливаться до создания нового id.',
'<strong>Ответ.</strong> Сначала сопоставьте attemptId, потом версии. Только после двух совпадений применяйте error или success.',
'<strong>Сообщение.</strong> У совпавшей field error должны быть source, field, текст следующего шага и declared semantic payload. При edit они очищаются одной операцией.',
'<strong>Откат.</strong> Если нужен локальный undo, возвращайте только последний accepted snapshot, увеличивайте version и разрешайте его один раз. Сетевую компенсацию проектируйте отдельно.',
'<strong>Проверка реализации.</strong> После fixture пройдите настоящий UI в целевых браузерах, проверьте выбранную разметку и adapter. Не приписывайте этим результатам то, чего не измеряли.',
]),
heading('Почему stale response не надо считать ошибкой пользователя'),
paragraph('Поздний ответ — нормальная возможность в асинхронном интерфейсе, а не доказательство, что человек «слишком быстро печатает». Попытка 1 действительно могла быть обработана позже попытки 2. Задача UI — не угадать сеть, а сохранить причинную связь: server result может говорить только о тех values, которые были в его request snapshot. Если этой связи нет, форма вынуждает пользователя разбираться во внутреннем времени приложения.'),
paragraph('При этом stale response не всегда нужно скрывать от технической диагностики. В модели он попадает в <code>log</code> как <code>response:stale:1</code>. Это не telemetry и не production metric; это локальный след для теста. В реальном приложении команда может отдельно решить, нужен ли debug-log и какие данные допустимо записывать. Важно не использовать такой log как оправдание показа старого сообщения в UI.'),
heading('Граница rollback и retry'),
paragraph('Retry повторяет проверку текущего snapshot и получает новый attemptId. Rollback не повторяет ничего: он возвращает values, которые уже были приняты учебной моделью, и не посылает сообщений наружу. Эти действия нельзя объединять одной кнопкой «Отменить/повторить». У retry есть риск нового результата сервера; у rollback — риск потерять несохранённую локальную правку. Поэтому перед внедрением надо назвать, какое ожидание продукта защищает каждое действие.'),
paragraph('Если форма сохраняет не один email, а заказ, деньги или права доступа, локального rollback может быть недостаточно и даже вреден. Там нужен отдельный server-side статус, правила конфликта, права на отмену и audit trail. Учебная модель специально не делает этот шаг. Она показывает только безопасное минимальное правило: локальный интерфейс не должен бесконечно применять один и тот же result и не должен выдавать возвращение своего snapshot за отмену доменной операции.'),
heading('Ограничение и следующий проверяемый шаг'),
paragraph('Здесь нет upload, debounce, optimistic update, автоматического retry, HTTP abort, реальных status codes, маршрутизации фокуса и проверки речи screen reader. У каждого из этих механизмов свой порядок ошибок. Добавлять их к этой модели можно только с новым fixture и явно расширенной границей. Иначе простой тест начнёт делать обещания о браузере и сети, которых его входы не содержат.'),
paragraph('Следующий шаг — взять один reducer реального компонента и сопоставить его переходы с таблицей выше. Добавьте сначала test на stale response и duplicate result, затем отдельный тест выбранного HTTP adapter. Для доступности подготовьте конкретную разметку и проверку в согласованных средах. Так форма получит две независимые опоры: unit contract для порядка state и платформенную проверку для того, что реально видит пользователь.'),
heading('Историческая граница июня 2022'),
paragraph('Технические ссылки остаются в границе времени: 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 или пользовательское исследование.'),
], commonSources,
);
export const revisions = [practiceArticle, mechanismArticle, fieldArticle].map(({ proseLength, ...revision }) => revision);
function verifyFixture() {
const result = runFormErrorsFixture();
const failed = Object.entries(result.assertions).filter(([, passed]) => passed !== true).map(([name]) => name);
if (failed.length) {
console.error('FAIL fixture: ' + failed.join(', '));
process.exitCode = 1;
return;
}
console.log('PASS fixture: ' + Object.keys(result.assertions).length + '/' + Object.keys(result.assertions).length + ' assertions');
}
if (process.argv.includes('--verify-fixture')) verifyFixture();
if (process.argv.includes('--print-revisions')) console.log(JSON.stringify(revisions));