'use client'; import { useState, useCallback, useMemo, useEffect, useRef } from 'react'; import { useQuery, useQueryClient, type QueryKey } from '@tanstack/react-query'; import { useSearchParams, useRouter, usePathname } from 'next/navigation'; import { apiFetch } from '@/lib/api/client'; import type { PaginatedResponse } from '@/types/api'; import { serializeFiltersToParams, deserializeFiltersFromParams, type FilterDefinition, type FilterValues, } from '@/components/shared/filter-bar'; interface UsePaginatedQueryOptions { queryKey: QueryKey; endpoint: string; initialPage?: number; initialPageSize?: number; /** Default sort applied when the URL has no `sort` param. Lets a list * surface advertise its preferred ordering (e.g. interests → most- * recently-updated first) without forcing the rep to click a header * on each visit. The active sort still serializes to / from the URL, * so deep-links keep working. */ initialSort?: { field: string; direction: 'asc' | 'desc' }; filterDefinitions?: FilterDefinition[]; } export function usePaginatedQuery({ queryKey, endpoint, initialPage = 1, initialPageSize = 25, initialSort, filterDefinitions = [], }: UsePaginatedQueryOptions) { const searchParams = useSearchParams(); const router = useRouter(); const pathname = usePathname(); const queryClient = useQueryClient(); // Read initial state from URL const pageFromUrl = Number(searchParams.get('page')) || initialPage; const pageSizeFromUrl = Number(searchParams.get('limit')) || initialPageSize; const sortFieldFromUrl = searchParams.get('sort') ?? undefined; const sortOrderFromUrl = (searchParams.get('order') as 'asc' | 'desc') ?? 'desc'; const [page, setPageState] = useState(pageFromUrl); const [pageSize, setPageSizeState] = useState(pageSizeFromUrl); const [sort, setSortState] = useState<{ field: string; direction: 'asc' | 'desc' } | undefined>( sortFieldFromUrl ? { field: sortFieldFromUrl, direction: sortOrderFromUrl } : initialSort, ); const [filters, setFiltersState] = useState(() => deserializeFiltersFromParams(searchParams, filterDefinitions), ); // Canonical query string for a given state. Sorted keys so two URLs // that carry the same params in a different order compare equal - this // is what lets the back/forward resync effect (H14) tell "the URL the // browser just restored" apart from "the URL we last wrote ourselves" // without thrashing on key ordering. const buildCanonicalQs = useCallback( (p: number, ps: number, s?: typeof sort, f?: FilterValues): string => { const params = new URLSearchParams(); if (p !== 1) params.set('page', String(p)); if (ps !== initialPageSize) params.set('limit', String(ps)); if (s) { params.set('sort', s.field); params.set('order', s.direction); } if (f) { const filterParams = serializeFiltersToParams(f); filterParams.forEach((value, key) => params.set(key, value)); } // Keep existing tab param const tab = searchParams.get('tab'); if (tab) params.set('tab', tab); params.sort(); return params.toString(); }, [searchParams, initialPageSize], ); // Records the query string we last pushed via `router.replace`, so the // resync effect (H14) can skip params it itself authored and only react // to genuinely-external URL changes (Back/Forward). const lastWrittenQsRef = useRef(null); // Sync state to URL const syncUrl = useCallback( (p: number, ps: number, s?: typeof sort, f?: FilterValues) => { const qs = buildCanonicalQs(p, ps, s, f); lastWrittenQsRef.current = qs; // eslint-disable-next-line @typescript-eslint/no-explicit-any router.replace(`${pathname}${qs ? `?${qs}` : ''}` as any, { scroll: false }); }, [pathname, router, buildCanonicalQs], ); function setPage(p: number) { setPageState(p); syncUrl(p, pageSize, sort, filters); } function setPageSize(ps: number) { setPageSizeState(ps); setPageState(1); syncUrl(1, ps, sort, filters); } function setSort(field: string, direction: 'asc' | 'desc') { const newSort = { field, direction }; setSortState(newSort); setPageState(1); syncUrl(1, pageSize, newSort, filters); } function setFilter(key: string, value: unknown) { const newFilters = { ...filters }; if (value === undefined || value === null) { delete newFilters[key]; } else { newFilters[key] = value; } setFiltersState(newFilters); setPageState(1); syncUrl(1, pageSize, sort, newFilters); } function clearFilters() { setFiltersState({}); setPageState(1); syncUrl(1, pageSize, sort, {}); } /** * Atomically replace the entire filter set. Used by the saved-views * apply path - calling `clearFilters()` + N x `setFilter()` in a row * lost all but the last setFilter because each one reads the stale * `filters` closure and overwrites with `{...filters, key: val}`. * setAllFilters writes the whole object in one setState so the view * lands intact. */ function setAllFilters(next: FilterValues) { setFiltersState(next); setPageState(1); syncUrl(1, pageSize, sort, next); } /** * Atomically apply a saved view's filters AND sort in a single URL * write. The saved-views apply path previously called `setAllFilters` * only, silently dropping the view's stored sort (H15). Splitting the * write into `setAllFilters` + `setSort` would also race: each reads a * stale closure and the second `syncUrl` clobbers the first. `applyView` * sets both pieces of state and emits ONE `syncUrl`, so the view lands * intact with its sort. * * `sort` is optional - a view saved without an explicit sort clears the * active sort back to the list's `initialSort` (handled by the consumer * passing `undefined`). The incoming `direction` is the loose `string` * shape from `SavedViewsDropdown`; we narrow non-'asc' to 'desc'. */ function applyView({ filters: nextFilters, sort: nextSort, }: { filters: FilterValues; sort?: { field: string; direction: string } | undefined; }) { const normalizedSort: { field: string; direction: 'asc' | 'desc' } | undefined = nextSort ? { field: nextSort.field, direction: nextSort.direction === 'asc' ? ('asc' as const) : ('desc' as const), } : initialSort; setFiltersState(nextFilters); setSortState(normalizedSort); setPageState(1); syncUrl(1, pageSize, normalizedSort, nextFilters); } /** * H14 - resync state FROM the URL on external navigation (Back/Forward). * * The slices above seed from the URL once (useState initializers) then * drive it one-way via `syncUrl` -> `router.replace`. Nothing pulled the * URL back into state, so Back/Forward moved the address bar but left the * list showing the previous page/sort/filters. * * Loop safety: every write we make records its canonical query string in * `lastWrittenQsRef`. Here we re-derive the canonical form of the params * the router is currently handing us and compare it against that ref. If * they match, this render was caused by our OWN write - bail before * touching state. We also compare against the canonical form of CURRENT * state and only call a setter for a slice that actually differs, so even * an external change that happens to equal current state is a no-op. Both * guards mean this effect cannot feed itself: it never calls `syncUrl`, * and it never setState's a value equal to what state already holds. */ useEffect(() => { const incomingQs = buildCanonicalQs( Number(searchParams.get('page')) || initialPage, Number(searchParams.get('limit')) || initialPageSize, searchParams.get('sort') ? { field: searchParams.get('sort') as string, direction: (searchParams.get('order') as 'asc' | 'desc') ?? 'desc', } : initialSort, deserializeFiltersFromParams(searchParams, filterDefinitions), ); // This params object is the one we just wrote - not an external nav. if (incomingQs === lastWrittenQsRef.current) return; // The state already matches the URL - nothing to do (also prevents a // redundant setState that could schedule an extra render). const currentQs = buildCanonicalQs(page, pageSize, sort, filters); if (incomingQs === currentQs) return; // Genuine external change (Back/Forward): pull each slice from the URL, // setting only the ones that drifted. const nextPage = Number(searchParams.get('page')) || initialPage; const nextPageSize = Number(searchParams.get('limit')) || initialPageSize; const nextSortField = searchParams.get('sort'); const nextSort = nextSortField ? { field: nextSortField, direction: (searchParams.get('order') as 'asc' | 'desc') ?? 'desc', } : initialSort; const nextFilters = deserializeFiltersFromParams(searchParams, filterDefinitions); /* eslint-disable react-hooks/set-state-in-effect -- Deliberate, guarded URL->state sync on Back/Forward: the URL is the external source of truth here. The two early-returns above ensure this runs ONLY on a genuine external navigation (never on our own write), and each setState is diff-guarded, so there is no cascading-render loop (audit H14). This is the documented external-store-subscription case the rule otherwise blocks. */ if (nextPage !== page) setPageState(nextPage); if (nextPageSize !== pageSize) setPageSizeState(nextPageSize); if (nextSort?.field !== sort?.field || nextSort?.direction !== sort?.direction) { setSortState(nextSort); } if (JSON.stringify(nextFilters) !== JSON.stringify(filters)) { setFiltersState(nextFilters); } /* eslint-enable react-hooks/set-state-in-effect */ // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchParams]); // Build query string for API const apiParams = useMemo(() => { const params = new URLSearchParams(); params.set('page', String(page)); params.set('limit', String(pageSize)); if (sort) { params.set('sort', sort.field); params.set('order', sort.direction); } const filterParams = serializeFiltersToParams(filters); filterParams.forEach((value, key) => params.set(key, value)); return params.toString(); }, [page, pageSize, sort, filters]); const fullQueryKey = [...queryKey, apiParams]; // Endpoints that already carry a query string (e.g. `/api/v1/documents?tab=eoi_queue`) // need our pagination params merged with `&`, not a second `?`. Without this guard // the URL becomes `…?tab=foo?page=1` and the API rejects it as 400. const separator = endpoint.includes('?') ? '&' : '?'; const { data, isLoading, isFetching } = useQuery>({ queryKey: fullQueryKey, queryFn: () => apiFetch>(`${endpoint}${separator}${apiParams}`), }); const pagination = data?.pagination ? { page: data.pagination.page, pageSize: data.pagination.pageSize, total: data.pagination.total, totalPages: data.pagination.totalPages, } : { page, pageSize, total: 0, totalPages: 0 }; /** * Optimistically removes an item from the cached list (e.g., after archive). */ function optimisticRemove(id: string) { queryClient.setQueryData>(fullQueryKey, (old) => { if (!old) return old; return { ...old, data: old.data.filter((item) => (item as Record).id !== id), pagination: { ...old.pagination, total: old.pagination.total - 1, }, }; }); } return { data: data?.data ?? [], pagination, isLoading, isFetching, sort, setSort, page, setPage, pageSize, setPageSize, filters, setFilter, setAllFilters, applyView, clearFilters, optimisticRemove, }; }