perf(web): свод 2 — refine по условиям данных, измерение высоты вне загрузки, окно по поколению

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-29 00:16:41 +04:00
parent d74f5286f9
commit fbc3601673
4 changed files with 44 additions and 19 deletions

View File

@ -382,7 +382,7 @@ export default function Svod2Page() {
}, },
muiTableContainerProps: { sx: SVOD2_TABLE_CONTAINER_SX, onScroll }, muiTableContainerProps: { sx: SVOD2_TABLE_CONTAINER_SX, onScroll },
muiTableBodyRowProps: ({ row }) => muiTableBodyRowProps: ({ row }) =>
isPlaceholderRow(row.original) ? { className: 'svod2-placeholder', 'data-svod2-placeholder': '' } : {}, isPlaceholderRow(row.original) ? { className: 'svod2-placeholder', 'data-svod2-placeholder': '', style: { height: rowHeight } } : {},
muiTableHeadProps: { muiTableHeadProps: {
sx: { sx: {
'& tr:not(:last-of-type) > th > .MuiCollapse-root': { '& tr:not(:last-of-type) > th > .MuiCollapse-root': {
@ -400,9 +400,14 @@ export default function Svod2Page() {
}); });
tableRef.current = table; tableRef.current = table;
// Единая высота строки для окна страниц: реальная высота первой строки с данными (по умолчанию ROW_HEIGHT). // Единая высота строки для окна страниц: реальная высота первой строки с данными (по умолчанию ROW_HEIGHT).
// Не измеряем во время загрузки (MRT рисует скелетон другой высоты); берём максимум по строкам с данными.
useLayoutEffect(() => { useLayoutEffect(() => {
const element = tableRef.current?.refs.tableContainerRef.current?.querySelector('tbody tr:not([data-svod2-placeholder])'); if (loading) return;
const measured = element ? Math.round(element.offsetHeight) : 0; const elements = tableRef.current?.refs.tableContainerRef.current?.querySelectorAll(
'tbody tr[data-index]:not([data-svod2-placeholder])',
);
let measured = 0;
for (const element of elements ?? []) measured = Math.max(measured, Math.round(element.offsetHeight));
if (measured > 0 && measured !== rowHeight) setRowHeight(measured); if (measured > 0 && measured !== rowHeight) setRowHeight(measured);
}); });
let content = <MaterialReactTable table={table} />; let content = <MaterialReactTable table={table} />;

View File

@ -104,8 +104,9 @@ export function shouldShowFound({ hasCurrentData, loading, facets, error = '' })
// Нужно ли уточнение (refine) при смене поиска/колоночных фильтров: на загруженных данных // Нужно ли уточнение (refine) при смене поиска/колоночных фильтров: на загруженных данных
// либо пока для текущего queryKey ещё летит первая загрузка (иначе ответы станут «текущими» по старым условиям). // либо пока для текущего queryKey ещё летит первая загрузка (иначе ответы станут «текущими» по старым условиям).
export function shouldRefine({ hasCurrentData, loading, pendingQueryKey, queryKey, prevRefineKey, refineKey }) { // dataRefineKey — условия, на которых построены загруженные (или летящие) данные вкладки; нет их — нет и refine.
if (prevRefineKey === refineKey) return false; export function shouldRefine({ hasCurrentData, loading, pendingQueryKey, queryKey, dataRefineKey, refineKey }) {
if (dataRefineKey === undefined || dataRefineKey === null || dataRefineKey === refineKey) return false;
if (hasCurrentData) return true; if (hasCurrentData) return true;
return Boolean(loading) && pendingQueryKey === queryKey; return Boolean(loading) && pendingQueryKey === queryKey;
} }

View File

@ -285,13 +285,25 @@ test('«Найдено» не показывается рядом с ошибк
assert.equal(shouldShowFound({ hasCurrentData: true, loading: false, error: 'x', facets: { total: 5, search_total: 1 } }), false); assert.equal(shouldShowFound({ hasCurrentData: true, loading: false, error: 'x', facets: { total: 5, search_total: 1 } }), false);
}); });
test('shouldRefine: на загруженных данных при смене условий', () => { test('shouldRefine: на загруженных данных при смене условий', () => {
const base = { hasCurrentData: true, loading: false, pendingQueryKey: 'q', queryKey: 'q', prevRefineKey: 'a', refineKey: 'b' }; const base = { hasCurrentData: true, loading: false, pendingQueryKey: 'q', queryKey: 'q', dataRefineKey: 'a', refineKey: 'b' };
assert.equal(shouldRefine(base), true); assert.equal(shouldRefine(base), true);
assert.equal(shouldRefine({ ...base, refineKey: 'a' }), false); assert.equal(shouldRefine({ ...base, refineKey: 'a' }), false);
}); });
test('shouldRefine: поиск изменён во время первой загрузки (T12.7)', () => { test('shouldRefine: поиск изменён во время первой загрузки (T12.7)', () => {
const base = { hasCurrentData: false, loading: true, pendingQueryKey: 'q', queryKey: 'q', prevRefineKey: 'a', refineKey: 'b' }; const base = { hasCurrentData: false, loading: true, pendingQueryKey: 'q', queryKey: 'q', dataRefineKey: 'a', refineKey: 'b' };
assert.equal(shouldRefine(base), true); assert.equal(shouldRefine(base), true);
assert.equal(shouldRefine({ ...base, pendingQueryKey: 'old' }), false); assert.equal(shouldRefine({ ...base, pendingQueryKey: 'old' }), false);
assert.equal(shouldRefine({ ...base, loading: false }), false); assert.equal(shouldRefine({ ...base, loading: false }), false);
}); });
test('shouldRefine: ключ A -> B -> A при изменённых фильтрах', () => {
// данные ключа A построены на фильтре 'a'; на B фильтры сброшены (''), возврат на A — данные устарели
const onA = { hasCurrentData: true, loading: false, pendingQueryKey: 'A', queryKey: 'A', dataRefineKey: 'a', refineKey: '' };
assert.equal(shouldRefine(onA), true);
const onB = { ...onA, hasCurrentData: false, queryKey: 'B' };
assert.equal(shouldRefine(onB), false);
});
test('shouldRefine: первое монтаж и смена вкладки без расхождения не запускают refine', () => {
const base = { hasCurrentData: false, loading: false, pendingQueryKey: '', queryKey: 'q', refineKey: 'a' };
assert.equal(shouldRefine({ ...base, dataRefineKey: undefined }), false);
assert.equal(shouldRefine({ ...base, hasCurrentData: true, dataRefineKey: 'a' }), false);
});

