Автосохранение черновиков
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.