From 6deebf950789731b0efb6f2b040826907ca1e248 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 16 Oct 2026 10:16:36 +0500 Subject: [PATCH] =?UTF-8?q?=D0=A1=D1=82=D0=B0=D1=80=D0=B0=D1=8F=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BF=D0=B8=D1=81=D1=8C=20=D0=B2=20localStorage=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=D0=B2=D0=BE=D0=B4=D0=B8=D1=82=D1=81=D1=8F=20=D0=BA?= =?UTF-8?q?=20=D1=84=D0=BE=D1=80=D0=BC=D0=B5=20=D1=82=D0=B5=D0=BA=D1=83?= =?UTF-8?q?=D1=89=D0=B5=D0=B9=20=D1=84=D0=BE=D1=80=D0=BC=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раньше сохранённое значение только мёржилось с дефолтом: удалённые поля оставались в состоянии, а значение другого типа (или null вместо объекта) роняло рендер. coerceStored отбрасывает лишние ключи и заменяет значения не того типа дефолтом; покрыто тестами. Co-Authored-By: Claude Sonnet 5.5 --- .../hooks/useLocalStorageState.test.ts | 39 +++++++++++++++++++ .../hooks/useLocalStorageState.ts | 36 +++++++++++++---- 2 files changed, 68 insertions(+), 7 deletions(-) create mode 100644 frontend/src/webparts/approvalAdmin/hooks/useLocalStorageState.test.ts 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));