На плохой сети кнопка «Сохранить» легко врёт. Пользователь нажал её, экран уже показал зелёное сообщение, но приложение не получило подтверждения. При следующем открытии формы черновик исчезает; при повторном нажатии команда отправляет вторую операцию. Цена ошибки не в сером индикаторе: человек не знает, можно ли закрыть страницу, нужно ли повторять действие и какая версия текста теперь считается сохранённой.
В июле 2022 года я бы начал не с offline-first лозунга и не с эмулятора сети. Сначала нужен видимый контракт одного действия: черновик отдельно от подтверждённого результата, явное состояние сети как вход интерфейса, ключ логического запроса и ключ конкретной попытки. Ниже — учебная in-memory fixture. Она не вызывает Fetch, не запускает service worker и не получает ответ сервера. Поэтому её состояния offline и unknown-outcome — проектные labels, а не отчёт о реальной сети.
Четыре состояния, которые нельзя склеивать
У отправки есть как минимум четыре разных факта. Первый: текст черновика сохранён локально в выбранном хранилище. Второй: интерфейс сейчас считает сеть недоступной или доступной. Третий: конкретная попытка ожидает прикладное подтверждение. Четвёртый: серверное подтверждение с идентификатором предметной операции действительно принято клиентской логикой. Если заменить эти факты одним boolean isSaved, UI обязательно назовёт один из них другим.
| Состояние интерфейса | Что известно | Что нельзя утверждать | Действие |
|---|---|---|---|
| Черновик готов | есть сохранённая локальная копия и её версия | что сервер видел текст | дать редактировать и показать дату/версию локальной копии |
| Ожидаем подтверждение | создан request key и одна attempt key | что операция уже завершилась | не показывать success; защитить повторный клик |
| Результат неизвестен | для попытки нет прикладного acknowledgement | что операция не дошла до сервера | сохранить черновик, дать повторить или перейти к восстановлению |
| Подтверждено | получен payload с request key, версией и submission id | что более новый черновик можно удалить | очистить только подтверждённую версию, новый draft оставить |
Почему «запрос упал» не равно «ничего не произошло»
Fetch Standard описывает request, response и network error, но у прикладного экрана другая задача: он должен решить, что показать человеку без домысла о чужой стороне. Между началом операции и сообщением UI могли существовать промежуточные границы, которых экран не видит. Поэтому здесь не используется фраза «сервер точно не сохранил». Вместо неё есть более узкое и проверяемое условие: текущая модель не получила acknowledgement, связанный с текущими requestKey и attemptKey.
Это продолжает июньский разбор ошибок формы. Там важно было не перезаписать исправленное поле поздней ошибкой. Здесь важно не стереть текст и не объявить победу поздним или отсутствующим подтверждением. В обоих случаях источник проблемы — два разных состояния получают одно имя «сохранено». Версия черновика, попытка доставки и признанный результат должны жить рядом, но не заменять друг друга.
Видимый текст должен следовать этой модели. «Черновик сохранён на устройстве» и «изменение подтверждено» — разные сообщения с разными условиями показа. Первое можно показать после успешного шага persistence выбранного приложения, второе — только после принятого acknowledgement. Если данные для первого сообщения ещё не записаны, интерфейс обязан показать это как отдельную ошибку, а не использовать сетевой значок вместо результата.
Учебная fixture: ключ запроса стабилен, ключ попытки меняется
В модели пользователь пишет черновик версии 1. Для него создаётся requestKey: draft-v1. Первая попытка получает attempt-01. Если модель не получает acknowledgement, она показывает result-unknown и сохраняет черновик как источник восстановления. После явно объявленного возвращения в online разрешён один retry: он сохраняет тот же request key, но использует attempt-02. Это защищает от параллельного повтора и позволяет отличить поздний ответ первой попытки от ответа второй.
# Запускается только in-memory учебная модель.
node web/scripts/upgrade-2022-07.mjs --verify-fixture
# PASS fixture: 15/15 assertions
# Нет Fetch, HTTP, service worker, DOM, localStorage, таймера или trace.
Пятнадцать assertions проверяют именно эту границу. Они подтверждают, что fixture не вызывает Fetch и не создаёт browser API; offline блокирует планирование новой попытки, но не удаляет persisted draft; retry не меняет logical request key; stale acknowledgement не коммитит состояние. Такой тест не доказывает доступность сети и не заменяет интеграционный сценарий. Он делает явными правила UI до того, как эти правила разъедутся между click handler, storage и toast.
Маршрут: симптом → причина → проверка → действие
- Симптом. После нажатия Save интерфейс быстро показывает успех, повторяет запрос при повторном клике либо открывается с пустым текстом после offline.
- Причина. Черновик, транспортная попытка и подтверждённая операция записываются в одно поле состояния. Отсутствие ответа трактуется как failure или success без отдельного правила.
- Проверка данных. Выпишите отдельно draft version, persisted draft, request key, attempt key, phase и acknowledgement payload. Если одно поле отвечает за два пункта, найдите владельца каждого перехода.
- Проверка неизвестного исхода. Смоделируйте ветку без acknowledgement. В ней не должно быть success и не должен исчезать persisted draft. В реальном проекте эту ветку затем проверяют отдельным контролируемым сценарием, а не выдают модель за trace.
- Действие. До acknowledgement показывайте «результат пока неизвестен», отключайте параллельный retry и разрешайте один осознанный повтор с тем же logical request key.
- Восстановление. Дайте безопасный путь оставить черновик для проверки. Автоматически очищайте только версию, которую признал payload; более новый текст не принадлежит старой попытке.
Где хранить черновик — отдельное решение
Fixture называет persistence in-memory-copy-only. Это намеренное ограничение: переменная в учебной модели переживает только её выполнение, а не перезагрузку, вкладки или сбой процесса. Реальное приложение должно отдельно выбрать хранилище, срок жизни, шифрование, правила очистки и поведение при нескольких вкладках. Service Workers CRD от 12 июля 2022 года показывает, что сервисный worker — событийный контекст с собственным жизненным циклом; это не основание предполагать, что он всегда запущен или уже спас конкретный черновик.
Полезно начать с малого контракта: «после каждого изменения черновик либо сохранён вместе с версией, либо UI честно показывает, что локальной копии нет». Потом добавляется реальное хранилище и отдельный тест его ошибок. Не стоит прятать это решение под названием offline cache. Для текста профиля и для перевода денег различаются срок хранения, чувствительность данных, право на повтор и даже допустимое сообщение на экране.
Ограничение и следующий проверяемый шаг
Эта модель не выбирает HTTP-метод, не реализует idempotency endpoint, не выполняет request, не хранит данные после рестарта и не решает конфликт двух устройств. RFC 9110 говорит о свойствах методов, но прикладной requestKey остаётся вашим контрактом: он должен быть понятен серверу и связан с предметной операцией, если она допускает повтор. Нельзя перенести строку draft-v1 из fixture в production как готовый механизм защиты.
Следующий шаг — взять одну настоящую форму и написать рядом с ней таблицу из четырёх состояний. Затем добавить локальный тест: редактирование создаёт новую version; отсутствие acknowledgement сохраняет черновик; поздний ответ не перезаписывает новый текст. После этого команда может провести отдельный browser и network сценарий с названными версиями среды. Его результаты будут новым артефактом, а не обещанием этой статьи.
Историческая граница июля 2022
Здесь использованы неизменяемые источники, доступные к июлю 2022 года: Fetch Review Draft от 19 декабря 2021 года, Service Workers Candidate Recommendation Draft от 12 июля 2022 года и RFC 9110 июня 2022 года. Ни один из них не используется как доказательство конкретного offline-прогона. Все ключи, версии, acknowledgement и видимые тексты — данные учебной модели пакета.
Проверяемые источники
- WHATWG Fetch Standard, Review Draft от 19 декабря 2021 года — датированный первичный снимок Fetch Standard. Он задаёт модель request, response и network error; он не превращает отсутствие application acknowledgement в доказательство, что серверное действие не произошло.
- W3C Service Workers, Candidate Recommendation Draft от 12 июля 2022 года — неизменяемая версия периода: service worker событийный и может быть остановлен user agent. В этой партии service worker намеренно не создаётся; документ используется только как историческая граница платформы.
- RFC 9110: HTTP Semantics, июнь 2022 года — нормативный RFC, доступный к июлю 2022 года. Он различает свойства HTTP-методов и не заменяет прикладной ключ запроса или подтверждение предметной операции.