Перейти к содержанию

Состояние между открытиями

Форма возвращает нормализованный выбор, но этого недостаточно для управления экраном. Появились минимум три состояния: подтверждённый сервером план, текущий draft и снимок находящейся в пути операции. В этом уроке разделим их и договоримся о поведении при ответе, возврате и новом открытии. Результат — полный небольшой модуль модели, который не содержит DOM, токена или собственного HTTP-сервера.

Представим, что серверный план содержит JavaScript и три занятия, version 4. Пользователь выбирает Markdown и два занятия, затем нажимает сохранение. Пока ответ идёт, он меняет частоту на четыре. Сервер подтвердит отправленное число два. Если интерфейс присвоит полученный объект одновременно base и draft, более новый ввод исчезнет. Если он объявит всю форму сохранённой без сравнения, на экране окажется ложный результат. Нужны отдельные владельцы данных.

Base, draft и pending

Base означает последний принятый подтверждённый ответ, draft — текущий выбор формы, pending — снимок отправленных значений и исходной версии. Dirty сравнивает содержимое draft и base, а не возраст события input. Токен сессии относится к транспорту и не помещается в эту модель. При смене владельца создаётся новый экземпляр состояния; персональный base предыдущего человека не должен появиться в новой сессии.

Ниже полный plan-state.js. Он ожидает уже проверенный API-клиентом план и доменный ввод из формы. Поэтому не повторяет все проверки HTTP-схемы внутри каждой функции. Модуль позволяет только одну незавершённую запись одновременно. Для примера выбрана возможность продолжать редактирование во время запроса: draft меняется, а pending сохраняет первоначальный снимок. Это независимый модуль серии, не незаметно добавленная логика в базовый читающий screen.js.

const copy = value => ({courseId: value.courseId,
  sessionsPerWeek: value.sessionsPerWeek});
const same = (a, b) => a.courseId === b.courseId &&
  a.sessionsPerWeek === b.sessionsPerWeek;

export function createPlanState(plan) {
  let base = {...plan};
  let draft = copy(plan);
  let pending = null;
  let readGeneration = 0;
  return {
    snapshot() {
      return {base: {...base}, draft: {...draft},
        pending: pending ? {...pending} : null, dirty: !same(base, draft)};
    },
    edit(value) { draft = copy(value); readGeneration++; },
    begin(operationId) {
      if (pending) throw new Error('Write already pending');
      pending = {...copy(draft), expectedVersion: base.version, operationId};
      readGeneration++;
      return {...pending};
    },
    confirm(operationId, result) {
      if (!pending || pending.operationId !== operationId ||
          result.version <= pending.expectedVersion || !same(result, pending)) {
        throw new Error('Confirmation mismatch');
      }
      base = {...result};
      pending = null;
      readGeneration++;
    },
    reject(operationId) {
      if (!pending || pending.operationId !== operationId) return false;
      pending = null;
      readGeneration++;
      return true;
    },
    beginRead() { return ++readGeneration; },
    acceptRead(generation, result) {
      if (generation !== readGeneration || pending || !same(base, draft) ||
          result.version < base.version) return false;
      base = {...result};
      draft = copy(result);
      return true;
    }
  };
}

Confirm не присваивает draft результату. Если пользователь ничего не менял после begin, draft совпадает с полученным base и dirty становится false. Если частота изменилась на четыре, base получит подтверждённые два, а draft останется четыре: dirty true. Ответ должен содержать тот же сохранённый выбор; несовпадение означает нарушение контракта, а не повод тихо подменить поля. Сервер не нормализует частоту к другому значению без отдельного договора.

Отказ и неизвестный итог

Reject применяется к определённому отказу операции, например отклонённому запросу или конфликту версии. Он освобождает pending, но не очищает draft. При write_unconfirmed pending сохраняется: нельзя начинать независимую повторную запись, забыв о неизвестном результате предыдущей. Позднее процесс восстановления повторит тот же идентификатор по серверному договору либо получит подтверждённый итог. У модуля нет метода «ошибка означает не записалось», потому что такая связь неверна.

