From 84c541b06892f8e4e1f254cab49bf11683cceae0 Mon Sep 17 00:00:00 2001 From: william-xue <20151622+william-xue@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:10:44 +0800 Subject: [PATCH 1/3] fix(vtable): map sorted view index to source record in deleteRecords/updateRecords deleteRecords/updateRecords receive body display indices, but the sync branches of DataSource used them directly as indices into this.records (_source, source order). Sorting keeps _source in source order and stores the view -> source mapping in currentIndexedData, so on a sorted table the wrong record was deleted/updated in the user's records array. --- ...ed-sync-record-index_2026-09-23-09-50.json | 11 ++++ .../data-update/listTable-data-events.test.ts | 60 +++++++++++++++++++ packages/vtable/src/data/DataSource.ts | 15 ++++- 3 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 common/changes/@visactor/vtable/fix-sorted-sync-record-index_2026-09-23-09-50.json diff --git a/common/changes/@visactor/vtable/fix-sorted-sync-record-index_2026-09-23-09-50.json b/common/changes/@visactor/vtable/fix-sorted-sync-record-index_2026-09-23-09-50.json new file mode 100644 index 000000000..a5c183b77 --- /dev/null +++ b/common/changes/@visactor/vtable/fix-sorted-sync-record-index_2026-09-23-09-50.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "fix(vtable): map sorted view index to the source record in deleteRecords/updateRecords", + "type": "patch", + "packageName": "@visactor/vtable" + } + ], + "packageName": "@visactor/vtable", + "email": "20151622+william-xue@users.noreply.github.com" +} diff --git a/packages/vtable/__tests__/data-update/listTable-data-events.test.ts b/packages/vtable/__tests__/data-update/listTable-data-events.test.ts index ebfa613ef..2a3723c09 100644 --- a/packages/vtable/__tests__/data-update/listTable-data-events.test.ts +++ b/packages/vtable/__tests__/data-update/listTable-data-events.test.ts @@ -427,4 +427,64 @@ describe('listTable data events test', () => { table.updateFilterRules(table.dataSource.dataConfig.filterRules as any); expect((table.records as any[]).map(r => r.name)).toEqual(['小明2', '小明3']); }); + + test('deleteRecords on a sorted table should remove the source record shown in that row', () => { + table.release(); + const records = [ + { id: 1, name: 'Employee 1' }, + { id: 2, name: 'Employee 2' }, + { id: 3, name: 'Employee 3' }, + { id: 4, name: 'Employee 4' }, + { id: 5, name: 'Employee 5' } + ]; + + table = new ListTable({ + container: containerDom, + columns: [ + { field: 'id', title: 'ID' }, + { field: 'name', title: 'Name' } + ], + records, + sortState: { field: 'id', order: 'desc' }, + syncRecordOperationsToSourceRecords: true + }); + + // 降序排列后 body 第一行是 id 为 5 的记录 + expect(table.getCellValue(0, table.columnHeaderLevelCount)).toBe(5); + + table.deleteRecords([0]); + + // 删除的是显示在第一行的记录(id 5),而不是数据源数组的第 0 条 + expect(records.map(record => record.id)).toEqual([1, 2, 3, 4]); + expect(table.getCellValue(0, table.columnHeaderLevelCount)).toBe(4); + }); + + test('updateRecords on a sorted table should update the source record shown in that row', () => { + table.release(); + const records = [ + { id: 1, name: 'Employee 1' }, + { id: 2, name: 'Employee 2' }, + { id: 3, name: 'Employee 3' }, + { id: 4, name: 'Employee 4' }, + { id: 5, name: 'Employee 5' } + ]; + + table = new ListTable({ + container: containerDom, + columns: [ + { field: 'id', title: 'ID' }, + { field: 'name', title: 'Name' } + ], + records, + sortState: { field: 'id', order: 'desc' }, + syncRecordOperationsToSourceRecords: true + }); + + table.updateRecords([{ id: 5, name: 'Employee 5 updated' }], [0]); + + // 更新的同样是显示在第一行的记录(id 5,数据源下标 4) + expect(records.map(record => record.id)).toEqual([1, 2, 3, 4, 5]); + expect(records[4].name).toBe('Employee 5 updated'); + expect(table.getCellValue(1, table.columnHeaderLevelCount)).toBe('Employee 5 updated'); + }); }); diff --git a/packages/vtable/src/data/DataSource.ts b/packages/vtable/src/data/DataSource.ts index 99e3a183d..331cb50a1 100644 --- a/packages/vtable/src/data/DataSource.ts +++ b/packages/vtable/src/data/DataSource.ts @@ -962,6 +962,16 @@ export class DataSource extends EventTarget implements DataSourceAPI { private _hasFilterInEffect(): boolean { return (this.dataConfig?.filterRules?.length ?? 0) >= 1 || (this.lastFilterRules?.length ?? 0) >= 1; } + /** + * body 中的显示索引 → this.records(数据源数组)索引。 + * 排序或筛选后 currentIndexedData 保存的是「视图顺序 → 数据源顺序」的映射, + * 未排序/未筛选时它是恒等映射,此时直接返回 viewIndex。 + */ + private _getRecordIndexFromViewIndex(viewIndex: number): number { + const indexedData = this.currentIndexedData; + const mappedIndex = Array.isArray(indexedData) ? indexedData[viewIndex] : undefined; + return typeof mappedIndex === 'number' ? mappedIndex : viewIndex; + } private _normalizeInsertIndex(index: number, length: number): number { if (index === undefined || index === null) { return length; @@ -1251,7 +1261,8 @@ export class DataSource extends EventTarget implements DataSourceAPI { if (viewIndex >= this.records.length || viewIndex < 0) { continue; } - const deletedRecord = this.records[viewIndex]; + // viewIndex 是 body 中的显示索引:排序后需先映射回数据源索引,否则会删到别的记录 + const deletedRecord = this.records[this._getRecordIndexFromViewIndex(viewIndex)]; const rawIndex = rawRecords.indexOf(deletedRecord); if (rawIndex >= 0) { rawRecords.splice(rawIndex, 1); @@ -1349,7 +1360,7 @@ export class DataSource extends EventTarget implements DataSourceAPI { if (recordIndex >= this.records.length || recordIndex < 0) { continue; } - const oldRecord = this.records[recordIndex]; + const oldRecord = this.records[this._getRecordIndexFromViewIndex(recordIndex)]; const rawIndex = rawRecords.indexOf(oldRecord); if (rawIndex >= 0) { rawRecords[rawIndex] = records[index]; From df4a503c230c046b5bebbc6be9a9a0f24fb02ec0 Mon Sep 17 00:00:00 2001 From: william-xue <20151622+william-xue@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:10:54 +0800 Subject: [PATCH 2/3] fix(vtable): resolve view indexes for sorted/paginated delete and update - resolve every body view index to its source record first, then delete the raw indexes in descending order, so a batch delete on a sorted table no longer misses records when rawRecords and this.records are the same array - map the body view index through _currentPagerIndexedData (same as getRecordIndexPaths) so deleteRecords/updateRecords on currentPage > 0 operate on the records shown on the current page - add tests for sorted batch delete, sorted update and paginated delete/update --- .../data-update/listTable-data-events.test.ts | 66 +++++++++++++++++-- packages/vtable/src/data/DataSource.ts | 50 ++++++++++---- 2 files changed, 100 insertions(+), 16 deletions(-) diff --git a/packages/vtable/__tests__/data-update/listTable-data-events.test.ts b/packages/vtable/__tests__/data-update/listTable-data-events.test.ts index 2a3723c09..159de1ab0 100644 --- a/packages/vtable/__tests__/data-update/listTable-data-events.test.ts +++ b/packages/vtable/__tests__/data-update/listTable-data-events.test.ts @@ -449,14 +449,39 @@ describe('listTable data events test', () => { syncRecordOperationsToSourceRecords: true }); - // 降序排列后 body 第一行是 id 为 5 的记录 - expect(table.getCellValue(0, table.columnHeaderLevelCount)).toBe(5); - + // 降序排列后 body 第一行是 id 为 5 的记录(数据源下标 4) table.deleteRecords([0]); // 删除的是显示在第一行的记录(id 5),而不是数据源数组的第 0 条 expect(records.map(record => record.id)).toEqual([1, 2, 3, 4]); - expect(table.getCellValue(0, table.columnHeaderLevelCount)).toBe(4); + expect((table.records as any[]).map((record: any) => record.id)).toEqual([1, 2, 3, 4]); + }); + + test('deleteRecords should remove every source record when several sorted view rows are deleted at once', () => { + table.release(); + const records = [ + { id: 1, name: 'Employee 1' }, + { id: 2, name: 'Employee 2' }, + { id: 3, name: 'Employee 3' }, + { id: 4, name: 'Employee 4' }, + { id: 5, name: 'Employee 5' } + ]; + + table = new ListTable({ + container: containerDom, + columns: [ + { field: 'id', title: 'ID' }, + { field: 'name', title: 'Name' } + ], + records, + sortState: { field: 'id', order: 'desc' }, + syncRecordOperationsToSourceRecords: true + }); + + // 降序视图 [5,4,3,2,1],删除视图行 [0,1] 即删除 id 5 与 id 4 + table.deleteRecords([0, 1]); + + expect(records.map(record => record.id)).toEqual([1, 2, 3]); }); test('updateRecords on a sorted table should update the source record shown in that row', () => { @@ -485,6 +510,37 @@ describe('listTable data events test', () => { // 更新的同样是显示在第一行的记录(id 5,数据源下标 4) expect(records.map(record => record.id)).toEqual([1, 2, 3, 4, 5]); expect(records[4].name).toBe('Employee 5 updated'); - expect(table.getCellValue(1, table.columnHeaderLevelCount)).toBe('Employee 5 updated'); + }); + + test('deleteRecords/updateRecords on a paginated sorted table should honour the page offset', () => { + table.release(); + const records = [ + { id: 1, name: 'Employee 1' }, + { id: 2, name: 'Employee 2' }, + { id: 3, name: 'Employee 3' }, + { id: 4, name: 'Employee 4' }, + { id: 5, name: 'Employee 5' } + ]; + + table = new ListTable({ + container: containerDom, + columns: [ + { field: 'id', title: 'ID' }, + { field: 'name', title: 'Name' } + ], + records, + sortState: { field: 'id', order: 'desc' }, + pagination: { totalCount: 5, perPageCount: 2, currentPage: 1 }, + syncRecordOperationsToSourceRecords: true + }); + + // 降序结果为 [id5,id4,id3,id2,id1],第二页显示 id 3 与 id 2(数据源下标 2 与 1) + table.deleteRecords([0]); + expect(records.map(record => record.id)).toEqual([1, 2, 4, 5]); + + // 删除后降序结果为 [id5,id4,id2,id1],第二页第一行仍是 id 2(数据源下标 1) + table.updateRecords([{ id: 2, name: 'Employee 2 updated' }], [0]); + expect(records.map(record => record.id)).toEqual([1, 2, 4, 5]); + expect(records[1].name).toBe('Employee 2 updated'); }); }); diff --git a/packages/vtable/src/data/DataSource.ts b/packages/vtable/src/data/DataSource.ts index 331cb50a1..474402b06 100644 --- a/packages/vtable/src/data/DataSource.ts +++ b/packages/vtable/src/data/DataSource.ts @@ -964,14 +964,27 @@ export class DataSource extends EventTarget implements DataSourceAPI { } /** * body 中的显示索引 → this.records(数据源数组)索引。 - * 排序或筛选后 currentIndexedData 保存的是「视图顺序 → 数据源顺序」的映射, - * 未排序/未筛选时它是恒等映射,此时直接返回 viewIndex。 + * body 显示索引是「当前页」内的索引:分页时当前页的映射保存在 _currentPagerIndexedData 中 + * (等价于 getRecordIndexPaths(viewIndex)),未分页时它与 currentIndexedData 一致。 + * 排序/筛选后它们保存的是「视图顺序 → 数据源顺序」的映射,未排序/未筛选时是恒等映射。 */ private _getRecordIndexFromViewIndex(viewIndex: number): number { - const indexedData = this.currentIndexedData; - const mappedIndex = Array.isArray(indexedData) ? indexedData[viewIndex] : undefined; + const pagerIndexedData = this._currentPagerIndexedData; + let mappedIndex: number | number[] | undefined; + if (Array.isArray(pagerIndexedData) && viewIndex >= 0 && viewIndex < pagerIndexedData.length) { + mappedIndex = pagerIndexedData[viewIndex]; + } + if (mappedIndex === undefined) { + const indexedData = this.currentIndexedData; + mappedIndex = Array.isArray(indexedData) && viewIndex >= 0 ? indexedData[viewIndex] : undefined; + } return typeof mappedIndex === 'number' ? mappedIndex : viewIndex; } + /** body 显示索引的有效范围:分页时为当前页行数,否则为数据源长度 */ + private _getViewRecordCount(): number { + const pagerIndexedData = this._currentPagerIndexedData; + return Array.isArray(pagerIndexedData) && pagerIndexedData.length > 0 ? pagerIndexedData.length : this.records.length; + } private _normalizeInsertIndex(index: number, length: number): number { if (index === undefined || index === null) { return length; @@ -1254,21 +1267,36 @@ export class DataSource extends EventTarget implements DataSourceAPI { } const realDeletedRecordIndexs: number[] = []; - const recordIndexsMaxToMin = recordIndexs.slice().sort((a, b) => b - a); const rawDeletedIndexs: number[] = []; + // 先一次性把 body 显示索引解析为「待删记录 + 原始下标」,再按原始下标降序删除: + // rawRecords 与 this.records 是同一数组(无筛选)时,边解析边 splice 会使后续映射失效、漏删记录 + // (如降序表批量删除视图行 [0,1] 时只删掉一条)。 + const pendingDeletes: number[] = []; + const handledViewIndexs = new Set(); + const recordIndexsMaxToMin = recordIndexs.slice().sort((a, b) => b - a); + const viewRecordCount = this._getViewRecordCount(); for (let index = 0; index < recordIndexsMaxToMin.length; index++) { const viewIndex = recordIndexsMaxToMin[index]; - if (viewIndex >= this.records.length || viewIndex < 0) { + if (viewIndex < 0 || viewIndex >= viewRecordCount || handledViewIndexs.has(viewIndex)) { continue; } - // viewIndex 是 body 中的显示索引:排序后需先映射回数据源索引,否则会删到别的记录 + handledViewIndexs.add(viewIndex); + // viewIndex 是 body 中的显示索引:分页/排序后需先映射回数据源索引,否则会删到别的记录 const deletedRecord = this.records[this._getRecordIndexFromViewIndex(viewIndex)]; + if (deletedRecord === undefined) { + continue; + } + realDeletedRecordIndexs.push(viewIndex); const rawIndex = rawRecords.indexOf(deletedRecord); if (rawIndex >= 0) { - rawRecords.splice(rawIndex, 1); - rawDeletedIndexs.push(rawIndex); + pendingDeletes.push(rawIndex); } - realDeletedRecordIndexs.push(viewIndex); + } + // 按原始下标从大到小删除,保证前面的删除不影响后面的下标 + pendingDeletes.sort((a, b) => b - a); + for (let index = 0; index < pendingDeletes.length; index++) { + rawRecords.splice(pendingDeletes[index], 1); + rawDeletedIndexs.push(pendingDeletes[index]); } this.beforeChangedRecordsMap.clear(); @@ -1357,7 +1385,7 @@ export class DataSource extends EventTarget implements DataSourceAPI { return acc[key].children; }, rawRecords)[recordIndex[recordIndex.length - 1]] = records[index]; } else { - if (recordIndex >= this.records.length || recordIndex < 0) { + if (recordIndex >= this._getViewRecordCount() || recordIndex < 0) { continue; } const oldRecord = this.records[this._getRecordIndexFromViewIndex(recordIndex)]; From cdab8fe0ecdb2a2a1ebea478e34f0077973a3521 Mon Sep 17 00:00:00 2001 From: william-xue <20151622+william-xue@users.noreply.github.com> Date: Fri, 16 Oct 2026 09:05:49 +0800 Subject: [PATCH 3/3] fix(vtable): give each deleted view row its own source slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deleteRecords 的同步分支先把显示行映射成「待删记录」,再用 rawRecords.indexOf 得到原始下标并按降序 splice。当同一个记录对象在源数组中被引用多次时(例如多张 表共用同一条记录),indexOf 会对多条显示行返回同一个原始下标,这个下标被重复 加入待删列表后连续 splice,会连带删掉别的行:源数组 [A, B, A, B] 删除显示行 [1, 2, 3] 时留下的是 B 而不是 A。 改为在解析阶段为每条显示行预留一个尚未占用的原始槽位(命中已占用的下标时, indexOf 从该位置继续向后查找),保证显示行与原始槽位一一对应。记录互不重复时 行为不变。 新增 1 条回归用例:源数组引用同一对象两次,批量删除 [1, 2, 3] 后只保留第 0 行 引用的记录。 --- .../data-update/listTable-data-events.test.ts | 24 +++++++++++++++++++ packages/vtable/src/data/DataSource.ts | 9 ++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/packages/vtable/__tests__/data-update/listTable-data-events.test.ts b/packages/vtable/__tests__/data-update/listTable-data-events.test.ts index 159de1ab0..1ad268003 100644 --- a/packages/vtable/__tests__/data-update/listTable-data-events.test.ts +++ b/packages/vtable/__tests__/data-update/listTable-data-events.test.ts @@ -543,4 +543,28 @@ describe('listTable data events test', () => { expect(records.map(record => record.id)).toEqual([1, 2, 4, 5]); expect(records[1].name).toBe('Employee 2 updated'); }); + + test('deleteRecords should use one source slot per deleted view row when a record object is reused', () => { + table.release(); + const reused = { id: 1, name: 'Employee 1' }; + const other = { id: 2, name: 'Employee 2' }; + // 同一个对象被源数组引用多次(例如多张表共用同一条记录) + const records = [reused, other, reused, other]; + + table = new ListTable({ + container: containerDom, + columns: [ + { field: 'id', title: 'ID' }, + { field: 'name', title: 'Name' } + ], + records, + syncRecordOperationsToSourceRecords: true + }); + + // 删除显示行 [1,2,3]:每一行各占一个原始槽位,保留的是第 0 行引用的那条记录 + table.deleteRecords([1, 2, 3]); + + expect(records.length).toBe(1); + expect(records[0]).toBe(reused); + }); }); diff --git a/packages/vtable/src/data/DataSource.ts b/packages/vtable/src/data/DataSource.ts index 474402b06..e8fd12d59 100644 --- a/packages/vtable/src/data/DataSource.ts +++ b/packages/vtable/src/data/DataSource.ts @@ -1273,6 +1273,7 @@ export class DataSource extends EventTarget implements DataSourceAPI { // (如降序表批量删除视图行 [0,1] 时只删掉一条)。 const pendingDeletes: number[] = []; const handledViewIndexs = new Set(); + const reservedRawIndexs = new Set(); const recordIndexsMaxToMin = recordIndexs.slice().sort((a, b) => b - a); const viewRecordCount = this._getViewRecordCount(); for (let index = 0; index < recordIndexsMaxToMin.length; index++) { @@ -1287,8 +1288,14 @@ export class DataSource extends EventTarget implements DataSourceAPI { continue; } realDeletedRecordIndexs.push(viewIndex); - const rawIndex = rawRecords.indexOf(deletedRecord); + // 同一个对象可能被源数组多次引用:此时每个显示行应各占一个原始槽位, + // 否则 indexOf 对多行返回同一原始下标,连续 splice 会误删其它行 + let rawIndex = rawRecords.indexOf(deletedRecord); + while (rawIndex >= 0 && reservedRawIndexs.has(rawIndex)) { + rawIndex = rawRecords.indexOf(deletedRecord, rawIndex + 1); + } if (rawIndex >= 0) { + reservedRawIndexs.add(rawIndex); pendingDeletes.push(rawIndex); } }