"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({}); export function TSPProvider({ screenId, componentId, children, }: { screenId?: number; componentId?: string; children: ReactNode; }) { const value = useMemo( () => ({ screenId, componentId }), [screenId, componentId], ); return {children}; } // ─── 직렬화 (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()); * const [scroll, setScroll] = usePersistedState('scrollTop', 0, { debounce: 100 }); */ export function usePersistedState( key: string, defaultValue: T, options?: PersistedStateOptions, ): [T, Dispatch>] { 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(() => { 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 | null>(null); const isFirstRender = useRef(true); const latestStateRef = useRef(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)); }