// Хук серверной постраничной загрузки свода 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, getCurrentPages, getMissingPages, getPageCount, getVirtualizerPadding, getVisiblePage, getWindowPages, getWindowRange, shouldFetchFirstPageFallback, shouldRefine, 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 makeRefineKey = (search, columnFilters) => JSON.stringify([search, buildSvod2QueryParams({ columnFilters })]); 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, signal = ctx.controller.signal) => { 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 }, ); 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, refineKey: makeRefineKey(current.search, current.columnFilters), }; 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 { // Страница по прокрутке и (на глубине) запасная offset=0 летят параллельно: если страница по прокрутке // за концом нового списка, сервер отвечает на неё долго и без строк, а страница 0 уже даёт total. const candidateController = new AbortController(); const zeroController = new AbortController(); const abortRequests = () => { candidateController.abort(); zeroController.abort(); }; ctx.controller.signal.addEventListener('abort', abortRequests); const settle = (request, index) => request.then( (result) => ({ result, index }), (error) => ({ error, index }), ); const candidateRequest = settle(fetchPage(ctx, candidate, candidateController.signal), candidate); const zeroRequest = candidate > 0 ? settle(fetchPage(ctx, 0, zeroController.signal), 0) : null; let answer = zeroRequest ? await Promise.race([candidateRequest, zeroRequest]) : await candidateRequest; if ( answer.index === 0 && candidate > 0 && !answer.error && shouldFetchFirstPageFallback({ candidate, total: answer.result.total }) ) { candidateController.abort(); } else { zeroController.abort(); answer = await candidateRequest; if (answer.error) throw answer.error; } if (ctx.controller.signal.aborted) return; const first = answer.result; const { total } = first; const pages = getWindowFor(total); const pageCount = getPageCount(total); let cache = new Map(); if (total === 0 || answer.index < pageCount) { const pageIndex = total === 0 ? 0 : answer.index; 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.dataGeneration === ctx.generation ? sheetState.pages : new Map(), 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 = makeRefineKey(search, columnFilters); // Условия, на которых построены данные вкладки (или летящего поколения), хранятся в ctx: сравнение с ними, а не с прошлым рендером. // biome-ignore lint/correctness/useExhaustiveDependencies: start и поля sheetState читаются из актуального рендера useEffect(() => { if ( shouldRefine({ hasCurrentData, loading: sheetState.loading, pendingQueryKey: sheetState.pendingQueryKey, queryKey, dataRefineKey: ctxRef.current[sheet]?.refineKey, refineKey, }) ) start('refine'); }, [sheet, refineKey, hasCurrentData, queryKey, sheetState.loading, sheetState.pendingQueryKey]); // Смена вкладки или высоты строки — пересчёт окна страниц. // biome-ignore lint/correctness/useExhaustiveDependencies: syncWindow читает актуальные props из ref useEffect(() => { syncWindow(); }, [sheet, rowHeight]); 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, getCurrentPages({ pages: sheetState.pages, dataGeneration: sheetState.dataGeneration, generation: sheetState.generation }), sheetState.total, ), [windowPages, sheetState.pages, sheetState.dataGeneration, sheetState.generation, 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 }; }