From f863f664ac49886cb1d5d0ef8b08638fb44f1b30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E9=B9=8F?= <2829624376@qq.com> Date: Wed, 15 Jul 2026 03:16:59 +0800 Subject: [PATCH] fix(frontend): clear stale DataTable row caches --- frontend/src/components/common/DataTable.vue | 49 ++++++- .../common/__tests__/DataTable.spec.ts | 138 ++++++++++++++++++ 2 files changed, 183 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/common/DataTable.vue b/frontend/src/components/common/DataTable.vue index e3821c3620..b28f0d0592 100644 --- a/frontend/src/components/common/DataTable.vue +++ b/frontend/src/components/common/DataTable.vue @@ -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(() => + (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>(() => { diff --git a/frontend/src/components/common/__tests__/DataTable.spec.ts b/frontend/src/components/common/__tests__/DataTable.spec.ts index b2fd6b86f6..22e6b4be3e 100644 --- a/frontend/src/components/common/__tests__/DataTable.spec.ts +++ b/frontend/src/components/common/__tests__/DataTable.spec.ts @@ -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 + 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 + expect(measureSpy).not.toHaveBeenCalled() + expect(sizeCache.size).toBe(100) + }) })