🐛 fix(datagrid): 修复空结果横向滚动条位置异常

- 实测表格 DOM 溢出与轨道宽度,轻微溢出时仍将滚动条固定到底部
- 空结果时关闭数据预览面板并清理聚焦状态,避免残留布局占位
- 扩展性能复现页支持零行数据并补充布局与预览面板回归测试
This commit is contained in:
Syngnat
2026-07-24 17:13:39 +08:00
parent 72432b639e
commit ef625ec911
7 changed files with 241 additions and 59 deletions

View File

@@ -740,7 +740,7 @@ describe('DataGrid layout', () => {
const handleDataPanelSaveSource = sliceCallback(
'const handleDataPanelSave = useCallback(() => {',
'const handleCellSetNull = useCallback(() => {',
"const lastReportedDataFingerprintRef = useRef('');",
);
const handleCellSetNullSource = sliceCallback(
'const handleCellSetNull = useCallback(() => {',
@@ -2953,5 +2953,7 @@ describe('DataGrid layout', () => {
expect(harnessSource).toContain("document.body.setAttribute('data-ui-version', uiVersion);");
expect(harnessSource).toContain("if (value === null || value === undefined || value === '') {");
expect(harnessSource).toContain("const currentState = useStore.getState();");
expect(harnessSource).toContain("new URLSearchParams(window.location.search).get('rows')");
expect(harnessSource).toContain('Math.max(0, Math.min(50000');
});
});

View File

@@ -48,11 +48,11 @@ import { filterRowsByGridConditions } from '../utils/dataGridClientFilter';
import { resolveGridSortInfoFromTableSorter } from '../utils/dataGridSort';
import {
absorbExtraWidthIntoFlexibleColumns,
calculateExternalHorizontalScrollInnerWidth,
calculateTableBodyBottomPadding,
calculateVirtualTableScrollX,
resolveDataGridColumnQuickFindScrollLeft,
resolveDataGridHorizontalWheelDelta,
resolveExternalHorizontalScrollMetrics,
} from './dataGridLayout';
import {
createDataGridIdleCommitScheduler,
@@ -942,29 +942,6 @@ const DataGrid: React.FC<DataGridProps> = ({
const virtualInlineInputRef = useRef<any>(null);
const virtualInlinePickerOpenRef = useRef(false);
const virtualInlineScrollLockRef = useRef<{ el: HTMLElement; handler: (e: WheelEvent) => void } | null>(null);
const {
dataPanelOpen,
dataPanelOpenRef,
focusedCellInfo,
dataPanelValue,
setDataPanelValue,
dataPanelIsJson,
dataPanelDirtyRef,
dataPanelOriginalRef,
toggleDataPanel,
updateFocusedCell,
handleDataPanelFormatJson,
} = useDataGridPreviewPanel({
toEditableText: mongoAwareEditableText,
looksLikeJsonText,
normalizeDateTimeString,
});
const focusedCellWritable = useMemo(() => (
canModifyData &&
!!focusedCellInfo &&
isWritableResultColumn(focusedCellInfo.dataIndex, effectiveEditLocator)
), [canModifyData, focusedCellInfo, effectiveEditLocator]);
// Cell Context Menu State
const [cellContextMenu, setCellContextMenu] = useState<{
visible: boolean;
@@ -1409,6 +1386,11 @@ const DataGrid: React.FC<DataGridProps> = ({
// Dynamic Height
const [tableHeight, setTableHeight] = useState(500);
const [tableViewportWidth, setTableViewportWidth] = useState(0);
const [measuredHorizontalScrollMetrics, setMeasuredHorizontalScrollMetrics] = useState({
scrollWidth: 0,
clientWidth: 0,
trackClientWidth: 0,
});
const [tableBodyBottomPadding, setTableBodyBottomPadding] = useState(0);
// P0 性能优化CSS 模板字符串 memoize仅在主题/布局变量变化时重算
@@ -1494,7 +1476,21 @@ const DataGrid: React.FC<DataGridProps> = ({
const rcVirtualHolderEl = target.querySelector('.rc-virtual-list-holder') as HTMLElement | null;
const virtualScrollbarEl = target.querySelector('.ant-table-tbody-virtual-scrollbar-horizontal') as HTMLElement | null;
const scrollableEl = virtualBodyEl || rcVirtualHolderEl || bodyEl;
const hasHorizontalOverflow = !!scrollableEl && (scrollableEl.scrollWidth - scrollableEl.clientWidth > 1);
const measuredScrollWidth = scrollableEl?.scrollWidth || 0;
const measuredClientWidth = scrollableEl?.clientWidth || 0;
const measuredTrackClientWidth = externalHorizontalScrollRef.current?.clientWidth || 0;
const hasHorizontalOverflow = measuredScrollWidth - measuredClientWidth > 1;
setMeasuredHorizontalScrollMetrics((current) => (
current.scrollWidth === measuredScrollWidth
&& current.clientWidth === measuredClientWidth
&& current.trackClientWidth === measuredTrackClientWidth
? current
: {
scrollWidth: measuredScrollWidth,
clientWidth: measuredClientWidth,
trackClientWidth: measuredTrackClientWidth,
}
));
// 普通表格可通过 body 底部内边距避开悬浮横向滚动条;
// 但虚拟表格的内部横向滚动轨道会直接覆盖在可视区底部,需要同时从 y 高度里扣掉安全区。
const nextBodyBottomPadding = calculateTableBodyBottomPadding({
@@ -2260,25 +2256,6 @@ const DataGrid: React.FC<DataGridProps> = ({
const handleCellSaveRef = useRef(handleCellSave);
handleCellSaveRef.current = handleCellSave;
const handleDataPanelSave = useCallback(() => {
if (!focusedCellInfo) return;
if (!focusedCellWritable) {
void message.info(translateDataGrid('data_grid.message.current_field_not_editable'));
return;
}
// 与 updateFocusedCell 设置的原始值比较,避免幽灵变更
if (dataPanelValue === dataPanelOriginalRef.current) {
dataPanelDirtyRef.current = false;
void message.info(translateDataGrid('data_grid.message.no_data_changes'));
return;
}
const nextRow: any = { ...focusedCellInfo.record, [focusedCellInfo.dataIndex]: dataPanelValue };
handleCellSave(nextRow);
dataPanelOriginalRef.current = dataPanelValue;
dataPanelDirtyRef.current = false;
void message.success(translateDataGrid('data_grid.message.saved'));
}, [focusedCellInfo, focusedCellWritable, dataPanelValue, handleCellSave, translateDataGrid]);
const handleCellSetNull = useCallback(() => {
if (!cellContextMenu.record) return;
if (!isWritableResultColumn(cellContextMenu.dataIndex, effectiveEditLocator)) {
@@ -2451,6 +2428,47 @@ const DataGrid: React.FC<DataGridProps> = ({
});
}, [displayData, modifiedRows, deletedRowKeys]);
mergedDisplayDataRef.current = mergedDisplayData;
const {
dataPanelOpen,
dataPanelOpenRef,
focusedCellInfo,
dataPanelValue,
setDataPanelValue,
dataPanelIsJson,
dataPanelDirtyRef,
dataPanelOriginalRef,
toggleDataPanel,
updateFocusedCell,
handleDataPanelFormatJson,
} = useDataGridPreviewPanel({
previewAvailable: mergedDisplayData.length > 0,
toEditableText: mongoAwareEditableText,
looksLikeJsonText,
normalizeDateTimeString,
});
const focusedCellWritable = useMemo(() => (
canModifyData &&
!!focusedCellInfo &&
isWritableResultColumn(focusedCellInfo.dataIndex, effectiveEditLocator)
), [canModifyData, focusedCellInfo, effectiveEditLocator]);
const handleDataPanelSave = useCallback(() => {
if (!focusedCellInfo) return;
if (!focusedCellWritable) {
void message.info(translateDataGrid('data_grid.message.current_field_not_editable'));
return;
}
// 与 updateFocusedCell 设置的原始值比较,避免幽灵变更
if (dataPanelValue === dataPanelOriginalRef.current) {
dataPanelDirtyRef.current = false;
void message.info(translateDataGrid('data_grid.message.no_data_changes'));
return;
}
const nextRow: any = { ...focusedCellInfo.record, [focusedCellInfo.dataIndex]: dataPanelValue };
handleCellSave(nextRow);
dataPanelOriginalRef.current = dataPanelValue;
dataPanelDirtyRef.current = false;
void message.success(translateDataGrid('data_grid.message.saved'));
}, [focusedCellInfo, focusedCellWritable, dataPanelValue, handleCellSave, translateDataGrid]);
const lastReportedDataFingerprintRef = useRef('');
useEffect(() => {
if (!onDataChange) return;
@@ -3942,11 +3960,23 @@ const DataGrid: React.FC<DataGridProps> = ({
isMacLike,
});
}, [totalWidth, isMacLike, tableViewportWidth]);
const horizontalScrollVisible = isTableSurfaceActive && tableScrollX > tableViewportWidth + 1;
const horizontalScrollWidth = useMemo(() => calculateExternalHorizontalScrollInnerWidth({
const externalHorizontalScrollMetrics = useMemo(() => resolveExternalHorizontalScrollMetrics({
tableScrollWidth: tableScrollX,
tableViewportWidth,
measuredScrollWidth: measuredHorizontalScrollMetrics.scrollWidth,
measuredClientWidth: measuredHorizontalScrollMetrics.clientWidth,
measuredTrackClientWidth: measuredHorizontalScrollMetrics.trackClientWidth,
trackInset: floatingScrollbarInset,
}), [tableScrollX, floatingScrollbarInset]);
}), [
floatingScrollbarInset,
measuredHorizontalScrollMetrics.clientWidth,
measuredHorizontalScrollMetrics.scrollWidth,
measuredHorizontalScrollMetrics.trackClientWidth,
tableScrollX,
tableViewportWidth,
]);
const horizontalScrollVisible = isTableSurfaceActive && externalHorizontalScrollMetrics.visible;
const horizontalScrollWidth = externalHorizontalScrollMetrics.innerWidth;
const tableScrollConfig = useMemo(() => ({ x: tableScrollX, y: tableHeight }), [tableScrollX, tableHeight]);
const virtualRowHeightSignature = `${displayRenderVersion}|${effectiveUiScale}`;
const measuredVirtualRowHeight = virtualRowHeightMeasurement?.signature === virtualRowHeightSignature

View File

@@ -5,6 +5,7 @@ import {
calculateExternalHorizontalScrollInnerWidth,
calculateTableBodyBottomPadding,
calculateVirtualTableScrollX,
resolveExternalHorizontalScrollMetrics,
resolveDataGridColumnQuickFindScrollLeft,
resolveDataGridHorizontalWheelDelta,
} from './dataGridLayout';
@@ -83,6 +84,21 @@ describe('dataGridLayout helpers', () => {
})).toBe(1);
});
it('uses measured DOM overflow when stretched columns exceed the theoretical table width', () => {
expect(resolveExternalHorizontalScrollMetrics({
tableScrollWidth: 1537,
tableViewportWidth: 1537,
measuredScrollWidth: 1539,
measuredClientWidth: 1526,
measuredTrackClientWidth: 1517,
trackInset: 10,
})).toEqual({
visible: true,
innerWidth: 1530,
maxScrollLeft: 13,
});
});
it('resolves quick-find target scrollLeft by centering the target column when possible', () => {
expect(resolveDataGridColumnQuickFindScrollLeft({
currentScrollLeft: 0,

View File

@@ -21,6 +21,19 @@ export interface ExternalHorizontalScrollInnerWidthOptions {
trackInset: number;
}
export interface ExternalHorizontalScrollMetricsOptions extends ExternalHorizontalScrollInnerWidthOptions {
tableViewportWidth: number;
measuredScrollWidth: number;
measuredClientWidth: number;
measuredTrackClientWidth?: number;
}
export interface ExternalHorizontalScrollMetrics {
visible: boolean;
innerWidth: number;
maxScrollLeft: number;
}
export interface DataGridColumnQuickFindScrollLeftOptions {
currentScrollLeft: number;
columnLeft: number;
@@ -146,6 +159,36 @@ export const calculateExternalHorizontalScrollInnerWidth = ({
return Math.max(1, safeTableScrollWidth - safeTrackInset * 2);
};
export const resolveExternalHorizontalScrollMetrics = ({
tableScrollWidth,
tableViewportWidth,
measuredScrollWidth,
measuredClientWidth,
measuredTrackClientWidth,
trackInset,
}: ExternalHorizontalScrollMetricsOptions): ExternalHorizontalScrollMetrics => {
const safeTableScrollWidth = Math.max(0, Math.ceil(tableScrollWidth));
const safeViewportWidth = Math.max(0, Math.floor(tableViewportWidth));
const safeMeasuredScrollWidth = Math.max(0, Math.ceil(measuredScrollWidth));
const safeMeasuredClientWidth = Math.max(0, Math.floor(measuredClientWidth));
const safeMeasuredTrackClientWidth = Math.max(0, Math.floor(measuredTrackClientWidth || 0));
const safeTrackInset = Math.max(0, Math.ceil(trackInset));
const calculatedMaxScrollLeft = safeViewportWidth > 0
? Math.max(0, safeTableScrollWidth - safeViewportWidth)
: 0;
const measuredMaxScrollLeft = Math.max(0, safeMeasuredScrollWidth - safeMeasuredClientWidth);
const maxScrollLeft = Math.max(calculatedMaxScrollLeft, measuredMaxScrollLeft);
const innerWidth = safeViewportWidth > 0
? Math.max(1, safeMeasuredTrackClientWidth || (safeViewportWidth - safeTrackInset * 2)) + maxScrollLeft
: calculateExternalHorizontalScrollInnerWidth({ tableScrollWidth, trackInset });
return {
visible: maxScrollLeft > 1,
innerWidth,
maxScrollLeft,
};
};
export const resolveDataGridColumnQuickFindScrollLeft = ({
currentScrollLeft,
columnLeft,

View File

@@ -0,0 +1,60 @@
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { afterEach, describe, expect, it } from 'vitest';
import { useDataGridPreviewPanel, type UseDataGridPreviewPanelResult } from './useDataGridPreviewPanel';
describe('useDataGridPreviewPanel', () => {
let renderer: ReactTestRenderer | null = null;
afterEach(() => {
act(() => {
renderer?.unmount();
});
renderer = null;
});
it('closes the preview when the result becomes empty and keeps it closed', () => {
let controller!: UseDataGridPreviewPanelResult;
let setPreviewAvailable!: React.Dispatch<React.SetStateAction<boolean>>;
const Harness = () => {
const [previewAvailable, setAvailable] = React.useState(true);
setPreviewAvailable = setAvailable;
controller = useDataGridPreviewPanel({
previewAvailable,
toEditableText: (value) => String(value ?? ''),
looksLikeJsonText: () => false,
normalizeDateTimeString: (value) => value,
});
return null;
};
act(() => {
renderer = create(<Harness />);
});
act(() => {
controller.toggleDataPanel();
controller.updateFocusedCell({ id: 1 }, 'id');
});
expect(controller.dataPanelOpen).toBe(true);
expect(controller.focusedCellInfo?.dataIndex).toBe('id');
act(() => {
setPreviewAvailable(false);
});
expect(controller.dataPanelOpen).toBe(false);
expect(controller.dataPanelOpenRef.current).toBe(false);
expect(controller.focusedCellInfo).toBeNull();
expect(controller.dataPanelValue).toBe('');
act(() => {
controller.toggleDataPanel();
});
expect(controller.dataPanelOpen).toBe(false);
expect(controller.dataPanelOpenRef.current).toBe(false);
});
});

View File

@@ -9,6 +9,7 @@ export interface DataGridFocusedCellInfo {
}
interface UseDataGridPreviewPanelParams {
previewAvailable: boolean;
toEditableText: (value: any, columnName?: string) => string;
looksLikeJsonText: (text: string) => boolean;
normalizeDateTimeString: (value: string) => string;
@@ -29,6 +30,7 @@ export interface UseDataGridPreviewPanelResult {
}
export const useDataGridPreviewPanel = ({
previewAvailable,
toEditableText,
looksLikeJsonText,
normalizeDateTimeString,
@@ -41,6 +43,16 @@ export const useDataGridPreviewPanel = ({
const dataPanelDirtyRef = React.useRef(false);
const dataPanelOriginalRef = React.useRef('');
const closeDataPanel = React.useCallback(() => {
dataPanelOpenRef.current = false;
setDataPanelOpen(false);
setFocusedCellInfo(null);
setDataPanelValue('');
setDataPanelIsJson(false);
dataPanelDirtyRef.current = false;
dataPanelOriginalRef.current = '';
}, []);
const updateFocusedCell = React.useCallback((record: GridRecord, dataIndex: string) => {
if (!record || !dataIndex) return;
const raw = record?.[dataIndex];
@@ -68,24 +80,30 @@ export const useDataGridPreviewPanel = ({
}, [dataPanelIsJson, dataPanelValue]);
const toggleDataPanel = React.useCallback(() => {
if (!previewAvailable) {
closeDataPanel();
return;
}
const next = !dataPanelOpenRef.current;
dataPanelOpenRef.current = next;
setDataPanelOpen(next);
if (!next) {
setFocusedCellInfo(null);
setDataPanelValue('');
setDataPanelIsJson(false);
dataPanelDirtyRef.current = false;
dataPanelOriginalRef.current = '';
closeDataPanel();
}
}, []);
}, [closeDataPanel, previewAvailable]);
React.useEffect(() => {
if (!previewAvailable) {
closeDataPanel();
}
}, [closeDataPanel, previewAvailable]);
React.useEffect(() => {
dataPanelOpenRef.current = dataPanelOpen;
}, [dataPanelOpen]);
return {
dataPanelOpen,
dataPanelOpen: previewAvailable && dataPanelOpen,
dataPanelOpenRef,
focusedCellInfo,
dataPanelValue,

View File

@@ -72,6 +72,19 @@ const clampHarnessFontSize = (value: unknown): number => {
return Math.min(20, Math.max(12, Math.round(numeric)));
};
const readHarnessRowCount = (): number => {
if (typeof window === 'undefined') return 10000;
try {
const rawValue = new URLSearchParams(window.location.search).get('rows');
if (rawValue === null || rawValue === '') return 10000;
const numeric = Number(rawValue);
if (!Number.isFinite(numeric)) return 10000;
return Math.min(50000, Math.max(0, Math.trunc(numeric)));
} catch {
return 10000;
}
};
const readHarnessRuntimeConfig = (): HarnessRuntimeConfig => {
if (typeof window === 'undefined') {
return { ...DEFAULT_HARNESS_CONFIG };
@@ -119,7 +132,7 @@ const buildHarnessColumns = (count: number): string[] => {
};
const buildHarnessData = (rowCount: number, columnNames: string[]): HarnessRow[] => {
const safeRows = Math.max(200, Math.min(50000, Math.trunc(rowCount || 0)));
const safeRows = Math.max(0, Math.min(50000, Math.trunc(rowCount || 0)));
return Array.from({ length: safeRows }, (_, rowIndex) => {
const rowNumber = rowIndex + 1;
const nextRow: HarnessRow = {
@@ -164,7 +177,7 @@ const PerfDataGridHarness: React.FC = () => {
const setTheme = useStore((state) => state.setTheme);
const setUiScale = useStore((state) => state.setUiScale);
const setFontSize = useStore((state) => state.setFontSize);
const [rowCount, setRowCount] = useState(10000);
const [rowCount, setRowCount] = useState(readHarnessRowCount);
const [columnCount, setColumnCount] = useState(24);
const [uiVersion, setUiVersion] = useState<HarnessUiVersion>(initialConfig.uiVersion);
const [density, setDensity] = useState<DataTableDensity>(initialConfig.density);
@@ -297,11 +310,11 @@ const PerfDataGridHarness: React.FC = () => {
]}
/>
<InputNumber
min={200}
min={0}
max={50000}
step={500}
value={rowCount}
onChange={(value) => setRowCount(Number(value) || 10000)}
onChange={(value) => setRowCount(value === null ? 0 : Number(value))}
addonBefore={t('dev.perf_data_grid.rows')}
/>
<InputNumber