Старая запись в localStorage приводится к форме текущей формы
Tests / frontend-test (pull_request) Successful in 1m32s
Tests / backend-test (pull_request) Successful in 2m13s

Раньше сохранённое значение только мёржилось с дефолтом: удалённые поля
оставались в состоянии, а значение другого типа (или null вместо
объекта) роняло рендер. coerceStored отбрасывает лишние ключи и заменяет
значения не того типа дефолтом; покрыто тестами.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
gorbenko.pavel
2026-10-16 10:16:36 +05:00
parent 872686e855
commit 6deebf9507
2 changed files with 68 additions and 7 deletions
@@ -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 недоступен.
*/
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);
if (!raw) return initial;
const parsed = JSON.parse(raw) as T;
return isPlainObject(initial) && isPlainObject(parsed) ? { ...initial, ...parsed } as T : parsed;
return coerceStored(initial, JSON.parse(raw));
} catch {
return initial;
}
@@ -25,10 +47,10 @@ export function readLocalStorage<T>(key: string, initial: T): T {
* в localStorage под key. Падение доступа к localStorage (приватный режим,
* квота) не ломает форму — просто не персистит.
*
* Сохранённое значение мелко мёржится с initial (для объектных T): если после
* обновления в форму добавили новое поле, у пользователей со старой записью в
* localStorage оно не потеряется как undefined (что уронило бы рендер на
* первом же .map/.trim по этому полю), а подставится дефолт из initial.
* Сохранённое значение приводится к форме 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));