191 lines
5.4 KiB
TypeScript
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));
|
|
}
|