Merge pull request #4300 from wp-a/fix/frontend-datatable-row-cache

fix(frontend): clear stale DataTable row caches
This commit is contained in:
Wesley Liddick
2026-07-15 11:08:58 +08:00
committed by GitHub
2 changed files with 183 additions and 4 deletions
+45 -4
View File
@@ -549,19 +549,21 @@ const compareSortValues = (a: any, b: any): number => {
if (res === 0) return 0
return res < 0 ? -1 : 1
}
const resolveRowKey = (row: any, index: number) => {
const resolveStableRowKey = (row: any): string | number | undefined => {
if (typeof props.rowKey === 'function') {
const key = props.rowKey(row)
return key ?? index
return key ?? undefined
}
if (typeof props.rowKey === 'string' && props.rowKey) {
const key = row?.[props.rowKey]
return key ?? index
return key ?? undefined
}
const key = row?.id
return key ?? index
return key ?? undefined
}
const resolveRowKey = (row: any, index: number) => resolveStableRowKey(row) ?? index
const dataColumns = computed(() => props.columns.filter((column) => column.key !== 'actions'))
const columnsSignature = computed(() =>
props.columns.map((column) => `${column.key}:${column.sortable ? '1' : '0'}`).join('|')
@@ -677,6 +679,45 @@ const measureElement = (el: any) => {
}
}
type RowIdentityToken = string | number | object | symbol
const rowIdentityKeys = computed<RowIdentityToken[]>(() =>
(sortedData.value ?? []).map((row) => {
const stableKey = resolveStableRowKey(row)
if (stableKey !== undefined) return stableKey
// Object references survive pure reordering but change across page/filter results.
// Primitive rows have no stable identity, so force conservative invalidation.
return row !== null && typeof row === 'object' ? row : Symbol('unstable-row')
})
)
const hasSameRowIdentitySet = (
current: RowIdentityToken[],
previous: RowIdentityToken[]
) => {
if (current.length !== previous.length) return false
const currentKeys = new Set(current)
const previousKeys = new Set(previous)
// Duplicate keys make row-to-cache ownership ambiguous, even when the unique
// key set looks unchanged (for example [1, 1, 2] -> [1, 2, 2]).
if (currentKeys.size !== current.length || previousKeys.size !== previous.length) return false
return [...currentKeys].every(key => previousKeys.has(key))
}
watch(
rowIdentityKeys,
(current, previous) => {
if (hasSameRowIdentitySet(current, previous)) return
// The virtualizer owns caches across option updates. A new page/filter result
// must release detached rows and sizes, while pure reordering keeps them.
rowVirtualizer.value.measureElement(null)
rowVirtualizer.value.measure()
},
{ flush: 'post' }
)
// 统一的渲染行列表:虚拟化开启时只取窗口内的行(需 measure 交给虚拟器测量),
// 关闭时取全部行(无需测量)。模板据此渲染,两种模式共用同一套单元格结构。
const renderRows = computed<Array<{ index: number; row: any; measure: boolean }>>(() => {
@@ -121,4 +121,142 @@ describe('DataTable', () => {
expect(instance.options.getItemKey(0)).toBe(100)
expect(instance.options.getItemKey(5)).toBe(105)
})
it('clears stale row and element caches when pagination replaces the row ID set', async () => {
const firstPage = Array.from({ length: 100 }, (_, i) => ({ id: i + 1, name: `First ${i + 1}` }))
const secondPage = Array.from({ length: 100 }, (_, i) => ({ id: i + 101, name: `Second ${i + 1}` }))
const wrapper = mount(DataTable, {
props: {
columns: [{ key: 'name', label: 'Name' }],
data: firstPage,
rowKey: 'id',
virtualizeThreshold: 1
}
})
await wrapper.vm.$nextTick()
const exposed = (wrapper.vm as any).virtualizer
const instance = exposed?.value ?? exposed
const firstPageIDs = firstPage.map(row => row.id)
;(instance as any).itemSizeCache = new Map(firstPageIDs.map(id => [id, 156]))
instance.elementsCache.clear()
for (const id of firstPageIDs) {
instance.elementsCache.set(id, document.createElement('tr'))
}
const measureElementSpy = vi.spyOn(instance, 'measureElement')
await wrapper.setProps({ data: secondPage })
await wrapper.vm.$nextTick()
const sizeCache = (instance as any).itemSizeCache as Map<number, number>
expect(sizeCache.size).toBeLessThanOrEqual(secondPage.length)
expect(instance.elementsCache.size).toBeLessThanOrEqual(secondPage.length)
expect(firstPageIDs.some(id => sizeCache.has(id))).toBe(false)
expect(firstPageIDs.some(id => instance.elementsCache.has(id))).toBe(false)
expect(measureElementSpy.mock.calls.some(([node]) => node === null)).toBe(true)
})
it('clears stale caches when equal-length pages replace rows without stable keys', async () => {
const firstPage = Array.from({ length: 12 }, (_, i) => ({ name: `First ${i + 1}` }))
const secondPage = Array.from({ length: 12 }, (_, i) => ({ name: `Second ${i + 1}` }))
const wrapper = mount(DataTable, {
props: {
columns: [{ key: 'name', label: 'Name' }],
data: firstPage,
virtualizeThreshold: 1
}
})
await wrapper.vm.$nextTick()
const exposed = (wrapper.vm as any).virtualizer
const instance = exposed?.value ?? exposed
const measureElementSpy = vi.spyOn(instance, 'measureElement')
await wrapper.setProps({ data: secondPage })
await wrapper.vm.$nextTick()
expect(measureElementSpy.mock.calls.some(([node]) => node === null)).toBe(true)
})
it('conservatively clears caches when duplicate row-key multiplicity changes', async () => {
const firstPage = [
{ id: 1, name: 'First A' },
{ id: 1, name: 'First B' },
{ id: 2, name: 'First C' }
]
const secondPage = [
{ id: 1, name: 'Second A' },
{ id: 2, name: 'Second B' },
{ id: 2, name: 'Second C' }
]
const wrapper = mount(DataTable, {
props: {
columns: [{ key: 'name', label: 'Name' }],
data: firstPage,
rowKey: 'id',
virtualizeThreshold: 1
}
})
await wrapper.vm.$nextTick()
const exposed = (wrapper.vm as any).virtualizer
const instance = exposed?.value ?? exposed
const measureElementSpy = vi.spyOn(instance, 'measureElement')
await wrapper.setProps({ data: secondPage })
await wrapper.vm.$nextTick()
expect(measureElementSpy.mock.calls.some(([node]) => node === null)).toBe(true)
})
it('preserves cache when rows without stable keys only reorder the same objects', async () => {
const data = Array.from({ length: 12 }, (_, i) => ({ name: `Row ${i + 1}` }))
const wrapper = mount(DataTable, {
props: {
columns: [{ key: 'name', label: 'Name' }],
data,
virtualizeThreshold: 1
}
})
await wrapper.vm.$nextTick()
const exposed = (wrapper.vm as any).virtualizer
const instance = exposed?.value ?? exposed
const measureSpy = vi.spyOn(instance, 'measure')
await wrapper.setProps({ data: [...data].reverse() })
await wrapper.vm.$nextTick()
expect(measureSpy).not.toHaveBeenCalled()
})
it('preserves stable row height cache when the same row IDs are only reordered', async () => {
const data = Array.from({ length: 100 }, (_, i) => ({ id: i + 1, name: `Row ${i + 1}` }))
const wrapper = mount(DataTable, {
props: {
columns: [{ key: 'name', label: 'Name' }],
data,
rowKey: 'id',
virtualizeThreshold: 1
}
})
await wrapper.vm.$nextTick()
const exposed = (wrapper.vm as any).virtualizer
const instance = exposed?.value ?? exposed
;(instance as any).itemSizeCache = new Map(data.map(row => [row.id, 156]))
const measureSpy = vi.spyOn(instance, 'measure')
await wrapper.setProps({ data: [...data].reverse() })
await wrapper.vm.$nextTick()
const sizeCache = (instance as any).itemSizeCache as Map<number, number>
expect(measureSpy).not.toHaveBeenCalled()
expect(sizeCache.size).toBe(100)
})
})