From fbc3601673ec0fbbbdfc8bdf5a8338218721112b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 29 Oct 2026 00:16:41 +0400 Subject: [PATCH] =?UTF-8?q?perf(web):=20=D1=81=D0=B2=D0=BE=D0=B4=202=20?= =?UTF-8?q?=E2=80=94=20refine=20=D0=BF=D0=BE=20=D1=83=D1=81=D0=BB=D0=BE?= =?UTF-8?q?=D0=B2=D0=B8=D1=8F=D0=BC=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85?= =?UTF-8?q?,=20=D0=B8=D0=B7=D0=BC=D0=B5=D1=80=D0=B5=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=B2=D1=8B=D1=81=D0=BE=D1=82=D1=8B=20=D0=B2=D0=BD=D0=B5=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B8,=20=D0=BE?= =?UTF-8?q?=D0=BA=D0=BD=D0=BE=20=D0=BF=D0=BE=20=D0=BF=D0=BE=D0=BA=D0=BE?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/src/pages/Svod2Page/Svod2Page.jsx | 11 ++++++-- web/src/pages/Svod2Page/svod2Paging.js | 5 ++-- web/src/pages/Svod2Page/svod2Paging.test.js | 16 +++++++++-- web/src/pages/Svod2Page/useSvod2Pages.js | 31 +++++++++++++-------- 4 files changed, 44 insertions(+), 19 deletions(-) diff --git a/web/src/pages/Svod2Page/Svod2Page.jsx b/web/src/pages/Svod2Page/Svod2Page.jsx index b2144d2..b739c59 100644 --- a/web/src/pages/Svod2Page/Svod2Page.jsx +++ b/web/src/pages/Svod2Page/Svod2Page.jsx @@ -382,7 +382,7 @@ export default function Svod2Page() { }, muiTableContainerProps: { sx: SVOD2_TABLE_CONTAINER_SX, onScroll }, muiTableBodyRowProps: ({ row }) => - isPlaceholderRow(row.original) ? { className: 'svod2-placeholder', 'data-svod2-placeholder': '' } : {}, + isPlaceholderRow(row.original) ? { className: 'svod2-placeholder', 'data-svod2-placeholder': '', style: { height: rowHeight } } : {}, muiTableHeadProps: { sx: { '& tr:not(:last-of-type) > th > .MuiCollapse-root': { @@ -400,9 +400,14 @@ export default function Svod2Page() { }); tableRef.current = table; // Единая высота строки для окна страниц: реальная высота первой строки с данными (по умолчанию ROW_HEIGHT). + // Не измеряем во время загрузки (MRT рисует скелетон другой высоты); берём максимум по строкам с данными. useLayoutEffect(() => { - const element = tableRef.current?.refs.tableContainerRef.current?.querySelector('tbody tr:not([data-svod2-placeholder])'); - const measured = element ? Math.round(element.offsetHeight) : 0; + if (loading) return; + 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); }); let content = ; diff --git a/web/src/pages/Svod2Page/svod2Paging.js b/web/src/pages/Svod2Page/svod2Paging.js index 93c49f9..3f763b8 100644 --- a/web/src/pages/Svod2Page/svod2Paging.js +++ b/web/src/pages/Svod2Page/svod2Paging.js @@ -104,8 +104,9 @@ export function shouldShowFound({ hasCurrentData, loading, facets, error = '' }) // Нужно ли уточнение (refine) при смене поиска/колоночных фильтров: на загруженных данных // либо пока для текущего queryKey ещё летит первая загрузка (иначе ответы станут «текущими» по старым условиям). -export function shouldRefine({ hasCurrentData, loading, pendingQueryKey, queryKey, prevRefineKey, refineKey }) { - if (prevRefineKey === refineKey) return false; +// dataRefineKey — условия, на которых построены загруженные (или летящие) данные вкладки; нет их — нет и refine. +export function shouldRefine({ hasCurrentData, loading, pendingQueryKey, queryKey, dataRefineKey, refineKey }) { + if (dataRefineKey === undefined || dataRefineKey === null || dataRefineKey === refineKey) return false; if (hasCurrentData) return true; return Boolean(loading) && pendingQueryKey === queryKey; } diff --git a/web/src/pages/Svod2Page/svod2Paging.test.js b/web/src/pages/Svod2Page/svod2Paging.test.js index ee19dc5..d14a7cd 100644 --- a/web/src/pages/Svod2Page/svod2Paging.test.js +++ b/web/src/pages/Svod2Page/svod2Paging.test.js @@ -285,13 +285,25 @@ test('«Найдено» не показывается рядом с ошибк assert.equal(shouldShowFound({ hasCurrentData: true, loading: false, error: 'x', facets: { total: 5, search_total: 1 } }), false); }); 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, refineKey: 'a' }), false); }); 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, pendingQueryKey: 'old' }), 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); +}); diff --git a/web/src/pages/Svod2Page/useSvod2Pages.js b/web/src/pages/Svod2Page/useSvod2Pages.js index adbef98..af0a00e 100644 --- a/web/src/pages/Svod2Page/useSvod2Pages.js +++ b/web/src/pages/Svod2Page/useSvod2Pages.js @@ -34,6 +34,8 @@ const getRawPage = (scrollTop, rowHeight) => { 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 }) { @@ -101,6 +103,7 @@ export function useSvod2Pages({ sheet, year, orgIds, queryKey, search, columnFil 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); @@ -194,7 +197,11 @@ export function useSvod2Pages({ sheet, year, orgIds, queryKey, search, columnFil 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)) { + 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; @@ -226,28 +233,28 @@ export function useSvod2Pages({ sheet, year, orgIds, queryKey, search, columnFil // Смена поиска или колоночных фильтров на загруженных данных — уточнение (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: реагируем только на смену поиска/фильтров и вкладки + const refineKey = makeRefineKey(search, columnFilters); + // Условия, на которых построены данные вкладки (или летящего поколения), хранятся в ctx: сравнение с ними, а не с прошлым рендером. + // biome-ignore lint/correctness/useExhaustiveDependencies: start и поля sheetState читаются из актуального рендера useEffect(() => { - const previous = previousRef.current; - previousRef.current = { sheet, refineKey }; - if (previous.sheet !== sheet) { - syncWindow(); - return; - } if ( shouldRefine({ hasCurrentData, loading: sheetState.loading, pendingQueryKey: sheetState.pendingQueryKey, queryKey, - prevRefineKey: previous.refineKey, + dataRefineKey: ctxRef.current[sheet]?.refineKey, refineKey, }) ) 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(() => { mountedRef.current = true;