"use client"; import * as React from "react"; import type { DataTableFilterField, ExtendedSortingState, } from "@/types/table"; import { getCoreRowModel, getFacetedRowModel, getFacetedUniqueValues, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, getGroupedRowModel, getExpandedRowModel, useReactTable, type ColumnFiltersState, type PaginationState, type RowSelectionState, type SortingState, type TableOptions, type TableState, type Updater, type VisibilityState, type ExpandedState, } from "@tanstack/react-table"; import { parseAsArrayOf, parseAsInteger, parseAsString, useQueryState, useQueryStates, type Parser, type UseQueryStateOptions, } from "nuqs"; import useSWRInfinite from "swr/infinite"; import { getSortingStateParser } from "@/lib/parsers"; import { useDebouncedCallback } from "@/hooks/use-debounced-callback"; import isEqual from "fast-deep-equal"; import deepEqual from "fast-deep-equal"; // ─────────────────────────────────────────────────────────────────────── // 무한 스크롤 관련 상수 및 타입 // ─────────────────────────────────────────────────────────────────────── const INFINITE_SCROLL_THRESHOLD = 1_000_000; interface InfiniteScrollConfig { apiEndpoint: string; tableName: string; maxPageSize?: number; } interface InfiniteScrollResponse { mode: "infinite"; data: TData[]; hasNextPage: boolean; nextCursor: string | null; total?: number | null; } // ─────────────────────────────────────────────────────────────────────── // Hook props // ─────────────────────────────────────────────────────────────────────── export interface UseDataTableProps extends Omit< TableOptions, | "state" | "pageCount" | "getCoreRowModel" | "manualFiltering" | "manualPagination" | "manualSorting" | "onGroupingChange" | "onExpandedChange" | "getExpandedRowModel" | "data" >, Required, "pageCount">> { filterFields?: DataTableFilterField[]; enableAdvancedFilter?: boolean; history?: "push" | "replace"; scroll?: boolean; shallow?: boolean; throttleMs?: number; debounceMs?: number; startTransition?: React.TransitionStartFunction; clearOnDefault?: boolean; initialState?: Omit, "sorting"> & { sorting?: ExtendedSortingState; grouping?: string[]; expanded?: Record; }; data?: TData[]; infiniteScrollConfig?: InfiniteScrollConfig; onStateToUrl?: (queryString: string) => void; } // ─────────────────────────────────────────────────────────────────────── // useDataTable // ─────────────────────────────────────────────────────────────────────── export function useDataTable({ pageCount = -1, filterFields = [], enableAdvancedFilter = false, history = "replace", scroll = false, shallow = true, throttleMs = 50, debounceMs = 300, clearOnDefault = false, startTransition, initialState, data: initialData = [], infiniteScrollConfig, onStateToUrl, ...props }: UseDataTableProps) { // ✅ 수정 1: queryStateOptions를 안정적으로 메모이제이션 const queryStateOptions = React.useMemo< Omit, "parse"> >(() => ({ history, scroll, shallow, throttleMs, debounceMs, clearOnDefault, startTransition, }), [ history, scroll, shallow, throttleMs, debounceMs, clearOnDefault, // startTransition 제거 - 함수 참조가 불안정할 수 있음 ]); // ✅ 수정 2: 초기 상태들을 안정적으로 처리 const stableInitialState = React.useMemo(() => initialState || {}, [initialState]); const [rowSelection, setRowSelection] = React.useState( () => stableInitialState?.rowSelection ?? {} ); const [columnVisibility, setColumnVisibility] = React.useState( () => stableInitialState?.columnVisibility ?? {} ); const [expanded, setExpanded] = React.useState({}); // ───────────────────────── URL ↔️ Pagination 동기화 ─────────────────── const defaultPageSize = React.useMemo(() => stableInitialState?.pagination?.pageSize ?? 10, [stableInitialState?.pagination?.pageSize] ); const [page, setPage] = useQueryState( "page", parseAsInteger.withOptions(queryStateOptions).withDefault(1), ); const [perPage, setPerPage] = useQueryState( "perPage", parseAsInteger.withOptions(queryStateOptions).withDefault(defaultPageSize), ); const isInfiniteMode = React.useMemo(() => !!(infiniteScrollConfig && perPage >= INFINITE_SCROLL_THRESHOLD), [infiniteScrollConfig, perPage] ); // ───────────────────────── URL ↔️ Sorting 동기화 ───────────────────── const defaultSorting = React.useMemo(() => stableInitialState?.sorting ?? [], [stableInitialState?.sorting] ); const [sorting, setSorting] = useQueryState( "sort", getSortingStateParser() .withOptions(queryStateOptions) .withDefault(defaultSorting), ); // ───────────────────────── URL ↔️ 기타 파라미터 ────────────────────── const [filters, setFilters] = useQueryState( "filters", parseAsString.withOptions(queryStateOptions).withDefault("[]"), ); const [joinOperator, setJoinOperator] = useQueryState( "joinOperator", parseAsString.withOptions(queryStateOptions).withDefault("and"), ); const [search, setSearch] = useQueryState( "search", parseAsString.withOptions(queryStateOptions).withDefault(""), ); const defaultGrouping = React.useMemo(() => stableInitialState?.grouping ?? [], [stableInitialState?.grouping] ); const [grouping, setGrouping] = useQueryState( "group", parseAsArrayOf(parseAsString, ",") .withOptions(queryStateOptions) .withDefault(defaultGrouping), ); // ───────────────────────── Pagination helper ───────────────────────── const pagination: PaginationState = React.useMemo(() => ({ pageIndex: page - 1, pageSize: perPage, }), [page, perPage]); // ✅ 수정 3: toQueryString을 안정적으로 메모이제이션 const toQueryString = React.useCallback( (pg: PaginationState, sort: SortingState) => { const p = new URLSearchParams(); if (pg.pageIndex > 0) p.set("page", String(pg.pageIndex + 1)); if (pg.pageSize !== defaultPageSize) p.set("perPage", String(pg.pageSize)); if (sort.length) p.set("sort", JSON.stringify(sort)); return p.toString(); }, [defaultPageSize], // perPage 대신 defaultPageSize 사용 ); // ✅ 수정 4: 페이징 변경 핸들러 최적화 const handlePageSizeChange = React.useCallback( (newPageSize: number) => { void setPerPage(newPageSize); const wasInfinite = perPage >= INFINITE_SCROLL_THRESHOLD; const willBeInfinite = newPageSize >= INFINITE_SCROLL_THRESHOLD; if (wasInfinite !== willBeInfinite) { void setPage(1); } }, [perPage, setPerPage, setPage], ); const onPaginationChange = React.useCallback((updater: Updater) => { if (isInfiniteMode) return; const next = typeof updater === "function" ? updater(pagination) : updater; if (onStateToUrl) { const qs = toQueryString(next, sorting); if (qs !== window.location.search.slice(1)) onStateToUrl(qs); } void setPage(next.pageIndex + 1); if (next.pageSize !== perPage) handlePageSizeChange(next.pageSize); }, [isInfiniteMode, pagination, sorting, toQueryString, onStateToUrl, setPage, perPage, handlePageSizeChange]); // ───────────────────────── Sorting 변경 ────────────────────────────── const onSortingChange = React.useCallback((updater: Updater) => { const next = typeof updater === "function" ? updater(sorting) : updater; if (onStateToUrl) { const qs = toQueryString(pagination, next); if (qs !== window.location.search.slice(1)) onStateToUrl(qs); } if (!isEqual(next, sorting)) { void setSorting(next as ExtendedSortingState); } }, [sorting, pagination, toQueryString, onStateToUrl, setSorting]); // ✅ 수정 5: SWR 관련 값들을 안정적으로 메모이제이션 const parsedFilters = React.useMemo(() => { try { return JSON.parse(filters) } catch { return [] } }, [filters]); const sortForSWR = React.useMemo(() => sorting.map(sort => ({ id: sort.id, desc: sort.desc })), [sorting] ); const effectivePageSize = React.useMemo(() => { if (!isInfiniteMode) return perPage const maxSize = infiniteScrollConfig?.maxPageSize || 100 return Math.min(50, maxSize) }, [isInfiniteMode, perPage, infiniteScrollConfig?.maxPageSize]); // ✅ 수정 6: getKey 함수의 의존성 최적화 const swrKeyParams = React.useMemo(() => ({ endpoint: infiniteScrollConfig?.apiEndpoint, pageSize: effectivePageSize, search: search || undefined, filters: parsedFilters.length ? JSON.stringify(parsedFilters) : undefined, joinOperator: joinOperator !== "and" ? joinOperator : undefined, sort: sortForSWR.length ? JSON.stringify(sortForSWR) : undefined, }), [ infiniteScrollConfig?.apiEndpoint, effectivePageSize, search, parsedFilters.length, parsedFilters, joinOperator, sortForSWR.length, sortForSWR ]); const getKey = React.useCallback( (pageIndex: number, previousPageData: InfiniteScrollResponse | null) => { if (!isInfiniteMode || !swrKeyParams.endpoint) return null const params = new URLSearchParams() if (pageIndex === 0) { params.set("limit", String(swrKeyParams.pageSize)) if (swrKeyParams.search) params.set("search", swrKeyParams.search) if (swrKeyParams.filters) params.set("filters", swrKeyParams.filters) if (swrKeyParams.joinOperator) params.set("joinOperator", swrKeyParams.joinOperator) if (swrKeyParams.sort) params.set("sort", swrKeyParams.sort) return `${swrKeyParams.endpoint}?${params.toString()}` } if (!previousPageData || !previousPageData.hasNextPage) return null params.set("cursor", previousPageData.nextCursor || "") params.set("limit", String(swrKeyParams.pageSize)) if (swrKeyParams.search) params.set("search", swrKeyParams.search) if (swrKeyParams.filters) params.set("filters", swrKeyParams.filters) if (swrKeyParams.joinOperator) params.set("joinOperator", swrKeyParams.joinOperator) if (swrKeyParams.sort) params.set("sort", swrKeyParams.sort) return `${swrKeyParams.endpoint}?${params.toString()}` }, [isInfiniteMode, swrKeyParams] ); // SWR Infinite 사용 const { data: swrData, error: swrError, isLoading: swrIsLoading, isValidating: swrIsValidating, mutate: swrMutate, size: swrSize, setSize: swrSetSize, } = useSWRInfinite>( getKey, async (url: string) => { const response = await fetch(url) if (!response.ok) throw new Error(`HTTP ${response.status}`) return response.json() }, { revalidateFirstPage: false, revalidateOnFocus: false, revalidateOnReconnect: true, } ); // ✅ 수정 7: 무한 스크롤 데이터와 액션들을 안정적으로 메모이제이션 const infiniteData = React.useMemo(() => { if (!isInfiniteMode || !swrData) return [] return swrData.flatMap(page => page.data) }, [swrData, isInfiniteMode]); const infiniteScrollActions = React.useMemo(() => ({ loadMore: () => { if (swrData && swrData[swrData.length - 1]?.hasNextPage && !swrIsValidating) { swrSetSize(prev => prev + 1) } }, reset: () => { swrSetSize(1) swrMutate() }, refresh: () => swrMutate(), }), [swrData, swrIsValidating, swrSetSize, swrMutate]); const infiniteMeta = React.useMemo(() => { if (!isInfiniteMode || !infiniteScrollConfig) return null const totalCount = swrData?.[0]?.total ?? null const hasNextPage = swrData?.[swrData.length - 1]?.hasNextPage ?? false const isLoadingMore = swrIsValidating && swrData && typeof swrData[swrSize - 1] !== "undefined" return { enabled: true, totalCount, hasNextPage, isLoadingMore, onLoadMore: infiniteScrollActions.loadMore, reset: infiniteScrollActions.reset, refresh: infiniteScrollActions.refresh, error: swrError, isLoading: swrIsLoading, isEmpty: swrData?.[0]?.data.length === 0, } }, [ isInfiniteMode, infiniteScrollConfig, swrData, swrIsValidating, swrSize, swrError, swrIsLoading, infiniteScrollActions ]); // ✅ 수정 8: 리셋 함수 최적화 const resetInfiniteScrollRef = React.useRef<() => void>() resetInfiniteScrollRef.current = () => { if (isInfiniteMode && infiniteScrollActions) { infiniteScrollActions.reset() } } const resetInfiniteScroll = React.useCallback(() => { resetInfiniteScrollRef.current?.() }, []) // 필터 변경 시 리셋 React.useEffect(() => { resetInfiniteScroll() }, [search, filters, joinOperator, resetInfiniteScroll]); // 최종 데이터 결정 const finalData = React.useMemo(() => isInfiniteMode ? infiniteData : initialData, [isInfiniteMode, infiniteData, initialData] ); const onGroupingChange = React.useCallback((updaterOrValue: Updater) => { if (typeof updaterOrValue === "function") { const newGrouping = updaterOrValue(grouping) void setGrouping(newGrouping) } else { void setGrouping(updaterOrValue) } }, [grouping, setGrouping]); const onExpandedChange = React.useCallback((updater: Updater) => { setExpanded((old) => (typeof updater === "function" ? updater(old) : updater)) }, []); // ✅ 수정 9: filterParsers 안정화 const filterParsers = React.useMemo(() => { return filterFields.reduce< Record | Parser> >((acc, field) => { if (field.options) { acc[field.id] = parseAsArrayOf(parseAsString, ",").withOptions( queryStateOptions ) } else { acc[field.id] = parseAsString.withOptions(queryStateOptions) } return acc }, {}) }, [filterFields, queryStateOptions]); const [filterValues, setFilterValues] = useQueryStates(filterParsers) const debouncedSetFilterValues = useDebouncedCallback( setFilterValues, debounceMs ) const initialColumnFilters: ColumnFiltersState = React.useMemo(() => { return enableAdvancedFilter ? [] : Object.entries(filterValues).reduce( (filters, [key, value]) => { if (value !== null) { filters.push({ id: key, value: Array.isArray(value) ? value : [value], }) } return filters }, [] ) }, [filterValues, enableAdvancedFilter]) const [columnFilters, setColumnFilters] = React.useState(initialColumnFilters) const { searchableColumns, filterableColumns } = React.useMemo(() => { return enableAdvancedFilter ? { searchableColumns: [], filterableColumns: [] } : { searchableColumns: filterFields.filter((field) => !field.options), filterableColumns: filterFields.filter((field) => field.options), } }, [filterFields, enableAdvancedFilter]) // ✅ 수정 10: onColumnFiltersChange 최적화 (deepEqual 의존성 제거) const onColumnFiltersChange = React.useCallback( (updater: Updater) => { setColumnFilters(prev => { const next = typeof updater === "function" ? updater(prev) : updater // deepEqual을 직접 호출 (의존성에서 제거) if (deepEqual(prev, next)) return prev if (!enableAdvancedFilter) { const updates: Record = {} next.forEach(f => { if (searchableColumns.some(c => c.id === f.id)) updates[f.id] = f.value as string else if (filterableColumns.some(c => c.id === f.id)) updates[f.id] = f.value as string[] }) prev.forEach(pf => { if (!next.some(nf => nf.id === pf.id)) updates[pf.id] = null }) void setPage(1) debouncedSetFilterValues(updates) } return next }) }, [ enableAdvancedFilter, searchableColumns, filterableColumns, debouncedSetFilterValues, setPage, ] ) // ✅ 수정 11: 테이블 설정을 안정적으로 메모이제이션 const tableState = React.useMemo(() => ({ pagination, sorting, columnVisibility, rowSelection, columnFilters: enableAdvancedFilter ? [] : columnFilters, grouping, expanded, }), [ pagination, sorting, columnVisibility, rowSelection, enableAdvancedFilter, columnFilters, grouping, expanded, ]) const table = useReactTable({ ...props, data: finalData, initialState: stableInitialState, pageCount: isInfiniteMode ? -1 : pageCount, state: tableState, columnResizeMode: "onChange", onRowSelectionChange: setRowSelection, onPaginationChange, onSortingChange, onColumnFiltersChange, onColumnVisibilityChange: setColumnVisibility, onGroupingChange, onExpandedChange, getCoreRowModel: getCoreRowModel(), getFilteredRowModel: enableAdvancedFilter ? undefined : getFilteredRowModel(), getPaginationRowModel: isInfiniteMode ? undefined : getPaginationRowModel(), getSortedRowModel: getSortedRowModel(), getGroupedRowModel: getGroupedRowModel(), getExpandedRowModel: getExpandedRowModel(), getFacetedRowModel: enableAdvancedFilter ? undefined : getFacetedRowModel(), getFacetedUniqueValues: enableAdvancedFilter ? undefined : getFacetedUniqueValues(), manualPagination: true, manualSorting: true, manualFiltering: true, }) // ✅ 수정 12: 반환값 안정화 const urlState = React.useMemo(() => ({ search, setSearch, filters: parsedFilters, setFilters: (newFilters: any[]) => setFilters(JSON.stringify(newFilters)), joinOperator, setJoinOperator, }), [search, setSearch, parsedFilters, setFilters, joinOperator, setJoinOperator]) return React.useMemo(() => ({ table, infiniteScroll: infiniteMeta, isInfiniteMode, effectivePageSize, handlePageSizeChange, urlState, }), [ table, infiniteMeta, isInfiniteMode, effectivePageSize, handlePageSizeChange, urlState, ]) }