Автосохранение черновиков

hooks/use-form-draft.ts — debounce-автосохранение состояния формы в localStorage. Защищает от потери работы при ребуте браузера или случайном закрытии вкладки. Применяется там, где форма долгая: рекап хроники, текст ноды, многострочный контент.


Зачем не «создать ноду сразу»

Альтернатива — немедленно создавать строку в БД и редактировать её. Но это порождает мусорные незавершённые строки при каждом Cancel или брошенной вкладке, требует статусных полей, GC, правок RLS. Для реальной проблемы — «потерял работу из-за ребута» — это оверкил. localStorage-черновик решает её без записи в БД.


Контракт хука

useFormDraft<T>({
  key: string | null,      // ключ в localStorage; null = хук no-op
  value: T,                // текущее значение формы, снапшотится при каждом изменении
  onRestore?: (v: T) => void, // колбэк: применить восстановленный черновик в стейт формы
  isEmpty?: (v: T) => boolean, // predicate: не сохранять пустую форму (иначе пустота перетрёт черновик)
  enabled?: boolean,       // false = хук заморожен; использовать пока форма ещё гидрируется
  debounceMs?: number,     // задержка (по умолчанию 600 мс)
})

Возвращает { pendingDraft, lastSavedAt, restoreDraft, discardDraft, clearDraft }.

  • pendingDraft — объект { value: T, savedAt: string } или null. Если не null — форма показывает янтарный баннер «найден несохранённый черновик от…».
  • restoreDraft — вызвать при нажатии «Восстановить»: пишет в родительский стейт через onRestore, снимает pendingDraft.
  • discardDraft и clearDraft — обе удаляют запись из хранилища и сбрасывают pendingDraft. Семантически одно и то же: discardDraft — отказ пользователя, clearDraft — успешное сохранение формы (вызывается после записи в БД, чтобы следующий визит не предлагал восстановить уже отправленное).

Жизненный цикл

Форма открывается
  │
  ├─ enabled + key → хук читает localStorage [ОДИН РАЗ на (enabled, key)]
  │     ├─ есть непустой черновик → pendingDraft ≠ null → баннер
  │     └─ нет / пустой / corrupt → localStorage.removeItem, pendingDraft = null
  │
  ├─ pendingDraft ≠ null → автосохранение ЗАБЛОКИРОВАНО
  │     (иначе пустая форма молча перетрёт старый черновик)
  │
  └─ pendingDraft = null → каждый onChange запускает таймер 600 мс
        ├─ isEmpty(value) = true → removeItem (пустую не сохраняем)
        └─ value изменился → setItem(key, JSON.stringify({ value, savedAt }))

dirty → save → wipe: пользователь вводит текст → через 600 мс снапшот уходит в localStorage → после успешной отправки формы вызов clearDraft() убирает запись.

cancel → wipe: пользователь нажимает «Отмена» → компонент вызывает discardDraft() → запись удаляется.

restore → resume: пользователь нажимает «Восстановить» → restoreDraft()onRestore(draft.value) → родительский стейт обновляется → автосохранение возобновляется от восстановленного значения.


Pristine-state predicate

Без isEmpty хук писал бы в localStorage каждый раз, в том числе начальное пустое состояние формы при монтировании. Это уничтожало бы черновик до того, как пользователь успел решить, что с ним делать.

isEmpty должен возвращать true для «формы без пользовательского ввода» — обычно проверка trimmed строк и длин массивов. Когда isEmpty(value) = true, хук делает removeItem вместо setItem.


Где подключён

  • components/create-node-form.tsx — создание ноды (название, тип, контент)
  • components/markdown-content.tsx — редактирование node_content (большие текстовые поля)
  • components/chronicles.tsx — ChronicleForm (рекапы сессий)

TECH-021: useSyncExternalStore

Текущая реализация читает localStorage через useEffect с setState внутри — lint-warning react-hooks/set-state-in-effect подавлен намеренно. Правильная замена — useSyncExternalStore, но localStorage не генерирует события между вкладками в нашем use-case, а JSON.parse возвращает новый объект при каждом вызове — это требует ручной мемоизации снапшота. Рефактор отслежен в backlog.md как TECH-021. До его реализации текущий подход работает корректно.

См. также: features/chronicles/README.md.