mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
Merge pull request #4300 from wp-a/fix/frontend-datatable-row-cache
fix(frontend): clear stale DataTable row caches
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user