Старая запись в localStorage приводится к форме текущей формы
Раньше сохранённое значение только мёржилось с дефолтом: удалённые поля оставались в состоянии, а значение другого типа (или null вместо объекта) роняло рендер. coerceStored отбрасывает лишние ключи и заменяет значения не того типа дефолтом; покрыто тестами. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,39 @@
|
|||||||
|
import { coerceStored } from './useLocalStorageState';
|
||||||
|
|
||||||
|
describe('coerceStored', () => {
|
||||||
|
const initial = { name: '', count: 0, flag: false, tags: [] as string[], nested: { a: '' } };
|
||||||
|
|
||||||
|
it('подставляет дефолт для полей, добавленных после сохранения', () => {
|
||||||
|
expect(coerceStored(initial, { name: 'x' })).toEqual({ ...initial, name: 'x' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('отбрасывает ключи, которых в форме больше нет', () => {
|
||||||
|
expect(coerceStored(initial, { name: 'x', removed: 1 })).not.toHaveProperty('removed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('заменяет значение другого типа дефолтом', () => {
|
||||||
|
expect(coerceStored(initial, { name: 5, tags: { a: 1 }, flag: 'yes', nested: 'oops' }))
|
||||||
|
.toEqual(initial);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('принимает массив нужного типа и мёржит вложенный объект', () => {
|
||||||
|
expect(coerceStored(initial, { tags: ['a'], nested: {} }))
|
||||||
|
.toEqual({ ...initial, tags: ['a'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('не падает на null и примитиве вместо объекта', () => {
|
||||||
|
expect(coerceStored(initial, null)).toEqual(initial);
|
||||||
|
expect(coerceStored(initial, 'x')).toEqual(initial);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('для примитивного initial проверяет тип', () => {
|
||||||
|
expect(coerceStored('', 5)).toBe('');
|
||||||
|
expect(coerceStored('', 'abc')).toBe('abc');
|
||||||
|
expect(coerceStored<number[]>([], [1, 2])).toEqual([1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('при initial === undefined принимает сохранённое как есть', () => {
|
||||||
|
expect(coerceStored<string | undefined>(undefined, 'a')).toBe('a');
|
||||||
|
expect(coerceStored({ x: undefined as string | undefined }, { x: 'a' })).toEqual({ x: 'a' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,7 +5,30 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Значение из localStorage под key, мелко смёрженное с initial (см. useLocalStorageState),
|
* Сохранённое значение, приведённое к форме 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 недоступен.
|
* или initial, если записи нет / она битая / localStorage недоступен.
|
||||||
*/
|
*/
|
||||||
export function readLocalStorage<T>(key: string, initial: T): T {
|
export function readLocalStorage<T>(key: string, initial: T): T {
|
||||||
@@ -13,8 +36,7 @@ export function readLocalStorage<T>(key: string, initial: T): T {
|
|||||||
const raw = window.localStorage.getItem(key);
|
const raw = window.localStorage.getItem(key);
|
||||||
if (!raw) return initial;
|
if (!raw) return initial;
|
||||||
|
|
||||||
const parsed = JSON.parse(raw) as T;
|
return coerceStored(initial, JSON.parse(raw));
|
||||||
return isPlainObject(initial) && isPlainObject(parsed) ? { ...initial, ...parsed } as T : parsed;
|
|
||||||
} catch {
|
} catch {
|
||||||
return initial;
|
return initial;
|
||||||
}
|
}
|
||||||
@@ -25,10 +47,10 @@ export function readLocalStorage<T>(key: string, initial: T): T {
|
|||||||
* в localStorage под key. Падение доступа к localStorage (приватный режим,
|
* в localStorage под key. Падение доступа к localStorage (приватный режим,
|
||||||
* квота) не ломает форму — просто не персистит.
|
* квота) не ломает форму — просто не персистит.
|
||||||
*
|
*
|
||||||
* Сохранённое значение мелко мёржится с initial (для объектных T): если после
|
* Сохранённое значение приводится к форме initial (см. coerceStored): новое поле
|
||||||
* обновления в форму добавили новое поле, у пользователей со старой записью в
|
* получает дефолт, удалённое отбрасывается, значение не того типа заменяется
|
||||||
* localStorage оно не потеряется как undefined (что уронило бы рендер на
|
* дефолтом — иначе старая запись в localStorage уронила бы рендер на первом же
|
||||||
* первом же .map/.trim по этому полю), а подставится дефолт из initial.
|
* .map/.trim по этому полю.
|
||||||
*/
|
*/
|
||||||
export function useLocalStorageState<T>(key: string, initial: T): [T, React.Dispatch<React.SetStateAction<T>>] {
|
export function useLocalStorageState<T>(key: string, initial: T): [T, React.Dispatch<React.SetStateAction<T>>] {
|
||||||
const [value, setValue] = React.useState<T>(() => readLocalStorage(key, initial));
|
const [value, setValue] = React.useState<T>(() => readLocalStorage(key, initial));
|
||||||
|
|||||||
Reference in New Issue
Block a user