From 1469127b186182c96fff64020fd39223eb3ecbc1 Mon Sep 17 00:00:00 2001 From: kseniyakuzina Date: Thu, 10 Sep 2026 11:18:58 +0300 Subject: [PATCH] fix(BaseTable): adaptive row virtualization improvements --- .../BaseHeaderRow/BaseHeaderRow.tsx | 15 +++- .../utils/shouldSkipHeaderRender.ts | 26 +++++++ .../BaseRow/hooks/useVirtualizedRow.ts | 12 +++ src/components/BaseTable/BaseTable.tsx | 2 + .../hooks/useAdaptiveTableVirtualization.ts | 32 +++++++- .../BaseTable/hooks/useTableVirtualization.ts | 1 + .../utils/createTableRenderVersion.ts | 2 +- .../utils/resolveHorizontalScrollElement.ts | 2 +- .../createAdaptiveVirtualizerController.ts | 73 +++++++++++++++---- src/hooks/useTable.ts | 24 ++++-- 10 files changed, 165 insertions(+), 24 deletions(-) create mode 100644 src/components/BaseHeaderRow/utils/shouldSkipHeaderRender.ts diff --git a/src/components/BaseHeaderRow/BaseHeaderRow.tsx b/src/components/BaseHeaderRow/BaseHeaderRow.tsx index cbd3ddc..343504f 100644 --- a/src/components/BaseHeaderRow/BaseHeaderRow.tsx +++ b/src/components/BaseHeaderRow/BaseHeaderRow.tsx @@ -10,6 +10,7 @@ import {b} from '../BaseTable/BaseTable.classname'; import {ColumnReorderingContext} from '../ColumnReorderingContext'; import {getCanReorderHeader} from './utils/getCanReorderHeader'; +import {shouldSkipHeaderRender} from './utils/shouldSkipHeaderRender'; export interface BaseHeaderRowProps extends Omit, 'className'> { @@ -24,6 +25,8 @@ export interface BaseHeaderRowProps renderSortIndicator: BaseHeaderCellProps['renderSortIndicator']; resizeHandleClassName?: BaseHeaderCellProps['resizeHandleClassName']; sortIndicatorClassName: BaseHeaderCellProps['sortIndicatorClassName']; + /** @internal */ + tableRenderVersion?: Readonly>; attributes?: | React.HTMLAttributes | (( @@ -33,7 +36,7 @@ export interface BaseHeaderRowProps cellAttributes?: BaseHeaderCellProps['attributes']; } -export const BaseHeaderRow = ({ +const BaseHeaderRowComponent = ({ cellClassName, className: classNameProp, headerGroup, @@ -43,6 +46,7 @@ export const BaseHeaderRow = ({ renderSortIndicator, resizeHandleClassName, sortIndicatorClassName, + tableRenderVersion: _tableRenderVersion, attributes: attributesProp, cellAttributes, ...restProps @@ -93,3 +97,12 @@ export const BaseHeaderRow = ({ ); }; + +export const BaseHeaderRow = React.memo(BaseHeaderRowComponent, shouldSkipHeaderRender) as (< + TData, + TValue = unknown, +>( + props: BaseHeaderRowProps, +) => React.ReactElement) & {displayName?: string}; + +BaseHeaderRow.displayName = 'BaseHeaderRow'; diff --git a/src/components/BaseHeaderRow/utils/shouldSkipHeaderRender.ts b/src/components/BaseHeaderRow/utils/shouldSkipHeaderRender.ts new file mode 100644 index 0000000..56fafc8 --- /dev/null +++ b/src/components/BaseHeaderRow/utils/shouldSkipHeaderRender.ts @@ -0,0 +1,26 @@ +import {areShallowEqual} from '../../../utils/areShallowEqual'; +import type {BaseHeaderRowProps} from '../BaseHeaderRow'; + +export const shouldSkipHeaderRender = ( + previousProps: BaseHeaderRowProps, + nextProps: BaseHeaderRowProps, +) => { + // Stable header references do not reflect table state changes without a render version. + if ( + previousProps.tableRenderVersion === undefined || + nextProps.tableRenderVersion === undefined + ) { + return false; + } + + const nextKeys = Object.keys(nextProps) as (keyof BaseHeaderRowProps)[]; + + return ( + Object.keys(previousProps).length === nextKeys.length && + nextKeys.every((key) => + key === 'tableRenderVersion' + ? areShallowEqual(previousProps.tableRenderVersion, nextProps.tableRenderVersion) + : previousProps[key] === nextProps[key], + ) + ); +}; diff --git a/src/components/BaseRow/hooks/useVirtualizedRow.ts b/src/components/BaseRow/hooks/useVirtualizedRow.ts index cd09c28..2b19a44 100644 --- a/src/components/BaseRow/hooks/useVirtualizedRow.ts +++ b/src/components/BaseRow/hooks/useVirtualizedRow.ts @@ -26,6 +26,7 @@ export function useVirtualizedRow(); const placeholderNodeRef = React.useRef(); const placeholderVirtualKeyRef = React.useRef(); const virtualIndex = virtualItem?.index; @@ -52,7 +53,17 @@ export function useVirtualizedRow [record.virtualKey, record]), + ); + const unmeasuredRealRows = renderedRows.filter((record) => { + if (record.deferred || rowVirtualizer.itemSizeCache.has(record.virtualKey)) { + return false; + } + const previousRow = previousRowsByKey.get(record.virtualKey); + return ( + !previousRow || + previousRow.deferred || + !Object.is(previousRow.rowKey, record.rowKey) || + previousRow.index !== record.index + ); + }); + // TanStack skips initial measurement during scrolling. Reconcile new real rows + // before their estimated height can overlap the following rows. + remeasureRenderedRows(rowVirtualizer, runtime.bodyElement, unmeasuredRealRows); + } + }, [ + controller, + directDomUpdates, + preparedData, + preparedRequiredIndexes, + renderedRows, + renderPlan, + rowVirtualizer, + runtime, + ]); useIsomorphicLayoutEffect( () => () => { diff --git a/src/components/BaseTable/hooks/useTableVirtualization.ts b/src/components/BaseTable/hooks/useTableVirtualization.ts index 1aba5b6..be0abbb 100644 --- a/src/components/BaseTable/hooks/useTableVirtualization.ts +++ b/src/components/BaseTable/hooks/useTableVirtualization.ts @@ -147,6 +147,7 @@ export function useTableVirtualization< return { bodyRows, + headerRenderVersion: rowRenderVersion, bodyStyle: { height: getVirtualBodyHeight(bodyRows.length > 0, rowVirtualizer), ...bodyStyle, diff --git a/src/components/BaseTable/utils/createTableRenderVersion.ts b/src/components/BaseTable/utils/createTableRenderVersion.ts index adce374..f904efc 100644 --- a/src/components/BaseTable/utils/createTableRenderVersion.ts +++ b/src/components/BaseTable/utils/createTableRenderVersion.ts @@ -6,7 +6,7 @@ export function createTableRenderVersion( const version: Record = {__columnGeometry: columnGeometry}; Object.entries(options).forEach(([key, value]) => { - if (key !== 'state') { + if (key !== 'state' && key !== 'onStateChange') { version[`option:${key}`] = value; } }); diff --git a/src/components/BaseTable/utils/resolveHorizontalScrollElement.ts b/src/components/BaseTable/utils/resolveHorizontalScrollElement.ts index a02b994..29711f6 100644 --- a/src/components/BaseTable/utils/resolveHorizontalScrollElement.ts +++ b/src/components/BaseTable/utils/resolveHorizontalScrollElement.ts @@ -6,7 +6,7 @@ export function resolveHorizontalScrollElement( if (!targetWindow) { return null; } - let ancestor = bodyElement?.parentElement; + let ancestor: HTMLElement | null = bodyElement; while (ancestor) { const {overflowX} = targetWindow.getComputedStyle(ancestor); if ( diff --git a/src/hooks/useAdaptiveVirtualizer/utils/createAdaptiveVirtualizerController.ts b/src/hooks/useAdaptiveVirtualizer/utils/createAdaptiveVirtualizerController.ts index 4b8903e..470f68a 100644 --- a/src/hooks/useAdaptiveVirtualizer/utils/createAdaptiveVirtualizerController.ts +++ b/src/hooks/useAdaptiveVirtualizer/utils/createAdaptiveVirtualizerController.ts @@ -471,9 +471,9 @@ export const createAdaptiveVirtualizerController = ( return; } - // A native terminal token is exact for the gesture that produced it. A new - // active direction invalidates it even before the offset has changed. - invalidateNativeSettled(); + if (direction !== lastMotionDirection) { + invalidateNativeSettled(); + } recordDirectionReversal(direction); updateScrollVelocity(direction, offset, timestamp); updateDirectionalSample(direction, offset, timestamp); @@ -576,20 +576,35 @@ export const createAdaptiveVirtualizerController = ( return []; } const direction = virtualizer?.scrollDirection ?? lastActiveDirection; - return prioritize( + const range = latestRange; + const indexes = prioritize( [...paintedDeferred].flatMap(([index, identity]) => sameIdentity(identity, deferred.get(index)) ? [index] : [], ), - latestRange, + range, direction, ); + if (getNativeSettled() || !virtualizer?.isScrolling) { + const isVisible = (index: number) => + index >= range.startIndex && index <= range.endIndex; + return [...indexes.filter(isVisible), ...indexes.filter((index) => !isVisible(index))]; + } + return indexes; }; + const getPendingRealizationIndexes = (snapshot: Snapshot | null) => + snapshot + ? snapshot.indexes.filter( + (index) => !snapshot.realIndexSet.has(index) && !deferred.has(index), + ) + : []; + const realizePainted = ( limit: number, reason: 'active_recovery' | 'idle_stable' | 'native_settle', ) => { - const indexes = getRealizationCandidates().slice(0, limit); + const remaining = Math.max(0, limit - getPendingRealizationIndexes(committed).length); + const indexes = getRealizationCandidates().slice(0, remaining); if (indexes.length === 0) { return []; } @@ -682,10 +697,25 @@ export const createAdaptiveVirtualizerController = ( return; } + const direction = virtualizer.scrollDirection ?? lastActiveDirection; + const currentTicket = paintTicket; + + if ( + currentTicket && + currentTicket.virtualizer === virtualizer && + currentTicket.lifecycle === lifecycle && + currentTicket.direction === direction && + currentTicket.identities.size === identities.size && + [...identities].every(([index, identity]) => + sameIdentity(identity, currentTicket.identities.get(index)), + ) + ) { + return; + } cancelPaintTicket('superseded'); const ticket: PaintTicket = { cancel: null, - direction: virtualizer.scrollDirection ?? lastActiveDirection, + direction, id: ++ticketSequence, identities, lifecycle, @@ -895,6 +925,13 @@ export const createAdaptiveVirtualizerController = ( state.canPrepareDirectionalCoverage), ); const nextDeferred = new Map(deferred); + const pendingRealizations = new Set(getPendingRealizationIndexes(snapshot)); + const usesBoundedRealization = + usesDeferredRecovery || nextDeferred.size > 0 || pendingRealizations.size > 0; + const realizationLimit = isScrolling + ? MAX_REALIZATION_CHUNK_SIZE + : MAX_NATIVE_SETTLED_REALIZATION_CHUNK_SIZE; + const remainingRealizations = Math.max(0, realizationLimit - pendingRealizations.size); const visibleSet = new Set(state.visible); const prioritizedRealCritical = [ ...prioritize( @@ -907,10 +944,10 @@ export const createAdaptiveVirtualizerController = ( range, state.plan.direction, ), - ]; + ].filter((index) => !nextDeferred.has(index) && !pendingRealizations.has(index)); const realCritical = new Set( - usesDeferredRecovery - ? prioritizedRealCritical.slice(0, MAX_REALIZATION_CHUNK_SIZE) + usesBoundedRealization + ? prioritizedRealCritical.slice(0, remainingRealizations) : prioritizedRealCritical, ); const realizedDeferredCritical = prioritizedRealCritical.filter( @@ -923,7 +960,7 @@ export const createAdaptiveVirtualizerController = ( } for (const index of missingCritical) { output.add(index); - if (usesDeferredRecovery && !realCritical.has(index)) { + if (usesBoundedRealization && !realCritical.has(index)) { nextDeferred.set(index, { rowKey: dataRows?.[index]?.id, virtualKey: getKey(index), @@ -937,7 +974,7 @@ export const createAdaptiveVirtualizerController = ( } let warmMissing: number[] = []; - if (!recoveryActive && deferred.size === 0) { + if (!recoveryActive && deferred.size === 0 && pendingRealizations.size === 0) { const mountLimit = isScrolling ? state.plan.mountChunkSize : MAX_WARM_MOUNT_CHUNK_SIZE; warmMissing = prioritize( state.target.filter((index) => !output.has(index)), @@ -996,7 +1033,7 @@ export const createAdaptiveVirtualizerController = ( const getNormalStaleRemoveLimit = (isScrolling: boolean) => { if (isScrolling) { - return WARM_UNMOUNT_CHUNK_SIZE; + return 0; } if (idleTrimReady) { return IDLE_WARM_UNMOUNT_CHUNK_SIZE; @@ -1017,13 +1054,19 @@ export const createAdaptiveVirtualizerController = ( (left, right) => distanceToRange(right, range) - distanceToRange(left, range) || right - left, ); - const outputLimit = getAdaptiveOutputLimit( + const adaptiveOutputLimit = getAdaptiveOutputLimit( range, state, recovery, requiredSet.size, snapshot, ); + const outputLimit = recovery.isScrolling + ? Math.min( + adaptiveOutputLimit, + Math.max(requiredSet.size, state.target.length + getGuard(range.overscan)), + ) + : adaptiveOutputLimit; const forcedExcess = Math.max(0, recovery.output.size - outputLimit); const staleRemoveLimit = Math.min( stale.length, @@ -1095,7 +1138,7 @@ export const createAdaptiveVirtualizerController = ( ) => { const needsWarmWork = !coversIndexes(state.target, recovery.output); const hasStaleOutput = outputIndexes.some((index) => !recovery.targetSet.has(index)); - if (needsWarmWork || (recovery.isScrolling && hasStaleOutput)) { + if (needsWarmWork || (!recovery.isScrolling && idleTrimReady && hasStaleOutput)) { scheduleFrame(); } else if (!recovery.isScrolling && hasStaleOutput) { scheduleIdleTrim(); diff --git a/src/hooks/useTable.ts b/src/hooks/useTable.ts index 5e267fb..3543f21 100644 --- a/src/hooks/useTable.ts +++ b/src/hooks/useTable.ts @@ -1,3 +1,5 @@ +import * as React from 'react'; + import type {TableOptions} from '@tanstack/react-table'; import { getCoreRowModel, @@ -11,6 +13,18 @@ import { import type {UseTableOptions} from '../types/base'; export const useTable = (options: UseTableOptions) => { + // Keep default factory identities stable so table render versions change only with input data or state. + const rowModelFactories = React.useMemo( + () => ({ + getCoreRowModel: getCoreRowModel(), + getExpandedRowModel: getExpandedRowModel(), + getGroupedRowModel: getGroupedRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + }), + [], + ); + const tableOptions: TableOptions = { ...options, enableColumnPinning: options.enableColumnPinning ?? false, @@ -22,19 +36,19 @@ export const useTable = (options: UseTableOptions) => { enableSorting: options.enableSorting ?? false, enableColumnFilters: options.enableColumnFilters ?? false, enableGlobalFilter: options.enableGlobalFilter ?? false, - getCoreRowModel: options.getCoreRowModel ?? getCoreRowModel(), + getCoreRowModel: options.getCoreRowModel ?? rowModelFactories.getCoreRowModel, getExpandedRowModel: options.enableExpanding - ? (options.getExpandedRowModel ?? getExpandedRowModel()) + ? (options.getExpandedRowModel ?? rowModelFactories.getExpandedRowModel) : undefined, getGroupedRowModel: options.enableGrouping - ? (options.getGroupedRowModel ?? getGroupedRowModel()) + ? (options.getGroupedRowModel ?? rowModelFactories.getGroupedRowModel) : undefined, getSortedRowModel: options.enableSorting - ? (options.getSortedRowModel ?? getSortedRowModel()) + ? (options.getSortedRowModel ?? rowModelFactories.getSortedRowModel) : undefined, getFilteredRowModel: options.enableColumnFilters || options.enableGlobalFilter - ? (options.getFilteredRowModel ?? getFilteredRowModel()) + ? (options.getFilteredRowModel ?? rowModelFactories.getFilteredRowModel) : undefined, manualGrouping: options.manualGrouping ?? false, manualSorting: options.manualSorting ?? false,