// Хук серверной постраничной загрузки свода 2: поколения запросов, окно страниц, догрузка при прокрутке. import axios from 'axios'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { toast } from 'react-toastify'; import { SummaryApi } from '../../api/summary'; import { PAGE_SIZE, buildSvod2PageParams, buildSvod2QueryParams, buildWindowRows, createSheetPagingState, facetsToMaps, getMissingPages, getPageCount, getVirtualizerPadding, getVisiblePage, getWindowPages, getWindowRange, svod2PagingReducer, } from './svod2Paging'; const SHEET_KEYS = ['GO', 'RF']; const LOAD_ERROR_FALLBACK = 'Не удалось загрузить свод. Попробуйте ещё раз.'; const SCROLL_ERROR_MESSAGE = 'Не удалось загрузить строки свода. Прокрутите ещё раз, чтобы повторить.'; const NO_PADDING = { paddingStart: 0, paddingEnd: 0 }; const createInitialState = () => Object.fromEntries(SHEET_KEYS.map((key) => [key, createSheetPagingState()])); const rootReducer = (state, action) => ({ ...state, [action.sheet]: svod2PagingReducer(state[action.sheet], action) }); const getRawPage = (scrollTop, rowHeight) => { const page = Math.floor(scrollTop / rowHeight / PAGE_SIZE); return Number.isFinite(page) && page > 0 ? page : 0; }; const isCanceled = (error, ctx) => axios.isCancel(error) || error?.code === 'ERR_CANCELED' || ctx.controller.signal.aborted; export function useSvod2Pages({ sheet, year, orgIds, queryKey, search, columnFilters, rowHeight, getScrollTop }) { // Состояние хранится в ref синхронно (редьюсер вызывается в dispatch), чтобы логика догрузки видела актуальные страницы. const stateRef = useRef(null); if (stateRef.current === null) stateRef.current = createInitialState(); const [state, setState] = useState(stateRef.current); const [scrollPage, setScrollPage] = useState(0); const generationRef = useRef(Object.fromEntries(SHEET_KEYS.map((key) => [key, 0]))); const ctxRef = useRef({}); const rafRef = useRef(0); const mountedRef = useRef(true); const propsRef = useRef(null); propsRef.current = { sheet, year, orgIds, queryKey, search, columnFilters, rowHeight, getScrollTop }; const dispatch = useCallback((action) => { const next = rootReducer(stateRef.current, action); if (next === stateRef.current) return; stateRef.current = next; if (mountedRef.current) setState(next); }, []); const fetchPage = useCallback(async (ctx, pageIndex) => { ctx.inflight.add(pageIndex); try { const response = await SummaryApi.getSvod2Page( ctx.sheet, ctx.year, buildSvod2PageParams({ orgIds: ctx.orgIds, search: ctx.search, columnFilters: ctx.columnFilters, offset: pageIndex * PAGE_SIZE, }), { signal: ctx.controller.signal }, ); const rows = response.result || []; return { rows, total: response.total ?? rows.length }; } finally { ctx.inflight.delete(pageIndex); } }, []); const getWindowFor = useCallback((total) => { const { rowHeight: height, getScrollTop: readScrollTop } = propsRef.current; const visible = getVisiblePage({ scrollTop: readScrollTop(), rowHeight: height, total }); return getWindowPages(visible, total); }, []); const start = useCallback( (mode) => { const current = propsRef.current; const sheetKey = current.sheet; ctxRef.current[sheetKey]?.controller.abort(); const generation = generationRef.current[sheetKey] + 1; generationRef.current[sheetKey] = generation; const ctx = { sheet: sheetKey, year: current.year, orgIds: current.orgIds, search: current.search, columnFilters: current.columnFilters, generation, controller: new AbortController(), inflight: new Set(), ready: false, }; ctxRef.current[sheetKey] = ctx; const candidate = getRawPage(current.getScrollTop(), current.rowHeight); setScrollPage(candidate); dispatch({ type: 'begin', sheet: sheetKey, generation, queryKey: current.queryKey, mode: mode === 'refine' ? 'refine' : 'load' }); let failed = false; const fail = (error) => { if (isCanceled(error, ctx) || failed) return; failed = true; dispatch({ type: 'fail', sheet: sheetKey, generation, mode: mode === 'refine' ? 'refine' : 'load', message: error?.response?.data?.detail || LOAD_ERROR_FALLBACK, }); ctx.controller.abort(); }; const loadFacets = async () => { try { const response = await SummaryApi.getSvod2Facets(ctx.sheet, ctx.year, buildSvod2QueryParams(ctx), { signal: ctx.controller.signal, }); if (ctx.controller.signal.aborted) return; dispatch({ type: 'facets', sheet: sheetKey, generation, facets: response.result }); } catch (error) { fail(error); } }; const loadPages = async () => { try { const first = await fetchPage(ctx, candidate); if (ctx.controller.signal.aborted) return; const { total } = first; const pages = getWindowFor(total); const pageCount = getPageCount(total); let cache = new Map(); if (total === 0 || candidate < pageCount) { const pageIndex = total === 0 ? 0 : candidate; dispatch({ type: 'page', sheet: sheetKey, generation, pageIndex, rows: first.rows, total, keepPages: pages }); cache = stateRef.current[sheetKey].pages; } ctx.ready = true; const missing = getMissingPages(pages, cache, ctx.inflight); await Promise.all( missing.map(async (pageIndex) => { try { const result = await fetchPage(ctx, pageIndex); if (ctx.controller.signal.aborted) return; dispatch({ type: 'page', sheet: sheetKey, generation, pageIndex, rows: result.rows, total: result.total, keepPages: getWindowFor(result.total), }); } catch (error) { fail(error); } }), ); } catch (error) { fail(error); } }; (async () => { try { await Promise.all([loadFacets(), loadPages()]); } finally { dispatch({ type: 'settled', sheet: sheetKey, generation }); } })(); }, [dispatch, fetchPage, getWindowFor], ); const load = useCallback(() => start('load'), [start]); // Догрузка недостающих страниц окна текущей вкладки в режиме scroll. const syncWindow = useCallback(() => { const current = propsRef.current; setScrollPage(getRawPage(current.getScrollTop(), current.rowHeight)); const ctx = ctxRef.current[current.sheet]; const sheetState = stateRef.current[current.sheet]; if (!ctx || !ctx.ready || ctx.controller.signal.aborted) return; if (sheetState.generation !== ctx.generation || sheetState.loadedQueryKey !== current.queryKey) return; const pages = getWindowFor(sheetState.total); for (const pageIndex of getMissingPages(pages, sheetState.pages, ctx.inflight)) { fetchPage(ctx, pageIndex) .then((result) => { if (ctx.controller.signal.aborted) return; dispatch({ type: 'page', sheet: ctx.sheet, generation: ctx.generation, pageIndex, rows: result.rows, total: result.total, keepPages: getWindowFor(result.total), }); }) .catch((error) => { if (isCanceled(error, ctx)) return; toast.error(SCROLL_ERROR_MESSAGE, { toastId: 'svod2-scroll-error' }); }); } }, [dispatch, fetchPage, getWindowFor]); const onScroll = useCallback(() => { if (rafRef.current) return; rafRef.current = requestAnimationFrame(() => { rafRef.current = 0; if (mountedRef.current) syncWindow(); }); }, [syncWindow]); // Смена поиска или колоночных фильтров на загруженных данных — уточнение (refine) теми же запросами. const sheetState = state[sheet]; const hasCurrentData = sheetState.loadedQueryKey === queryKey; const refineKey = JSON.stringify([search, buildSvod2QueryParams({ columnFilters })]); const previousRef = useRef({ sheet, refineKey }); // biome-ignore lint/correctness/useExhaustiveDependencies: реагируем только на смену поиска/фильтров и вкладки useEffect(() => { const previous = previousRef.current; previousRef.current = { sheet, refineKey }; if (previous.sheet !== sheet) { syncWindow(); return; } if (previous.refineKey !== refineKey && hasCurrentData) start('refine'); }, [sheet, refineKey]); useEffect(() => { mountedRef.current = true; const contexts = ctxRef.current; return () => { mountedRef.current = false; if (rafRef.current) cancelAnimationFrame(rafRef.current); rafRef.current = 0; for (const ctx of Object.values(contexts)) ctx.controller.abort(); }; }, []); const visiblePage = getVisiblePage({ scrollTop: scrollPage * PAGE_SIZE * rowHeight, rowHeight, total: sheetState.total }); const windowPages = useMemo(() => getWindowPages(visiblePage, sheetState.total), [visiblePage, sheetState.total]); const windowRows = useMemo( () => buildWindowRows(windowPages, sheetState.pages, sheetState.total), [windowPages, sheetState.pages, sheetState.total], ); const padding = useMemo(() => { if (!hasCurrentData) return NO_PADDING; const { windowStart, windowEnd } = getWindowRange(windowPages, sheetState.total); return getVirtualizerPadding({ windowStart, windowEnd, total: sheetState.total, rowHeight }); }, [hasCurrentData, windowPages, sheetState.total, rowHeight]); const facetsMap = useMemo(() => facetsToMaps(sheetState.facets), [sheetState.facets]); return { state: sheetState, windowRows, padding, facetsMap, load, onScroll }; }