6deebf9507
Раньше сохранённое значение только мёржилось с дефолтом: удалённые поля оставались в состоянии, а значение другого типа (или null вместо объекта) роняло рендер. coerceStored отбрасывает лишние ключи и заменяет значения не того типа дефолтом; покрыто тестами. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
68 lines
3.3 KiB
TypeScript
68 lines
3.3 KiB
TypeScript
import * as React from 'react';
|
|
|
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
/**
|
|
* Сохранённое значение, приведённое к форме initial. Запись могла быть сделана
|
|
* старой версией формы, поэтому ей нельзя доверять слепо:
|
|
* - объект мёржится с initial — добавленные с тех пор поля получают дефолт;
|
|
* - ключи, которых в initial нет (поле удалили), отбрасываются;
|
|
* - значение, чей тип не совпал с initial (было число — стала строка, было
|
|
* [] — стал объект), заменяется дефолтом: иначе .map/.trim по нему уронил бы рендер;
|
|
* - null/undefined в initial типа не задают — там принимаем любое сохранённое.
|
|
*/
|
|
export function coerceStored<T>(initial: T, stored: unknown): T {
|
|
if (initial === null || initial === undefined) return stored as T;
|
|
if (Array.isArray(initial)) return (Array.isArray(stored) ? stored : initial) as unknown as T;
|
|
if (isPlainObject(initial)) {
|
|
if (!isPlainObject(stored)) return initial;
|
|
const result: Record<string, unknown> = { ...initial };
|
|
Object.keys(initial).forEach(k => {
|
|
if (k in stored) result[k] = coerceStored(initial[k], stored[k]);
|
|
});
|
|
return result as T;
|
|
}
|
|
return (typeof stored === typeof initial ? stored : initial) as T;
|
|
}
|
|
|
|
/**
|
|
* Значение из localStorage под key, приведённое к форме initial (см. coerceStored),
|
|
* или initial, если записи нет / она битая / localStorage недоступен.
|
|
*/
|
|
export function readLocalStorage<T>(key: string, initial: T): T {
|
|
try {
|
|
const raw = window.localStorage.getItem(key);
|
|
if (!raw) return initial;
|
|
|
|
return coerceStored(initial, JSON.parse(raw));
|
|
} catch {
|
|
return initial;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Как React.useState, но значение переживает перезагрузку страницы — хранится
|
|
* в localStorage под key. Падение доступа к localStorage (приватный режим,
|
|
* квота) не ломает форму — просто не персистит.
|
|
*
|
|
* Сохранённое значение приводится к форме initial (см. coerceStored): новое поле
|
|
* получает дефолт, удалённое отбрасывается, значение не того типа заменяется
|
|
* дефолтом — иначе старая запись в localStorage уронила бы рендер на первом же
|
|
* .map/.trim по этому полю.
|
|
*/
|
|
export function useLocalStorageState<T>(key: string, initial: T): [T, React.Dispatch<React.SetStateAction<T>>] {
|
|
const [value, setValue] = React.useState<T>(() => readLocalStorage(key, initial));
|
|
|
|
React.useEffect(() => {
|
|
try {
|
|
window.localStorage.setItem(key, JSON.stringify(value));
|
|
} catch {
|
|
// приватный режим / переполненная квота — не критично, просто не персистим
|
|
}
|
|
}, [key, value]);
|
|
|
|
return [value, setValue];
|
|
}
|