diff --git a/frontend/src/webparts/approvalAdmin/hooks/useLocalStorageState.test.ts b/frontend/src/webparts/approvalAdmin/hooks/useLocalStorageState.test.ts new file mode 100644 index 0000000..3c27a8c --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/hooks/useLocalStorageState.test.ts @@ -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([], [1, 2])).toEqual([1, 2]); + }); + + it('при initial === undefined принимает сохранённое как есть', () => { + expect(coerceStored(undefined, 'a')).toBe('a'); + expect(coerceStored({ x: undefined as string | undefined }, { x: 'a' })).toEqual({ x: 'a' }); + }); +}); diff --git a/frontend/src/webparts/approvalAdmin/hooks/useLocalStorageState.ts b/frontend/src/webparts/approvalAdmin/hooks/useLocalStorageState.ts index b24785b..8524115 100644 --- a/frontend/src/webparts/approvalAdmin/hooks/useLocalStorageState.ts +++ b/frontend/src/webparts/approvalAdmin/hooks/useLocalStorageState.ts @@ -5,7 +5,30 @@ function isPlainObject(value: unknown): value is Record { } /** - * Значение из localStorage под key, мелко смёрженное с initial (см. useLocalStorageState), + * Сохранённое значение, приведённое к форме initial. Запись могла быть сделана + * старой версией формы, поэтому ей нельзя доверять слепо: + * - объект мёржится с initial — добавленные с тех пор поля получают дефолт; + * - ключи, которых в initial нет (поле удалили), отбрасываются; + * - значение, чей тип не совпал с initial (было число — стала строка, было + * [] — стал объект), заменяется дефолтом: иначе .map/.trim по нему уронил бы рендер; + * - null/undefined в initial типа не задают — там принимаем любое сохранённое. + */ +export function coerceStored(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 = { ...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(key: string, initial: T): T { @@ -13,8 +36,7 @@ export function readLocalStorage(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(key: string, initial: T): T { * в localStorage под key. Падение доступа к localStorage (приватный режим, * квота) не ломает форму — просто не персистит. * - * Сохранённое значение мелко мёржится с initial (для объектных T): если после - * обновления в форму добавили новое поле, у пользователей со старой записью в - * localStorage оно не потеряется как undefined (что уронило бы рендер на - * первом же .map/.trim по этому полю), а подставится дефолт из initial. + * Сохранённое значение приводится к форме initial (см. coerceStored): новое поле + * получает дефолт, удалённое отбрасывается, значение не того типа заменяется + * дефолтом — иначе старая запись в localStorage уронила бы рендер на первом же + * .map/.trim по этому полю. */ export function useLocalStorageState(key: string, initial: T): [T, React.Dispatch>] { const [value, setValue] = React.useState(() => readLocalStorage(key, initial));