На конфликте 409 человеку нужно увидеть актуальный серверный выбор и свой draft рядом. AcceptRead специально не заменяет dirty автоматически. Контроллер получает отдельный result и предлагает оставить свой выбор, принять серверный либо сформировать новую осознанную запись с новой базой. Полный интерфейс слияния не поставляется в этой небольшой модели. Ошибка была бы в том, чтобы принудительно обнулить dirty перед acceptRead и тем самым скрыть потерю вводимых данных.

Позднее чтение

ReadGeneration помогает отбрасывать ответ GET, который уже не соответствует жизненному циклу формы. BeginRead выдаёт номер, edit или begin увеличивает его. Если старый ответ приходит после нового действия, acceptRead возвращает false. Это клиентский признак актуальности, не серверная version и не механизм отмены запроса. Даже если fetch был отменён, полезно отдельно проверять актуальность результата: контроллер мог получить ответ из другой операции или заново созданного экрана.

Два GET без редактирования также имеют порядок. Последний beginRead делает предыдущий номер устаревшим. Серверная version не должна идти назад, поэтому result с меньшей version отвергается. Если после этого требуется показать конфликт и сравнение, контроллер использует результат отдельно, не превращая его в безусловную команду перерисовать весь draft. Такой предел ответственности делает асинхронный код понятнее: API возвращает факт, модель решает, можно ли его принять.

У модели есть и ограничение: копии snapshot являются обычными объектами для чтения контроллером, а не глубоким реактивным хранилищем. Контроллер не должен изменять полученный pending и ожидать, что этим изменил внутреннюю операцию. Все изменения проходят через edit, begin, confirm или reject. Такой договор достаточно прост для нашего небольшого плана; если поля станут вложенными, потребуется отдельно определить копирование и сравнение. Расширять объект произвольным массивом уроков без обновления same нельзя: тогда dirty перестанет отражать настоящие изменения.

Возврат и новое открытие

Внутренний переход course → catalog не пересоздаёт модель автоматически. Навигационный модуль меняет route; сохранённый отдельно draft остаётся доступным. Полное закрытие страницы уничтожает память. При следующем открытии сначала подтверждается новый запуск и читается серверный план. Нельзя показывать сохранившийся в локальном хранилище draft как уже подтверждённую запись. Источник, время и версия восстановления должны быть видны программе и человеку.

У одного origin могут открываться разные Telegram-аккаунты. Поэтому персональные данные нельзя считать принадлежащими устройству вообще. После выхода или смены сессии предыдущая модель уничтожается, pending не переносится к другому owner. Несекретная настройка размера текста может быть общей, но учебный план — пользовательский. В этой серии пока нет постоянного draft storage: модель честно сохраняет данные только в текущем экземпляре приложения.

Проверяем смысл на примере

Ожидаемая последовательность: base JavaScript/3/version4; draft Markdown/2; begin с одним UUID; edit Markdown/4; confirm Markdown/2/version5. После неё base подтверждён, draft отличается, pending отсутствует. Второй сценарий — старый GET после edit не меняет draft. Третий — write_unconfirmed оставляет pending для сверки и не сообщает человеку «выбор отменён». Это именно ожидаемые сценарии чтения кода; JavaScript и сервер здесь не выполнялись.

Модель не обеспечивает конкурентность базы и не выдаёт право изменять чужой ресурс. Атомарная version и привязка operationId к владельцу остаются серверным контрактом уроков одиннадцать и семнадцать. Жизненный цикл SDK также описан отдельно в справочнике Telegram: закрытие WebView не является подтверждением нашей записи. Следующий урок выберет безопасные пользовательские настройки для хранения и объяснит, почему токен и base не нужно отправлять в localStorage заодно с цветом интерфейса.

Оглавление серии