321 lines
12 KiB
JavaScript
321 lines
12 KiB
JavaScript
// Хук серверной постраничной загрузки свода 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 };
|
||
}
|