ERP-node/frontend/hooks/usePersistedState.tsx

191 lines
5.4 KiB
TypeScript

"use client";
import {
useState,
useEffect,
useRef,
createContext,
useContext,
useMemo,
} from "react";
import type { Dispatch, SetStateAction, ReactNode } from "react";
// ─── TSP (Tab State Persistence) 중앙 관리 훅 ───
// 모든 컴포넌트의 UI 상태를 sessionStorage에 자동 보존/복원
// useState 대신 usePersistedState를 사용하면 탭 상태 보존이 자동 적용됨
const TSP_PREFIX = "tsp-";
// ─── Context ───
interface TSPContextValue {
screenId?: number;
componentId?: string;
}
const TSPContext = createContext<TSPContextValue>({});
export function TSPProvider({
screenId,
componentId,
children,
}: {
screenId?: number;
componentId?: string;
children: ReactNode;
}) {
const value = useMemo(
() => ({ screenId, componentId }),
[screenId, componentId],
);
return <TSPContext.Provider value={value}>{children}</TSPContext.Provider>;
}
// ─── 직렬화 (Set, Map, Date 지원) ───
function serialize(value: unknown): string {
return JSON.stringify(value, (_key, val) => {
if (val instanceof Set) return { __tsp: "Set", d: Array.from(val) };
if (val instanceof Map) return { __tsp: "Map", d: Array.from(val.entries()) };
if (val instanceof Date) return { __tsp: "Date", d: val.toISOString() };
return val;
});
}
function deserialize(raw: string): unknown {
return JSON.parse(raw, (_key, val) => {
if (val && typeof val === "object" && val.__tsp === "Set") return new Set(val.d);
if (val && typeof val === "object" && val.__tsp === "Map") return new Map(val.d);
if (val && typeof val === "object" && val.__tsp === "Date") return new Date(val.d);
return val;
});
}
// ─── 메인 훅 ───
interface PersistedStateOptions {
/** sessionStorage 저장 지연 시간 (기본 300ms) */
debounce?: number;
}
/**
* useState와 동일한 인터페이스로 탭 상태를 자동 보존하는 훅
*
* @param key - 상태 식별 키 (같은 컴포넌트 내에서 고유해야 함)
* @param defaultValue - 캐시가 없을 때 사용할 기본값
* @param options - debounce 등 옵션
*
* @example
* const [selectedRow, setSelectedRow] = usePersistedState('selectedRow', null);
* const [expanded, setExpanded] = usePersistedState('expanded', new Set<string>());
* const [scroll, setScroll] = usePersistedState('scrollTop', 0, { debounce: 100 });
*/
export function usePersistedState<T>(
key: string,
defaultValue: T,
options?: PersistedStateOptions,
): [T, Dispatch<SetStateAction<T>>] {
const { screenId, componentId } = useContext(TSPContext);
const debounceMs = options?.debounce ?? 300;
// tsp-{screenId}-{componentId}-{key}
const cacheKey =
screenId != null && componentId
? `${TSP_PREFIX}${screenId}-${componentId}-${key}`
: null;
// 안정적인 참조 (cacheKey가 렌더 중 변하지 않도록)
const cacheKeyRef = useRef(cacheKey);
cacheKeyRef.current = cacheKey;
const [state, setState] = useState<T>(() => {
if (!cacheKey || typeof window === "undefined") return defaultValue;
try {
const raw = sessionStorage.getItem(cacheKey);
if (raw !== null) return deserialize(raw) as T;
} catch {
/* 파싱 실패 시 기본값 사용 */
}
return defaultValue;
});
// debounce 저장
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isFirstRender = useRef(true);
const latestStateRef = useRef<T>(state);
latestStateRef.current = state;
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
const ck = cacheKeyRef.current;
if (!ck) return;
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
try {
sessionStorage.setItem(ck, serialize(state));
} catch {
/* 용량 초과 무시 */
}
timerRef.current = null;
}, debounceMs);
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [state, debounceMs]);
// unmount 시 미저장 상태 flush (탭 전환 중 데이터 유실 방지)
useEffect(() => {
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
const ck = cacheKeyRef.current;
if (ck) {
try { sessionStorage.setItem(ck, serialize(latestStateRef.current)); } catch {}
}
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return [state, setState];
}
// ─── 캐시 정리 유틸리티 ───
/**
* 특정 화면(또는 화면+컴포넌트)의 TSP 캐시를 일괄 삭제
*
* @param screenId - 화면 ID
* @param componentId - (선택) 특정 컴포넌트만 삭제할 때
*/
export function clearTSPCache(screenId: number, componentId?: string) {
const prefix = componentId
? `${TSP_PREFIX}${screenId}-${componentId}-`
: `${TSP_PREFIX}${screenId}-`;
const toRemove: string[] = [];
for (let i = 0; i < sessionStorage.length; i++) {
const k = sessionStorage.key(i);
if (k?.startsWith(prefix)) toRemove.push(k);
}
toRemove.forEach((k) => sessionStorage.removeItem(k));
}
/**
* 모든 TSP 캐시 삭제 (로그아웃 등)
*/
export function clearAllTSPCache() {
const toRemove: string[] = [];
for (let i = 0; i < sessionStorage.length; i++) {
const k = sessionStorage.key(i);
if (k?.startsWith(TSP_PREFIX)) toRemove.push(k);
}
toRemove.forEach((k) => sessionStorage.removeItem(k));
}