View File

@ -34,6 +34,8 @@ const getRawPage = (scrollTop, rowHeight) => {
return Number.isFinite(page) && page > 0 ? page : 0; 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; 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 }) { export function useSvod2Pages({ sheet, year, orgIds, queryKey, search, columnFilters, rowHeight, getScrollTop }) {
@ -101,6 +103,7 @@ export function useSvod2Pages({ sheet, year, orgIds, queryKey, search, columnFil
controller: new AbortController(), controller: new AbortController(),
inflight: new Set(), inflight: new Set(),
ready: false, ready: false,
refineKey: makeRefineKey(current.search, current.columnFilters),
}; };
ctxRef.current[sheetKey] = ctx; ctxRef.current[sheetKey] = ctx;
const candidate = getRawPage(current.getScrollTop(), current.rowHeight); const candidate = getRawPage(current.getScrollTop(), current.rowHeight);
@ -194,7 +197,11 @@ export function useSvod2Pages({ sheet, year, orgIds, queryKey, search, columnFil
if (!ctx || !ctx.ready || ctx.controller.signal.aborted) return; if (!ctx || !ctx.ready || ctx.controller.signal.aborted) return;
if (sheetState.generation !== ctx.generation || sheetState.loadedQueryKey !== current.queryKey) return; if (sheetState.generation !== ctx.generation || sheetState.loadedQueryKey !== current.queryKey) return;
const pages = getWindowFor(sheetState.total); const pages = getWindowFor(sheetState.total);
for (const pageIndex of getMissingPages(pages, sheetState.pages, ctx.inflight)) { for (const pageIndex of getMissingPages(
pages,
sheetState.dataGeneration === ctx.generation ? sheetState.pages : new Map(),
ctx.inflight,
)) {
fetchPage(ctx, pageIndex) fetchPage(ctx, pageIndex)
.then((result) => { .then((result) => {
if (ctx.controller.signal.aborted) return; if (ctx.controller.signal.aborted) return;
@ -226,28 +233,28 @@ export function useSvod2Pages({ sheet, year, orgIds, queryKey, search, columnFil
// Смена поиска или колоночных фильтров на загруженных данных — уточнение (refine) теми же запросами. // Смена поиска или колоночных фильтров на загруженных данных — уточнение (refine) теми же запросами.
const sheetState = state[sheet]; const sheetState = state[sheet];
const hasCurrentData = sheetState.loadedQueryKey === queryKey; const hasCurrentData = sheetState.loadedQueryKey === queryKey;
const refineKey = JSON.stringify([search, buildSvod2QueryParams({ columnFilters })]); const refineKey = makeRefineKey(search, columnFilters);
const previousRef = useRef({ sheet, refineKey }); // Условия, на которых построены данные вкладки (или летящего поколения), хранятся в ctx: сравнение с ними, а не с прошлым рендером.
// biome-ignore lint/correctness/useExhaustiveDependencies: реагируем только на смену поиска/фильтров и вкладки // biome-ignore lint/correctness/useExhaustiveDependencies: start и поля sheetState читаются из актуального рендера
useEffect(() => { useEffect(() => {
const previous = previousRef.current;
previousRef.current = { sheet, refineKey };
if (previous.sheet !== sheet) {
syncWindow();
return;
}
if ( if (
shouldRefine({ shouldRefine({
hasCurrentData, hasCurrentData,
loading: sheetState.loading, loading: sheetState.loading,
pendingQueryKey: sheetState.pendingQueryKey, pendingQueryKey: sheetState.pendingQueryKey,
queryKey, queryKey,
prevRefineKey: previous.refineKey, dataRefineKey: ctxRef.current[sheet]?.refineKey,
refineKey, refineKey,
}) })
) )
start('refine'); start('refine');
}, [sheet, refineKey]); }, [sheet, refineKey, hasCurrentData, queryKey, sheetState.loading, sheetState.pendingQueryKey]);
// Смена вкладки или высоты строки — пересчёт окна страниц.
// biome-ignore lint/correctness/useExhaustiveDependencies: syncWindow читает актуальные props из ref
useEffect(() => {
syncWindow();
}, [sheet, rowHeight]);
useEffect(() => { useEffect(() => {
mountedRef.current = true; mountedRef.current = true;