Merge pull request #4048 from iMouseWu/fix/datatable-scroll-jank

fix(frontend): 小数据量关闭 DataTable 虚拟化并按行主键缓存行高,消除账号列表滚动抖动
This commit is contained in:
Wesley Liddick authored and GitHub committed 2026-07-13 15:32:08 +08:00
commit 0465540195
5 files changed
+218 -13

No files matched your search

@@ -0,0 +1,74 @@
import { describe, it, expect } from 'vitest'
import { findRowIndexByDomPosition } from '../useSwipeSelect'
/**
* Build a fake scroll element whose `tbody tr[data-index]` rows expose stubbed
* vertical rects, so we can exercise findRowIndexByDomPosition without a real DOM.
* `index` is the value placed in the row's data-index attribute (its absolute
* position in the sorted data), which need not equal the array position.
*/
function makeScrollEl(rows: Array<{ index: number; top: number; bottom: number }>): Element {
const trs = rows.map((r) => ({
getAttribute: (name: string) => (name === 'data-index' ? String(r.index) : null),
getBoundingClientRect: () => ({
top: r.top,
bottom: r.bottom,
left: 0,
right: 0,
width: 0,
height: r.bottom - r.top,
x: 0,
y: r.top,
toJSON: () => ({})
})
})) as unknown as HTMLElement[]
return {
querySelectorAll: (sel: string) =>
(sel === 'tbody tr[data-index]' ? trs : []) as unknown as NodeListOf<Element>
} as unknown as Element
}
describe('findRowIndexByDomPosition (swipe-select full-render fallback)', () => {
// Variable row heights on purpose — the third row is taller.
const rows = [
{ index: 0, top: 100, bottom: 200 },
{ index: 1, top: 200, bottom: 300 },
{ index: 2, top: 300, bottom: 450 }
]
const el = makeScrollEl(rows)
it('returns -1 when no rows are rendered', () => {
expect(findRowIndexByDomPosition(makeScrollEl([]), 250)).toBe(-1)
})
it('locates the row whose rect contains the Y coordinate', () => {
expect(findRowIndexByDomPosition(el, 150)).toBe(0)
expect(findRowIndexByDomPosition(el, 250)).toBe(1)
expect(findRowIndexByDomPosition(el, 400)).toBe(2) // inside the tall row
})
it('clamps to the first/last row when Y is outside the rendered range', () => {
expect(findRowIndexByDomPosition(el, 50)).toBe(0) // above the first row
expect(findRowIndexByDomPosition(el, 999)).toBe(2) // below the last row
})
it('picks the closer row when Y falls in a gap between rows', () => {
const gapped = makeScrollEl([
{ index: 0, top: 100, bottom: 180 },
{ index: 1, top: 220, bottom: 300 }
])
expect(findRowIndexByDomPosition(gapped, 190)).toBe(0) // 10px from row0.bottom vs 30px from row1.top
expect(findRowIndexByDomPosition(gapped, 215)).toBe(1) // 35px from row0.bottom vs 5px from row1.top
})
it('returns the data-index attribute value, not the array position', () => {
const remapped = makeScrollEl([
{ index: 5, top: 100, bottom: 200 },
{ index: 9, top: 200, bottom: 300 }
])
expect(findRowIndexByDomPosition(remapped, 150)).toBe(5)
expect(findRowIndexByDomPosition(remapped, 250)).toBe(9)
})
})
@@ -38,6 +38,41 @@ export interface SwipeSelectVirtualContext {
getRowId: (row: any, index: number) => number
}
/**
* Locate a row index from a viewport Y coordinate using the real DOM rows
* (`tbody tr[data-index]`). Used when the virtualizer window is empty because the
* list is small enough to be rendered in full (virtualization disabled). Rows are
* vertically ordered, so this binary-searches — mirroring findRowIndexAtY — and
* returns each row's `data-index` (its absolute index in the sorted data), or -1
* when no rows are rendered. Exported for unit testing.
*/
export function findRowIndexByDomPosition(scrollEl: Element, clientY: number): number {
const domRows = Array.from(scrollEl.querySelectorAll('tbody tr[data-index]')) as HTMLElement[]
const len = domRows.length
if (len === 0) return -1
const idxOf = (el: HTMLElement) => Number(el.getAttribute('data-index'))
// Boundary checks
if (clientY < domRows[0].getBoundingClientRect().top) return idxOf(domRows[0])
if (clientY > domRows[len - 1].getBoundingClientRect().bottom) return idxOf(domRows[len - 1])
// Binary search — rows are vertically ordered
let lo = 0, hi = len - 1
while (lo <= hi) {
const mid = (lo + hi) >>> 1
const rect = domRows[mid].getBoundingClientRect()
if (clientY < rect.top) hi = mid - 1
else if (clientY > rect.bottom) lo = mid + 1
else return idxOf(domRows[mid])
}
// In a gap between rows — pick the closer one
if (hi < 0) return idxOf(domRows[0])
if (lo >= len) return idxOf(domRows[len - 1])
const rHi = domRows[hi].getBoundingClientRect()
const rLo = domRows[lo].getBoundingClientRect()
return (clientY - rHi.bottom < rLo.top - clientY) ? idxOf(domRows[hi]) : idxOf(domRows[lo])
}
export function useSwipeSelect(
containerRef: Ref<HTMLElement | null>,
adapter: SwipeSelectAdapter,
@@ -125,6 +160,13 @@ export function useSwipeSelect(
if (contentY >= item.start && contentY < item.end) return item.index
}
// Virtualization disabled (small list rendered in full): the window is empty, so
// locate the row via real DOM rows instead of the (now-misleading) height estimate.
if (items.length === 0) {
const domIdx = findRowIndexByDomPosition(scrollEl, clientY)
if (domIdx >= 0) return domIdx
}
// Outside visible range: estimate
const totalCount = virtualContext!.getSortedData().length
if (totalCount === 0) return -1